站内《游戏商业核心指标详解》 给的是付费率、ARPU 这些指标的定义;站内《上线变现与长线运营》 讲的是怎么把收入做上去。本节与两者分工不同:它不定义指标,也不谈增长,只做内购链路收尾的三道闸——钱到底进没进、进了多少、该不该退。上一节把「一定发货」的订单链路收干净了,但发货只是把虚拟货品给了玩家;钱还躺在平台的结算池里,要经过退款、风控与对账三关才真正落袋。
三个金额口径:下单 / 回调 / 结算
同一笔订单,在链路上会留下三个不同的金额,它们对不上就说明中间某一环出了问题:
| 口径 | 含义 | 来源 |
|---|---|---|
amountFen | 下单金额 | 我方创建订单时记录 |
callbackAmountFen | 我方收到的支付回调金额 | 平台回调报文 |
settlementAmountFen | 平台结算单金额 | 平台对账单 |
三者的关系是:回调金额 == 下单金额(否则被篡改或口径错),结算金额 ≈ 下单金额 ×(1 − 平台抽成)。抽成是平台的商业条款,属于公开可观察机制(各平台费率公开),本节按千分比 platformCutPermille 建模。任何一个口径为负,代表该环节的记录缺失——这是对账要抓的第一类问题。
对账分类:四种异常,上游优先
把每一单按三处金额做分类,规则是上游优先(越靠前的异常越严重,先命中先归类):
callbackAmountFen < 0→missingCallback:漏单,回调根本没到。callbackAmountFen != amountFen→amountMismatch:金额不符,回调被篡改或口径错。settlementAmountFen < 0→missingSettlement:未结算,结算单里缺这笔。- 其余 →
ok:三处对齐,正常。
为什么上游优先?因为下游的缺失往往是上游缺失的后果——回调都没到,结算自然也不会有。若不做优先判定,同一笔单会被重复计入多个异常,虚增问题数。
S 层:10 单对账回放
下面这段是 S 层仿真脚本 sim/o07_recon.py。它读 config/o07_recon.json,把同一批订单在三处的金额逐单比对分类,算出毛收入、结算额、净收入与按商品的退款率风控,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o07_recon.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o07_recon.py --config config/ --out out/
"""退款 / 风控 / 对账仿真:读 config/o07_recon.json,把同一批订单在三处的金额
(下单 / 支付回调 / 平台结算单)逐单比对分类,再算净收入与按商品的退款率风控。
分类(上游优先,逐单唯一):
callbackAmountFen < 0 → missingCallback (漏单:回调没到)
callbackAmountFen != amountFen → amountMismatch (金额不符:回调被篡改或口径错)
settlementAmountFen < 0 → missingSettlement(未结算:结算单缺这笔)
否则 → ok
指标:
grossFen = Σ amountFen
settledFen = Σ settlementAmountFen(>=0 的部分)
netFen = Σ settlementAmountFen(仅未退款的单)
refundRate = 退款单数 / 订单数(千分比)
风控 = 按商品分组算退款率,>= riskRefundRatePermille 的商品计入 riskCount
口径:真实结算单 / 退款单 / 回调流水需真实业务环境,命令行不可复现(X)。
产出 out/o07_recon.json + .csv + .svg。全整数,无浮点。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
CLASSES = ("ok", "missingCallback", "amountMismatch", "missingSettlement")
def classify(o: dict) -> str:
if o["callbackAmountFen"] < 0:
return "missingCallback"
if o["callbackAmountFen"] != o["amountFen"]:
return "amountMismatch"
if o["settlementAmountFen"] < 0:
return "missingSettlement"
return "ok"
def draw_svg(path: str, per_product: list, threshold: int) -> int:
"""每个商品一根退款率横条 + 风控阈值竖线(千分比)。"""
W, H, PAD, ROW = 720, 80 + 44 * len(per_product), 190, 44
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 = "各商品退款率 vs 风控阈值(千分比)"
span = W - PAD - 120
peak = max([p["refundRatePermille"] for p in per_product] + [threshold + 100, 100])
xt = PAD + span * threshold // peak
ET.SubElement(svg, "line", {"x1": str(xt), "y1": "44", "x2": str(xt), "y2": str(H - 16),
"stroke": "#ef4444", "stroke-dasharray": "4 4"})
ET.SubElement(svg, "text", {"x": str(xt + 4), "y": "58", "font-size": "11",
"fill": "#ef4444"}).text = f"阈值 {threshold}‰"
for i, p in enumerate(per_product):
y = 60 + i * ROW
w = span * p["refundRatePermille"] // peak
color = "#ef4444" if p["flagged"] else "#16a34a"
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
"fill": "#374151"}).text = p["product"]
ET.SubElement(svg, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "22",
"rx": "4", "fill": color})
ET.SubElement(svg, "text", {"x": str(PAD + w + 8), "y": str(y + 22), "font-size": "11",
"fill": "#6b7280"}).text = (
f'{p["refundRatePermille"]}‰ ({p["refunded"]}/{p["orders"]})' + (" · 预警" if p["flagged"] else ""))
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_recon.json"), encoding="utf-8") as f:
cfg = json.load(f)
orders = cfg["orders"]
rows = [{**o, "reconClass": classify(o)} for o in orders]
counts = {k: sum(1 for r in rows if r["reconClass"] == k) for k in CLASSES}
gross = sum(r["amountFen"] for r in rows)
settled = sum(r["settlementAmountFen"] for r in rows if r["settlementAmountFen"] >= 0)
refunded_count = sum(r["refunded"] for r in rows)
refund_rate = refunded_count * 1000 // len(rows)
net = sum(r["settlementAmountFen"] for r in rows if r["settlementAmountFen"] >= 0 and r["refunded"] == 0)
refund_fen = sum(r["amountFen"] for r in rows if r["refunded"] == 1)
products = sorted({r["product"] for r in rows})
per_product = []
for p in products:
ps = [r for r in rows if r["product"] == p]
rr = sum(r["refunded"] for r in ps)
rate = rr * 1000 // len(ps)
per_product.append({"product": p, "orders": len(ps), "refunded": rr,
"refundRatePermille": rate, "flagged": rate >= cfg["riskRefundRatePermille"]})
risk_count = sum(1 for p in per_product if p["flagged"])
digest = (f"orders={len(rows)},ok={counts['ok']},missingCallback={counts['missingCallback']},"
f"amountMismatch={counts['amountMismatch']},missingSettlement={counts['missingSettlement']},"
f"gross={gross},settled={settled},net={net},refundRate={refund_rate},risk={risk_count}")
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "o07_recon.svg"), per_product, cfg["riskRefundRatePermille"])
with open(os.path.join(a.out, "o07_recon.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "product", "amountFen", "callbackAmountFen", "settlementAmountFen", "refunded", "reconClass"])
for r in rows:
w.writerow([r["id"], r["product"], r["amountFen"], r["callbackAmountFen"],
r["settlementAmountFen"], r["refunded"], r["reconClass"]])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"platformCutPermille": cfg["platformCutPermille"], "riskRefundRatePermille": cfg["riskRefundRatePermille"],
"orderCount": len(rows), "counts": counts, "grossFen": gross, "settledFen": settled,
"netFen": net, "refundFen": refund_fen, "refundCount": refunded_count,
"refundRatePermille": refund_rate, "perProduct": per_product, "riskCount": risk_count,
"digest": digest, "svg": os.path.join(a.out, "o07_recon.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "o07_recon.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "counts": counts, "grossFen": gross, "settledFen": settled,
"netFen": net, "refundRatePermille": refund_rate, "perProduct": per_product,
"riskCount": risk_count}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
参数放在 config/o07_recon.json。它是纯数据,没有任何浮点:金额用分、抽成与阈值用千分比、退款标记用 0/1:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "对账与退款风控参数。口径是「公开可观察机制」的建模假设,不是任何平台结算单的实测快照。orders 是同一批订单在三处的金额:amountFen=下单金额(分);callbackAmountFen=我方收到的支付回调金额(-1 表示未收到回调,漏单);settlementAmountFen=平台结算单金额(-1 表示未结算);refunded=是否发生退款(1/0)。platformCutPermille=平台抽成千分比;riskRefundRatePermille=退款率风控阈值(千分比),按商品分组统计。真实结算单 / 退款单 / 回调流水需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"platformCutPermille": 300,
"riskRefundRatePermille": 400,
"orders": [
{ "id": "O-1001", "product": "p_starter", "amountFen": 600, "callbackAmountFen": 600, "settlementAmountFen": 420, "refunded": 0 },
{ "id": "O-1002", "product": "p_gems_mid", "amountFen": 3000, "callbackAmountFen": 3000, "settlementAmountFen": 2100, "refunded": 0 },
{ "id": "O-1003", "product": "p_gems_big", "amountFen": 9800, "callbackAmountFen": -1, "settlementAmountFen": -1, "refunded": 0 },
{ "id": "O-1004", "product": "p_monthcard", "amountFen": 2500, "callbackAmountFen": 2500, "settlementAmountFen": 1750, "refunded": 1 },
{ "id": "O-1005", "product": "p_starter", "amountFen": 600, "callbackAmountFen": 600, "settlementAmountFen": -1, "refunded": 0 },
{ "id": "O-1006", "product": "p_gems_mid", "amountFen": 3000, "callbackAmountFen": 2800, "settlementAmountFen": -1, "refunded": 0 },
{ "id": "O-1007", "product": "p_gems_big", "amountFen": 9800, "callbackAmountFen": 9800, "settlementAmountFen": 6860, "refunded": 1 },
{ "id": "O-1008", "product": "p_monthcard", "amountFen": 2500, "callbackAmountFen": 2500, "settlementAmountFen": 1750, "refunded": 0 },
{ "id": "O-1009", "product": "p_starter", "amountFen": 600, "callbackAmountFen": 600, "settlementAmountFen": 420, "refunded": 0 },
{ "id": "O-1010", "product": "p_gems_mid", "amountFen": 3000, "callbackAmountFen": 3000, "settlementAmountFen": 2100, "refunded": 1 }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/o07_recon.py --config config/ --out out/,10 单的分类与金额口径是(同机相对量级,绝对数值不可移植):
| 指标 | 数值 | 含义 |
|---|---|---|
| ok | 7 单 | 三处金额对齐 |
| missingCallback | 1 单 | 漏单:O-1003 回调与结算都缺 |
| amountMismatch | 1 单 | 金额不符:O-1006 回调 2800 ≠ 下单 3000 |
| missingSettlement | 1 单 | 未结算:O-1005 结算单缺这笔 |
| grossFen | 35400 | 毛收入(下单总额,分) |
| settledFen | 15400 | 平台结算总额(含已退款单) |
| netFen | 4690 | 净收入(排除已退款单) |
这里最容易被忽略的一对数字是 settledFen=15400 与 netFen=4690:结算总额不是你的钱。已退款的 3 单(O-1004 / O-1007 / O-1010)虽然出现在结算单里,但退款会把它们抽回去,所以真正落袋的净收入只有 4690 分。只盯结算总额会把收入高估三倍以上。
P 层内核:分类 + 跨语言对拍
分类与净收入口径对不对,要靠一份独立实现来证。P 层 TypeScript 内核 core/src/o07/recon.ts 复算同一套分类与金额口径,读同一份 config/o07_recon.json,产出与 Python 逐字符一致的摘要。
// file: core/src/o07/recon.ts
// tier: P
// verify: node --test 'core/test/o07_*.test.ts'
//
// 退款 / 风控 / 对账内核:与 sim/o07_recon.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o07_recon.json;全整数、地板除、无浮点、无 Math.random。
export interface ReconOrder {
readonly id: string; readonly product: string; readonly amountFen: number;
readonly callbackAmountFen: number; readonly settlementAmountFen: number; readonly refunded: number;
}
export interface ReconConfig {
readonly version: number; readonly game: string; readonly platformCutPermille: number;
readonly riskRefundRatePermille: number; readonly orders: readonly ReconOrder[];
}
export interface ProductRisk {
readonly product: string; readonly orders: number; readonly refunded: number;
readonly refundRatePermille: number; readonly flagged: boolean;
}
export interface ReconResult {
readonly counts: Record<string, number>; readonly grossFen: number; readonly settledFen: number;
readonly netFen: number; readonly refundFen: number; readonly refundRatePermille: number;
readonly perProduct: ProductRisk[]; readonly riskCount: number;
}
/** 逐单分类(上游优先):漏回调 / 金额不符 / 未结算 / 正常。 */
export function classify(o: ReconOrder): string {
if (o.callbackAmountFen < 0) return "missingCallback";
if (o.callbackAmountFen !== o.amountFen) return "amountMismatch";
if (o.settlementAmountFen < 0) return "missingSettlement";
return "ok";
}
export function reconcile(cfg: ReconConfig): ReconResult {
const rows = cfg.orders;
const counts: Record<string, number> = { ok: 0, missingCallback: 0, amountMismatch: 0, missingSettlement: 0 };
for (const o of rows) counts[classify(o)] += 1;
let grossFen = 0; let settledFen = 0; let netFen = 0; let refundFen = 0; let refundedCount = 0;
for (const o of rows) {
grossFen += o.amountFen;
if (o.settlementAmountFen >= 0) settledFen += o.settlementAmountFen;
if (o.settlementAmountFen >= 0 && o.refunded === 0) netFen += o.settlementAmountFen;
if (o.refunded === 1) { refundedCount += 1; refundFen += o.amountFen; }
}
const refundRatePermille = Math.floor((refundedCount * 1000) / rows.length);
const products = [...new Set(rows.map((r) => r.product))].sort();
const perProduct: ProductRisk[] = products.map((p) => {
const ps = rows.filter((r) => r.product === p);
const refunded = ps.reduce((s, r) => s + r.refunded, 0);
const rate = Math.floor((refunded * 1000) / ps.length);
return { product: p, orders: ps.length, refunded, refundRatePermille: rate,
flagged: rate >= cfg.riskRefundRatePermille };
});
const riskCount = perProduct.filter((p) => p.flagged).length;
return { counts, grossFen, settledFen, netFen, refundFen, refundRatePermille, perProduct, riskCount };
}
/** 跨语言对拍摘要:与 sim/o07_recon.py 的 digest 逐字符一致。 */
export function digest(r: ReconResult, orderCount: number): string {
return `orders=${orderCount},ok=${r.counts.ok},missingCallback=${r.counts.missingCallback},`
+ `amountMismatch=${r.counts.amountMismatch},missingSettlement=${r.counts.missingSettlement},`
+ `gross=${r.grossFen},settled=${r.settledFen},net=${r.netFen},`
+ `refundRate=${r.refundRatePermille},risk=${r.riskCount}`;
}
配套测试 core/test/o07_recon.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并验分类上游优先、净收入口径与按商品风控。在本机跑 node --test 'core/test/o07_recon.test.ts',5 个用例全过;两套独立实现给出同一个摘要:
digest = orders=10,ok=7,missingCallback=1,amountMismatch=1,missingSettlement=1,gross=35400,settled=15400,net=4690,refundRate=300,risk=2
风控那一层值得单独看:按商品分组后,p_gems_big 与 p_monthcard 的退款率都是 500‰,都 ≥ 400‰ 阈值,被标红预警(risk=2);而 p_gems_mid 是 333‰,低于阈值不预警。风控阈值是「按商品」而不是「全站」的——全站退款率只有 300‰ 看着健康,但大包商品已经到 500‰,正是要单独盯的对象。只要有一边的上游优先顺序、净收入口径或风控取整写错,摘要立刻分叉。
X 层:真实结算单与退款流水不可复现
边界要划清。上面算的是对账模型侧:给定三处金额,分类与净收入如何得出。而真实的平台结算单、退款流水、回调金额,属于 X 层,需真实业务环境,命令行不可复现(受平台结算周期、退款审核策略、分账规则共同影响,平台侧不透明)。
工程上正确的做法是:把可复现的对账逻辑与不可复现的原始流水分开——分类与金额口径进单测(本节),原始流水靠平台对账单每日导入。对账脚本本身也要能容忍结算延迟:某天的单可能下一结算周期才出现,不能因为当天没对上就直接判未结算,否则会误伤大量正常单。
自检表
| 检查项 | 合格标准 |
|---|---|
| 三口径对齐 | 下单 / 回调 / 结算三处金额逐单比对 |
| 分类上游优先 | 漏单优先于未结算,同一单不重复计数 |
| 净收入口径 | 排除已退款单,不把结算总额当收入 |
| 风控按商品 | 退款率按商品分组,超阈值单独预警 |
内购链路最容易亏在看不见的地方:回调没到的单、结算单缺的单、被退款抽走的钱,都不会主动报警,只会让账目悄悄对不上。对账不是财务的事,是工程的事——把三个金额口径钉死在代码里,每天自动跑一遍,异常进人工队列。至此卷四的支付链路讲完了,下一节转向变现的另一半:广告内容的合规与审核。
阅读导航:上一节:7.2 订单、回调与补单 · 下一节:8.1 广告内容合规与审核 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。