7.3 退款、风控与对账

退款、风控与对账是内购链路收尾的三道闸:把下单金额、支付回调金额、平台结算金额三个口径逐单比对,用对账分类抓出漏单、金额不符与未结算,用净收入口径算真正到手的钱,并按商品分组用退款率做风控预警。含可跑的 10 单对账仿真与 TypeScript 内核跨语言逐位对拍。

站内《游戏商业核心指标详解》 给的是付费率、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 单的分类与金额口径是(同机相对量级,绝对数值不可移植):

指标数值含义
ok7 单三处金额对齐
missingCallback1 单漏单:O-1003 回调与结算都缺
amountMismatch1 单金额不符:O-1006 回调 2800 ≠ 下单 3000
missingSettlement1 单未结算:O-1005 结算单缺这笔
grossFen35400毛收入(下单总额,分)
settledFen15400平台结算总额(含已退款单)
netFen4690净收入(排除已退款单)

这里最容易被忽略的一对数字是 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 广告内容合规与审核 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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