站内《上线变现与长线运营》 讲的是卖什么、卖多少钱、留存怎么撑起付费;站内《游戏商业核心指标详解》 给的是指标的定义。本节与两者分工不同:它不碰定价,也不定义指标,只做内购链路里最容易出事故的一环——玩家付了钱,我们怎么确定地知道、怎么保证一定发货。上一节把「该走哪条支付通道」解析清楚了,但通道打通只是下单成功;真正的坑在付款之后:平台要把结果通知给你的服务端,你才敢发货,而这条通知路径不可靠。
支付回调不是「通知」,是一条会丢、会重、会乱序的消息
外行把支付回调理解成「平台打个电话告诉你:付好了」。真实系统里它是一条**至少一次(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 单回放的结局分布是(同机相对量级,绝对数值不可移植):
| 结局 | 单数 | 占订单千分比 | 含义 |
|---|---|---|---|
| first | 1676 | 838‰ | 首次回调即发货 |
| retry | 238 | 119‰ | 重试后发货 |
| reissue | 61 | 30‰ | 回调全丢、主动查单补发 |
| stuck | 25 | 12‰ | 连查单都没查到,卡单待人工 |
四个数字读出一条完整的容错链: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 退款、风控与对账 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。