《互联网产品的客户端架构·交易、增长与平台化》1.1 交易端的四条链路:浏览、下单、支付、售后

交易端不是「商品页加一个购买按钮」,而是浏览、下单、支付、售后四条失败边界完全不同的链路。本节逐条说明每条链路需要哪些数据、哪些字段必须由服务端裁决、失败时用户能退到哪里,并给出一个把四条链路的依赖写成常量与校验函数的 TypeScript 模型,作为 trade-lab 的起点。

交易端为什么不能当成一个页面

卷二的客户端围绕「内容怎么流动」展开:信息流可以重复、可以晚到、可以降级成静态列表,用户最多觉得「不太新」。交易端不一样。价格错了是资金损失,库存错了是超卖,支付结果判错是重复扣款,售后状态错了是投诉与监管风险。

于是同样一套客户端分层,在交易场景里会被四条完全不同的失败边界切开:

  • 浏览:可以缓存、可以预览、可以过期,错了顶多显示旧价格,下单前必须重新确认。
  • 下单:一旦提交就产生服务端事实,重复提交会产生两张订单,必须幂等。
  • 支付:涉及外部渠道,结果可能长时间「不确定」,端上不能自行猜测成功或失败。
  • 售后:事实已经发生,端上做的是证据整理与状态跟踪,不能修改历史。

这四条链路共享商品、订单、用户这些实体,却不共享失败策略。把它们塞进一个「交易模块」,最常见的后果是:浏览页的缓存价格被复用到了结算页,或者支付轮询的超时把整个订单页打成错误态。

本节先不写具体实现,只做一件更基础的事:把四条链路各自需要什么、归谁所有、失败时退到哪里写清楚。这些结论会成为后面十二章的判断依据。

四条链路的数据需求

先看每条链路在端上真正消费什么。注意这里列的是「渲染所必需」,不是「接口返回了什么」。

链路必需数据可以过期失败时用户能做什么
浏览价格、库存、规格、时效可以,需标注继续看,下单前重算
下单收货信息、优惠明细、实付金额、幂等键不可以停在确认页,不产生订单
支付渠道、订单号、支付单号、结果确认不可以查询结果,不能重复支付
售后订单快照、退款金额、原因、证据不可以提交申请,等待处理

这张表有两个容易忽略的点。

第一,「可以过期」不等于「可以随便显示」。商详页展示 30 分钟前的价格是合理的,但必须让用户知道这是参考价,并且在进入结算时强制刷新。把过期数据静默当成实时数据,是交易端最典型的欺骗。

第二,「失败时用户能做什么」决定了 UI 结构。支付失败不是弹一个「请重试」就完事:用户需要知道这笔钱到底扣没扣。端上必须提供「查询支付结果」的入口,而不是只提供「重新支付」。后者会把「不确定」变成「重复支付」。

状态归属:哪些字段端上永远不能写

交易端最常见的架构错误,是让端上「先改一下,回头再同步」。在内容场景里这很合理,点赞可以先加一;在交易场景里,同样的做法会把服务端事实改坏。

把字段按归属分两类:

服务端所有(端上只读)

  • 商品价格、优惠后金额、实付金额。
  • 库存与可售状态。
  • 订单状态、支付状态、退款状态。
  • 支付单号、交易流水号。

端上所有(服务端只存副本)

  • 购物车勾选、排序、本地备注。
  • 结算页的临时收货信息草稿。
  • 售后申请的原因文本与图片选择(提交前)。

这条边界不是「谁更权威」的抽象讨论,而是可执行的规则:任何会影响金额或订单事实的字段,端上只能展示服务端给的值;端上算出来的值必须标记为「预估」,并且在提交前被服务端值替换。

后面的 1.2 会把这条规则写成一个可测试的所有权模型。这里先记住结论:端上可以做价格预演,但不能用预演结果下单。

浏览链路:先让用户看懂,再让他买对

浏览链路的目标不是「展示商品」,而是「让用户在下单前形成正确预期」。它包含三件事:

  1. 价格:当前价、划线价、会员价、活动价。多份价格并存时,端上要明确哪一份是「即将支付的」。
  2. 库存:可售、紧张、售罄。可售不等于有货,有货不等于能发到你所在地区。
  3. 时效:预计送达、发货地、预售时间。时效是交易体验的一部分,不是物流页的附属信息。

浏览链路允许缓存,原因不是「不重要」,而是「错了可以纠正」:用户进入结算时会重新拉一次价格与库存。真正危险的是把浏览页的缓存直接带进结算页,跳过重新确认。

一个可执行的判断:从商品页点「立即购买」时,端上必须发起一次新的价格/库存确认请求,而不是复用列表里的对象。 这条规则会让结算页多一次请求,但能消掉绝大多数「下单后价格变了」的争议。

下单链路:提交是一次不可逆的承诺

下单是四条链路里唯一「点一下就可能产生事实」的动作。它需要的不是更多数据,而是更强的约束:

  • 幂等键:每次进入确认页生成一个提交 id,重试复用同一个 id。服务端据此判断「这是同一笔提交」还是「用户真的想下两单」。
  • 金额确认:提交前用服务端返回的实付金额覆盖端上预演值,用户看到的数字必须就是提交的数字。
  • 可取消的等待:提交请求超时不等于下单失败。端上要能查询「这个幂等键对应的订单是否存在」,而不是直接让用户重试。

很多「重复下单」不是用户手抖,而是端上在超时后把「未知」当成了「失败」,引导用户再点一次。第 4 章会展开这套提交状态机。

支付链路:把「不确定」当成一等状态

支付是四条链路里唯一涉及外部系统的。外部系统意味着端上无法通过「请求成功/失败」判断事实,只能通过查询确认。

支付结果至少有四种,而不是两种:

结果含义端上动作
成功渠道确认扣款展示成功,进入订单
失败渠道明确拒绝展示失败,允许重试
待支付用户未完成支付保留订单,展示倒计时
不确定超时、断网、回跳丢失查询,不重试支付

第四种是架构上的关键。端上必须有一个「支付结果查询」的能力,并且它要独立于「发起支付」。把两者混在一个按钮里,用户会在不确定时重复付款。

第 5 章会把这条链路拆成拉起、轮询、回跳三个动作,并给出一个显式的支付状态机。

售后链路:只整理证据,不改写历史

售后链路的端上职责与前三条不同:它不产生新的交易事实,只负责把用户的诉求与证据完整地送到服务端,并把处理进度如实展示。

三个约束:

  • 订单快照不可变:售后页展示的订单信息必须来自下单时的快照,而不是当前商品页。商品可能已经改价、下架。
  • 金额口径单一:退款金额、运费、优惠分摊要使用服务端返回的明细,端上不做二次计算。
  • 证据先本地留存:图片、描述在提交前保存在本地,提交失败不丢;提交成功后也不允许端上修改。

第 6 章与第 11 章会分别展开退款状态与售后证据。

用一个模型把四条链路固定下来

下面的模块是 trade-lab 的第一个文件,属于 L1:只有类型、常量与纯函数,没有网络、没有定时器。它做三件事:声明每条链路需要什么、给出订单状态的合法迁移、把金额运算限制在「分」上。

// file: src/core/trade-model.ts
// tier: L1
// verify: node --test test/core/trade-model.test.ts
export type Chain = "browse" | "order" | "pay" | "aftersale";

export type Currency = "CNY";

export interface Money {
  cents: number;
  currency: Currency;
}

export type OrderState =
  | "created"
  | "pending_payment"
  | "paid"
  | "shipped"
  | "completed"
  | "cancelled"
  | "refunding"
  | "refunded";

export interface Sku {
  id: string;
  title: string;
  price: Money;
  stock: number;
  maxPerOrder: number;
}

export interface OrderLine {
  skuId: string;
  title: string;
  unitPrice: Money;
  quantity: number;
}

export interface Order {
  id: string;
  state: OrderState;
  lines: OrderLine[];
  total: Money;
  createdAt: number;
}

const CHAIN_NEEDS: Record<Chain, string[]> = {
  browse: ["价格", "库存", "规格", "时效"],
  order: ["收货信息", "优惠明细", "实付金额", "幂等键"],
  pay: ["渠道", "订单号", "支付单号", "结果确认"],
  aftersale: ["订单快照", "退款金额", "原因", "证据"],
};

const VISIBLE_STATES: Record<OrderState, string> = {
  created: "已创建",
  pending_payment: "待支付",
  paid: "已支付",
  shipped: "已发货",
  completed: "已完成",
  cancelled: "已取消",
  refunding: "退款中",
  refunded: "已退款",
};

// 状态机只允许这些迁移,端上用它判断按钮是否可用。
const TRANSITIONS: Record<OrderState, OrderState[]> = {
  created: ["pending_payment", "cancelled"],
  pending_payment: ["paid", "cancelled"],
  paid: ["shipped", "refunding"],
  shipped: ["completed", "refunding"],
  completed: ["refunding"],
  cancelled: [],
  refunding: ["refunded", "paid"],
  refunded: [],
};

export function chainNeeds(chain: Chain): string[] {
  return [...CHAIN_NEEDS[chain]];
}

export function visibleState(state: OrderState): string {
  return VISIBLE_STATES[state];
}

export function canTransition(from: OrderState, to: OrderState): boolean {
  return TRANSITIONS[from].includes(to);
}

export function yuan(cents: number): string {
  if (!Number.isSafeInteger(cents)) {
    throw new Error("金额必须是安全整数分");
  }
  const sign = cents < 0 ? "-" : "";
  const abs = Math.abs(cents);
  const integer = Math.floor(abs / 100);
  const fraction = String(abs % 100).padStart(2, "0");
  return `${sign}${integer}.${fraction}`;
}

export function sumCents(lines: OrderLine[]): number {
  let total = 0;
  for (const line of lines) {
    if (!Number.isSafeInteger(line.unitPrice.cents) || line.unitPrice.cents < 0) {
      throw new Error(`单价非法:${line.skuId}`);
    }
    if (!Number.isInteger(line.quantity) || line.quantity < 1) {
      throw new Error(`数量非法:${line.skuId}`);
    }
    const lineTotal = line.unitPrice.cents * line.quantity;
    if (!Number.isSafeInteger(lineTotal)) {
      throw new Error(`行金额溢出:${line.skuId}`);
    }
    total += lineTotal;
    if (!Number.isSafeInteger(total)) {
      throw new Error("订单金额溢出");
    }
  }
  return total;
}

export function lineFromSku(sku: Sku, quantity: number): OrderLine {
  if (quantity < 1 || quantity > sku.maxPerOrder) {
    throw new Error(`超出限购:${sku.id}`);
  }
  return {
    skuId: sku.id,
    title: sku.title,
    unitPrice: { ...sku.price },
    quantity,
  };
}

export function orderTotal(order: Order): Money {
  const cents = sumCents(order.lines);
  if (cents !== order.total.cents) {
    throw new Error("订单总额与明细不一致");
  }
  return { ...order.total };
}

三个决定值得单独解释。

金额用整数分。 浮点数在累加时会出现 0.1 + 0.2 = 0.30000000000000004,在展示前四舍五入看起来正常,但一旦参与优惠分摊与退款计算,误差会被放大。统一用「分」存储,只在渲染时转成元,是交易端成本最低的正确做法。

状态机是白名单。 TRANSITIONS 列出每个状态允许去的下一站,canTransition 只回答「是否允许」。这比在 UI 里写「如果状态是待支付或已支付就显示退款按钮」更可靠:新增状态时,编译器会强制你补全映射。

订单总额要和明细对账。 orderTotal 不是简单地返回 order.total,而是先重算明细再比较。服务端返回的总额与明细不一致时,端上宁可报错,也不能把两个不一致的数字都渲染出来。

测试与真实输出

本机运行结果如下:

$ node --test test/core/trade-model.test.ts
✔ 金额以分存储并格式化 (0.50875ms)
✔ 金额拒绝非安全整数 (0.230625ms)
✔ 每条链路声明自己需要的数据 (0.39075ms)
✔ 状态机只允许合法迁移 (0.069167ms)
✔ 状态有稳定的中文展示 (0.058167ms)
✔ 行金额按单价乘数量求和 (0.429375ms)
✔ 求和拒绝非法单价与数量 (0.145375ms)
✔ 从 SKU 生成订单行并校验限购 (0.094417ms)
✔ 订单总额与明细不一致时拒绝渲染 (0.116959ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 125.467667

九条用例分别盯住:金额格式化、非法金额、链路依赖、状态迁移、状态展示、行金额、非法明细、限购、总额对账。它们不验证服务端是否真的按这个状态机运行,那属于契约测试的范围。

一次「浏览价带进结算」的事故

「回声」早期的商详页与结算页共用同一个商品对象。商品对象来自列表接口,带 10 分钟缓存。

一次大促前,运营在 10:00 调整了某款商品的价格,从 129 元降到 99 元。列表缓存尚未过期,商详页显示 99 元;用户点「立即购买」后,结算页直接复用了这个对象,仍然显示 99 元。提交时服务端按当前价 129 元生成订单,用户看到的实付金额在最后一刻跳成 129 元。投诉集中在一句话上:「你们页面写 99,扣我 129。」

复盘发现两个问题叠加:

  1. 结算页没有重新确认价格,直接信任了列表对象。
  2. 提交前的金额覆盖是静默的,用户没有看到「价格已更新」的提示。

修复做了三件事:结算页进入时强制拉取价格与库存;价格发生变化时展示对比并让用户确认;提交时把实付金额与幂等键一起锁进请求,服务端以此为准。上线后同类投诉归零。

这个事故的教训不是「缓存不好」,而是缓存可以用于浏览,不能用于承诺。交易端的每一次承诺(提交、支付、退款)都必须建立在一次新鲜的服务端确认上。

四条链路什么时候可以合并

四条链路共享实体,但不要共享状态容器。可以合并的是:

  • 商品基础信息(标题、图片)——浏览与售后都需要,可缓存。
  • 用户收货地址——下单与售后都需要,端上可编辑草稿。
  • 订单号——下单、支付、售后都用它做关联键。

不能合并的是:

  • 价格:浏览用参考价,下单用确认价,退款用快照价。
  • 状态:订单状态、支付状态、退款状态是三套独立的状态机,不能压成一个 status 字段。
  • 错误处理:浏览失败可以降级展示,支付失败必须查询,售后失败必须保留证据。

把「能合并的」与「不能合并的」分开,是后面十二章反复出现的结构。

小结

交易端由四条失败边界不同的链路组成:浏览可以缓存但要重新确认,下单是幂等承诺,支付必须把「不确定」当成一等状态,售后只整理证据不改写历史。

本节给出的模型只做三件事:声明链路依赖、约束状态迁移、把金额锁在整数分上。它很小,但确定了整卷的基调:交易端的每个值都要能回答「谁写的、能不能改、错了退到哪里」。

下一节讨论第二件事:当服务端与端上同时持有金额和订单状态时,谁覆盖谁。

阅读导航:上一节:目录 · 下一节:1.2 trade-lab 的第一条约束:金额与订单状态归谁所有 。

常见错误与边界

  • 把交易端当成一个大页面,四条链路共用一个 loading 与一个错误提示。
  • 用浮点数存金额,在优惠分摊与退款时出现分级误差。
  • 浏览页的缓存对象直接进入结算页,跳过价格与库存确认。
  • 只给订单一个 status 字段,把支付状态与退款状态压进去。
  • 支付超时后直接提示「支付失败」,引导用户重复付款。
  • 售后页读取当前商品信息,而不是下单快照。
  • 状态机用「不在黑名单里就允许」实现,新增状态时静默放行。
  • 端上重算总额后直接展示,不与服务端返回的总额对账。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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