先回答「谁能写」,再回答「怎么同步」
卷二用了一整章讨论状态归属:服务端状态、会话状态、瞬时 UI 状态要分开。交易端把这个问题推到极致——因为有些字段一旦被端上写错,损失是真实的钱。
所以 trade-lab 的第一个文件不是商品模型,而是所有权模型。它只回答两个问题:
- 这个字段归谁写?
- 两边都有值时,谁覆盖谁?
第一个问题决定代码结构:归服务端的字段,端上只能有一个只读副本;归端上的字段,服务端只存副本,冲突时以端上为准。第二个问题决定合并逻辑:不能「后到的赢」,也不能「本地的赢」,必须有明确的优先级。
两类字段,两套规则
把交易端会碰到的字段列出来,按所有者分类:
| 字段 | 所有者 | 端上能否写 | 冲突时 |
|---|---|---|---|
| 商品价格 | 服务端 | 否 | 服务端覆盖 |
| 优惠后金额 | 服务端 | 否 | 服务端覆盖 |
| 实付金额 | 服务端 | 否 | 服务端覆盖 |
| 库存 | 服务端 | 否 | 服务端覆盖 |
| 订单状态 | 服务端 | 否 | 服务端覆盖 |
| 支付结果 | 服务端 | 否 | 服务端覆盖 |
| 购物车勾选 | 端上 | 是 | 端上覆盖 |
| 备注草稿 | 端上 | 是 | 端上覆盖 |
| 售后原因草稿 | 端上 | 是 | 端上覆盖 |
这张表看起来简单,难的是执行。真实代码里常见三种越界:
端上算金额。 为了「减少一次请求」,端上按商品价、优惠券、运费自己算出实付金额,然后直接展示。问题不在算法,而在于端上拿不到服务端的全部规则:限购、会员折扣、渠道价、风控拦截都可能改变结果。
端上改状态。 支付回跳后,端上直接把订单标成「已支付」,再异步通知服务端。如果回调最终失败,端上就展示了一个不存在的成功。
端上清除服务端值。 请求失败时,把金额置空或回退到旧值,用户看到「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。它的问题不是覆盖,而是覆盖之后没人知道发生了什么。
差异列表至少有三个用途:
- 触发副作用。状态从待支付变成已支付,端上要停止倒计时、关闭支付浮层、刷新订单列表。
- 排查时序问题。支付回跳与轮询同时到达时,差异列表能还原「谁先谁后」。
- 避免静默丢失用户操作。如果本地刚提交了取消,而服务端返回的仍是待支付,差异会暴露这个冲突。
差异的格式不需要复杂,字段:旧值->新值 足够。关键是它必须被消费,而不是记录后丢掉。
测试与真实输出
本机运行结果如下:
$ 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。
根因有两层:
- 端上把预演值写进了订单对象,而不是只写进展示层。
- 提交前没有用服务端金额覆盖,服务端按自己的计算收款,端上却一直显示预演值。
修复对应两点:预演值单独存为 preview,订单对象的金额只能由服务端写入;提交前重新拉取一次结算信息,若与当前展示不一致,弹出确认。修复后,端上再也不能「自己算出一个价格然后当真」。
第二个事故更隐蔽。一次网络抖动导致结算请求超时,端上把金额回退成上一次的值,但库存与优惠券状态没有一起回退。用户看到的是一组互相矛盾的数:金额是旧的,券显示已使用,库存显示充足。提交后被服务端拒绝,提示「优惠券不可用」。
这说明金额、库存、优惠是同一份结算快照的三个字段,必须一起更新或一起标记过期,不能各自回退。后面的 3.2 会把「价格明细」做成一个不可分割的视图对象。
端上仍然要做的三件事
所有权归服务端,不等于端上什么都不做。端上仍然负责:
- 格式化:把分转成元、按地区显示货币符号、处理千分位。
- 预演:在用户调整数量、勾选优惠时给出即时反馈,但标记为预览。
- 一致性展示:当金额、库存、优惠来自不同请求时,用一个版本号把它们绑定成同一份快照。
前两件是体验,第三件是正确性。很多「价格不对」的工单,根因不是计算错,而是三份数据来自不同时刻。
与内容状态的差别
卷二的点赞也讲所有权:本地先加一,服务端为准。交易端的规则更严格,差别在三点:
- 内容可以容错,交易不能。 点赞数多一个没人追责,金额多一元要退款。
- 内容可以最终一致,交易要先一致再展示。 订单状态必须先确认再渲染「已支付」。
- 内容可以重试,交易要幂等。 重试点赞最多多一个赞,重试下单会多一张订单。
这三点会在第 4 章(幂等提交)、第 5 章(支付状态)、第 6 章(重复支付)反复出现。
小结
交易端的第一条约束是所有权:金额、库存、订单状态、支付结果归服务端;勾选、草稿归端上。预览值可以存在,但必须标注来源,并且不能写进订单对象。
amountView 处理过期与预览,reconcileOrder 让服务端覆盖本地并记录差异。两者都很小,但它们把「谁能写」从口头约定变成了可测试的代码。
下一节把这条约束落到验证上:哪些逻辑可以用纯函数验证,哪些必须起进程内服务端,哪些必须接真实回调。
阅读导航:上一节:1.1 交易端的四条链路:浏览、下单、支付、售后 · 下一节:1.3 lab 的三条验证线:纯计算、进程内下单、真实回调 。
常见错误与边界
- 端上计算实付金额并写进订单对象,提交时与服务端结果不一致。
- 服务端金额过期后回退到预览值,用户看到的是更旧的数据。
- 合并订单时直接赋值,不记录状态差异,副作用全部丢失。
- 金额、库存、优惠分别回退,形成互相矛盾的结算快照。
- 把「端上可写」理解成「端上优先」,勾选状态被服务端旧值覆盖。
- 用
stateRank判断「谁更靠后所以更可信」,忽略取消与退款分支。 - 预览值不标注,用户以为看到的就是最终价格。
- 订单号不一致时仍然合并,把两张订单的明细拼在一起。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。