12.1 平台分成与结算周期

平台分成与结算周期决定《合成小岛》每赚一块钱最终到手多少、多久到手:把毛收入拆成广告扣点、内购扣点与代扣代缴三层,算出每个平台的到账净额、综合扣点率、账期天数与平均在途资金,并用 TypeScript 内核与 Python 仿真跨语言对拍,避免只看扣点漏掉账期。

站内有一篇独立游戏全球财务与多币种结构 ,那是资金架构层面的顶层设计:境外主体、多币种账户、利润回流路径怎么搭。本节不碰那套架构,只做H5 小游戏单款产品口径的结算算术:一笔广告收入或内购流水,经过平台、渠道、代扣几道手,最后落到你账上的净额是多少、隔多少天到账。站内《上线变现与长线运营》 把 eCPM、LTV、回本建过模,但那是「赚多少」;本节接上最后一环——「赚到的钱什么时候、以什么净额到账」。两者的分工很清楚:前两者回答钱从哪来、架构怎么搭,本节回答钱怎么落袋。

前面十一章我们把「怎么投、怎么接、怎么审」走了一遍。到这一章要面对一个更朴素的问题:收入不是现金。后台显示的「本月收入」是毛流水,真正进你银行账户的是一个更小的数,而且要等。搞清楚这两件事——扣多少、等多久——是运营操盘手必须有的账感。

分成这件事,拆成三个扣点

一个 H5 小游戏平台上的收入,通常来自两条通道:广告(IAA)与内购(IAP)。两条通道的扣点不是同一个数:

扣点作用对象谁在收口径
广告分成IAA 毛收入平台(含聚合网络)千分比
内购分成IAP 毛流水平台 / 支付通道千分比
代扣代缴扣点后的净额税务 / 平台代扣千分比

顺序很重要:扣点是先扣的,代扣是在扣点之后的净额上再扣。把顺序写反,账就对不上。用整数表达就是:

iaaNet      = grossIaa × (1000 − iaaShare) // 1000
iapNet      = grossIap × (1000 − iapShare) // 1000
subtotal    = iaaNet + iapNet
withhold    = subtotal × withholdPermille // 1000
netSettle   = subtotal − withhold
takePermille= (gross − netSettle) × 1000 // gross

最后一个 takePermille 是综合扣点率——它把两三条通道、两三层扣点折叠成一个数。操盘手口头说的「这个平台抽多少」,指的通常就是它。注意它不是把各档扣点简单相加:因为广告和内购的毛额不同,加权之后才是真实综合扣点。

结算周期:钱从赚到到到手的时间

扣点决定到手多少,结算周期决定多久到手。这两件事合起来才是完整的账。结算周期本身又分两段:

  • 结算周期:平台按月结还是半月结,账单周期多长;
  • 冻结/账期:结算之后还要压多久才打款,部分平台会留一段冻结期防退款与作弊。

两段相加才是「钱从赚到那天到真正到账那天」的天数。这个天数直接决定你放量时要垫多少营运资金——账期越长,你越像一个给平台垫资的银行。

把净额和账期合起来,可以定义一个粗略的「平均在途资金」:假设结算额按月均匀产生,账期 T 天,则平均有 netSettle × T / 30 的钱卡在平台上。这个数不是精确的财务口径,但它给你一个量级感:账期从 30 天拉到 60 天,你被占用的现金大致翻倍。

S 层仿真:把每个平台算到底

下面这段是 S 层仿真脚本 sim/o12_settlement.py。它读 config/o12_settlement.json 的四个平台,按上面的整数公式算出每个平台的综合扣点率、账期天数、平均在途资金,并汇总全网口径,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o12_settlement.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o12_settlement.py --config config/ --out out/
"""平台分成与结算周期仿真:把 H5 小游戏每个平台/渠道的「毛收入 → 扣点 → 代扣 → 到账」
算成整数,并给出综合扣点率、账期天数与平均在途资金。

对 config/o12_settlement.json 的每个平台:

    iaaNetFen   = grossIaaFen * (1000 - iaaSharePermille) // 1000   # 广告净额(分)
    iapNetFen   = grossIapFen * (1000 - iapSharePermille) // 1000   # 内购净额(分)
    subtotalFen = iaaNetFen + iapNetFen
    withholdFen = subtotalFen * withholdPermille // 1000            # 代扣代缴
    netSettleFen= subtotalFen - withholdFen                         # 实际到账
    takePermille= (grossFen - netSettleFen) * 1000 // grossFen      # 综合扣点(千分比)
    cashDays    = settleCycleDays + holdDays                        # 结算周期 + 冻结期
    floatFen    = netSettleFen * cashDays // periodDays             # 平均在途资金

口径:真实分成比例、真实结算单、真实账期需真实业务环境,命令行不可复现(X)。
产出 out/o12_settlement.json + .csv + .svg。全整数、地板除,无浮点。
"""
from __future__ import annotations

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


def load(d: str) -> dict:
    with open(os.path.join(d, "o12_settlement.json"), encoding="utf-8") as f:
        return json.load(f)


def evaluate(p: dict, period_days: int) -> dict:
    iaa_net = p["grossIaaFen"] * (1000 - p["iaaSharePermille"]) // 1000
    iap_net = p["grossIapFen"] * (1000 - p["iapSharePermille"]) // 1000
    subtotal = iaa_net + iap_net
    withhold = subtotal * p["withholdPermille"] // 1000
    net = subtotal - withhold
    gross = p["grossIaaFen"] + p["grossIapFen"]
    take = (gross - net) * 1000 // gross if gross else 0
    cash_days = p["settleCycleDays"] + p["holdDays"]
    float_fen = net * cash_days // period_days
    return dict(p, grossFen=gross, iaaNetFen=iaa_net, iapNetFen=iap_net, subtotalFen=subtotal,
                withholdFen=withhold, netSettleFen=net, effectiveTakePermille=take,
                cashDays=cash_days, floatFen=float_fen)


def draw_svg(path: str, rows: list, total_take: int) -> int:
    """每个平台两根横条:综合扣点(‰)与到账净额(相对最大值),右侧标注账期。"""
    W, H, PAD, ROW = 760, 70 + 62 * len(rows), 180, 62
    root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                              "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15",
                                 "fill": "#111827"}).text = (
        f"H5 小游戏平台分成与账期(综合扣点‰ / 到账净额,全网综合 {total_take}‰)")
    span = W - PAD - 190
    max_net = max(r["netSettleFen"] for r in rows) or 1
    for i, r in enumerate(rows):
        y = 54 + i * ROW
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 16), "font-size": "12",
                                     "fill": "#374151"}).text = r["name"]
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 32), "font-size": "11",
                                     "fill": "#6b7280"}).text = f'账期 {r["cashDays"]} 天'
        take_w = span * min(r["effectiveTakePermille"], 600) // 600
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(take_w),
                                     "height": "16", "rx": "3", "fill": "#f97316"})
        ET.SubElement(root, "text", {"x": str(PAD + take_w + 6), "y": str(y + 18), "font-size": "11",
                                     "fill": "#6b7280"}).text = f'扣点 {r["effectiveTakePermille"]}‰'
        net_w = span * r["netSettleFen"] // max_net
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 28), "width": str(net_w),
                                     "height": "16", "rx": "3", "fill": "#2563eb"})
        ET.SubElement(root, "text", {"x": str(PAD + net_w + 6), "y": str(y + 40), "font-size": "11",
                                     "fill": "#6b7280"}).text = f'到账 {r["netSettleFen"]}分'
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(root))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config)
    period = cfg["periodDays"]
    rows = [evaluate(p, period) for p in cfg["platforms"]]

    total_gross = sum(r["grossFen"] for r in rows)
    total_net = sum(r["netSettleFen"] for r in rows)
    total_take = (total_gross - total_net) * 1000 // total_gross if total_gross else 0
    total_float = sum(r["floatFen"] for r in rows)

    # 跨语言对拍摘要:core/test/o12_settlement.test.ts 复算同一串数字,逐位比对。
    digest = ",".join(
        f'{r["id"]}:{r["effectiveTakePermille"]}:{r["cashDays"]}:{r["netSettleFen"]}'
        for r in rows) + f";totalNet={total_net},totalTake={total_take}"

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "o12_settlement.svg"), rows, total_take)
    with open(os.path.join(a.out, "o12_settlement.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "name", "grossFen", "iaaNetFen", "iapNetFen", "withholdFen",
                "netSettleFen", "effectiveTakePermille", "settleCycleDays", "holdDays",
                "cashDays", "floatFen")
        w.writerow(cols)
        for r in rows:
            w.writerow([r[k] for k in cols])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "periodDays": period, "platformCount": len(rows), "platforms": rows,
           "totalGrossFen": total_gross, "totalNetFen": total_net,
           "totalTakePermille": total_take, "totalFloatFen": total_float,
           "worstTake": max(rows, key=lambda r: r["effectiveTakePermille"])["id"],
           "longestCash": max(rows, key=lambda r: r["cashDays"])["id"],
           "digest": digest, "csv": os.path.join(a.out, "o12_settlement.csv"),
           "svg": os.path.join(a.out, "o12_settlement.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "o12_settlement.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"platforms": [{"id": r["id"], "grossFen": r["grossFen"],
                                     "netSettleFen": r["netSettleFen"],
                                     "effectiveTakePermille": r["effectiveTakePermille"],
                                     "cashDays": r["cashDays"], "floatFen": r["floatFen"]}
                                    for r in rows],
                      "totalGrossFen": total_gross, "totalNetFen": total_net,
                      "totalTakePermille": total_take, "totalFloatFen": total_float,
                      "digest": digest, "worstTake": rep["worstTake"],
                      "longestCash": rep["longestCash"]}, ensure_ascii=False, indent=2))
    return 0


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

平台参数放在 config/o12_settlement.json。注意这里的比例是「公开可观察机制」口径的建模假设,不是任何平台结算单的实测:真实分成比例、真实账期属于 X 层——需真实业务环境,命令行不可复现。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "periodDays": 30,
  "note": "H5 小游戏平台分成与结算周期参数。口径是「公开可观察机制」的建模假设,不是任何平台后台结算单实测。grossIaaFen=广告毛收入(分/月);grossIapFen=内购毛流水(分/月);iaaSharePermille/iapSharePermille=平台(含渠道/支付通道)扣点千分比;withholdPermille=代扣代缴千分比(在扣点之后);settleCycleDays=结算周期(天);holdDays=冻结/账期(天)。真实分成比例、真实结算单、真实账期需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "platforms": [
    { "id": "wechat", "name": "微信小游戏", "grossIaaFen": 5000000, "grossIapFen": 2000000, "iaaSharePermille": 400, "iapSharePermille": 300, "withholdPermille": 0, "settleCycleDays": 30, "holdDays": 15 },
    { "id": "douyin", "name": "抖音小游戏", "grossIaaFen": 3000000, "grossIapFen": 1000000, "iaaSharePermille": 350, "iapSharePermille": 300, "withholdPermille": 0, "settleCycleDays": 15, "holdDays": 7 },
    { "id": "h5_web", "name": "海外 H5 平台", "grossIaaFen": 1200000, "grossIapFen": 400000, "iaaSharePermille": 300, "iapSharePermille": 300, "withholdPermille": 0, "settleCycleDays": 30, "holdDays": 0 },
    { "id": "union", "name": "联运渠道", "grossIaaFen": 800000, "grossIapFen": 300000, "iaaSharePermille": 500, "iapSharePermille": 500, "withholdPermille": 30, "settleCycleDays": 45, "holdDays": 30 }
  ]
}

在本机跑 for f in sim/o12_*.py; do /opt/homebrew/bin/python3 "$f" --config config/ --out out/; done,这一条链跑出来的结果(同机相对量级,绝对数值不可移植):

平台毛收入(分)到账净额(分)综合扣点‰账期(天)平均在途(分)
微信小游戏70000004400000371456600000
抖音小游戏40000002650000337221943333
海外 H5 平台16000001120000300301120000
联运渠道1100000533500515751333750
全网137000008703500364—10997083

这张表把「哪个平台更划算」这件事拆成了两个独立维度。看综合扣点:联运渠道 515‰ 最狠,海外 H5 300‰ 最轻。看账期:联运渠道 75 天最长,抖音 22 天最短。真正麻烦的是两个维度同时差的平台——联运渠道扣点最高、账期最长,正是「又贵又慢」。反之抖音扣点 337‰ 不算最低,但 22 天账期让它成为现金流最友好的平台。只看扣点不看账期,是运营最常见的账盲。

P 层内核:跨语言对拍,证明公式没写错

一张表你没法确认「是模型对」还是「算错了碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o12/settlement.ts 复算同一条链:同样的整数地板除、同样的扣点顺序、同样的账期定义。它读同一份 config/o12_settlement.json,产出和 Python 逐字符一致的摘要。

// file: core/src/o12/settlement.ts
// tier: P
// verify: node --test 'core/test/o12_*.test.ts'
//
// 平台分成与结算周期内核:与 sim/o12_settlement.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o12_settlement.json;全整数、地板除、无浮点、无 Math.random。

export interface Platform {
  readonly id: string; readonly name: string;
  readonly grossIaaFen: number; readonly grossIapFen: number;
  readonly iaaSharePermille: number; readonly iapSharePermille: number;
  readonly withholdPermille: number;
  readonly settleCycleDays: number; readonly holdDays: number;
}
export interface SettlementConfig {
  readonly version: number; readonly game: string; readonly periodDays: number;
  readonly platforms: readonly Platform[];
}
export interface PlatformResult {
  readonly id: string; readonly grossFen: number;
  readonly iaaNetFen: number; readonly iapNetFen: number;
  readonly withholdFen: number; readonly netSettleFen: number;
  readonly effectiveTakePermille: number; readonly cashDays: number; readonly floatFen: number;
}

/** 单个平台:毛收入 → 扣点 → 代扣 → 到账净额、综合扣点率、账期、平均在途。 */
export function evaluate(p: Platform, periodDays: number): PlatformResult {
  const iaaNetFen = Math.floor((p.grossIaaFen * (1000 - p.iaaSharePermille)) / 1000);
  const iapNetFen = Math.floor((p.grossIapFen * (1000 - p.iapSharePermille)) / 1000);
  const subtotalFen = iaaNetFen + iapNetFen;
  const withholdFen = Math.floor((subtotalFen * p.withholdPermille) / 1000);
  const netSettleFen = subtotalFen - withholdFen;
  const grossFen = p.grossIaaFen + p.grossIapFen;
  const effectiveTakePermille = grossFen ? Math.floor(((grossFen - netSettleFen) * 1000) / grossFen) : 0;
  const cashDays = p.settleCycleDays + p.holdDays;
  const floatFen = Math.floor((netSettleFen * cashDays) / periodDays);
  return { id: p.id, grossFen, iaaNetFen, iapNetFen, withholdFen, netSettleFen,
    effectiveTakePermille, cashDays, floatFen };
}

export function evaluateAll(cfg: SettlementConfig): PlatformResult[] {
  return cfg.platforms.map((p) => evaluate(p, cfg.periodDays));
}

export function totals(rows: readonly PlatformResult[]): { grossFen: number; netFen: number; takePermille: number; floatFen: number } {
  const grossFen = rows.reduce((s, r) => s + r.grossFen, 0);
  const netFen = rows.reduce((s, r) => s + r.netSettleFen, 0);
  const floatFen = rows.reduce((s, r) => s + r.floatFen, 0);
  const takePermille = grossFen ? Math.floor(((grossFen - netFen) * 1000) / grossFen) : 0;
  return { grossFen, netFen, takePermille, floatFen };
}

/** 跨语言对拍摘要:与 sim/o12_settlement.py 的 digest 逐字符一致。 */
export function digest(rows: readonly PlatformResult[]): string {
  const t = totals(rows);
  return rows.map((r) => `${r.id}:${r.effectiveTakePermille}:${r.cashDays}:${r.netSettleFen}`)
    .join(",") + `;totalNet=${t.netFen},totalTake=${t.takePermille}`;
}

配套测试 core/test/o12_settlement.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并顺手验扣点顺序与账期定义。在本机跑 node --test 'core/test/o12_*.test.ts',5 个用例全过;两套独立实现给出同一个摘要:

✔ 跨语言一致:TS 结算摘要 == Python 金标准 (0.573541ms)
✔ 综合扣点 = (毛收入 - 到账净额) × 1000 // 毛收入 (0.087708ms)
✔ 代扣代缴在扣点之后:union 有 3% 代扣 (0.07275ms)
✔ 账期 = 结算周期 + 冻结期;平均在途 = 净额 × 账期 // 月天数 (0.068791ms)
✔ 全网口径:综合扣点与在途资金汇总 (0.079166ms)
ℹ tests 5   ℹ pass 5   ℹ fail 0
digest = wechat:371:45:4400000,douyin:337:22:2650000,h5_web:300:30:1120000,union:515:75:533500;totalNet=8703500,totalTake=364

只要有一边的扣点顺序、整数语义、账期定义写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。

X 层:真实结算单不可复现

边界必须划清。上面算的是模型侧:给定扣点比例与账期,到手多少、多久到手。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的平台分成比例与结算条款(各家不同、会调整、常有阶梯与激励返点,公开渠道只能看到大致区间);
  • 真实的结算单与账期(含对账差异、退款扣减、作弊扣减,平台侧逐笔核算);
  • 真实的代扣代缴规则(由税务政策与主体性质共同决定,不是平台单方面说了算)。

这些都不是「跑个脚本就能知道」的。工程上正确的做法是:把可复现的模型层与不可复现的真实数据层分开——模型层用来做「如果分成是 X、账期是 Y,结果如何」,真实数据层用来在拿到结算单后反标定模型参数(把实际扣点与账期填回 config,模型立刻反映现实)。这也是第 16 章全链路对账要跟这些字段一一对应的原因。

自检表

检查项合格标准
扣点顺序代扣在扣点之后,不颠倒
双通道广告与内购分别算,不混为一谈
综合扣点用加权口径,不做简单相加
账期拆解结算周期与冻结期分开列
在途量化给出平均在途资金,不只看净额
边界清晰真实结算单与代扣规则标 X,不写成「实测」

分成和账期算清楚了,只是把「收入」这一列填满。但收入不等于现金——你账上确认了 13.1 万收入,不代表这 13.1 万现在就能花。下一节我们就把这条时间差单独拉出来,看现金流缺口到底有多大、峰值出现在哪一天。

阅读导航:上一节:11.3 资质倒排与上线前置 · 下一节:12.2 现金流与收入确认 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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