《互联网产品的客户端架构·交易、增长与平台化》13.1 端到端:选品、下单、支付、退款

前十二章分别验证了金额、库存、幂等、支付与退款,但真实事故往往发生在模块交界处。本节用五个窄端口把选品、加购、下单、支付、退款串成一条可断言主线,假实现放在测试侧,断言金额一致、只有一笔订单、未确认支付不退款,并复盘一次「每段都绿、整条链路金额对不上」的问题。

为什么单模块全绿,整条链路仍会错

前十二章的测试各自守住了边界:trade-model 守住整数分,ownership 守住字段归属,price-breakdown 守住同一份明细,submit 守住幂等键,支付与退款模块各自守住状态迁移。单独看,每一组用例都能通过。

但交易事故很少发生在某个模块内部。它更常出现在模块交接的那一行:购物车算出的金额传给下单时换了口径,下单返回的订单号传给支付时为空,支付成功后调用退款时用了支付前的订单快照。单模块测试不经过这些交接点,所以它们全绿,链路却错了。

端到端场景的作用不是「再测一遍」,而是把交接点变成断言。它只回答四个问题:

  1. 用户看到的金额、提交的金额、订单的金额、支付的金额、退款的金额,是不是同一个数?
  2. 一次点击最终只产生一笔订单吗?
  3. 支付结果不确定时,系统是去查询,还是直接退款或再付一次?
  4. 失败停在哪一步,后面哪些动作绝对没有发生?

这四个问题无法由任何单个模块回答。

场景边界:一条主线,五个阶段

场景只保留一条最短主线,不覆盖所有分支。分支由前十二章的分模块测试负责,端到端负责「串起来仍然成立」。

阶段输入关键断言失败后停在哪
浏览SKU、数量、地区可售、未超限购浏览
加购单价、数量、优惠、运费购物车小计与明细一致购物车
下单幂等键、实付金额只产生一笔订单下单
支付订单号、金额金额一致;未知先查询支付
退款支付单号、退款金额不超过实付;未支付不退款退款

这张表里最重要的不是「做什么」,而是最后一列「失败后停在哪」。很多实现失败后仍然继续往下走:下单被拒绝,页面却已经拉起支付;支付结果未知,代码却进入退款分支。端到端场景通过「提前返回」把这条线钉死。

另一个边界是不接真实渠道。真实签名、真实网络延迟、真实回调丢包属于 L3,本节保持 L2:进程内假服务端加假时钟。这样才能在毫秒级反复跑,而不是每次等几十秒回调。

关键决策:真模块、窄端口、假实现

写端到端最容易走两个极端:全部用真模块,测试变成集成环境;全部用假对象,测试退化成自说自话。本节的分工是:

  • 真模块:购物车汇总与价格明细直接调用第 3 章的 summarize 与 buildBreakdown,确保金额口径不是场景自己编的。
  • 窄端口:商品、下单、支付、退款只暴露场景真正需要的五个方法,不把内部状态机类型泄漏进来。
  • 假实现放测试侧:源文件只保留端口与编排,内存世界写在测试文件里。支付与对账模块仍在演进,这样替换真实实现时不用改场景代码。

端口设计有三条约束:

  1. 每个方法都带时间参数或由时钟注入,逻辑里没有 Date.now()。
  2. 提交与退款都接受幂等键,场景不假设「只点一次」。
  3. 查询是独立方法,不能把「发起支付」和「查询结果」合成一个调用。

第三条尤其重要。把两者合成一个 payAndConfirm,场景就无法表达「支付超时后只查询、不再扣款」这个关键分支。

场景代码

源文件只做编排:按顺序调用端口,记录轨迹,检查金额与订单数,任何一步失败就提前返回。

// file: src/summary/e2e-scenario.ts
// tier: L2
// verify: node --test test/summary/e2e-scenario.test.ts
import { summarize } from "../cart/selection.ts";
import { buildBreakdown } from "../cart/price-breakdown.ts";

export type E2eStage = "browse" | "cart" | "submit" | "pay" | "refund";

export interface TraceEntry { stage: E2eStage; at: number; action: string }

export interface Quote {
  skuId: string; unitCents: number; available: number; maxPerOrder: number;
}

export interface SubmitCommand {
  key: string; skuId: string; quantity: number; payableCents: number;
}

export type SubmitReceipt =
  | { status: "accepted"; orderId: string; payableCents: number }
  | { status: "unknown"; queryKey: string }
  | { status: "rejected"; reason: string };

export type PayReceipt =
  | { status: "succeeded"; payId: string }
  | { status: "failed"; reason: string }
  | { status: "unknown"; payId: string }
  | { status: "rejected"; reason: string };

export type PayQuery =
  | { status: "succeeded"; payId: string }
  | { status: "failed"; reason: string }
  | { status: "pending" };

export type RefundReceipt =
  | { status: "accepted"; refundId: string; amountCents: number }
  | { status: "rejected"; reason: string };

// 场景只依赖五个窄接口;支付与对账实现可以随时替换。
export interface TradePorts {
  quote(skuId: string, quantity: number, now: number): Quote | null;
  submit(command: SubmitCommand, now: number): SubmitReceipt;
  findOrder(key: string): { orderId: string; payableCents: number } | null;
  pay(orderId: string, amountCents: number, now: number): PayReceipt;
  queryPay(payId: string): PayQuery;
  refund(orderId: string, payId: string, amountCents: number, now: number): RefundReceipt;
  refundedCents(orderId: string): number;
  stats(): { orders: number; payAttempts: number; refunds: number };
}

export interface ScenarioClock { now(): number; advance(ms: number): number }

export interface ScenarioInput {
  skuId: string; quantity: number; submitKey: string; now: number;
  discountCents?: number; shippingCents?: number;
}

export type ScenarioOutcome = "refunded" | "paid" | "payment_failed" | "blocked";

export interface ScenarioResult {
  ok: boolean; outcome: ScenarioOutcome; payableCents: number | null;
  orderId: string | null; payId: string | null; refundId: string | null;
  refundedCents: number; trace: TraceEntry[]; problems: string[];
}

function note(
  rows: TraceEntry[], stage: E2eStage, at: number, action: string,
): void {
  rows.push({ stage, at, action });
}

// 一条主线:选品 → 加购 → 下单 → 支付 → 退款。每一步只做一次,失败立即停。
export function runScenario(
  ports: TradePorts,
  input: ScenarioInput,
  clock: ScenarioClock,
  refundCents?: number,
): ScenarioResult {
  const rows: TraceEntry[] = [];
  const problems: string[] = [];
  const now = (): number => clock.now();
  const quote = ports.quote(input.skuId, input.quantity, now());
  note(rows, "browse", now(), "quote");
  if (quote === null) {
    problems.push("商品不可售或数量超出限购");
    return finish("blocked", null, null, null, null, rows, problems, ports);
  }
  if (quote.available < input.quantity) {
    problems.push("可售库存不足");
    return finish("blocked", null, null, null, null, rows, problems, ports);
  }

  const cart = summarize([{
    skuId: quote.skuId, unitCents: quote.unitCents, quantity: input.quantity,
    maxQuantity: quote.maxPerOrder, status: "active", selected: true,
  }]);
  const breakdown = buildBreakdown({
    version: 1,
    lines: [{ skuId: quote.skuId, unitCents: quote.unitCents, quantity: input.quantity }],
    discountCents: input.discountCents ?? 0,
    shippingCents: input.shippingCents ?? 0,
    taxCents: 0,
    source: "server",
  });
  note(rows, "cart", now(), "summarize");
  if (breakdown.payableCents !==
    cart.selectedSubtotalCents + (input.shippingCents ?? 0) -
    (input.discountCents ?? 0)) {
    problems.push("购物车小计与价格明细不一致");
  }

  const receipt = ports.submit({
    key: input.submitKey, skuId: input.skuId, quantity: input.quantity,
    payableCents: breakdown.payableCents,
  }, now());
  note(rows, "submit", now(), `submit:${receipt.status}`);
  if (receipt.status === "rejected") {
    problems.push(`下单被拒绝:${receipt.reason}`);
    return finish("blocked", breakdown.payableCents, null, null, null, rows, problems, ports);
  }
  let orderId: string;
  let payableCents: number;
  if (receipt.status === "accepted") {
    orderId = receipt.orderId;
    payableCents = receipt.payableCents;
  } else {
    const found = ports.findOrder(receipt.queryKey);
    note(rows, "submit", now(), "query_order");
    if (found === null) {
      problems.push("提交结果未知且查询不到订单");
      return finish("blocked", breakdown.payableCents, null, null, null, rows, problems, ports);
    }
    orderId = found.orderId;
    payableCents = found.payableCents;
  }
  if (payableCents !== breakdown.payableCents) {
    problems.push("订单实付与结算明细不一致");
  }

  const paid = ports.pay(orderId, payableCents, now());
  note(rows, "pay", now(), `pay:${paid.status}`);
  let payId: string;
  if (paid.status === "succeeded") {
    payId = paid.payId;
  } else if (paid.status === "failed") {
    return finish("payment_failed", payableCents, orderId, null, null, rows, problems, ports);
  } else if (paid.status === "rejected") {
    problems.push(`支付被拒绝:${paid.reason}`);
    return finish("blocked", payableCents, orderId, null, null, rows, problems, ports);
  } else {
    payId = paid.payId;
    let resolved: PayQuery | null = null;
    for (let i = 0; i < 3; i += 1) {
      clock.advance(1_000);
      resolved = ports.queryPay(payId);
      note(rows, "pay", now(), `query:${resolved.status}`);
      if (resolved.status !== "pending") break;
    }
    if (resolved === null || resolved.status === "pending") {
      problems.push("支付结果三次查询仍未确认");
      return finish("payment_failed", payableCents, orderId, payId, null, rows, problems, ports);
    }
    if (resolved.status === "failed") {
      return finish("payment_failed", payableCents, orderId, payId, null, rows, problems, ports);
    }
  }

  const amount = refundCents ?? payableCents;
  if (amount <= 0 || amount > payableCents) {
    problems.push("退款金额不在可退范围内");
    return finish("paid", payableCents, orderId, payId, null, rows, problems, ports);
  }
  const refund = ports.refund(orderId, payId, amount, now());
  note(rows, "refund", now(), `refund:${refund.status}`);
  if (refund.status === "rejected") {
    problems.push(`退款被拒绝:${refund.reason}`);
    return finish("paid", payableCents, orderId, payId, null, rows, problems, ports);
  }
  return finish("refunded", payableCents, orderId, payId, refund.refundId, rows, problems, ports);
}

function finish(
  outcome: ScenarioOutcome,
  payableCents: number | null,
  orderId: string | null,
  payId: string | null,
  refundId: string | null,
  rows: TraceEntry[],
  problems: string[],
  ports: TradePorts,
): ScenarioResult {
  const stats = ports.stats();
  if (stats.orders > 1) problems.push(`产生了 ${stats.orders} 笔订单`);
  const refunded = orderId === null ? 0 : ports.refundedCents(orderId);
  if (refunded > 0 && (payId === null || outcome === "payment_failed")) {
    problems.push("未确认支付却发生了退款");
  }
  return {
    ok: problems.length === 0, outcome, payableCents, orderId, payId, refundId,
    refundedCents: refunded, trace: rows, problems,
  };
}

export function stageOrder(result: ScenarioResult): E2eStage[] {
  return result.trace.map((entry) => entry.stage);
}

代码里有三个值得单独说明的决定。

金额从明细来,不从商品页来。 场景先调用 buildBreakdown,再把 payableCents 传给下单与支付。这样任何一个环节偷偷换了金额,后面的断言都会失败。

未知不是失败。 支付返回 unknown 时,场景最多查询三次;三次都 pending 才记为问题。它不会在未知时调用退款,也不会再调用一次 pay。

完成函数统一做收口断言。 finish 检查订单数是否大于一、是否在未确认支付时发生退款。所有提前返回都经过它,避免某个分支漏掉检查。

测试与真实输出

测试侧的 MemoryWorld 实现 TradePorts,用脚本控制支付结果是成功、失败还是「先未知、查询后成功」。真实运行结果如下:

$ node --test --test-concurrency=1 test/summary/e2e-scenario.test.ts
✔ 选品到退款的完整主线全部成功 (1.05ms)
✔ 场景按浏览、购物车、提交、支付、退款推进 (0.438125ms)
✔ 服务端无该商品时停在浏览阶段 (0.112792ms)
✔ 购买数量超过限购时不下单 (0.063042ms)
✔ 可售库存不足时停在浏览阶段 (0.070208ms)
✔ 同一个幂等键重复提交只产生一笔订单 (0.101375ms)
✔ 不同幂等键会各自产生订单,场景能识别 (0.103666ms)
✔ 提交结果未知时通过查询键找回同一张订单 (0.093417ms)
✔ 支付超时后查询成功再退款 (0.175083ms)
✔ 支付超时后查询失败则不退款 (0.136333ms)
✔ 渠道明确失败时不进入退款阶段 (0.088542ms)
✔ 退款金额超过实付时被拒绝 (0.072ms)
✔ 退款金额必须为正 (0.058375ms)
✔ 部分退款只记录实际退款金额 (0.051542ms)
ℹ tests 14
ℹ suites 0
ℹ pass 14
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 179.81375

十四条用例覆盖三组:正常主线与阶段顺序、浏览与下单的前置失败、支付与退款的异常分支。它们不验证真实渠道,只验证编排在给定端口行为下是否守住四个问题。

一次「每段都绿、整条链路金额对不上」的复盘

某交易客户端在改版时把结算页拆成了两个团队维护:购物车团队负责小计,下单团队负责实付。两边都有自己的单元测试,覆盖率都很高。

上线后发现一类投诉:用户用了「满 200 减 30」的券,购物车显示应付 170 元,订单详情却是 200 元。复盘发现,购物车团队的小计包含了优惠,下单团队拿到的却是优惠前的金额;两边各自测试都通过,因为没有人测「购物车输出直接喂给下单」这一跳。

修复不是改某一个公式,而是补了一条端到端用例:从加购开始,经过 buildBreakdown,把 payableCents 原样传给下单,再断言订单金额等于明细金额。用例第一次运行就失败,暴露出两个团队对「小计」的定义不同。

第二个问题更隐蔽:支付超时后,编排代码把 unknown 当成了 failed,直接进入退款分支。退款接口因为订单未支付而拒绝,用户看到的是「退款失败」,实际上根本没有扣款。端到端用例补了「未知 → 查询 → 失败」的分支,明确断言退款次数为零。

这两个问题的共同点是:单模块测试验证的是函数,端到端验证的是承诺。承诺在交接处,交接处没有测试,覆盖率再高也守不住。

小结

端到端场景用五个窄端口把选品、加购、下单、支付、退款串成一条主线。它依赖真实的购物车与价格明细,把假实现留在测试侧,用假时钟推进查询。

四条断言构成收口:金额全程一致、只产生一笔订单、未知支付先查询、未确认支付不退款。它们比「每个模块都有测试」更接近用户真正经历的那条路径。

下一节把这条主线拆开,主动注入三类故障:重复提交、支付超时、回调乱序。

阅读导航:上一节:12.3 漏斗与归因的端上边界 · 下一节:13.2 故障注入:重复提交、支付超时、回调乱序 。

常见错误与边界

  • 端到端场景直接复用商品页对象,跳过结算时的价格与库存确认。
  • 把「发起支付」和「查询结果」合成一个方法,无法表达超时后的正确动作。
  • 支付返回未知时按失败处理,提前进入退款分支。
  • 场景只断言最终状态,不记录阶段轨迹,失败后无法定位停在哪一步。
  • 假实现放在源文件里,替换真实服务端时被迫修改被测代码。
  • 端到端测试依赖真实时钟,跑一次要等完整的支付超时时间。
  • 只测正常主线,不测浏览、下单、支付的前置失败,异常分支全部留给线上。
  • 用端到端测试替代分模块测试,反馈周期变长,边界条件反而覆盖不足。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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