《互联网产品的客户端架构·交易、增长与平台化》1.3 lab 的三条验证线:纯计算、进程内下单、真实回调

交易端的测试最容易「全绿但线上出错」,因为很多问题只在进程边界与真实回调里出现。本节把 trade-lab 的验证分成 L1 纯计算、L2 进程内下单、L3 真实支付回调三级,给出每级的判定条件、命令与证据要求,并用一个可运行的分类函数把「这条逻辑该在哪一级验证」变成可执行的规则。

为什么交易端更需要分级验证

卷二的实验分三级:L1 纯逻辑、L2 进程内流程、L3 本地实时通道。交易端沿用这个结构,但分级的依据不同——不是「有没有网络」,而是**「这条逻辑的错误会不会造成资金或状态事实的错误」**。

一条金额格式化写错,用户看到 49.0 或 49.00,是展示问题;一条幂等键生成写错,用户点两次产生两张订单,是资金问题;一条支付回调处理写错,用户付了钱但订单一直待支付,是资金加信任问题。

三级验证对应三类风险:

级别范围能抓住什么抓不住什么
L1纯函数金额、状态迁移、规则判定时序、网络、服务端行为
L2进程内服务端 + 假时钟提交、轮询、超时、重试真实回调、真实渠道
L3本地 HTTP 回调 + 查询回调乱序、重复通知、结果确认真实渠道的签名与网络

关键是最后一行:每级都明确写出「抓不住什么」。没有这一行,团队会把 L1 全绿当成支付没问题。

L1:所有能算的,都先算成纯函数

交易端有大量纯逻辑:

  • 金额的加减、分摊、格式化。
  • 订单状态的合法迁移。
  • 优惠券是否可用、是否叠加。
  • 限购、库存、预售时间的判定。
  • 埋点事件的字段拼装。

这些逻辑的共同点是:给定输入就一定有确定的输出,不依赖网络、时钟、随机数。把它们写成纯函数,测试成本最低,回归速度最快,也最容易在重构时保住行为。

L1 的判定很简单:函数体里不出现 fetch、setTimeout、Date.now()、Math.random()、process.env。出现了就降级为 L2。

L1 的命令是单目录测试:

node --test test/core

注意这里不需要 --test-concurrency=1,因为纯函数之间没有共享状态。加上它反而会让测试变慢。

L2:进程内跑完整链路,用假时钟控制时间

L2 解决的是「多个纯函数组合起来是否正确」。典型场景:

  • 提交订单 → 生成幂等键 → 服务端接受 → 返回订单号。
  • 支付轮询 → 三次未支付 → 超时取消。
  • 断网 → 请求进入队列 → 恢复后按顺序重放。

这些场景需要「服务端」,但不一定需要真实网络。trade-lab 的做法是用一个内存适配器实现服务端接口:订单存在 Map 里,支付结果由测试脚本控制,时间由假时钟推进。

假时钟是 L2 的核心工具。交易端大量逻辑与时间有关:待支付 15 分钟超时、优惠券有效期、退款到账时限、埋点事件的 session 窗口。用真实时钟等 15 分钟不现实,用「把超时改成 1 秒」又会改变被测逻辑。假时钟让「推进 15 分钟」变成一次函数调用,同时保持业务代码里的常量不变。

L2 的命令需要串行:

node --test --test-concurrency=1 test/order

原因是假时钟与内存服务端是进程级共享状态。并发跑不同测试文件,时间会互相干扰,出现「单独跑通过、一起跑失败」的假故障。

L3:真实回调是交易端唯一不可替代的一级

支付是 L3 存在的原因。L2 可以模拟「服务端说支付成功」,但模拟不了一次真实回调带来的这些问题:

  • 回调与轮询的时序竞争:用户从渠道回跳时,端上开始轮询;回调可能同时到达,两者更新同一个订单。
  • 重复回调:渠道在网络抖动时会重发通知,服务端与端上都要能处理。
  • 回调丢失:渠道通知失败,只能靠主动查询补上。
  • 签名与验签:真实渠道的回调带签名,验签失败必须拒绝。

这些问题不是业务逻辑问题,是协议与时序问题,只有在真实 HTTP 边界上才会出现。

trade-lab 的 L3 用 Node 标准库起一个本地 HTTP 服务,模拟渠道回调:测试脚本向回调地址发 POST,端上或服务端接收后更新订单,再通过查询接口确认结果。它不接真实支付渠道,但保留了 HTTP 边界、并发与乱序。

L3 的命令同样是串行:

node --test --test-concurrency=1 test/callback

L3 的证据要求也比前两级高:不能只写「测试通过」,要记录「回调到达时间、查询返回时间、最终订单状态」三者的关系。因为这一级要验证的正是时序。

把分级写成代码

下面的模块把上面的判定写成可执行规则:给定一个测试计划,回答它属于哪一级、该用哪条命令、需要什么证据。

// file: src/core/lab.ts
// tier: L1
// verify: node --test test/core/lab.test.ts
import type { Chain } from "./trade-model.ts";

export type Tier = "L1" | "L2" | "L3";

export interface TradePlan {
  usesNetwork: boolean;
  usesTimers: boolean;
  usesPaymentCallback: boolean;
}

export interface TierGate {
  tier: Tier;
  command: string;
  evidence: string;
}

export function classifyTier(plan: TradePlan): Tier {
  if (plan.usesPaymentCallback) return "L3";
  if (plan.usesNetwork || plan.usesTimers) return "L2";
  return "L1";
}

export function gateFor(tier: Tier): TierGate {
  if (tier === "L3") {
    return {
      tier,
      command: "node --test --test-concurrency=1 test/callback",
      evidence: "真实 HTTP 回调 + 轮询结果一致",
    };
  }
  if (tier === "L2") {
    return {
      tier,
      command: "node --test --test-concurrency=1 test/order",
      evidence: "进程内服务端 + 假时钟,断言订单状态",
    };
  }
  return {
    tier,
    command: "node --test test/core",
    evidence: "纯函数输入输出,无网络无定时器",
  };
}

const CHAIN_MIN_TIER: Record<Chain, Tier> = {
  browse: "L1",
  order: "L2",
  pay: "L3",
  aftersale: "L2",
};

const RANK: Record<Tier, number> = { L1: 1, L2: 2, L3: 3 };

export function meetsChainMinimum(tier: Tier, chain: Chain): boolean {
  return RANK[tier] >= RANK[CHAIN_MIN_TIER[chain]];
}

export function minimumTier(chain: Chain): Tier {
  return CHAIN_MIN_TIER[chain];
}

classifyTier 的判定顺序很重要:先看支付回调,再看网络与定时器,最后才是纯逻辑。顺序错了会出现「有回调但被归为 L2」的低估。

meetsChainMinimum 把「链路的最低验证级别」写成常量。浏览可以只用 L1,下单至少 L2,支付必须 L3。这条规则让「支付只写了单元测试」变成可被检查的缺陷,而不是评审时的主观判断。

三级之间的关系

三级不是「越往后越高级」,而是「越往后越贵」。正确的策略是:

  1. 能用 L1 验证的,绝不放到 L2。
  2. 能在 L2 模拟的,绝不放到 L3。
  3. 支付链路必须有 L3,不能只靠 L1 + L2。

反过来,常见错误是把所有测试都塞进 L3:跑一次要起服务、占端口、等回调,反馈周期从毫秒变成秒级,最后没人愿意跑。

一个健康的 trade-lab,L1 用例应占七成以上,L2 占两成,L3 只保留支付与对账的关键路径。

测试与真实输出

本机运行结果如下:

$ node --test test/core/lab.test.ts
✔ 纯计算归 L1 (0.490667ms)
✔ 有网络但没有真实回调归 L2 (0.075333ms)
✔ 接入真实支付回调归 L3 (0.058375ms)
✔ 每级给出命令与证据要求 (0.13325ms)
✔ 支付链路的验证门槛是 L3 (0.070958ms)
✔ 浏览与下单的门槛不同 (0.057834ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 116.792167

六条用例分别验证:三级的判定、命令与证据、支付门槛、链路差异。

一次「L2 全绿但线上重复扣款」的事故

「回声」的支付模块曾有一套很完整的 L2 测试:模拟服务端返回「支付成功」,断言端上把订单标成已支付、停止轮询、跳转订单页。全部通过。

上线后出现重复扣款。复盘发现,端上在支付超时后调用「重新支付」,而渠道其实已经扣款成功,只是回调晚了 40 秒。L2 测试里,服务端永远「立即返回成功」,从来不会「回调晚于超时」。这个问题在 L2 的结构里不可能暴露——不是测试写得不好,而是这一级抓不住时序。

修复有两条:

  1. 端上增加「支付结果查询」,超时后先查再决定是否重试。
  2. 增加 L3 用例:回调延迟 40 秒到达,端上在此期间轮询,最终必须只有一笔支付。

新增的 L3 用例第一次运行就失败了,因为端上把「轮询超时」当成了「支付失败」。这正是它要抓的东西。

这个事故留下一条团队约定:支付相关 PR 必须附一条 L3 用例,只有 L2 不算验证。

证据怎么写

每一级要求的证据不同:

  • L1:测试命令与通过数量。若涉及金额,附一两个边界值的实际输出。
  • L2:测试命令、假时钟推进的时长、关键状态的断言。最好附一段「时间线」:第几秒发生了什么。
  • L3:回调请求与查询响应的原始记录、时间戳、最终状态。必要时附端口与并发数。

证据不是给别人看的,是给三个月后的自己看的。交易端的问题往往在版本迭代后才暴露,没有时间线就无法还原。

与卷二分级的关系

卷二的分级依据是「通道类型」:纯逻辑、进程内、真实端口。卷三的分级依据是「链路风险」:同样的 L3,在卷二验证的是「消息有没有到」,在卷三验证的是「钱有没有扣对」。

这也是为什么本节把「抓不住什么」写进表格:级别只是工具,真正决定验证质量的是「知不知道自己没验证什么」。

小结

trade-lab 分三级:L1 纯计算、L2 进程内下单、L3 真实回调。分级依据不是技术复杂度,而是错误会不会造成资金或状态事实错误。

classifyTier 把判定写成代码,meetsChainMinimum 把「支付必须 L3」写成可检查的规则。七成用例留在 L1,两成放 L2,L3 只守支付与对账。

下一章进入交易端的第一条业务链路:商品与库存展示。

阅读导航:上一节:1.2 trade-lab 的第一条约束:金额与订单状态归谁所有 · 下一节:2.1 商详页的三份数据:价格、库存与时效 。

常见错误与边界

  • 把「有网络」当成 L3,实际只是 L2,支付时序从未被验证。
  • L1 函数里读 Date.now() 或 Math.random(),测试结果随运行时间变化。
  • L2 并发跑多个测试文件,假时钟互相干扰,出现假失败。
  • 支付链路只有 L2 用例,回调延迟、重复通知、验签失败全部没覆盖。
  • 所有测试都塞进 L3,反馈周期过长,最后没人跑。
  • 证据只写「测试通过」,不记录时间线,出问题无法还原。
  • 用「把超时改成 1 秒」缩短测试,改变了被测逻辑本身。
  • 把级别当成质量评级,认为 L3 一定比 L1 更可信。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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