《互联网产品的客户端架构·交易、增长与平台化》13.3 质量场景与风险清单:进入卷四

收口不是把前十二章再讲一遍,而是把每条约束改写成可执行断言、证据要求与风险等级。本节给出 16 条覆盖第 1 到 12 章的质量场景,用台账函数检查断言、证据、章节覆盖与卷四移交项,并用发布门禁区分高风险阻断与中风险警告,最后说明多端、规模化与平台化为什么留给卷四。

收口要留下可执行的东西

读到最后一节,很容易把收口写成名词清单:金额、幂等、状态机、回调、退款、埋点。清单的问题是它无法执行——没人知道「注意金额」到底要在哪个环境、跑哪条命令、看什么证据。

本节做三件事:

  1. 把前十二章的关键约束改写成可执行断言,每条都写清楚验证级别与证据。
  2. 把断言组织成风险台账,按高风险、中风险排序,并给出发布门禁规则。
  3. 明确哪些问题这一卷不解决,移交给卷四,避免读者误以为读完就能覆盖多端与规模化。

判断一条约束是否合格,只问一句:它能不能变成一条会失败的用例? 不能,就还只是口号。

16 条质量场景

下表每条都对应 trade-lab 里的一个模块或一组行为。L1/L2/L3 是验证级别,不是重要性;高风险约束也可能只需要 L1,因为它的错误在纯函数层就能暴露。

章节约束可执行断言级别风险
1金额整数分运算前后都是安全整数,只在渲染转元L1高
1字段所有权端上不能写服务端字段,合并记录差异L1高
2三源同快照价格与库存来自同一版本,不拼旧缓存L1高
2库存服务端确认端上只判断可尝试,扣减以服务端为准L1高
3明细单一版本同版本金额冲突直接报错L1高
4提交幂等同键重试同一订单,未知先查询L2高
4状态白名单未知状态不开放支付、取消、退款L1高
4支付超时查询确认前不允许再次支付L3高
5支付编排拉起、轮询、回跳共享同一支付单L3高
6禁止重复支付成功后重试入口关闭,重复通知只生效一次L3高
7券资格与叠加不满足门槛或互斥不可选,顺序固定L1中
8会员与积分会员价服务端下发,积分账本只增不减L1中
9分享与邀请参数完整透传,邀请关系只绑定一次L1中
10实验分流同用户同配置同分组,变更可追溯L1中
11售后证据读订单快照,证据本地留存,失败可恢复L2中
12归因边界只报白名单字段,去重键服务端下发L2中

这张表有两个用途。对工程师,它是回归清单:每次改交易链路,至少跑对应级别的那几条。对评审者,它是提问清单:如果一条高风险约束没有证据,就不应该合入。

风险台账与发布门禁

台账不是把表抄进代码,而是让「覆盖是否完整」可计算。模块提供四个能力:

  • validateScenario:单条场景是否缺断言、缺证据、章节号非法。
  • evaluateLedger:全量检查重复 id、统计可执行条数、高风险条数与卷四移交数。
  • releaseGate:高风险失败阻断,中风险失败警告,未知场景按阻断处理。
  • volume4Items:按多端、规模化、平台化三个方向分组。

门禁规则值得单独解释。很多团队把所有失败都当阻断,结果发布永远卡住;也有团队把所有失败都当警告,高风险问题被稀释。这里的分法是:高风险失败一定阻断,中风险失败进入发布说明,未知场景按高风险处理。未知不能当成无害。

台账代码

// file: src/summary/quality-scenarios.ts
// tier: L1
// verify: node --test test/summary/quality-scenarios.test.ts

export type QualityTier = "L1" | "L2" | "L3";
export type RiskLevel = "high" | "medium" | "low";
export type Volume4Area = "multi-client" | "scale" | "platform";

export interface QualityScenario {
  id: string; chapter: number; title: string; assertion: string;
  tier: QualityTier; risk: RiskLevel; evidence: string; volume4: Volume4Area | null;
}

// 前 12 章的关键约束,每条都必须能变成一条可执行断言。
export const QUALITY_SCENARIOS: QualityScenario[] = [
  {
    id: "money-integer-cents", chapter: 1, title: "金额只用整数分",
    assertion: "任意金额运算前后都是安全整数,渲染只在边界转元",
    tier: "L1", risk: "high", evidence: "金额边界用例 + 溢出拒绝用例", volume4: null,
  },
  {
    id: "field-ownership", chapter: 1, title: "金额与订单状态归服务端",
    assertion: "端上不能写服务端字段,合并时服务端覆盖并记录差异",
    tier: "L1", risk: "high", evidence: "所有权查表与合并差异用例", volume4: "multi-client",
  },
  {
    id: "three-sources", chapter: 2, title: "价格、库存、时效同源",
    assertion: "结算页的价格与库存来自同一版本快照,不拼接旧缓存",
    tier: "L1", risk: "high", evidence: "版本号绑定与过期标注用例", volume4: null,
  },
  {
    id: "stock-server-confirm", chapter: 2, title: "库存以下单结果为准",
    assertion: "端上只判断可尝试,扣减结果由服务端返回",
    tier: "L1", risk: "high", evidence: "降级状态与 requiresServerConfirm 用例", volume4: "scale",
  },
  {
    id: "single-breakdown", chapter: 3, title: "价格明细单一版本",
    assertion: "所有展示读同一个明细对象,同版本金额冲突直接报错",
    tier: "L1", risk: "high", evidence: "版本合并与金额不一致用例", volume4: "scale",
  },
  {
    id: "idempotent-submit", chapter: 4, title: "提交幂等",
    assertion: "同幂等键重试得到同一订单,结果未知时先查询",
    tier: "L2", risk: "high", evidence: "重复提交与超时查询时间线", volume4: null,
  },
  {
    id: "order-state-whitelist", chapter: 4, title: "订单状态白名单",
    assertion: "未知状态不猜测语义,不开放支付、取消、退款入口",
    tier: "L1", risk: "high", evidence: "未知状态与非法迁移拒绝用例", volume4: null,
  },
  {
    id: "payment-timeout", chapter: 4, title: "支付超时先查后重试",
    assertion: "超时不是失败,查询确认前不允许再次发起支付",
    tier: "L3", risk: "high", evidence: "回调延迟与查询确认用例", volume4: "platform",
  },
  {
    id: "pay-orchestration", chapter: 5, title: "支付编排只产生一条支付单",
    assertion: "拉起、轮询、回跳共享同一支付单,重复动作不新建支付",
    tier: "L3", risk: "high", evidence: "回跳丢失与轮询确认时间线", volume4: "platform",
  },
  {
    id: "no-double-pay", chapter: 6, title: "禁止重复支付",
    assertion: "成功支付后任何重试入口关闭,渠道重复通知只生效一次",
    tier: "L3", risk: "high", evidence: "重复通知与重试拒绝用例", volume4: null,
  },
  {
    id: "coupon-eligibility", chapter: 7, title: "优惠券资格与叠加",
    assertion: "不满足门槛或互斥的券不可选,叠加顺序固定",
    tier: "L1", risk: "medium", evidence: "门槛边界与互斥组合用例", volume4: "platform",
  },
  {
    id: "member-benefit", chapter: 8, title: "会员价与积分账本",
    assertion: "会员价由服务端下发,积分只增不减地展示账本,到期时间统一口径",
    tier: "L1", risk: "medium", evidence: "会员价来源与积分账本汇总用例", volume4: "multi-client",
  },
  {
    id: "share-attribution", chapter: 9, title: "分享参数与邀请绑定",
    assertion: "分享参数完整透传,邀请关系只绑定一次,降级页不丢关键参数",
    tier: "L1", risk: "medium", evidence: "参数透传与重复绑定用例", volume4: "multi-client",
  },
  {
    id: "experiment-split", chapter: 10, title: "实验分流可复现",
    assertion: "同一用户与实验配置得到同一分组,配置变更可追溯",
    tier: "L1", risk: "medium", evidence: "分流稳定性与配置版本用例", volume4: "platform",
  },
  {
    id: "aftersale-evidence", chapter: 11, title: "售后上下文与证据留存",
    assertion: "售后页读订单快照,证据本地留存,提交失败可恢复且不改写历史",
    tier: "L2", risk: "medium", evidence: "断网恢复与快照不可变用例", volume4: null,
  },
  {
    id: "attribution-boundary", chapter: 12, title: "漏斗与归因的端上边界",
    assertion: "端上只上报白名单字段,去重键由服务端下发,不本地补归因",
    tier: "L2", risk: "medium", evidence: "字段白名单与重复上报用例", volume4: "multi-client",
  },
];

const RISK_RANK: Record<RiskLevel, number> = { high: 0, medium: 1, low: 2 };

export function validateScenario(scenario: QualityScenario): string[] {
  const issues: string[] = [];
  if (scenario.id.trim() === "") issues.push("场景 id 不能为空");
  if (!Number.isInteger(scenario.chapter) || scenario.chapter < 1) {
    issues.push(`章节号非法:${scenario.id}`);
  }
  if (scenario.assertion.trim().length < 8) {
    issues.push(`断言不可执行:${scenario.id}`);
  }
  if (scenario.evidence.trim() === "") {
    issues.push(`缺少证据要求:${scenario.id}`);
  }
  return issues;
}

export interface LedgerSummary {
  total: number; executable: number; highRisk: number; volume4: number;
  issues: string[];
}

export function evaluateLedger(list: QualityScenario[]): LedgerSummary {
  const seen = new Set<string>();
  const issues: string[] = [];
  let highRisk = 0;
  let volume4 = 0;
  for (const scenario of list) {
    if (seen.has(scenario.id)) issues.push(`场景 id 重复:${scenario.id}`);
    seen.add(scenario.id);
    issues.push(...validateScenario(scenario));
    if (scenario.risk === "high") highRisk += 1;
    if (scenario.volume4 !== null) volume4 += 1;
  }
  return {
    total: list.length,
    executable: list.filter(
      (scenario) =>
        scenario.assertion.trim().length >= 8 && scenario.evidence.trim() !== "",
    ).length,
    highRisk,
    volume4,
    issues,
  };
}

export function chapterCoverage(list: QualityScenario[]): number[] {
  return [...new Set(list.map((scenario) => scenario.chapter))].sort((a, b) => a - b);
}

export function missingChapters(list: QualityScenario[], expected: number[]): number[] {
  const covered = new Set(chapterCoverage(list));
  return expected.filter((chapter) => !covered.has(chapter));
}

export function volume4Items(
  list: QualityScenario[],
): Record<Volume4Area, QualityScenario[]> {
  const grouped: Record<Volume4Area, QualityScenario[]> = {
    "multi-client": [], scale: [], platform: [],
  };
  for (const scenario of list) {
    if (scenario.volume4 !== null) grouped[scenario.volume4].push(scenario);
  }
  return grouped;
}

export interface GateResult { passed: boolean; blocked: string[]; warnings: string[] }

// 高风险失败阻断发布;中风险失败进入警告;未知场景按阻断处理。
export function releaseGate(list: QualityScenario[], failedIds: string[]): GateResult {
  const byId = new Map(list.map((scenario) => [scenario.id, scenario]));
  const blocked: string[] = [];
  const warnings: string[] = [];
  for (const id of failedIds) {
    const scenario = byId.get(id);
    if (scenario === undefined) blocked.push(`未知场景:${id}`);
    else if (scenario.risk === "high") blocked.push(id);
    else warnings.push(id);
  }
  return { passed: blocked.length === 0, blocked, warnings };
}

export interface RiskRow {
  id: string; chapter: number; risk: RiskLevel;
  tier: QualityTier; volume4: Volume4Area | null;
}

export function riskLedger(list: QualityScenario[]): RiskRow[] {
  return [...list]
    .sort((a, b) => RISK_RANK[a.risk] - RISK_RANK[b.risk] ||
      a.chapter - b.chapter || a.id.localeCompare(b.id))
    .map((scenario) => ({
      id: scenario.id, chapter: scenario.chapter, risk: scenario.risk,
      tier: scenario.tier, volume4: scenario.volume4,
    }));
}

export function countByTier(list: QualityScenario[]): Record<QualityTier, number> {
  return {
    L1: list.filter((scenario) => scenario.tier === "L1").length,
    L2: list.filter((scenario) => scenario.tier === "L2").length,
    L3: list.filter((scenario) => scenario.tier === "L3").length,
  };
}

数据与函数分开,是为了让新增场景只改数据,不改判断逻辑。QUALITY_SCENARIOS 是唯一事实来源,测试、发布门禁与文档都读它。

测试与真实输出

$ node --test --test-concurrency=1 test/summary/quality-scenarios.test.ts
✔ 完整台账没有校验问题 (2.475ms)
✔ 场景 id 不重复 (0.102708ms)
✔ 第 1 到 12 章都有覆盖 (0.199583ms)
✔ 高风险场景不少于十个 (0.110125ms)
✔ 缺少断言时校验失败 (0.1545ms)
✔ 缺少证据时校验失败 (0.09275ms)
✔ 章节号非法时校验失败 (0.067625ms)
✔ 空 id 与合法场景分别被拒绝和接受 (0.065083ms)
✔ 重复 id 进入台账问题 (0.08125ms)
✔ 卷四移交项按三个方向分组且总数一致 (0.165833ms)
✔ 高风险失败阻断发布 (0.107458ms)
✔ 中风险失败只产生警告 (0.062708ms)
✔ 没有失败项时门禁通过 (0.045041ms)
✔ 未知失败项按阻断处理 (0.043667ms)
✔ 风险台账高风险排在低风险之前 (8.93775ms)
✔ 三级数量之和等于场景总数 (0.125625ms)
✔ 支付相关场景至少有一条 L3 (0.072375ms)
✔ 每条场景都有可读标题与证据 (0.066292ms)
ℹ tests 18
ℹ suites 0
ℹ pass 18
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 161.870708

十八条用例覆盖:全量台账无问题、id 唯一、第 1 到 12 章全覆盖、高风险数量、四类校验失败、卷四分组、门禁的阻断/警告/通过/未知、风险排序、三级数量守恒、支付场景含 L3、标题与证据非空。

一次「清单很长、发布仍然出事」的复盘

某团队维护了一份三十多页的发布检查表,交易相关条目有四十多条。每次发布都有人逐条打勾,但事故仍然发生。

复盘发现三个问题:

  1. 条目不可执行。「确认金额正确」无法判断通过还是失败,打勾只是表态。
  2. 没有证据要求。同一条目,有人跑单元测试,有人只看代码,结论都写「已确认」。
  3. 风险等级缺失。四十多条权重相同,发布前时间不够时,团队从第一条开始砍,砍掉的恰好是支付回调。

改造没有增加条目,反而删到十几条,但每条都补了三样东西:一条会失败的断言、一个验证级别、一份证据要求。发布门禁按风险分级,高风险项没有证据就不能发布。

改造后的第一次发布,门禁拦下了一条:「支付超时先查后重试」只有 L2 用例,没有 L3 回调延迟用例。团队补测后发现,端上确实把「查询超时」当成了「支付失败」。这条约束在旧清单里写作「注意支付超时」,从来没有失败过。

这个复盘的结论是:质量清单的价值不在长度,而在每条都能判真假。

哪些问题留给卷四

这一卷把「单客户端、单Region、固定配置」下的交易链路讲完了。以下三类问题刻意没有展开:

  • 多端:同一账号在 App、小程序、Web 同时操作购物车与订单,冲突合并、跨端登录态、端能力差异。本卷的所有权模型可以复用,但「谁是主端」需要重新设计。
  • 规模化:大促峰值下的请求合并、降级分层、端上缓存容量、灰度与回滚。本卷只讲了单请求的正确性,没有讲十万级并发的取舍。
  • 平台化:交易能力被多个业务线复用时,规则配置、实验平台、埋点契约如何治理。本卷的模块是单业务的,平台化需要把「可变部分」抽成配置与契约。

这三类问题不是「更难」,而是约束条件变了:多端改变一致性边界,规模化改变失败概率,平台化改变变更节奏。卷四会围绕这三个变化重新组织案例。

小结

质量场景把前十二章的约束变成 16 条可执行断言,每条都带验证级别、证据要求与风险等级。台账函数检查覆盖与重复,发布门禁把高风险失败变成阻断,把中风险失败变成警告。

这一卷的收口不是「学完了」,而是「知道哪些已经能验证、哪些还没有」。多端、规模化、平台化留给卷四,因为它们的约束条件与本卷不同。

阅读导航:上一节:13.2 故障注入:重复提交、支付超时、回调乱序 · 下一节:目录与学习路径 。

常见错误与边界

  • 质量清单只写「注意」「确认」,没有可失败的断言。
  • 所有条目权重相同,发布前先砍掉没有即时反馈的支付与退款项。
  • 高风险约束只有 L1 用例,却声称已经覆盖支付时序。
  • 台账只统计条数,不检查章节覆盖,某一章整章遗漏也显示全绿。
  • 发布门禁把未知场景当成通过,拼错的 id 静默放行。
  • 把卷四的问题提前塞进本卷,多端与规模化都没有足够篇幅讲清。
  • 质量场景写成一次性文档,代码变更后不再同步。
  • 用「测试全绿」替代证据要求,不记录跑了哪一级、断言了什么。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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