订单页面通常同时承担三类职责:展示当前状态、提供与状态相关的操作、解释订单经历过的变化。简单页面可以用一个 status 字段加若干 if 判断完成,但当系统加入拼单、支付、取消、超时、退款和配送等流程后,条件会迅速分散到列表、详情、按钮和弹窗中。
常见故障包括:
- 接口返回“已支付”,页面仍显示“待支付”,因为多个请求按不同顺序完成。
- “取消订单”按钮在支付完成后仍短暂可见,用户点击后得到难以理解的错误。
- 前端为了追求即时反馈先修改状态,请求失败后没有恢复列表、时间线和按钮权限。
- 状态字段可以被任意字符串赋值,后端新增状态后,旧版本页面静默进入错误分支。
解决这类问题的关键,是把订单生命周期从分散的条件判断提升为明确的状态模型。页面只负责展示模型计算出的结果,操作也必须通过统一的转换规则执行。
核心原理
有限状态机
有限状态机由状态集合、事件集合和转换关系组成。订单可以处于 pending_payment、paid、preparing、completed 等状态;用户点击支付、商家接单、系统超时等行为属于事件;只有满足规则的状态和事件组合,才允许转换到下一个状态。
可以把转换写成:
(当前状态, 事件) -> 下一个状态
例如:
(pending_payment, PAY_SUCCESS) -> paid(pending_payment, CANCEL) -> canceled(paid, MERCHANT_ACCEPT) -> preparing(preparing, COMPLETE) -> completed
如果某个组合不存在,就应当拒绝,而不是猜测一个状态。这样可以把非法操作变成可观测的业务错误。
状态与视图状态分离
订单状态描述服务端事实,视图状态描述页面过程。paid 是业务状态;submitting、loading、retryable 是界面状态。两者混在一个字段里,容易出现“支付中”和“已支付”互相覆盖的问题。
建议至少分成三层:
order.status:服务端确认的订单状态。requestState:当前请求是否提交中、失败或可重试。permissions:根据订单状态和当前用户角色计算出的可执行操作。
时间线也不应由页面根据当前状态临时拼接。它应当来自后端事件记录,缺少事件时可以展示有限的状态摘要,但不能把推测结果伪装成审计记录。
建模步骤
1. 先定义稳定的状态集合
使用 TypeScript 的联合类型限制可接受的状态值。示例采用一个简化的拼单订单流程,实际项目应以服务端契约为准。
type OrderStatus =
| "pending_payment"
| "paid"
| "preparing"
| "ready"
| "completed"
| "canceled"
| "refunding"
| "refunded";
type OrderEvent =
| "PAY_SUCCESS"
| "CANCEL"
| "MERCHANT_ACCEPT"
| "MARK_READY"
| "COMPLETE"
| "REQUEST_REFUND"
| "REFUND_SUCCESS";
类型约束只能防止一部分开发错误,不能替代运行时校验。接口返回数据仍然需要在边界处解析和校验,尤其是跨版本部署时。
2. 用转换表集中描述规则
转换表比嵌套条件更容易审查,也便于生成测试用例。不存在的键代表该事件在当前状态下不允许执行。
const transitions: Partial<
Record<OrderStatus, Partial<Record<OrderEvent, OrderStatus>>>
> = {
pending_payment: {
PAY_SUCCESS: "paid",
CANCEL: "canceled"
},
paid: {
MERCHANT_ACCEPT: "preparing",
REQUEST_REFUND: "refunding"
},
preparing: {
MARK_READY: "ready",
REQUEST_REFUND: "refunding"
},
ready: {
COMPLETE: "completed"
},
refunding: {
REFUND_SUCCESS: "refunded"
}
};
function nextStatus(
current: OrderStatus,
event: OrderEvent
): OrderStatus {
const next = transitions[current]?.[event];
if (!next) {
throw new Error(`非法订单操作: ${current} + ${event}`);
}
return next;
}
生产代码中可以将错误改造成带有业务码的异常,例如 ORDER_TRANSITION_NOT_ALLOWED,由页面映射成“订单状态已变化,请刷新后重试”,而不是直接展示内部字符串。
3. 统一计算操作权限
操作按钮不能只由角色决定,也不能只由当前状态决定。权限通常是角色、状态、订单归属和服务端策略的交集。前端计算结果用于改善体验,最终授权仍必须在服务端再次确认。
type Action = "pay" | "cancel" | "accept" | "complete" | "refund";
function availableActions(
status: OrderStatus,
role: "buyer" | "merchant"
): Action[] {
if (role === "buyer") {
if (status === "pending_payment") return ["pay", "cancel"];
if (["paid", "preparing"].includes(status)) return ["refund"];
return [];
}
if (status === "paid") return ["accept"];
if (status === "ready") return ["complete"];
return [];
}
列表页、详情页和移动端页面都调用同一函数,避免出现不同页面的按钮规则不一致。按钮隐藏只是交互优化,不能当作安全边界。
处理异步竞态
状态机解决的是业务转换,不能自动解决请求竞态。页面打开后可能同时发生刷新、支付回调和用户手动重试。旧响应晚到时,可能覆盖更新的数据。
一种实用做法是为每次加载维护递增序号,只接受最后一次请求的响应:
let latestRequest = 0;
async function loadOrder(id: string) {
const requestId = ++latestRequest;
setViewState({ kind: "loading" });
try {
const response = await fetch(`/orders/${encodeURIComponent(id)}`);
if (!response.ok) throw new Error("订单查询失败");
const order = await response.json() as { status: OrderStatus };
if (requestId !== latestRequest) return;
setOrder(order);
setViewState({ kind: "ready" });
} catch (error) {
if (requestId !== latestRequest) return;
setViewState({ kind: "error", message: String(error) });
}
}
对于支持取消的请求,还可以配合 AbortController 中止旧请求。无论采用哪种方式,都要在组件卸载或页面切换时停止更新已失效的视图。
乐观更新与回滚
“取消订单”这类操作适合先更新界面,再等待服务端确认,但必须保留旧快照。快照不能只保存状态,还应包括操作列表、时间线和分页缓存,否则失败回滚后页面仍然自相矛盾。
async function cancelOrder(order: Order) {
const snapshot = structuredClone(order);
const optimistic: Order = {
...order,
status: "canceled"
};
setOrder(optimistic);
try {
const response = await fetch(`/orders/${order.id}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "buyer_request" })
});
if (!response.ok) throw new Error("取消失败");
await loadOrder(order.id);
} catch (error) {
setOrder(snapshot);
showError("订单可能已被其他操作改变,请刷新后确认");
}
}
服务端接口应具备幂等语义,至少需要请求幂等键或能够识别重复操作。前端回滚并不等于服务端回滚;网络超时后,服务端可能已经成功,页面应优先重新拉取事实状态。
时间线设计
时间线建议采用事件数组,而不是根据状态名称硬编码:
type OrderEventRecord = {
id: string;
type: OrderEvent;
occurredAt: string;
actor: "buyer" | "merchant" | "system";
note?: string;
};
渲染时按 occurredAt 排序,并对未知事件保留通用展示,例如“发生了一次新的订单变更”。不要因为前端不认识某个事件就删除整条记录。时间格式化、操作者名称和文案映射应与业务逻辑分离,便于国际化和审计检查。
测试策略
状态机的测试重点不是覆盖每一个组件,而是验证转换边界:
- 每个允许的状态转换都能得到预期结果。
- 未定义的事件组合必须抛出业务错误。
- 终态不能被普通事件重新打开,除非产品明确设计了逆向流程。
- 重复提交同一事件不会产生重复时间线。
- 请求失败时,订单对象、操作权限和时间线能够恢复到一致快照。
- 旧请求响应不能覆盖新请求结果。
可以根据转换表自动生成“允许转换”测试,但仍应人工补充终态、权限和并发场景。涉及支付、退款等资金流程时,还要用接口级测试验证服务端幂等和状态校验。
常见问题
是否应该把所有业务流程都做成状态机?
不是。只有存在明确阶段、合法迁移和状态相关操作的流程才适合。简单的表单编辑不需要引入完整状态机;但即使不使用专门库,也建议保留集中式转换函数。
前端发现非法状态时怎么办?
不要自动猜测下一个状态。记录原始响应和订单标识,进入只读或错误视图,并重新请求服务端。若服务端版本可能返回未知状态,解析层应显式标记 unknown,同时上报监控。
状态机能替代后端校验吗?
不能。浏览器中的规则可被修改,且多个客户端可能并发操作。后端必须基于数据库中的最新状态执行原子校验,成功后再写入事件记录。前端状态机主要用于一致的交互和更早的错误反馈。
时间线能否直接由前端补一条记录?
不建议把未确认事件当成正式历史。可以显示“正在提交”这样的临时提示,但服务端确认前应明确标识为本地状态;确认失败时要移除,确认成功后以服务端返回的事件为准。
总结
复杂订单页的稳定性来自清晰的状态边界,而不是更多的条件判断。实践时可以按以下顺序落地:先定义状态和事件,再集中维护合法转换;将业务状态与请求状态分开;统一计算操作权限;为异步请求处理竞态;对乐观更新保存完整快照;最后用事件记录驱动时间线,并围绕边界、并发和幂等补充测试。
这种设计不会消除业务复杂度,但会把复杂度放在可审查、可测试的模型中。只要服务端契约、数据库迁移和前端类型定义保持同步,新增一个状态或操作就能明确评估影响范围,页面也更容易在异常和版本演进中保持可解释性。