9.2 联运分成与结算周期

联运分成与结算周期是渠道合作里最容易被忽略的现金流变量:渠道先按分成拿走一部分,剩下的还要等账期滞后才到账。这一节把「逐月毛流水 → 扣分成 → 按账期滞后到账」算成一条现金流,量化峰值资金占用缺口,并用账期扫描给出敏感度对照;配可跑的 Python 仿真。

站内《H5 游戏产业全景》 反复强调一件事:结算链不是定数——平台政策一变,到账路径就可能变,所以要保留多渠道冗余;《游戏商业核心指标详解》 则把 ARPU、LTV 这些指标定义得很清楚。但它们讲的都是「赚多少」,不是「什么时候拿到」。本节处理的是后者:联运分成与结算周期。上一节9.1 渠道分级与对接清单 讲了接哪个渠道,这一节讲接进来之后,钱按什么比例分、什么时候到账、账期会给现金流带来多大的占用。这也和买量渠道与投放机制 形成对照——那节算的是「花钱的效率」,这节算的是「收钱的节奏」。

分成与账期是两件事

很多人把「渠道分成」和「结算周期」混为一谈,其实它们是两个独立的变量:

  • 分成(share)决定分多少:毛流水里渠道拿走百分之几,剩下的才是你的净收入;
  • 结算周期(settlement cycle)决定什么时候到:这笔净收入要等账期滞后多少天才真正打进你的账户。

分成是「利润率」问题,账期是「现金流」问题。一款游戏可能分成很友好(渠道只拿 20%),但账期拖到 90 天,结果就是账面赚钱、现金吃紧——你得先垫着服务器、买量、人力,等渠道慢慢结。对一款还在放量的小游戏来说,现金流断裂比利润率低更致命。

现金流公式:已赚与已到账

把分成与账期写成整数模型。对第 m 个自然月(m 从 0 起):

netMonthlyFen[m]  = grossFen[m] × (1000 − sharePermille) // 1000   # 当月净收入(分)
receivedFen[m]    = netMonthlyFen[m − lag]   若 m−lag ≥ 0 否则 0    # 当月到账(分)
gapFen[m]         = Σ_{k≤m} netMonthlyFen[k] − Σ_{k≤m} receivedFen[k]
peakGapFen        = max_m gapFen[m]

gapFen[m] 是应收未收——到这个月为止你已经赚了、但还没到账的钱。它的峰值 peakGapFen 就是你为账期付出的最大资金占用:你得有多少现金储备,才能撑过「赚了但没到账」的那段时间。账期越长,峰值越高。

S 层仿真:逐月现金流与账期扫描

下面这段是 S 层仿真脚本 sim/o09_settlement.py。它读 config/o09_settlement.json,把逐月毛流水按分成扣完、再按账期滞后到账,算出逐月缺口与峰值占用,并用账期扫描给出敏感度对照,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o09_settlement.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o09_settlement.py --config config/ --out out/
"""联运分成与结算周期仿真:读 config/o09_settlement.json,把「逐月毛流水 → 扣分成 →
按账期滞后到账」算成一条现金流,量化结算周期带来的资金占用缺口。

对第 m 个自然月(m 从 0 起):

    netMonthlyFen[m]   = grossFen[m] * (1000 - sharePermille) // 1000   # 当月净收入(分)
    receivedFen[m]     = netMonthlyFen[m - lag]  若 m-lag >= 0 否则 0     # 当月到账(分)
    cumEarnedFen[m]    = Σ_{k<=m} netMonthlyFen[k]
    cumReceivedFen[m]  = Σ_{k<=m} receivedFen[k]
    gapFen[m]          = cumEarnedFen[m] - cumReceivedFen[m]             # 应收未收(资金占用)
    peakGapFen         = max_m gapFen[m]

结算周期越长,同一笔流水「已赚未收」的时间越长,峰值资金占用越大——这就是账期
对现金流的真实代价。lagSweepMonths 扫描不同账期,给出峰值缺口的敏感度对照。
产出 out/o09_settlement.json + .csv + .svg。全程整数、地板除,无浮点。
真实账期与到账日属真实业务环境,命令行不可复现(X)。
"""
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, "o09_settlement.json"), encoding="utf-8") as f:
        return json.load(f)


def run(gross: list, share: int, lag: int) -> dict:
    """给定逐月毛流水、分成、账期滞后(月),返回逐月现金流与峰值缺口。"""
    net = [g * (1000 - share) // 1000 for g in gross]
    received = [net[m - lag] if m - lag >= 0 else 0 for m in range(len(gross))]
    rows = []
    ce = cr = 0
    peak = 0
    for m in range(len(gross)):
        ce += net[m]
        cr += received[m]
        gap = ce - cr
        peak = max(peak, gap)
        rows.append({"month": m, "grossFen": gross[m], "netFen": net[m],
                     "receivedFen": received[m], "cumEarnedFen": ce,
                     "cumReceivedFen": cr, "gapFen": gap})
    total_gross = sum(gross)
    total_net = sum(net)
    return {"lagMonths": lag, "months": len(gross), "totalGrossFen": total_gross,
            "totalNetFen": total_net, "sharePermille": share, "peakGapFen": peak,
            "finalGapFen": rows[-1]["gapFen"], "rows": rows}


def draw_svg(path: str, res: dict, sweep: list) -> int:
    """上半:逐月「已赚」与「已到账」两条累积柱;下半:账期扫描的峰值缺口横条。"""
    rows = res["rows"]
    W, ROW = 760, 26
    H = 120 + ROW * len(rows) + 40 + 30 * len(sweep)
    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 = (
        f'结算周期(账期滞后 {res["lagMonths"]} 月)· 峰值资金占用 {res["peakGapFen"]} 分')
    span = W - 200
    hi = max(r["cumEarnedFen"] for r in rows) or 1
    for i, r in enumerate(rows):
        y = 50 + i * ROW
        we = span * r["cumEarnedFen"] // hi
        wr = span * r["cumReceivedFen"] // hi
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 14), "font-size": "11",
                                    "fill": "#374151"}).text = f'M{r["month"]}'
        ET.SubElement(svg, "rect", {"x": "50", "y": str(y + 2), "width": str(we), "height": "16",
                                    "rx": "2", "fill": "#cbd5e1"})
        ET.SubElement(svg, "rect", {"x": "50", "y": str(y + 2), "width": str(wr), "height": "16",
                                    "rx": "2", "fill": "#2563eb"})
        ET.SubElement(svg, "text", {"x": str(50 + we + 6), "y": str(y + 14), "font-size": "10",
                                    "fill": "#6b7280"}).text = f'缺口 {r["gapFen"]}'
    y0 = 60 + ROW * len(rows)
    ET.SubElement(svg, "text", {"x": "16", "y": str(y0), "font-size": "13",
                                "fill": "#111827"}).text = "账期扫描:峰值资金占用(分)"
    peak_hi = max(s["peakGapFen"] for s in sweep) or 1
    for j, s in enumerate(sweep):
        y = y0 + 12 + j * 30
        w = (W - 260) * s["peakGapFen"] // peak_hi
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 16), "font-size": "11",
                                    "fill": "#374151"}).text = f'滞后 {s["lagMonths"]} 月'
        ET.SubElement(svg, "rect", {"x": "130", "y": str(y + 2), "width": str(w), "height": "16",
                                    "rx": "3", "fill": "#f59e0b"})
        ET.SubElement(svg, "text", {"x": str(130 + w + 6), "y": str(y + 16), "font-size": "11",
                                    "fill": "#6b7280"}).text = str(s["peakGapFen"])
    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()
    cfg = load(a.config)
    res = run(cfg["monthlyGrossFen"], cfg["sharePermille"], cfg["settlementLagMonths"])
    sweep = [run(cfg["monthlyGrossFen"], cfg["sharePermille"], lag) for lag in cfg["lagSweepMonths"]]
    sweep_view = [{"lagMonths": s["lagMonths"], "peakGapFen": s["peakGapFen"],
                   "finalGapFen": s["finalGapFen"]} for s in sweep]
    digest = (f'{cfg["sharePermille"]}:{res["lagMonths"]}:{res["totalGrossFen"]}:'
              f'{res["totalNetFen"]}:{res["peakGapFen"]}:{res["finalGapFen"]}')

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "o09_settlement.svg"), res, sweep_view)
    with open(os.path.join(a.out, "o09_settlement.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["month", "grossFen", "netFen", "receivedFen", "cumEarnedFen",
                    "cumReceivedFen", "gapFen"])
        for r in res["rows"]:
            w.writerow([r["month"], r["grossFen"], r["netFen"], r["receivedFen"],
                        r["cumEarnedFen"], r["cumReceivedFen"], r["gapFen"]])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "sharePermille": cfg["sharePermille"], "settlementLagMonths": cfg["settlementLagMonths"],
           "result": res, "lagSweep": sweep_view, "digest": digest,
           "csv": os.path.join(a.out, "o09_settlement.csv"),
           "svg": os.path.join(a.out, "o09_settlement.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "o09_settlement.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"digest": digest, "totalGrossFen": res["totalGrossFen"],
                      "totalNetFen": res["totalNetFen"], "peakGapFen": res["peakGapFen"],
                      "finalGapFen": res["finalGapFen"],
                      "gapByMonth": [r["gapFen"] for r in res["rows"]],
                      "lagSweep": sweep_view}, ensure_ascii=False, indent=2))
    return 0


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

参数放在 config/o09_settlement.json:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "联运分成与结算周期参数。口径是「公开可观察机制」的建模假设,不是任何渠道结算单的实测。sharePermille=渠道分成千分比;settlementLagMonths=账期滞后(自然月,账期月结束后第几个月到账);monthlyGrossFen=逐月毛流水(分);lagSweepMonths=结算周期敏感度扫描档位。真实账期与到账日由合同与渠道财务流程决定,需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "sharePermille": 300,
  "settlementLagMonths": 1,
  "monthlyGrossFen": [10000000, 12000000, 11000000, 9000000, 8000000, 7000000],
  "lagSweepMonths": [0, 1, 2, 3]
}

在本机跑,分成 30%、账期滞后 1 个月时,逐月缺口是(同机相对量级,绝对数值不可移植):

月份毛流水(分)净收入(分)当月到账(分)应收未收缺口(分)
M010000000700000007000000
M112000000840000070000008400000
M211000000770000084000007700000
M39000000630000077000006300000
M48000000560000063000005600000
M57000000490000056000004900000

峰值占用出现在 M1:8400000 分。M0 完全没有到账(因为账期滞后一个月),缺口直接堆到 7000000;M1 到账的是 M0 的净收入(7000000),但 M1 自己又赚了 8400000,缺口反而涨到峰值。之后随着流水回落,缺口慢慢收窄,到 M5 降到 4900000。

账期扫描:账期多长,资金占用就多大

固定分成 30%,把账期从 0 扫到 3 个月,峰值占用是这样的:

账期滞后峰值占用(分)期末缺口(分)
0 月00
1 月84000004900000
2 月1610000010500000
3 月2310000016800000

规律单调:账期每多滞后一个月,峰值占用就多压一个月的净收入。账期 0 时完全没有占用(当月赚当月到);账期 3 个月时,峰值占用 23100000 分,几乎是单月毛流水的两倍多。这不是「提升 N 倍」的绝对结论,而是同机同参下的相对量级——换成别的流水曲线会变,但「账期越长占用越大」的方向不会变。工程上的含义是:接一个账期很长的渠道之前,先确认自己的现金储备能不能扛住峰值占用,否则账面盈利也可能被现金流拖死。

W 层原型:在浏览器里拖着看账期代价

上面两组数字来自 S 层仿真,改一个参数就得重跑一次脚本。把同一份内核搬进单文件 HTML 就能拖着看:web/build_o09_calculator.mjs 复用 web/pack.mjs,把 core/src/o09/settlement.ts 内联进单文件 HTML;web/verify_o09_calculator.mjs 用 Playwright 无头打开它、拖动两个滑块,断言峰值占用与 S 层仿真逐位相同——上面表里的 0 / 8400000 / 23100000 分,就是浏览器里拖出来的同一组数,因为 Python 仿真(S)、TS 内核与测试(P)、浏览器原型(W)读的是同一份 config/o09_settlement.json。

#!/usr/bin/env node
// file: web/build_o09_calculator.mjs
// tier: W
// verify: node web/build_o09_calculator.mjs
// 把 core/src/o09/settlement.ts 打成单文件 HTML:联运分成与结算周期计算器。产物 web/dist/o09.html。
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "o09_settlement.json"), "utf8"));
const LAG_MAX = Math.max(...cfg.lagSweepMonths);
const js = bundle(ROOT, ["core/src/o09/settlement.ts"]);
const CONS = inlineConfig({ sharePermille: cfg.sharePermille, lagMonths: cfg.settlementLagMonths, gross: cfg.monthlyGrossFen, lagMax: LAG_MAX });
const script = `const C = ${CONS};
${js}
const $ = (id) => document.getElementById(id);
const draw = () => {
  const share = Number($("share").value), lag = Number($("lag").value);
  const r = runSettlement(C.gross, share, lag);
  $("shareOut").textContent = share + " ‰"; $("lagOut").textContent = lag + " 月";
  $("peak").textContent = r.peakGapFen + " 分"; $("final").textContent = r.finalGapFen + " 分";
  $("bars").textContent = r.rows.map((x) => "M" + x.month + " " + x.gapFen).join(" · ");
  globalThis.__pebble = { sharePermille: share, lagMonths: lag, peakGapFen: r.peakGapFen,
    finalGapFen: r.finalGapFen, gapByMonth: r.rows.map((x) => x.gapFen), ready: true };
};
$("share").addEventListener("input", draw); $("lag").addEventListener("input", draw);
$("reset").addEventListener("click", () => { $("share").value = String(C.sharePermille); $("lag").value = String(C.lagMonths); draw(); });
draw();
`;
const body = `  <h1>《合成小岛》· 联运分成与结算周期 计算器</h1>
  <div class="sub">复用 core/src/o09/settlement.ts:拖分成与账期,看「已赚未收」的资金占用怎么涨</div>
  <div class="card"><div class="k">渠道分成</div><div class="big" id="shareOut">-</div>
    <input id="share" type="range" min="0" max="600" step="10" value="${cfg.sharePermille}" style="width:100%"></div>
  <div class="card"><div class="k">账期滞后</div><div class="big" id="lagOut">-</div>
    <input id="lag" type="range" min="0" max="${LAG_MAX}" step="1" value="${cfg.settlementLagMonths}" style="width:100%"></div>
  <div class="card row"><div><div class="k">峰值资金占用</div><div class="big" id="peak">-</div></div>
    <div><div class="k">期末缺口</div><div class="big" id="final">-</div></div></div>
  <div class="card"><div class="k">逐月应收未收缺口(分)</div><div id="bars" style="font-size:15px;color:#38bdf8">-</div></div>
  <div class="card row"><button id="reset">回到 config 原值</button></div>`;
const bytes = writePage(join(ROOT, "web/dist/o09.html"), page({ title: "《合成小岛》分成与结算计算器 · o09", body, script }));
console.log(JSON.stringify({ html: "web/dist/o09.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
#!/usr/bin/env node
// file: web/verify_o09_calculator.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_o09_calculator.mjs web/dist/o09.html
// W 层验收:Playwright 无头打开计算器,拖分成与账期,断言峰值占用与 S 层仿真逐位一致。
import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/o09.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch(); const warm = await browser.newPage(); await warm.goto(url); await warm.close();
const errors = []; const page = await browser.newPage({ viewport: { width: 900, height: 900 } });
page.on("pageerror", (e) => errors.push(String(e))); await page.goto(url);
await page.waitForFunction(() => globalThis.__pebble?.ready === true, null, { timeout: 15000 });
const set = (share, lag) => page.evaluate(([s, l]) => {
  const a = document.getElementById("share"); a.value = String(s); a.dispatchEvent(new Event("input"));
  const b = document.getElementById("lag"); b.value = String(l); b.dispatchEvent(new Event("input"));
  return globalThis.__pebble;
}, [share, lag]);
const base = await set(300, 1), lag0 = await set(300, 0), lag3 = await set(300, 3), half = await set(500, 1);
await page.screenshot({ path: join(ROOT, "out/shots/o09_prototype.png"), fullPage: true });
await browser.close();
assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(base.peakGapFen, 8400000); assert.equal(base.finalGapFen, 4900000);
assert.deepEqual(base.gapByMonth, [7000000, 8400000, 7700000, 6300000, 5600000, 4900000]);
assert.equal(lag0.peakGapFen, 0); assert.equal(lag3.peakGapFen, 23100000);
assert.equal(lag3.finalGapFen, 16800000); assert.equal(half.peakGapFen, 6000000);
assert.ok(lag3.peakGapFen > base.peakGapFen, "账期越长峰值占用越大");
console.log(JSON.stringify({ page: "web/dist/o09.html", base, lag3, half, screenshot: "out/shots/o09_prototype.png" }, null, 2));
console.log("SMOKE-W-o09: PASS");

X 层:真实账期与到账日不可复现

边界必须划清。上面算的是模型侧:给定流水、分成与账期滞后,现金流缺口是多少。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的账期条款(结算周期、对账口径、到账日、起结金额,由合同与渠道财务流程决定);
  • 真实的到账延迟(对账争议、发票问题、节假日都会让实际到账晚于合同账期);
  • 真实的流水曲线(受买量、活动、赛季影响,波动远大于本节用的平滑假设)。

工程上正确的做法是:把可复现的现金流模型与不可复现的真实账期分开——模型用来回答「如果账期是 N 个月,我需要多少现金储备」,真实账期靠合同落地后反标定(把实际到账日填回 config,缺口立刻反映现实)。第 12 章会把这条链与收入确认、税务、跨境收款接起来。

自检表

检查项合格标准
分成与账期分开分多少、什么时候到,两个变量分别建模
缺口可复算逐月应收未收缺口、峰值占用逐位可复算
账期有扫描至少给两档账期对照,量化占用差异
到账口径明确账期滞后按月还是按天、起结日写清楚
全程整数净收入与缺口全为整数地板除,无浮点
边界清晰真实账期与到账日标 X,不写成「实测」

结算周期最危险的错觉,是把「合同上的账期」当成「实际到账日」。合同写 T+30,实际可能是 T+45——中间那 15 天,对现金流的占用是真实的。下一节我们把视角从「钱怎么分」转到「包怎么发」——一个游戏要同时上多个渠道,每个渠道的包该长什么样。

阅读导航:上一节:9.1 渠道分级与对接清单 · 下一节:9.3 渠道包与差异化配置 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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