《互联网产品的客户端架构·交易、增长与平台化》4.2 订单状态机与端上映射

服务端订单状态有八种,端上却要面对按钮、倒计时、浮层与列表刷新四类副作用。本节把状态映射写成纯函数,用未知状态的前向兼容、取消与支付的竞争分支、迁移副作用的去重,说明端上如何只做翻译而不发明状态,并附一组 L1 纯函数测试与完整真实运行输出。

问题:端上不是状态机的主人

订单状态归服务端所有,这句话说起来没有争议。落到代码里,端上却总要回答另外三个问题:这个状态该显示成什么文案?哪些按钮可以点?状态变化时哪些界面动作要跟着发生?

「回声」最早的实现把这些判断散落在三个地方:详情页的 switch、列表页的 if、支付浮层自己的回调。结果是同一个 refunding,详情页写「退款中」,列表页写「售后处理中」,支付浮层还留着「去支付」。更麻烦的是,服务端后来加了一个状态,端上没人知道,三处默认分支各自给了不同答案。

端上不应该发明状态,但必须完整地翻译状态。翻译包含三样东西:展示、动作、副作用。三样都收在一个纯函数里,才不会随着页面增多而分叉。

映射表:状态、展示与动作

先列出服务端会返回的八个已知状态。settled 表示不会再自动推进,但它不等于「什么都不能做」——已完成仍可退款。

状态展示可做动作settled
created订单已创建取消否
pending_payment待支付支付、取消否
paid已支付退款、联系客服否
shipped已发货确认收货、退款、联系客服否
completed已完成退款、再次购买、联系客服是
cancelled已取消再次购买是
refunding退款中联系客服否
refunded已退款再次购买、联系客服是

映射函数只做查表,不推断。比如不能因为 paid 在时间上晚于 pending_payment,就认为 refunding 一定可以取消;取消只属于待支付分支,退款属于已支付之后的分支。

未知状态是前向兼容的入口

服务端增加状态通常早于客户端发版。端上遇到不认识的字符串时,最危险的三种默认处理是:

  • 归到「已完成」,隐藏退款入口,用户以为售后被拒绝。
  • 归到「已支付」,露出退款按钮,服务端拒绝后用户反复尝试。
  • 直接显示原始英文状态,用户看不懂,客服也无法解释。

正确做法是把未知状态当成一个显式的视图:文案「状态同步中」,不暴露任何交易动作,同时触发一次详情刷新。known: false 不是错误标记,而是告诉上层「别猜,去问」。

未知状态还要求「锁定动作」。如果用户在一个未知状态上仍能点支付或取消,端上就等于替服务端做了决定。等详情刷新回来、状态变成已知,再解锁。

迁移副作用必须显式列出

状态变化不只是换文案。从待支付变成已支付,至少要停止倒计时、关闭支付浮层、提示成功、刷新详情和列表;从待支付变成已取消,则要关闭浮层、提示取消。把这些动作写成 Effect 数组,而不是散落在回调里,有三个好处:

  1. 可测试。 给定前后状态,副作用列表可以逐项断言。
  2. 可去重。 多个事件同时到达时,stop_countdown 只应执行一次。
  3. 可审查。 新增状态时,代码评审能看到「这个迁移会触发什么」,而不是靠猜。

副作用列表的顺序也要稳定:先停计时,再关浮层,再提示,最后刷新。顺序不稳定会让「提示已支付」和「关闭支付浮层」互相打架。

状态机模块的实现

下面的模块属于 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 待支付超时与取消 。

常见错误与边界

  • 把未知状态归入「已完成」或「已支付」,替服务端做了结论。
  • 端上根据「状态更靠后」推断可信度,忽略取消与退款分支。
  • 多个页面各自维护状态文案,同一状态出现多种说法。
  • 状态迁移的副作用写在组件回调里,事件重放时重复执行。
  • 收到跳跃状态时拒绝渲染,用户停在旧页面。
  • 取消返回已支付后仍展示已取消,用户再次发起支付。
  • 未知状态下仍保留支付、取消按钮,请求被服务端反复拒绝。
  • 新增状态只改文案,不同步动作与副作用,按钮语义错位。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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