《互联网产品的客户端架构·端形态与体验基础》13.3 质量属性场景与风险清单

把弱网打开、进程回收恢复、支付回调重复、包体超限四个质量属性场景转成可评分风险条目,用影响、概率、发现难度三维打分并排序,给出阈值、结论判定与固定输出格式,附一次基线评审记录、风险关闭过程,以及清单如何进入下一轮迭代并转化为可执行的发布条件。

场景不等于风险

“弱网下要能打开”是场景,“弱网打开可能让用户在 5 秒内看不到任何内容,而团队直到投诉才知道”是风险。前者描述期望,后者描述偏离期望的后果、概率与可见性。评审时只列场景,讨论会停在“应该做”;把场景转成风险,才能排序、分配责任、判断什么时候必须关闭。

四个场景贯穿卷一:弱网打开、进程回收恢复、支付回调重复、包体超限。它们分别对应性能、可靠性、安全与可维护性,也分别落在数据层、状态层、写路径与构建产物上。把它们放进同一张清单,是为了用同一把尺子比较,而不是让每个专项各自定义“严重”。

风险条目必须可观察。触发条件写成“2G 下打开发现页”,期望写成“5 秒内出现可读首屏”,缓解写成“缓存优先渲染加请求预算”。如果一条风险写不出触发条件,它大概率不是风险,而是担忧。

三个维度与阈值

评分用三个 1 到 5 的整数:

维度1 分5 分为什么单独计分
影响 impact可忽略阻断核心任务决定后果上限
概率 likelihood罕见几乎每天发生决定复现频率
发现难度 detectability一眼可见只能靠投诉决定反馈延迟

分数是三者相乘,范围 1 到 125。阈值是案例假设值,不是行业标准:>= 60 为 high,>= 25 为 medium,其余为 low。把发现难度单独乘一次,是因为“用户先发现”的风险不能按低概率处理;一个影响 5、概率 3、但用户第二天才投诉的问题,实际暴露时间远大于它的概率。

等级只是排序工具,结论还看状态。mitigated 与 accepted 的高风险不阻塞发布,但 accepted 必须写明谁接受、接受多久;open 的高风险只要存在一条,评审结论就是 fail。这条规则故意做得硬:允许团队关闭风险,不允许团队假装看不见。

四个场景的初始评分

编号场景影响概率发现难度分数等级状态
R-01弱网打开44348mediumopen
R-02进程回收恢复43560highopen
R-03支付回调重复54480highopen
R-04包体超限35230mediummitigated

支付回调排第一,不是因为它最常发生,而是因为影响与发现难度都高:重复扣款一旦发生,用户会先发现,团队后知道。进程回收排第二,因为它的发现难度是 5,用户往往只描述“刚才写的东西没了”,很难复现。包体超限虽然有 5 的概率,但影响与发现难度都低,且已经有体积预算门禁,所以状态是已缓解。

这张表要在评审前发给所有参与者,而不是在会上现场填。分数可以争论,但争论必须落到某一个维度:是影响被低估,还是概率没有数据,还是发现手段缺失。笼统地说“我觉得这个更严重”,无法进入排序。

评分、排序与输出

下面的文件实现三件事:校验维度、生成带排名的清单、输出固定文本。它不读埋点,也不连监控,所有输入由调用方提供。

// file: src/summary/risk-register.ts
// tier: L1
// verify: node --test test/summary/risk-register.test.ts

export type QualityAttribute =
  | "performance"
  | "reliability"
  | "security"
  | "maintainability";

export type RiskStatus = "open" | "mitigated" | "accepted";
export type RiskLevel = "high" | "medium" | "low";
export type Score = 1 | 2 | 3 | 4 | 5;

export interface RiskScenario {
  id: string;
  title: string;
  attribute: QualityAttribute;
  /** 可观察的触发条件与期望响应 */
  scenario: string;
  /** 影响面:1 可忽略,5 阻断核心任务 */
  impact: Score;
  /** 发生概率:1 罕见,5 几乎每天发生 */
  likelihood: Score;
  /** 发现难度:1 一眼可见,5 只能靠用户投诉 */
  detectability: Score;
  mitigation: string;
  owner: string;
  status: RiskStatus;
}

export interface ScoredRisk extends RiskScenario {
  score: number;
  level: RiskLevel;
  rank: number;
}

export interface RiskRegister {
  entries: ScoredRisk[];
  counts: Record<RiskLevel, number>;
  openHigh: string[];
  verdict: "pass" | "fail";
}

const HIGH_SCORE = 60;
const MEDIUM_SCORE = 25;

function isScore(value: number): value is Score {
  return Number.isInteger(value) && value >= 1 && value <= 5;
}

function levelOf(score: number): RiskLevel {
  if (score >= HIGH_SCORE) return "high";
  if (score >= MEDIUM_SCORE) return "medium";
  return "low";
}

/**
 * 把质量属性场景转成可比较的分数:影响 × 概率 × 发现难度。
 * 发现难度单独乘一次,是因为“用户先发现”的风险不能按低概率处理。
 */
export function scoreRisk(input: RiskScenario): Omit<ScoredRisk, "rank"> {
  if (input.id.trim().length === 0 || input.title.trim().length === 0) {
    throw new Error("风险条目必须有 id 与 title");
  }
  if (input.mitigation.trim().length === 0) {
    throw new Error(`风险 ${input.id} 必须写明缓解措施`);
  }
  for (const [name, value] of [
    ["impact", input.impact],
    ["likelihood", input.likelihood],
    ["detectability", input.detectability],
  ] as const) {
    if (!isScore(value)) {
      throw new Error(`风险 ${input.id} 的 ${name} 必须是 1-5 的整数,收到 ${value}`);
    }
  }
  const score = input.impact * input.likelihood * input.detectability;
  return { ...input, score, level: levelOf(score) };
}

/** 按分数降序、同分按 id 升序生成排名;只有 open 的高风险会阻塞结论。 */
export function buildRiskRegister(scenarios: RiskScenario[]): RiskRegister {
  const seen = new Set<string>();
  for (const scenario of scenarios) {
    if (seen.has(scenario.id)) throw new Error(`重复风险编号: ${scenario.id}`);
    seen.add(scenario.id);
  }

  const entries: ScoredRisk[] = scenarios
    .map(scoreRisk)
    .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
    .map((risk, index) => ({ ...risk, rank: index + 1 }));

  const counts: Record<RiskLevel, number> = { high: 0, medium: 0, low: 0 };
  for (const entry of entries) counts[entry.level] += 1;

  const openHigh = entries
    .filter((entry) => entry.status === "open" && entry.level === "high")
    .map((entry) => entry.id);

  return {
    entries,
    counts,
    openHigh,
    verdict: openHigh.length === 0 ? "pass" : "fail",
  };
}

/** 固定格式的评审输出:第一行是分布,中间是排名,最后一行是结论。 */
export function formatRiskRegister(register: RiskRegister): string[] {
  const { high, medium, low } = register.counts;
  const lines = [
    `风险清单:${register.entries.length} 条,high ${high} / medium ${medium} / low ${low}`,
  ];
  for (const entry of register.entries) {
    lines.push(
      `${entry.rank}. [${entry.level} ${entry.score}] ${entry.id} ${entry.title} / ${entry.attribute} / ${entry.status} / owner=${entry.owner}`,
    );
  }
  lines.push(
    register.verdict === "pass"
      ? "评审结论:pass"
      : `评审结论:fail,未关闭高风险:${register.openHigh.join(",")}`,
  );
  return lines;
}

实现有几个刻意的限制。

第一,维度越界直接抛错,不截断到 1 或 5。静默截断会让一条“影响 9”的错误输入变成“影响 5”,评审者以为分数可信,实际上输入已经坏了。

第二,重复编号在排序前拒绝。风险编号是引用锚点,评审记录、工单和发布说明都会引用它;两个 R-03 会让“关闭 R-03”这句话失去意义。

第三,排序规则固定为分数降序、同分按编号升序。同分时按编号而不是按标题,是为了让两次运行的输出可直接比较;标题会改,编号不该改。

第四,结论只看 open 的高风险。这避免两个极端:把所有高风险都算成阻塞,团队永远发不了版;或者把“已接受”当成“已解决”,风险从清单上消失但后果还在。

测试与真实输出

测试覆盖排序与计数、高风险关闭后的结论、非法维度、重复编号、固定输出格式。完整测试在 test/summary/risk-register.test.ts。

单文件命令:

node --test test/summary/risk-register.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 四个场景按影响×概率×发现难度排序,只有 open 高风险阻塞结论 (0.958917ms)
✔ 高风险全部关闭后结论转为 pass,medium 未关闭不阻塞 (0.101084ms)
✔ 评分维度越界或非整数时直接拒绝,不静默截断 (0.277292ms)
✔ 重复风险编号在进入排名前被拒绝 (0.075167ms)
✔ 评审输出固定为分布、排名、结论三段 (0.111875ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 176.362208

输出格式固定为三段:第一行是等级分布,中间每个风险一行,最后一行是结论。下面的文本由同一份测试数据生成:

风险清单:4 条,high 2 / medium 2 / low 0
1. [high 80] R-03 支付回调重复 / security / open / owner=支付组
2. [high 60] R-02 进程回收恢复 / reliability / open / owner=客户端组
3. [medium 48] R-01 弱网打开 / performance / open / owner=客户端组
4. [medium 30] R-04 包体超限 / maintainability / mitigated / owner=构建组
评审结论:fail,未关闭高风险:R-03,R-02

一次基线评审记录

时间:卷一收口评审。参与:客户端、支付、构建、测试。输入:13.1 的 L2 基线、13.2 的依赖图、上面四个场景。

第一个争议是 R-03 的概率。支付组认为回调重复“服务端已经去重”,概率应该降到 1。测试追问:客户端重复提交同一订单时,服务端按什么键去重?回答是订单号加用户 id,但客户端在超时重试时会生成新的请求 id。于是概率维持 4,缓解措施改成“客户端复用幂等键,服务端按幂等键去重”,责任留在支付组。

第二个争议是 R-02 的发现难度。产品认为“用户会反馈”,应该降到 3。客户端展示基线记录:页面栈没有持久化,进程回收后用户回到 feed 根页,草稿有队列但页面位置丢失;这类问题在埋点里只表现为“会话变短”,不会自动告警。发现难度维持 5,新增一条缓解:恢复失败时上报 restore_miss 事件,带上缺失的层级。

第三个结论是 R-04 可以关闭。构建组出示体积预算门禁:首包超过 2MB 时 CI 直接失败,最近三次构建都在 1.7MB 左右。评审把状态改为 mitigated,但保留在清单里,每季度复核一次阈值。

最终结论:fail,两条 open 高风险。发布条件写成三条:支付幂等键联调通过;进程回收恢复至少覆盖页面栈与草稿;两条风险的关闭证据进入发布说明。评审没有把“基线测试全绿”当成发布依据,因为基线只覆盖 L2。

风险清单怎么用

清单不是评审当天的一次性文档。每次发布前重新跑一遍评分,状态变化要写明证据:测试报告、灰度数据、线上告警。分数变了要能解释是哪个维度变了,而不是“感觉好一点”。

清单也不该无限增长。关闭的风险保留一个季度后归档,新增风险必须能追溯到场景或事故。超过二十条 open 风险的清单通常说明没有排序,而不是风险真的多。

最重要的是责任人。没有 owner 的风险会在评审后自然消失,因为它不属于任何人的待办。owner 不一定是修复者,但必须是能在发布评审上说“这条现在是什么状态”的人。

小结

质量属性场景描述期望,风险条目描述偏离期望的后果、概率与可见性。三维评分把三者压成一个可排序的分数,阈值与状态决定它是否阻塞发布;固定输出让两次评审可以比较,而不是各说各话。

卷一的四个场景最终收敛为两条 open 高风险与一条已缓解的中风险。清单的价值不在于分数本身,而在于它逼团队把“应该做”翻译成“谁在什么时候拿出什么证据关闭它”。

阅读导航:上一节:13.2 模块划分与依赖图 · 下一节:目录与学习路径 。

常见错误与边界

  • 只列场景不列风险。没有后果、概率与可见性,就无法排序。
  • 把发现难度漏掉。用户先发现的问题,实际暴露时间远大于概率估计。
  • 维度越界时静默截断。坏输入会伪装成可信分数。
  • 允许重复编号。引用锚点一旦重复,“关闭某条风险”就失去意义。
  • 把 accepted 当成 resolved。接受是有人承担后果,不是问题消失。
  • 用高风险数量制造恐慌。只看 open 状态,关闭要有证据。
  • 清单只增不减。归档旧风险,新增必须可追溯。
  • 没有 owner。评审结束后风险会自然消失,因为没人对它负责。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查