7.2 订单、回调与补单

订单、回调与补单是内购能否「一定发货」的工程保障:把支付回调当成一条会丢、会重、会乱序的消息,用幂等状态机保证重复回调不重复发货,用主动查单补住回调全丢的单,并配可跑的 2000 单回放仿真与 TypeScript 状态机内核跨语言逐位对拍。

站内《上线变现与长线运营》 讲的是卖什么、卖多少钱、留存怎么撑起付费;站内《游戏商业核心指标详解》 给的是指标的定义。本节与两者分工不同:它不碰定价,也不定义指标,只做内购链路里最容易出事故的一环——玩家付了钱,我们怎么确定地知道、怎么保证一定发货。上一节把「该走哪条支付通道」解析清楚了,但通道打通只是下单成功;真正的坑在付款之后:平台要把结果通知给你的服务端,你才敢发货,而这条通知路径不可靠。

支付回调不是「通知」,是一条会丢、会重、会乱序的消息

外行把支付回调理解成「平台打个电话告诉你:付好了」。真实系统里它是一条**至少一次(at-least-once)**的消息投递,三种故障都可能发生:

故障现象后果
丢失玩家付了,回调没到玩家没收到货,客服投诉 / 差评
重复同一次支付,回调到两次若不去重,同一笔钱发两次货(资损)
乱序发货通知先于支付通知到达状态机被推进到非法态

三种故障对应三条工程约束:丢失靠「主动查单」补(补单);重复靠「订单号唯一」去重(幂等);乱序靠状态机的合法迁移兜住(非法事件被忽略)。三者缺一,账就错。

订单状态机:三个状态,两条迁移

把订单建模成一个最小状态机,一切都清楚了:

created --(callback | query: 支付确认)--> paid --(deliver: 发货)--> delivered
非法迁移:created --deliver--> created(未支付不发货)
幂等:同一 eventId 只处理一次;delivered 后再次 deliver 被忽略
  • created → paid 由支付确认触发,来源可以是回调(callback)或主动查单(query)。两者等价——这正是补单能生效的原因。
  • paid → delivered 由发货触发,且只发生一次;非法迁移(未支付就发货)被忽略,不改变状态。

「幂等」不是一句口号,而是一条可测的断言:把重复事件喂进去,deliveredCount 必须恒为 1。

S 层:2000 单回放,看清四种结局

下面这段是 S 层仿真脚本 sim/o07_order_flow.py。它读 config/o07_order.json,用可播种整数 PCG32 逐单回放「下单 → 回调 → 发货」的整条链路,统计四种结局与重复回调条数,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o07_order_flow.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o07_order_flow.py --config config/ --out out/
"""订单 / 回调 / 补单状态机仿真:读 config/o07_order.json,用可播种整数 PCG32 逐单回放
「下单 → 支付回调 → 发货」整条链路,统计四种结局与重复回调条数。

    第 i 单、第 k 次抽签:state = (seed*MULT + i*1000003 + k*0x2545F491) mod 2^64,
    步进两次后做 PCG32 输出置换,取模 1000 得千分位。首次回调 < callbackFirstSuccessPermille;
    否则重试 r=1..R(< callbackRetrySuccessPermille);再失败则主动查单补单(< querySuccessPermille);
    重复回调 < duplicatePermille(幂等去重,不重复发货)。

四种结局:first / retry / reissue(回调全丢、主动查单补发)/ stuck(卡单待人工)。
真实回调到达率 / 重试成功 / 查单成功需真实业务环境,命令行不可复现(X)。
产出 out/o07_order_flow.json + .csv + .svg。全整数,无浮点,禁 Math.random(用 seed)。
"""
from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

M64 = (1 << 64) - 1
PCG_MULT = 6364136223846793005
PCG_INC = 1442695040888963407
OUTCOMES = ("first", "retry", "reissue", "stuck")


def pcg_out(state: int) -> int:
    """PCG32 输出置换:先 xorshift 再按高位旋转,取低 32 位。"""
    xorshifted = (((state >> 18) ^ state) >> 27) & 0xFFFFFFFF
    rot = state >> 59
    return ((xorshifted >> rot) | (xorshifted << ((32 - rot) & 31))) & 0xFFFFFFFF


def draw(seed: int, i: int, k: int) -> int:
    """第 i 单第 k 次抽签:PCG32 步进两次后输出置换,取 mod 1000。"""
    st = (seed * PCG_MULT + i * 1000003 + k * 0x2545F491) & M64
    st = (st * PCG_MULT + PCG_INC) & M64
    st = (st * PCG_MULT + PCG_INC) & M64
    return pcg_out(st) % 1000


def settle(cfg: dict, i: int) -> dict:
    """回放第 i 单:返回结局、成功所需尝试次数与是否出现重复回调。"""
    if draw(cfg["seed"], i, 0) < cfg["callbackFirstSuccessPermille"]:
        outcome, attempt = "first", 0
    else:
        outcome, attempt = "stuck", -1
        for r in range(1, cfg["retryMax"] + 1):
            if draw(cfg["seed"], i, r) < cfg["callbackRetrySuccessPermille"]:
                outcome, attempt = "retry", r
                break
        if outcome == "stuck" and draw(cfg["seed"], i, 99) < cfg["querySuccessPermille"]:
            outcome, attempt = "reissue", 99
    duplicate = 1 if draw(cfg["seed"], i, 50) < cfg["duplicatePermille"] else 0
    return {"orderIndex": i, "outcome": outcome, "attempt": attempt, "duplicate": duplicate,
            "delivered": 1 if outcome in ("first", "retry", "reissue") else 0}


def draw_svg(path: str, counts: dict, total: int) -> int:
    """四种结局一根横条,标出占订单总数的千分比。"""
    W, PAD, ROW = 720, 170, 44
    H = 90 + ROW * len(OUTCOMES)
    colors = {"first": "#16a34a", "retry": "#2563eb", "reissue": "#f59e0b", "stuck": "#ef4444"}
    peak = max(counts.values()) or 1
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15", "fill": "#111827"}).text = "订单结局分布(同机相对量级,千分比)"
    for i, k in enumerate(OUTCOMES):
        y, w = 54 + i * ROW, (W - PAD - 120) * counts[k] // peak
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12", "fill": "#374151"}).text = k
        ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "22", "rx": "4", "fill": colors[k]})
        ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 22), "font-size": "11", "fill": "#6b7280"}).text = f"{counts[k]} 单 · {counts[k] * 1000 // total}‰"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    with open(os.path.join(a.config, "o07_order.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    n = cfg["orderCount"]
    rows = [settle(cfg, i) for i in range(n)]
    counts = {k: sum(1 for r in rows if r["outcome"] == k) for k in OUTCOMES}
    dup = sum(r["duplicate"] for r in rows)
    delivered = sum(r["delivered"] for r in rows)
    rate = delivered * 1000 // n
    digest = (f"first={counts['first']},retry={counts['retry']},reissue={counts['reissue']},"
              f"stuck={counts['stuck']},dup={dup},delivered={delivered},rate={rate}")

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "o07_order_flow.svg"), counts, n)
    with open(os.path.join(a.out, "o07_order_flow.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["orderIndex", "outcome", "attempt", "duplicate", "delivered"])
        for r in rows[:200]:  # 明细只留前 200 单,避免 CSV 过大
            w.writerow([r["orderIndex"], r["outcome"], r["attempt"], r["duplicate"], r["delivered"]])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "seed": cfg["seed"], "orderCount": n, "counts": counts, "duplicateCount": dup,
           "deliveredCount": delivered, "deliveryRatePermille": rate,
           "stuckRatePermille": counts["stuck"] * 1000 // n, "digest": digest,
           "svg": os.path.join(a.out, "o07_order_flow.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "o07_order_flow.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "counts": counts, "duplicateCount": dup,
                      "deliveredCount": delivered, "deliveryRatePermille": rate,
                      "stuckRatePermille": rep["stuckRatePermille"]}, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

参数放在 config/o07_order.json。它是纯数据,没有任何浮点:比率用千分比、次数用整数、种子用整数(保证可播种回放):

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "订单 / 回调 / 补单链路参数。口径是「公开可观察机制」的建模假设,不是任何平台后台的实测。seed=可播种整数种子(禁 Math.random);orderCount=回放订单数;callbackFirstSuccessPermille=首次支付回调成功千分比;callbackRetrySuccessPermille=每次重试成功千分比;retryMax=最大重试次数;querySuccessPermille=主动查单(补单)成功千分比;duplicatePermille=出现重复回调的千分比(幂等去重后仍只发一次货)。真实回调到达率 / 重试成功 / 查单成功需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "seed": 20261013,
  "orderCount": 2000,
  "callbackFirstSuccessPermille": 820,
  "callbackRetrySuccessPermille": 350,
  "retryMax": 3,
  "querySuccessPermille": 700,
  "duplicatePermille": 60
}

在本机跑 for f in sim/o07_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,2000 单回放的结局分布是(同机相对量级,绝对数值不可移植):

结局单数占订单千分比含义
first1676838‰首次回调即发货
retry238119‰重试后发货
reissue6130‰回调全丢、主动查单补发
stuck2512‰连查单都没查到,卡单待人工

四个数字读出一条完整的容错链:838‰ 的单一次回调就搞定,剩下的靠重试(119‰)与补单(30‰)兜住,最后仍有 12‰ 卡单——这 12‰ 就是「必须有人盯」的部分,补单是自动化的最后一环,但不是万能,卡单必须进人工对账队列。回放里还有 113 单出现重复回调(dup=113),但因为订单号唯一、状态机幂等,它们没有造成任何一次重复发货。最终发货率是 987‰。

P 层内核:状态机 + 跨语言对拍

回放的数字对不对,要靠一份独立实现来证。本套用一份 P 层 TypeScript 内核 core/src/o07/order.ts 复算同一套抽签与判定,并额外把幂等状态机也做成纯函数——它读同一份 config/o07_order.json,产出与 Python 逐字符一致的摘要。

// file: core/src/o07/order.ts
// tier: P
// verify: node --test 'core/test/o07_*.test.ts'
//
// 订单 / 回调 / 补单内核:与 sim/o07_order_flow.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o07_order.json;全整数、地板除、无浮点、禁 Math.random(用 seed)。

export interface OrderConfig {
  readonly version: number; readonly game: string; readonly seed: number; readonly orderCount: number;
  readonly callbackFirstSuccessPermille: number; readonly callbackRetrySuccessPermille: number;
  readonly retryMax: number; readonly querySuccessPermille: number; readonly duplicatePermille: number;
}
export interface OrderOutcome {
  readonly orderIndex: number; readonly outcome: string; readonly attempt: number; readonly duplicate: number; readonly delivered: number;
}

export const M64 = (1n << 64n) - 1n;
export const PCG_MULT = 6364136223846793005n;
export const PCG_INC = 1442695040888963407n;

/** PCG32 输出置换:先 xorshift 再按高位旋转,取低 32 位。 */
export function pcgOut(state: bigint): number {
  const xorshifted = (((state >> 18n) ^ state) >> 27n) & 0xFFFFFFFFn;
  const rot = state >> 59n;
  return Number(((xorshifted >> rot) | (xorshifted << ((32n - rot) & 31n))) & 0xFFFFFFFFn);
}

/** 第 i 单第 k 次抽签:PCG32 步进两次后输出置换,取 mod 1000。 */
export function draw(seed: number, i: number, k: number): number {
  let st = (BigInt(seed) * PCG_MULT + BigInt(i) * 1000003n + BigInt(k) * 0x2545F491n) & M64;
  st = (st * PCG_MULT + PCG_INC) & M64;
  st = (st * PCG_MULT + PCG_INC) & M64;
  return pcgOut(st) % 1000;
}

/** 回放第 i 单:返回结局、成功所需尝试次数与是否出现重复回调。 */
export function settleOrder(cfg: OrderConfig, i: number): OrderOutcome {
  let outcome = "stuck";
  let attempt = -1;
  if (draw(cfg.seed, i, 0) < cfg.callbackFirstSuccessPermille) {
    outcome = "first"; attempt = 0;
  } else {
    for (let r = 1; r <= cfg.retryMax; r += 1) {
      if (draw(cfg.seed, i, r) < cfg.callbackRetrySuccessPermille) { outcome = "retry"; attempt = r; break; }
    }
    if (outcome === "stuck" && draw(cfg.seed, i, 99) < cfg.querySuccessPermille) {
      outcome = "reissue"; attempt = 99;
    }
  }
  const duplicate = draw(cfg.seed, i, 50) < cfg.duplicatePermille ? 1 : 0;
  const delivered = outcome === "first" || outcome === "retry" || outcome === "reissue" ? 1 : 0;
  return { orderIndex: i, outcome, attempt, duplicate, delivered };
}

export function settleAll(cfg: OrderConfig): OrderOutcome[] {
  const rows: OrderOutcome[] = [];
  for (let i = 0; i < cfg.orderCount; i += 1) rows.push(settleOrder(cfg, i));
  return rows;
}

/** 跨语言对拍摘要:与 sim/o07_order_flow.py 的 digest 逐字符一致。 */
export function digest(cfg: OrderConfig, rows: readonly OrderOutcome[]): string {
  const counts: Record<string, number> = { first: 0, retry: 0, reissue: 0, stuck: 0 };
  let dup = 0; let delivered = 0;
  for (const r of rows) {
    counts[r.outcome] += 1; dup += r.duplicate; delivered += r.delivered;
  }
  const rate = Math.floor((delivered * 1000) / cfg.orderCount);
  return `first=${counts.first},retry=${counts.retry},reissue=${counts.reissue},`
    + `stuck=${counts.stuck},dup=${dup},delivered=${delivered},rate=${rate}`;
}

// ---- 状态机:幂等发货(重复回调不重复发货) -----------------------------------
export type OrderState = "created" | "paid" | "delivered";

export interface OrderEvent {
  readonly type: "callback" | "query" | "deliver"; readonly orderId: string; readonly eventId: string;
}
export interface OrderMachine {
  readonly orderId: string; readonly state: OrderState; readonly deliveredCount: number; readonly seen: readonly string[];
}

export function newMachine(orderId: string): OrderMachine {
  return { orderId, state: "created", deliveredCount: 0, seen: [] };
}

/** 幂等状态迁移:同一 eventId 只处理一次;deliver 在已 delivered 时忽略。 */
export function applyEvent(m: OrderMachine, e: OrderEvent): OrderMachine {
  if (m.seen.includes(e.eventId)) return m;            // 幂等:事件去重
  const seen = [...m.seen, e.eventId];
  let state: OrderState = m.state;
  let deliveredCount = m.deliveredCount;
  if (e.type === "callback" || e.type === "query") {
    if (state === "created") state = "paid";
  } else if (e.type === "deliver") {
    if (state === "paid") { state = "delivered"; deliveredCount += 1; }  // 只发一次货
  }
  return { orderId: m.orderId, state, deliveredCount, seen };
}

export function reduceOrder(orderId: string, events: readonly OrderEvent[]): OrderMachine {
  let m = newMachine(orderId);
  for (const e of events) m = applyEvent(m, e);
  return m;
}

配套测试 core/test/o07_order.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验幂等与状态机。在本机跑 node --test 'core/test/o07_*.test.ts',本节的 6 个用例全过;两套独立实现给出同一个摘要:

digest = first=1676,retry=238,reissue=61,stuck=25,dup=113,delivered=1975,rate=987

只要有一边的 PCG 溢出处理、抽签顺序、结局判定或发货率取整写错,摘要立刻分叉。这里最值钱的一条断言不是摘要,而是幂等:测试把「同一回调事件到两次、发货事件到两次」喂进状态机,断言 deliveredCount === 1——这正是 dup=113 却不产生重复发货的原因。

X 层:真实回调到达率与查单结果不可复现

必须把边界划清。上面算的是容错模型侧:给定回调成功率与查单成功率,四种结局如何分布。而真实的回调到达率与重试成功、真实的主动查单成功率、真实的重复回调比例,属于 X 层,需真实业务环境,命令行不可复现(受平台投递策略、服务端可用性、网络抖动共同影响,平台侧不透明)。

工程上正确的做法是:把可复现的状态机与不可复现的投递行为分开——状态机进单测(本节),投递行为靠线上监控反标定。补单定时任务本身也要幂等:它可能和回调同时推进同一单,两者撞上时状态机必须只发一次货。

自检表

检查项合格标准
三态明确created / paid / delivered,非法迁移被忽略
幂等可测重复回调 / 重复发货事件喂进去,只发一次货
补单有据主动查单能替代回调把 created 推到 paid
跨语言一致Python 与 TS 两套实现的摘要逐字符相等

订单链路最危险的错觉,是把「回调来了」当成「一定付过钱」、把「回调没来」当成「一定没付钱」。回调只保证至少一次,不保证恰好一次也不保证一定到达——所以既要去重,也要补单。下一节处理链路的最后一段:钱进了平台口袋之后,怎么退、怎么防、怎么对——退款、风控与对账。

阅读导航:上一节:7.1 平台内购通道与虚拟支付 · 下一节:7.3 退款、风控与对账 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制