问题:端上不是状态机的主人
订单状态归服务端所有,这句话说起来没有争议。落到代码里,端上却总要回答另外三个问题:这个状态该显示成什么文案?哪些按钮可以点?状态变化时哪些界面动作要跟着发生?
「回声」最早的实现把这些判断散落在三个地方:详情页的 switch、列表页的 if、支付浮层自己的回调。结果是同一个 refunding,详情页写「退款中」,列表页写「售后处理中」,支付浮层还留着「去支付」。更麻烦的是,服务端后来加了一个状态,端上没人知道,三处默认分支各自给了不同答案。
端上不应该发明状态,但必须完整地翻译状态。翻译包含三样东西:展示、动作、副作用。三样都收在一个纯函数里,才不会随着页面增多而分叉。
映射表:状态、展示与动作
先列出服务端会返回的八个已知状态。settled 表示不会再自动推进,但它不等于「什么都不能做」——已完成仍可退款。
| 状态 | 展示 | 可做动作 | settled |
|---|---|---|---|
created | 订单已创建 | 取消 | 否 |
pending_payment | 待支付 | 支付、取消 | 否 |
paid | 已支付 | 退款、联系客服 | 否 |
shipped | 已发货 | 确认收货、退款、联系客服 | 否 |
completed | 已完成 | 退款、再次购买、联系客服 | 是 |
cancelled | 已取消 | 再次购买 | 是 |
refunding | 退款中 | 联系客服 | 否 |
refunded | 已退款 | 再次购买、联系客服 | 是 |
映射函数只做查表,不推断。比如不能因为 paid 在时间上晚于 pending_payment,就认为 refunding 一定可以取消;取消只属于待支付分支,退款属于已支付之后的分支。
未知状态是前向兼容的入口
服务端增加状态通常早于客户端发版。端上遇到不认识的字符串时,最危险的三种默认处理是:
- 归到「已完成」,隐藏退款入口,用户以为售后被拒绝。
- 归到「已支付」,露出退款按钮,服务端拒绝后用户反复尝试。
- 直接显示原始英文状态,用户看不懂,客服也无法解释。
正确做法是把未知状态当成一个显式的视图:文案「状态同步中」,不暴露任何交易动作,同时触发一次详情刷新。known: false 不是错误标记,而是告诉上层「别猜,去问」。
未知状态还要求「锁定动作」。如果用户在一个未知状态上仍能点支付或取消,端上就等于替服务端做了决定。等详情刷新回来、状态变成已知,再解锁。
迁移副作用必须显式列出
状态变化不只是换文案。从待支付变成已支付,至少要停止倒计时、关闭支付浮层、提示成功、刷新详情和列表;从待支付变成已取消,则要关闭浮层、提示取消。把这些动作写成 Effect 数组,而不是散落在回调里,有三个好处:
- 可测试。 给定前后状态,副作用列表可以逐项断言。
- 可去重。 多个事件同时到达时,
stop_countdown只应执行一次。 - 可审查。 新增状态时,代码评审能看到「这个迁移会触发什么」,而不是靠猜。
副作用列表的顺序也要稳定:先停计时,再关浮层,再提示,最后刷新。顺序不稳定会让「提示已支付」和「关闭支付浮层」互相打架。
状态机模块的实现
下面的模块属于 L1:纯函数、无网络、无定时器。它只做三件事:把服务端状态映射成视图、把状态变化翻译成副作用、把取消与退款的结果解释成新的状态变化。
// file: src/order/state-machine.ts
// tier: L1
// verify: node --test test/order/state-machine.test.ts
export type OrderAction =
| "pay"
| "cancel"
| "refund"
| "confirm_receipt"
| "reorder"
| "contact";
export type Effect =
| "stop_countdown"
| "close_pay_sheet"
| "refresh_detail"
| "refresh_list"
| "lock_actions"
| "unlock_actions"
| "toast_paid"
| "toast_cancelled"
| "toast_refunded"
| "fetch_detail";
export interface StateSpec {
label: string;
tone: "neutral" | "positive" | "warning" | "danger" | "muted";
settled: boolean;
actions: OrderAction[];
}
// settled 表示不会再自动推进;已完成仍可退款,所以动作与 settled 分开描述。
const STATES: Record<string, StateSpec> = {
created: { label: "订单已创建", tone: "neutral", settled: false, actions: ["cancel"] },
pending_payment: { label: "待支付", tone: "warning", settled: false, actions: ["pay", "cancel"] },
paid: { label: "已支付", tone: "positive", settled: false, actions: ["refund", "contact"] },
shipped: {
label: "已发货",
tone: "positive",
settled: false,
actions: ["confirm_receipt", "refund", "contact"],
},
completed: {
label: "已完成",
tone: "muted",
settled: true,
actions: ["refund", "reorder", "contact"],
},
cancelled: { label: "已取消", tone: "muted", settled: true, actions: ["reorder"] },
refunding: { label: "退款中", tone: "warning", settled: false, actions: ["contact"] },
refunded: {
label: "已退款",
tone: "muted",
settled: true,
actions: ["reorder", "contact"],
},
};
export interface OrderView {
state: string;
known: boolean;
label: string;
tone: StateSpec["tone"];
settled: boolean;
actions: OrderAction[];
canPay: boolean;
canCancel: boolean;
canRefund: boolean;
}
function normalize(state: string): string {
return typeof state === "string" ? state.trim() : "";
}
export function isKnownState(state: string): boolean {
return Object.hasOwn(STATES, normalize(state));
}
// 未知状态不猜测语义:展示为同步中,并关闭所有会影响资金的入口。
export function mapOrderState(state: string): OrderView {
const key = normalize(state);
if (!Object.hasOwn(STATES, key)) {
return {
state: key,
known: false,
label: "状态同步中",
tone: "neutral",
settled: false,
actions: [],
canPay: false,
canCancel: false,
canRefund: false,
};
}
const spec = STATES[key];
return {
state: key,
known: true,
label: spec.label,
tone: spec.tone,
settled: spec.settled,
actions: [...spec.actions],
canPay: spec.actions.includes("pay"),
canCancel: spec.actions.includes("cancel"),
canRefund: spec.actions.includes("refund"),
};
}
export function availableActions(state: string): OrderAction[] {
return mapOrderState(state).actions;
}
export interface StateChange {
previous: string;
next: string;
changed: boolean;
known: boolean;
effects: Effect[];
}
// 服务端是状态的唯一来源;端上只负责把变化翻译成副作用。
export function reconcileState(previous: string, next: string): StateChange {
const prev = normalize(previous);
const nx = normalize(next);
const known = isKnownState(nx);
if (prev === nx) return { previous: prev, next: nx, changed: false, known, effects: [] };
const effects: Effect[] = [];
const add = (effect: Effect): void => {
if (!effects.includes(effect)) effects.push(effect);
};
if (prev === "pending_payment") add("stop_countdown");
if (!known) {
add("fetch_detail");
add("lock_actions");
add("refresh_list");
return { previous: prev, next: nx, changed: true, known: false, effects };
}
if (prev !== "" && !isKnownState(prev)) add("unlock_actions");
if (nx === "paid") {
if (prev === "pending_payment") add("close_pay_sheet");
add("toast_paid");
}
if (nx === "cancelled") {
if (prev === "pending_payment") add("close_pay_sheet");
add("toast_cancelled");
}
if (nx === "refunded") add("toast_refunded");
add("refresh_detail");
add("refresh_list");
return { previous: prev, next: nx, changed: true, known: true, effects };
}
export type CancelResult =
| { kind: "cancelled"; state: string }
| { kind: "paid"; state: string }
| { kind: "rejected"; reason: string };
export interface CancelInterpretation extends StateChange {
message: string;
raceLost: boolean;
}
// 取消请求可能撞上已完成的支付:以服务端返回的状态为准,不能改回已取消。
export function interpretCancel(
previous: string,
result: CancelResult,
): CancelInterpretation {
if (result.kind === "rejected") {
return {
previous: normalize(previous),
next: normalize(previous),
changed: false,
known: isKnownState(previous),
effects: ["refresh_detail"],
message: result.reason,
raceLost: false,
};
}
const target = normalize(result.state) || "cancelled";
const change = reconcileState(previous, target);
if (result.kind === "paid") {
return { ...change, message: "支付已完成,取消未生效", raceLost: true };
}
return { ...change, message: "订单已取消", raceLost: false };
}
export type RefundResult =
| { kind: "submitted"; state: string }
| { kind: "rejected"; reason: string };
export interface RefundInterpretation {
change: StateChange;
message: string;
}
export function interpretRefund(
previous: string,
result: RefundResult,
): RefundInterpretation {
if (result.kind === "rejected") {
return {
change: {
previous: normalize(previous),
next: normalize(previous),
changed: false,
known: isKnownState(previous),
effects: ["refresh_detail"],
},
message: result.reason,
};
}
return {
change: reconcileState(previous, normalize(result.state) || "refunding"),
message: "退款申请已提交,等待处理",
};
}
mapOrderState() 返回的 actions 是副本,调用方修改不会污染内部映射表;这一点在测试里单独守住。
reconcileState() 接受「服务端跳跃上报」。轮询可能错过中间状态,比如从 created 直接看到 shipped。端上不能因为「不是合法迁移」就拒绝渲染,服务端才是事实来源;但可以把这次跳跃记录下来,作为排查线索。
interpretCancel() 处理取消与支付的竞争:取消请求发出后,服务端可能返回 paid,说明支付先完成了。这时端上必须展示已支付,而不是把本地状态改成已取消。raceLost 就是给界面用的:告诉用户「取消没赶上」,而不是「取消成功但订单又变成已支付」。
测试与真实输出
22 条用例分成三组:八种已知状态与未知状态的映射(8 条)、状态迁移的副作用与去重(7 条)、取消与退款分支(7 条)。全部是同步纯函数断言。
本机运行结果如下:
$ node --test --test-concurrency=1 test/order/state-machine.test.ts
✔ 待支付映射为可支付、可取消 (0.959208ms)
✔ 已支付不可支付、不可取消,但可以退款 (0.074042ms)
✔ 已发货要求确认收货 (0.083333ms)
✔ 已完成与已退款标记为 settled (0.059584ms)
✔ 已取消只能重新购买 (0.076917ms)
✔ 退款中只保留联系客服 (0.065458ms)
✔ 未知状态标记为同步中且不暴露交易动作 (0.05675ms)
✔ 空状态按未知状态处理 (0.057959ms)
✔ 同一状态重复上报不产生副作用 (0.095125ms)
✔ 待支付变为已支付时停止计时并关闭支付浮层 (0.112625ms)
✔ 待支付变为已取消时关闭浮层并提示取消 (0.075792ms)
✔ 已支付进入退款中只刷新详情与列表 (0.054625ms)
✔ 退款完成时提示并刷新 (0.045458ms)
✔ 收到未知状态时拉详情并锁定动作 (0.050416ms)
✔ 从未知状态回到已知状态时解锁动作 (0.045875ms)
✔ 取消返回已支付说明支付抢跑 (0.082ms)
✔ 取消返回已取消时提示成功 (0.038084ms)
✔ 取消被拒绝时保持原状态并刷新详情 (0.063292ms)
✔ 退款申请提交后进入退款中 (0.077833ms)
✔ 退款被拒绝时保持原状态 (0.061834ms)
✔ 动作列表是副本,外部修改不影响内部状态 (0.045167ms)
✔ 服务端跳跃上报也被接受并触发刷新 (0.037375ms)
ℹ tests 22
ℹ suites 0
ℹ pass 22
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 124.419208
一次把新状态显示成「已完成」的复盘
某交易客户端上线「部分退款」后,服务端新增了 partial_refunded,端上没有同步发版。详情页的 switch 没有这个分支,落进了 default,而 default 恰好返回「已完成」。
用户看到的是:退款进度消失,订单变成已完成,退款按钮也不见了。客服只能让用户卸载重装,但重装后仍然是旧版本。问题不在服务端状态多了一个,而在端上把「不认识」解释成了「结束」。
修复做了三件事:把默认分支改成显式的未知视图;未知状态触发详情刷新并锁定动作;发版前用契约测试枚举服务端全部状态,缺映射就失败。回归时重点验证旧版本遇到新状态不会展示错误的成功或失败,而是展示「状态同步中」。
这个复盘说明:前向兼容不是多写一个 default,而是让 default 的行为足够保守。
小结
订单状态机的主人永远是服务端。端上做三件事:把已知状态映射成文案与动作,把状态变化翻译成副作用,把未知状态当成「需要刷新」而不是「已经结束」。
映射表要完整,未知分支要保守,副作用要显式且去重,取消与支付的竞争要以服务端返回为准。这四条成立,端上才不会在状态增多时逐渐失控。
下一节讨论待支付状态特有的问题:倒计时该信谁,超时前后又该如何取消。
阅读导航:上一节:4.1 提交防重:幂等键与按钮状态 · 下一节:4.3 待支付超时与取消 。
常见错误与边界
- 把未知状态归入「已完成」或「已支付」,替服务端做了结论。
- 端上根据「状态更靠后」推断可信度,忽略取消与退款分支。
- 多个页面各自维护状态文案,同一状态出现多种说法。
- 状态迁移的副作用写在组件回调里,事件重放时重复执行。
- 收到跳跃状态时拒绝渲染,用户停在旧页面。
- 取消返回已支付后仍展示已取消,用户再次发起支付。
- 未知状态下仍保留支付、取消按钮,请求被服务端反复拒绝。
- 新增状态只改文案,不同步动作与副作用,按钮语义错位。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。