收口要留下可执行的东西
读到最后一节,很容易把收口写成名词清单:金额、幂等、状态机、回调、退款、埋点。清单的问题是它无法执行——没人知道「注意金额」到底要在哪个环境、跑哪条命令、看什么证据。
本节做三件事:
- 把前十二章的关键约束改写成可执行断言,每条都写清楚验证级别与证据。
- 把断言组织成风险台账,按高风险、中风险排序,并给出发布门禁规则。
- 明确哪些问题这一卷不解决,移交给卷四,避免读者误以为读完就能覆盖多端与规模化。
判断一条约束是否合格,只问一句:它能不能变成一条会失败的用例? 不能,就还只是口号。
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、标题与证据非空。
一次「清单很长、发布仍然出事」的复盘
某团队维护了一份三十多页的发布检查表,交易相关条目有四十多条。每次发布都有人逐条打勾,但事故仍然发生。
复盘发现三个问题:
- 条目不可执行。「确认金额正确」无法判断通过还是失败,打勾只是表态。
- 没有证据要求。同一条目,有人跑单元测试,有人只看代码,结论都写「已确认」。
- 风险等级缺失。四十多条权重相同,发布前时间不够时,团队从第一条开始砍,砍掉的恰好是支付回调。
改造没有增加条目,反而删到十几条,但每条都补了三样东西:一条会失败的断言、一个验证级别、一份证据要求。发布门禁按风险分级,高风险项没有证据就不能发布。
改造后的第一次发布,门禁拦下了一条:「支付超时先查后重试」只有 L2 用例,没有 L3 回调延迟用例。团队补测后发现,端上确实把「查询超时」当成了「支付失败」。这条约束在旧清单里写作「注意支付超时」,从来没有失败过。
这个复盘的结论是:质量清单的价值不在长度,而在每条都能判真假。
哪些问题留给卷四
这一卷把「单客户端、单Region、固定配置」下的交易链路讲完了。以下三类问题刻意没有展开:
- 多端:同一账号在 App、小程序、Web 同时操作购物车与订单,冲突合并、跨端登录态、端能力差异。本卷的所有权模型可以复用,但「谁是主端」需要重新设计。
- 规模化:大促峰值下的请求合并、降级分层、端上缓存容量、灰度与回滚。本卷只讲了单请求的正确性,没有讲十万级并发的取舍。
- 平台化:交易能力被多个业务线复用时,规则配置、实验平台、埋点契约如何治理。本卷的模块是单业务的,平台化需要把「可变部分」抽成配置与契约。
这三类问题不是「更难」,而是约束条件变了:多端改变一致性边界,规模化改变失败概率,平台化改变变更节奏。卷四会围绕这三个变化重新组织案例。
小结
质量场景把前十二章的约束变成 16 条可执行断言,每条都带验证级别、证据要求与风险等级。台账函数检查覆盖与重复,发布门禁把高风险失败变成阻断,把中风险失败变成警告。
这一卷的收口不是「学完了」,而是「知道哪些已经能验证、哪些还没有」。多端、规模化、平台化留给卷四,因为它们的约束条件与本卷不同。
阅读导航:上一节:13.2 故障注入:重复提交、支付超时、回调乱序 · 下一节:目录与学习路径 。
常见错误与边界
- 质量清单只写「注意」「确认」,没有可失败的断言。
- 所有条目权重相同,发布前先砍掉没有即时反馈的支付与退款项。
- 高风险约束只有 L1 用例,却声称已经覆盖支付时序。
- 台账只统计条数,不检查章节覆盖,某一章整章遗漏也显示全绿。
- 发布门禁把未知场景当成通过,拼错的 id 静默放行。
- 把卷四的问题提前塞进本卷,多端与规模化都没有足够篇幅讲清。
- 质量场景写成一次性文档,代码变更后不再同步。
- 用「测试全绿」替代证据要求,不记录跑了哪一级、断言了什么。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。