交易端为什么不能当成一个页面
卷二的客户端围绕「内容怎么流动」展开:信息流可以重复、可以晚到、可以降级成静态列表,用户最多觉得「不太新」。交易端不一样。价格错了是资金损失,库存错了是超卖,支付结果判错是重复扣款,售后状态错了是投诉与监管风险。
于是同样一套客户端分层,在交易场景里会被四条完全不同的失败边界切开:
- 浏览:可以缓存、可以预览、可以过期,错了顶多显示旧价格,下单前必须重新确认。
- 下单:一旦提交就产生服务端事实,重复提交会产生两张订单,必须幂等。
- 支付:涉及外部渠道,结果可能长时间「不确定」,端上不能自行猜测成功或失败。
- 售后:事实已经发生,端上做的是证据整理与状态跟踪,不能修改历史。
这四条链路共享商品、订单、用户这些实体,却不共享失败策略。把它们塞进一个「交易模块」,最常见的后果是:浏览页的缓存价格被复用到了结算页,或者支付轮询的超时把整个订单页打成错误态。
本节先不写具体实现,只做一件更基础的事:把四条链路各自需要什么、归谁所有、失败时退到哪里写清楚。这些结论会成为后面十二章的判断依据。
四条链路的数据需求
先看每条链路在端上真正消费什么。注意这里列的是「渲染所必需」,不是「接口返回了什么」。
| 链路 | 必需数据 | 可以过期 | 失败时用户能做什么 |
|---|---|---|---|
| 浏览 | 价格、库存、规格、时效 | 可以,需标注 | 继续看,下单前重算 |
| 下单 | 收货信息、优惠明细、实付金额、幂等键 | 不可以 | 停在确认页,不产生订单 |
| 支付 | 渠道、订单号、支付单号、结果确认 | 不可以 | 查询结果,不能重复支付 |
| 售后 | 订单快照、退款金额、原因、证据 | 不可以 | 提交申请,等待处理 |
这张表有两个容易忽略的点。
第一,「可以过期」不等于「可以随便显示」。商详页展示 30 分钟前的价格是合理的,但必须让用户知道这是参考价,并且在进入结算时强制刷新。把过期数据静默当成实时数据,是交易端最典型的欺骗。
第二,「失败时用户能做什么」决定了 UI 结构。支付失败不是弹一个「请重试」就完事:用户需要知道这笔钱到底扣没扣。端上必须提供「查询支付结果」的入口,而不是只提供「重新支付」。后者会把「不确定」变成「重复支付」。
状态归属:哪些字段端上永远不能写
交易端最常见的架构错误,是让端上「先改一下,回头再同步」。在内容场景里这很合理,点赞可以先加一;在交易场景里,同样的做法会把服务端事实改坏。
把字段按归属分两类:
服务端所有(端上只读)
- 商品价格、优惠后金额、实付金额。
- 库存与可售状态。
- 订单状态、支付状态、退款状态。
- 支付单号、交易流水号。
端上所有(服务端只存副本)
- 购物车勾选、排序、本地备注。
- 结算页的临时收货信息草稿。
- 售后申请的原因文本与图片选择(提交前)。
这条边界不是「谁更权威」的抽象讨论,而是可执行的规则:任何会影响金额或订单事实的字段,端上只能展示服务端给的值;端上算出来的值必须标记为「预估」,并且在提交前被服务端值替换。
后面的 1.2 会把这条规则写成一个可测试的所有权模型。这里先记住结论:端上可以做价格预演,但不能用预演结果下单。
浏览链路:先让用户看懂,再让他买对
浏览链路的目标不是「展示商品」,而是「让用户在下单前形成正确预期」。它包含三件事:
- 价格:当前价、划线价、会员价、活动价。多份价格并存时,端上要明确哪一份是「即将支付的」。
- 库存:可售、紧张、售罄。可售不等于有货,有货不等于能发到你所在地区。
- 时效:预计送达、发货地、预售时间。时效是交易体验的一部分,不是物流页的附属信息。
浏览链路允许缓存,原因不是「不重要」,而是「错了可以纠正」:用户进入结算时会重新拉一次价格与库存。真正危险的是把浏览页的缓存直接带进结算页,跳过重新确认。
一个可执行的判断:从商品页点「立即购买」时,端上必须发起一次新的价格/库存确认请求,而不是复用列表里的对象。 这条规则会让结算页多一次请求,但能消掉绝大多数「下单后价格变了」的争议。
下单链路:提交是一次不可逆的承诺
下单是四条链路里唯一「点一下就可能产生事实」的动作。它需要的不是更多数据,而是更强的约束:
- 幂等键:每次进入确认页生成一个提交 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。」
复盘发现两个问题叠加:
- 结算页没有重新确认价格,直接信任了列表对象。
- 提交前的金额覆盖是静默的,用户没有看到「价格已更新」的提示。
修复做了三件事:结算页进入时强制拉取价格与库存;价格发生变化时展示对比并让用户确认;提交时把实付金额与幂等键一起锁进请求,服务端以此为准。上线后同类投诉归零。
这个事故的教训不是「缓存不好」,而是缓存可以用于浏览,不能用于承诺。交易端的每一次承诺(提交、支付、退款)都必须建立在一次新鲜的服务端确认上。
四条链路什么时候可以合并
四条链路共享实体,但不要共享状态容器。可以合并的是:
- 商品基础信息(标题、图片)——浏览与售后都需要,可缓存。
- 用户收货地址——下单与售后都需要,端上可编辑草稿。
- 订单号——下单、支付、售后都用它做关联键。
不能合并的是:
- 价格:浏览用参考价,下单用确认价,退款用快照价。
- 状态:订单状态、支付状态、退款状态是三套独立的状态机,不能压成一个
status字段。 - 错误处理:浏览失败可以降级展示,支付失败必须查询,售后失败必须保留证据。
把「能合并的」与「不能合并的」分开,是后面十二章反复出现的结构。
小结
交易端由四条失败边界不同的链路组成:浏览可以缓存但要重新确认,下单是幂等承诺,支付必须把「不确定」当成一等状态,售后只整理证据不改写历史。
本节给出的模型只做三件事:声明链路依赖、约束状态迁移、把金额锁在整数分上。它很小,但确定了整卷的基调:交易端的每个值都要能回答「谁写的、能不能改、错了退到哪里」。
下一节讨论第二件事:当服务端与端上同时持有金额和订单状态时,谁覆盖谁。
阅读导航:上一节:目录 · 下一节:1.2 trade-lab 的第一条约束:金额与订单状态归谁所有 。
常见错误与边界
- 把交易端当成一个大页面,四条链路共用一个 loading 与一个错误提示。
- 用浮点数存金额,在优惠分摊与退款时出现分级误差。
- 浏览页的缓存对象直接进入结算页,跳过价格与库存确认。
- 只给订单一个
status字段,把支付状态与退款状态压进去。 - 支付超时后直接提示「支付失败」,引导用户重复付款。
- 售后页读取当前商品信息,而不是下单快照。
- 状态机用「不在黑名单里就允许」实现,新增状态时静默放行。
- 端上重算总额后直接展示,不与服务端返回的总额对账。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。