《移动游戏产品的架构·商业化与发行工程》5.1 订单状态机与幂等发货

支付回调通过之后,订单才真正开始。本节以《潮汐消消》月订单 42 万笔、掉单率 0.35%、退款率 1.2% 为背景,把订单拆成七种状态和行项目发货账,用事件转移表与 grantId 处理多行订单、部分发货和重复发货,并说明状态粒度、发货粒度与人工兜底之间的取舍。

付款成功只是订单的第二个状态

玩家在收银台看到「支付成功」,订单通常刚进入 paid,离玩家真正拿到道具还有一段路。把订单当成一个布尔值——已付或未付——会在这段路上丢掉三类信息:钱到了但货没发、货只发了一半、货发了又被退款。

订单状态机要回答的不是「付没付」,而是「此刻允许发生什么」。同一个 pay 事件,在 created 状态合法,在 delivered 状态就是重放;同一个发货动作,在 paid 状态可以开始,在 refunded 状态必须拒绝。把这些规则写进一张转移表,服务端才有统一的判断入口,客服工具、补偿脚本和监控告警也才能引用同一套状态名。

本节讨论订单从支付事实成立到发货完成的内部结构。支付回调怎么验签、凭证怎么校验,属于 4.3;订单卡住之后怎么查单、怎么处理申诉,属于 5.2。这里先把「订单是什么」和「发货凭什么只发生一次」说清楚。

七种状态与一张转移表

案例里的订单只保留七个状态。状态不是越多越好,每一个状态都必须有明确的进入条件、退出事件和对外语义,否则就会变成没人敢改的字符串集合。

状态含义允许的事件去向
created订单已创建,等待支付结果pay、closepaid、closed
paid已确认收款,等待发货begin-delivery、request-refund、closedelivering、refunding、closed
delivering发货中,允许部分行项目未完成confirm-delivery、request-refunddelivered、refunding
delivered全部行项目已发放request-refundrefunding
refunding退款处理中,等待渠道结果confirm-refundrefunded
refunded已退款,终态无无
closed已关闭,终态无无

这张表刻意没有「支付失败」状态。支付失败在大多数商店和渠道里表现为「没有支付事实」,订单留在 created,由超时任务关闭;把它单独建模成一个状态,会让「失败后重新支付」变成两条路径,而实际上它只是同一笔订单继续等待 pay。

delivered 之后仍允许 request-refund,因为退款可能发生在发货后几天甚至几个月。反过来,refunded 和 closed 是终态,任何事件都返回空,不再推进。终态不可逆不是保守,而是为了让重复回调和延迟消息有一个确定的落点:它们到达时只会被拒绝,不会把已经结束的订单重新打开。

为什么布尔字段不够

用 paid: boolean 加 delivered: boolean 表达订单,短期能跑,长期会在四个地方漏水。

第一,无法表达部分发货。一个礼包拆成货币、外观和体力三行,货币到账、外观还在发放队列里时,两个布尔值只能选「已发」或「未发」,客服看到的进度是错的。

第二,无法表达退款中。退款不是瞬时动作,渠道受理、审核、回执各有时延。把退款直接写成 refunded,会在渠道拒绝退款时留下一个无法回滚的假终态。

第三,无法审计。布尔字段只保留最后结果,不保留「谁在什么时候因为什么事件推进了状态」。出现争议订单时,只能去翻应用日志。

第四,无法做并发控制。两个请求同时把 delivered 置为 true,数据库不会报错,但发货可能已经发生了两次。状态机配合版本号,才能让「基于旧状态做决策」的请求失败,而不是静默覆盖。

发货幂等要落到行项目

4.3 用「路由 + 交易号」作为账本唯一键,解决的是同一条支付回调重复到达的问题。到了 5.1,支付事实已经成立,新的问题是:一笔订单可能包含多行商品,每一行都可能被重试发放,幂等键必须下沉到行项目。

案例实现用 orderId:sku 作为行项目键,用 grantId 区分同一行的不同发放批次。规则有三条:

  • 同一个 grantId 重复到达:返回 duplicate,不新增记录。这是重试和消息重投的正常路径。
  • 不同 grantId、数量未超过行项目总量:允许累加。这是分批发放的正常路径,例如同一行货币先发一部分、再补剩余。
  • 累加后超过行项目总量:返回 over-delivery,不写入。这是配置错误或重复补偿的异常路径。

把「批次」和「行项目」分开,是因为二者解决的不是同一个问题。行项目键保证「这件商品最多发多少」,grantId 保证「同一次发放最多生效一次」。只用行项目键,分批发放会被误判为重复;只用 grantId,不同批次可以无限叠加,最后发超。

多行订单与部分发货

一个 ¥18 档位的礼包,在案例里可以拆成货币和外观两类行项目。两类商品的发放链路可能完全不同:货币走游戏内资产服务,外观走账号外观服务。任何一行失败,订单都不能直接进入 delivered。

正确的推进顺序是:

  1. 支付事实成立,订单从 created 进入 paid。
  2. 发货协调器发出 begin-delivery,订单进入 delivering。
  3. 逐行调用发放服务,每次发放携带稳定的 grantId,失败的行走重试。
  4. 所有行的 pending 都为 0 后,发出 confirm-delivery,订单进入 delivered。

delivering 状态的价值就在这里:它把「正在发」和「发完了」分开。客服看到 delivering,知道应该等待或重试;看到 delivered,知道应该解释权益已到账;看到 paid 长时间不动,才知道发货协调器可能根本没启动。

部分发货也是退款争议的高发区。玩家买了礼包,钻石到账、外观没到,此时申请退款,订单可能同时处于 delivering 和 refunding 的竞争里。案例的做法是让退款事件优先:delivering 允许直接进入 refunding,已发放的行项目在退款确认时按商品类型决定回收还是冲销,而不是等发货完成再处理退款。

案例里的量级

订单状态机不是纯理论。把事实表的月订单量、掉单率和退款率放在一起,可以看到状态迁移每天要承受多少流量。

指标《潮汐消消》《潮汐方舟》
月订单量42 万笔15 万笔
平均客单价¥14.5¥182
掉单率0.35%,约 1,470 笔/月0.20%,约 300 笔/月
退款率1.2%,约 5,040 笔/月0.8%,约 1,200 笔/月
主力档位¥18,占订单 46%¥128,占订单 38%

消消每月约 1,470 笔掉单,摊到每天约 49 笔;方舟约 300 笔,每天约 10 笔。掉单意味着订单会长时间停在 created 或 paid,如果没有按状态和账龄建立的索引,补单任务只能全表扫描。退款意味着每月约 5,040 笔和 1,200 笔订单要经过 refunding,这个状态的停留时长直接决定客服工单量。

两款产品的客单价差异也影响状态机的使用方式。消消平均 ¥14.5,单笔金额小、订单密度高,自动化处理的比例可以更高;方舟平均 ¥182,主力档位 ¥128,单笔争议的金额更大,refunding 和人工复核需要更细的记录。同一套状态机可以共用,但状态停留阈值和人工介入规则必须按产品配置,不能写死。

把状态机写成可运行的纯函数

下面的实现只做三件事:查转移表、按行项目记账、计算发货进度。它不碰数据库,也不发消息,所有输入输出都是不可变数据,方便在 Node 里直接断言。

// file: src/biz/order-fsm.ts
// tier: L1
// verify: node --test test/biz/order-fsm.test.ts

export type OrderState =
  | "created"
  | "paid"
  | "delivering"
  | "delivered"
  | "refunding"
  | "refunded"
  | "closed";

export type OrderEvent =
  | "pay"
  | "begin-delivery"
  | "confirm-delivery"
  | "request-refund"
  | "confirm-refund"
  | "close";

const TRANSITIONS: Readonly<Record<OrderState, Readonly<Partial<Record<OrderEvent, OrderState>>>>> = {
  created: { pay: "paid", close: "closed" },
  paid: { "begin-delivery": "delivering", "request-refund": "refunding", close: "closed" },
  delivering: { "confirm-delivery": "delivered", "request-refund": "refunding" },
  delivered: { "request-refund": "refunding" },
  refunding: { "confirm-refund": "refunded" },
  refunded: {},
  closed: {},
};

export function transition(state: OrderState, event: OrderEvent): OrderState | null {
  return TRANSITIONS[state][event] ?? null;
}

export interface OrderLine {
  readonly sku: string;
  readonly quantity: number;
}

export interface Order {
  readonly id: string;
  readonly state: OrderState;
  readonly version: number;
  readonly lines: readonly OrderLine[];
}

export interface GrantRecord {
  readonly key: string;
  readonly orderId: string;
  readonly sku: string;
  readonly quantity: number;
  readonly grantId: string;
}

export type GrantCode =
  | "applied"
  | "duplicate"
  | "invalid-state"
  | "unknown-sku"
  | "invalid-quantity"
  | "over-delivery";

export interface GrantOutcome {
  readonly code: GrantCode;
  readonly grants: readonly GrantRecord[];
  readonly detail: string;
}

export function grantKey(orderId: string, sku: string): string {
  return `${orderId}:${sku}`;
}

export function applyGrant(
  grants: readonly GrantRecord[],
  order: Order,
  sku: string,
  quantity: number,
  grantId: string,
): GrantOutcome {
  if (order.state !== "paid" && order.state !== "delivering") {
    return { code: "invalid-state", grants, detail: `order ${order.id} is ${order.state}` };
  }
  const line = order.lines.find((item) => item.sku === sku);
  if (line === undefined) {
    return { code: "unknown-sku", grants, detail: `${sku} is not in order ${order.id}` };
  }
  if (!Number.isInteger(quantity) || quantity <= 0) {
    return { code: "invalid-quantity", grants, detail: `quantity ${quantity} is not a positive integer` };
  }
  const key = grantKey(order.id, sku);
  const existing = grants.filter((grant) => grant.key === key);
  if (existing.some((grant) => grant.grantId === grantId)) {
    return { code: "duplicate", grants, detail: `${key} already has grant ${grantId}` };
  }
  const granted = existing.reduce((sum, grant) => sum + grant.quantity, 0);
  if (granted + quantity > line.quantity) {
    return {
      code: "over-delivery",
      grants,
      detail: `${key} would reach ${granted + quantity}, expected ${line.quantity}`,
    };
  }
  const record: GrantRecord = { key, orderId: order.id, sku, quantity, grantId };
  return { code: "applied", grants: [...grants, record], detail: `${key} +${quantity}` };
}

export interface LineProgress {
  readonly sku: string;
  readonly expected: number;
  readonly granted: number;
  readonly pending: number;
}

export function deliveryProgress(order: Order, grants: readonly GrantRecord[]): readonly LineProgress[] {
  return order.lines.map((line) => {
    const granted = grants
      .filter((grant) => grant.orderId === order.id && grant.sku === line.sku)
      .reduce((sum, grant) => sum + grant.quantity, 0);
    return {
      sku: line.sku,
      expected: line.quantity,
      granted,
      pending: Math.max(0, line.quantity - granted),
    };
  });
}

export function isFullyDelivered(order: Order, grants: readonly GrantRecord[]): boolean {
  return deliveryProgress(order, grants).every((line) => line.pending === 0);
}

export function formatOrder(order: Order, grants: readonly GrantRecord[]): string {
  const progress = deliveryProgress(order, grants);
  const granted = progress.reduce((sum, line) => sum + line.granted, 0);
  const expected = progress.reduce((sum, line) => sum + line.expected, 0);
  return `${order.id} ${order.state} ${granted}/${expected} lines=${progress.length}`;
}

代码里的三条边界

transition 是纯查表函数。合法转移返回新状态,非法转移返回 null,不抛异常。支付回调、运营后台和补偿脚本都会调用它,如果非法转移抛异常,每个调用方都要写 try/catch,最后很容易演变成「捕获到异常就当成功」。返回 null 迫使调用方显式处理拒绝。

applyGrant 在记账前做四道检查:订单必须处于 paid 或 delivering,SKU 必须属于该订单,数量必须是正整数,累加后不能超过行项目总量。四道检查的顺序是先便宜后昂贵:状态和 SKU 是内存比较,数量校验也是常数时间,不需要查任何外部服务。真实实现里,grants 会落在带唯一索引的表上,grantId 的唯一性由存储层兜底,而不是靠应用层先查后写。

deliveryProgress 和 isFullyDelivered 把「订单状态」和「行项目进度」分开计算。订单处于 delivering 不代表所有行都没完成,处于 paid 也不代表一行都没发。客服工具应该同时展示两个维度,而不是只显示一个订单级状态。

取舍:状态越多,修复越贵

七个状态比「已付 / 已发 / 已退」三个状态更难维护。每多一个状态,就要多一套监控指标、一条数据修复脚本、一段客服话术,以及一次历史数据迁移。状态机的收益是让非法路径显式失败,代价是每次业务变化都要重新审视转移表。

案例里保留 delivering 和 refunding,是因为这两个中间态直接对应客服最常问的两个问题:「我的东西为什么还没到」和「我的退款为什么还没到」。如果业务不需要区分「处理中」和「已完成」,把它们合并成两个布尔字段反而更省成本。状态粒度应该由对外语义决定,而不是由数据库设计的对称性决定。

发货幂等也有代价。按行项目记账比按订单记账多出数倍记录,grantId 需要全局唯一,分批发放还要处理顺序和超发。换来的是部分发货可恢复、重复消息不重复发、超发能在写入前被拦住。对于只卖单一货币、从不分批的产品,这套结构确实过重;但只要订单里出现第二种商品,或者发货链路出现第二个服务,行项目账就是必要的。

最后是版本号。代码里的 Order 带了 version 字段,但纯函数没有实现比较和自增。生产实现应该在写入时带上 WHERE version = ?,让基于旧状态的更新失败并重读。这会增加一次重试,但能挡住并发退款和并发发货同时推进订单的竞争。

小结

  • 订单不是布尔值,而是一组状态和一组行项目;状态回答「允许发生什么」,行项目回答「发了多少」。
  • 七个状态覆盖创建、已付、发货中、已发、退款中、已退和关闭;终态不可逆,重复事件只会被拒绝。
  • 发货幂等要下沉到 orderId:sku,用 grantId 区分批次,用行项目总量拦截超发。
  • 消消每月约 1,470 笔掉单、5,040 笔退款,方舟约 300 笔和 1,200 笔;状态和账龄必须有索引,否则补单只能扫全表。
  • 状态粒度和发货粒度都是成本,只有对外语义需要区分时才值得增加。

阅读导航:上一节:4.3 支付回调与凭证校验 · 下一节:5.2 补单、掉单与用户申诉 。

常见错误与边界

  1. 用 paid 布尔值代替状态机。无法表达发货中和退款中,客服看到的进度与真实情况不一致,争议订单只能靠日志还原。
  2. 把支付失败建模成独立终态。支付失败通常只是没有支付事实,订单应留在待支付并超时关闭;独立终态会让重新支付多出一条路径。
  3. 允许从终态复活订单。退款完成或订单关闭后再次收到支付事件就重新打开,会让重复回调变成第二笔发货。
  4. 只在订单级做幂等。同一订单的多行商品共用一个键,分批发放会被误判为重复,或者不同商品互相覆盖。
  5. 只靠 grantId 去重,不校验行项目总量。不同批次可以无限叠加,配置错误或重复补偿会直接把道具发超。
  6. 先发货再推进状态。发货成功但状态更新失败,订单会停在 paid 并被再次发货;应先进入 delivering 再发,发货完成后统一确认。
  7. 部分发货时提前把订单标成已发。未完成的行项目失去重试入口,玩家只能走申诉,5.2 的补单量会被放大。
  8. 状态更新不带版本号。并发退款和并发发货同时基于旧状态写入,后写的一方会静默覆盖先写的一方。
  9. 退款直接写终态。渠道可能拒绝退款,订单需要一个 refunding 中间态承接受理、审核和回执。
  10. 状态名只存在于代码里。客服、运营、数据团队各有一套叫法,对账和报表无法按状态聚合;状态名必须成为跨团队契约。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查