《互联网产品的客户端架构·交易、增长与平台化》1.2 trade-lab 的第一条约束:金额与订单状态归谁所有

交易端的第一条约束不是选框架,而是给每个字段标注所有者:金额、库存、订单状态与支付结果归服务端,勾选与草稿归端上。本节把这条约束写成一个可测试的所有权模型,用 amountView 处理过期与预览、用 reconcileOrder 让服务端覆盖本地并记录差异,并复盘一次端上预演价覆盖服务端金额的事故。

先回答「谁能写」,再回答「怎么同步」

卷二用了一整章讨论状态归属:服务端状态、会话状态、瞬时 UI 状态要分开。交易端把这个问题推到极致——因为有些字段一旦被端上写错,损失是真实的钱。

所以 trade-lab 的第一个文件不是商品模型,而是所有权模型。它只回答两个问题:

  1. 这个字段归谁写?
  2. 两边都有值时,谁覆盖谁?

第一个问题决定代码结构:归服务端的字段,端上只能有一个只读副本;归端上的字段,服务端只存副本,冲突时以端上为准。第二个问题决定合并逻辑:不能「后到的赢」,也不能「本地的赢」,必须有明确的优先级。

两类字段,两套规则

把交易端会碰到的字段列出来,按所有者分类:

字段所有者端上能否写冲突时
商品价格服务端否服务端覆盖
优惠后金额服务端否服务端覆盖
实付金额服务端否服务端覆盖
库存服务端否服务端覆盖
订单状态服务端否服务端覆盖
支付结果服务端否服务端覆盖
购物车勾选端上是端上覆盖
备注草稿端上是端上覆盖
售后原因草稿端上是端上覆盖

这张表看起来简单,难的是执行。真实代码里常见三种越界:

端上算金额。 为了「减少一次请求」,端上按商品价、优惠券、运费自己算出实付金额,然后直接展示。问题不在算法,而在于端上拿不到服务端的全部规则:限购、会员折扣、渠道价、风控拦截都可能改变结果。

端上改状态。 支付回跳后,端上直接把订单标成「已支付」,再异步通知服务端。如果回调最终失败,端上就展示了一个不存在的成功。

端上清除服务端值。 请求失败时,把金额置空或回退到旧值,用户看到「0 元」或「价格加载失败」,却仍能点提交按钮。

所有权模型的作用,是把这三种越界变成类型与函数层面的错误,而不是靠代码评审去抓。

预览值不是谎言,但要标注

端上确实需要做价格预演:用户勾选优惠券、切换规格、修改数量时,实付金额应该立即变化。禁止预演会让体验变差,允许预演又不标注则会造成误导。

折中方案是给金额加一个来源标记:

  • server:服务端返回,且仍在有效期内。
  • stale:服务端返回,但已超过有效期,需要刷新。
  • preview:端上预演,尚未被服务端确认。

渲染层根据来源决定展示方式:server 正常显示;stale 显示旧值并提示「价格可能已更新」;preview 显示预估值并标注「以结算页为准」。提交按钮只在 server 状态下可用——这一条能消掉绝大多数「看到的价格和扣款不一致」。

一个可运行的所有权模块

下面的模块把上面的规则写成代码。它属于 L1:纯函数,无网络、无定时器。

// file: src/core/ownership.ts
// tier: L1
// verify: node --test test/core/ownership.test.ts
import type { Money, Order, OrderState } from "./trade-model.ts";

export type Ownership = "server" | "client";

export type Field =
  | "price"
  | "stock"
  | "orderState"
  | "paymentResult"
  | "cartSelection"
  | "draftComment";

const OWNERS: Record<Field, Ownership> = {
  price: "server",
  stock: "server",
  orderState: "server",
  paymentResult: "server",
  cartSelection: "client",
  draftComment: "client",
};

export function ownerOf(field: Field): Ownership {
  return OWNERS[field];
}

export function clientMayWrite(field: Field): boolean {
  return OWNERS[field] === "client";
}

export interface AmountView {
  cents: number;
  currency: Money["currency"];
  source: "server" | "preview" | "stale";
  provisional: boolean;
}

export interface TimedAmount {
  money: Money;
  at: number;
}

// 服务端金额永远压过端上预览;预览只在服务端金额缺席时展示,并明确标注。
export function amountView(
  server: TimedAmount | null,
  preview: Money | null,
  now: number,
  ttlMs = 30_000,
): AmountView | null {
  if (server !== null) {
    const fresh = now - server.at <= ttlMs;
    return {
      cents: server.money.cents,
      currency: server.money.currency,
      source: fresh ? "server" : "stale",
      provisional: !fresh,
    };
  }
  if (preview !== null) {
    return {
      cents: preview.cents,
      currency: preview.currency,
      source: "preview",
      provisional: true,
    };
  }
  return null;
}

const STATE_ORDER: OrderState[] = [
  "created",
  "pending_payment",
  "paid",
  "shipped",
  "completed",
  "cancelled",
  "refunding",
  "refunded",
];

// 端上订单只接收服务端状态;本地状态只能用于展示,不能反向覆盖。
export function reconcileOrder(
  local: Order,
  remote: Order,
): { order: Order; conflicts: string[] } {
  const conflicts: string[] = [];
  if (local.id !== remote.id) {
    throw new Error("订单号不一致,不能合并");
  }
  if (local.state !== remote.state) {
    conflicts.push(`state:${local.state}->${remote.state}`);
  }
  if (local.total.cents !== remote.total.cents) {
    conflicts.push(`total:${local.total.cents}->${remote.total.cents}`);
  }
  if (local.lines.length !== remote.lines.length) {
    conflicts.push(`lines:${local.lines.length}->${remote.lines.length}`);
  }
  const order: Order = {
    ...remote,
    lines: remote.lines.map((line) => ({ ...line })),
    total: { ...remote.total },
  };
  return { order, conflicts };
}

export function stateRank(state: OrderState): number {
  return STATE_ORDER.indexOf(state);
}

三个函数分别对应三种场景。

ownerOf / clientMayWrite:把归属写成查表,而不是散落在各处的 if。UI 组件可以用它决定字段是否可编辑;测试可以用它守住「金额不可写」这条线。

amountView:处理「服务端值存在但过期」这种情况。注意它不会在服务端值过期时回退到预览值——因为预览可能更旧,也可能基于过期数据算出。过期就标注过期,让上层决定是否请求刷新。

reconcileOrder:服务端覆盖本地,同时把差异记下来。差异不是为了展示给用户,而是为了排查:如果一次合并里出现了 state:pending_payment->paid,说明有一笔支付在端上不知情的情况下完成了,后续逻辑必须据此调整。

为什么合并要返回差异

很多实现把「用服务端数据覆盖本地」写成一行赋值:this.order = remote。它的问题不是覆盖,而是覆盖之后没人知道发生了什么。

差异列表至少有三个用途:

  1. 触发副作用。状态从待支付变成已支付,端上要停止倒计时、关闭支付浮层、刷新订单列表。
  2. 排查时序问题。支付回跳与轮询同时到达时,差异列表能还原「谁先谁后」。
  3. 避免静默丢失用户操作。如果本地刚提交了取消,而服务端返回的仍是待支付,差异会暴露这个冲突。

差异的格式不需要复杂,字段:旧值->新值 足够。关键是它必须被消费,而不是记录后丢掉。

测试与真实输出

本机运行结果如下:

$ node --test test/core/ownership.test.ts
✔ 金额、库存与订单状态归服务端 (0.508458ms)
✔ 勾选与草稿归端上 (0.07025ms)
✔ 服务端金额优先于预览 (0.087417ms)
✔ 过期的服务端金额标注 stale 而不是回退预览 (0.063834ms)
✔ 没有服务端金额时才展示预览并标记 provisional (0.055209ms)
✔ 两边都没有金额时返回 null (0.046416ms)
✔ 合并订单时服务端覆盖本地并列出差异 (0.841209ms)
✔ 订单号不同时拒绝合并 (0.243042ms)
✔ 状态次序可用于比较进度 (0.119583ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 132.681541

九条用例覆盖:归属查表、端上可写范围、服务端优先、过期标注、预览标注、空值、合并差异、订单号校验、状态次序。

一次端上预演价覆盖服务端金额的事故

「回声」的结算页支持选用优惠券。端上为了即时反馈,按「商品价 − 券面额」算出实付金额,并直接写进订单对象。

上线后收到一类投诉:用了「满 100 减 20」的券,页面显示实付 80 元,点支付却扣了 109 元。排查发现,该商品参加了会员价活动,服务端的实付是「会员价 129 − 券 20 = 109」。端上不知道会员价,用原价 100 减 20 得到 80。

根因有两层:

  1. 端上把预演值写进了订单对象,而不是只写进展示层。
  2. 提交前没有用服务端金额覆盖,服务端按自己的计算收款,端上却一直显示预演值。

修复对应两点:预演值单独存为 preview,订单对象的金额只能由服务端写入;提交前重新拉取一次结算信息,若与当前展示不一致,弹出确认。修复后,端上再也不能「自己算出一个价格然后当真」。

第二个事故更隐蔽。一次网络抖动导致结算请求超时,端上把金额回退成上一次的值,但库存与优惠券状态没有一起回退。用户看到的是一组互相矛盾的数:金额是旧的,券显示已使用,库存显示充足。提交后被服务端拒绝,提示「优惠券不可用」。

这说明金额、库存、优惠是同一份结算快照的三个字段,必须一起更新或一起标记过期,不能各自回退。后面的 3.2 会把「价格明细」做成一个不可分割的视图对象。

端上仍然要做的三件事

所有权归服务端,不等于端上什么都不做。端上仍然负责:

  • 格式化:把分转成元、按地区显示货币符号、处理千分位。
  • 预演:在用户调整数量、勾选优惠时给出即时反馈,但标记为预览。
  • 一致性展示:当金额、库存、优惠来自不同请求时,用一个版本号把它们绑定成同一份快照。

前两件是体验,第三件是正确性。很多「价格不对」的工单,根因不是计算错,而是三份数据来自不同时刻。

与内容状态的差别

卷二的点赞也讲所有权:本地先加一,服务端为准。交易端的规则更严格,差别在三点:

  1. 内容可以容错,交易不能。 点赞数多一个没人追责,金额多一元要退款。
  2. 内容可以最终一致,交易要先一致再展示。 订单状态必须先确认再渲染「已支付」。
  3. 内容可以重试,交易要幂等。 重试点赞最多多一个赞,重试下单会多一张订单。

这三点会在第 4 章(幂等提交)、第 5 章(支付状态)、第 6 章(重复支付)反复出现。

小结

交易端的第一条约束是所有权:金额、库存、订单状态、支付结果归服务端;勾选、草稿归端上。预览值可以存在,但必须标注来源,并且不能写进订单对象。

amountView 处理过期与预览,reconcileOrder 让服务端覆盖本地并记录差异。两者都很小,但它们把「谁能写」从口头约定变成了可测试的代码。

下一节把这条约束落到验证上:哪些逻辑可以用纯函数验证,哪些必须起进程内服务端,哪些必须接真实回调。

阅读导航:上一节:1.1 交易端的四条链路:浏览、下单、支付、售后 · 下一节:1.3 lab 的三条验证线:纯计算、进程内下单、真实回调 。

常见错误与边界

  • 端上计算实付金额并写进订单对象,提交时与服务端结果不一致。
  • 服务端金额过期后回退到预览值,用户看到的是更旧的数据。
  • 合并订单时直接赋值,不记录状态差异,副作用全部丢失。
  • 金额、库存、优惠分别回退,形成互相矛盾的结算快照。
  • 把「端上可写」理解成「端上优先」,勾选状态被服务端旧值覆盖。
  • 用 stateRank 判断「谁更靠后所以更可信」,忽略取消与退款分支。
  • 预览值不标注,用户以为看到的就是最终价格。
  • 订单号不一致时仍然合并,把两张订单的明细拼在一起。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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