站内《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 个月时,逐月缺口是(同机相对量级,绝对数值不可移植):
| 月份 | 毛流水(分) | 净收入(分) | 当月到账(分) | 应收未收缺口(分) |
|---|---|---|---|---|
| M0 | 10000000 | 7000000 | 0 | 7000000 |
| M1 | 12000000 | 8400000 | 7000000 | 8400000 |
| M2 | 11000000 | 7700000 | 8400000 | 7700000 |
| M3 | 9000000 | 6300000 | 7700000 | 6300000 |
| M4 | 8000000 | 5600000 | 6300000 | 5600000 |
| M5 | 7000000 | 4900000 | 5600000 | 4900000 |
峰值占用出现在 M1:8400000 分。M0 完全没有到账(因为账期滞后一个月),缺口直接堆到 7000000;M1 到账的是 M0 的净收入(7000000),但 M1 自己又赚了 8400000,缺口反而涨到峰值。之后随着流水回落,缺口慢慢收窄,到 M5 降到 4900000。
账期扫描:账期多长,资金占用就多大
固定分成 30%,把账期从 0 扫到 3 个月,峰值占用是这样的:
| 账期滞后 | 峰值占用(分) | 期末缺口(分) |
|---|---|---|
| 0 月 | 0 | 0 |
| 1 月 | 8400000 | 4900000 |
| 2 月 | 16100000 | 10500000 |
| 3 月 | 23100000 | 16800000 |
规律单调:账期每多滞后一个月,峰值占用就多压一个月的净收入。账期 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 渠道包与差异化配置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。