12.2 现金流与收入确认

现金流与收入确认的核心是「收入在赚到那天确认、现金在账期到那天到账」的时点差:把《合成小岛》每日净收入与结算账期拉成两条累计曲线,算出资金缺口峰值出现在第几天、需要垫多少营运资金,并用 B 基准检验资金缺口对账期天数的敏感度,给出每压一天账期要多垫多少现金。

站内《上线变现与长线运营》 把收入模型、LTV、回本算得很细,站内游戏商业核心指标详解 定义了 ARPDAU、LTV 这些指标的口径。但那些指标讲的都是利润表口径的「赚了多少」,本节要补的是现金流量表口径的「钱到了没有」。两者经常对不上,而对不上的那一段,正是把小团队拖垮的地方。分工很清楚:前两者回答「赚多少」,本节回答「什么时候能花」。

上一节我们算清了每个平台的综合扣点与账期。那一节停留在「一笔收入到手多少」的静态账。这一节把时间轴拉进来——同一笔钱,赚到的那天和到账的那天之间隔了几十天,这几十天里你要照常发工资、付服务器、投下一波量。这就是现金流问题。

收入确认 vs 现金到账:两个时点

会计上,收入在履约完成时确认——玩家看了广告、买了内购,这一天你就「赚到」了这笔钱,哪怕一分钱还没到账。这叫权责发生制。而现金在平台打款时才真正进账,这叫收付实现制。两个时点之间的差额,形成两样东西:

  • 应收账款:已确认但未收到的钱,是资产,也是风险;
  • 资金缺口:为维持运营需要垫付的现金,是压力。

对小团队来说,第二样更要命。你没有大公司的授信额度,缺口只能用自有现金或借款填。所以运营操盘手必须能回答:我放量到某个规模,最多要垫多少钱、垫多久。

资金缺口 = 每日净收入 × 账期

先把最简单的模型说清楚。假设每天稳定产生 dailyNet 的净收入,账期 lag 天,那么:

  • 第 d 天确认的收入,第 d + lag 天才到账;
  • 稳定生产时,任意一天的「已确认未收到」= dailyNet × lag;
  • 这就是资金缺口的稳态值——也是你放量前必须先准备好的现金量。

这个乘法极其朴素,但它的推论不朴素:缺口和账期成正比,和规模也成正比。规模翻倍,缺口翻倍;账期从 30 天拉到 60 天,缺口再翻一倍。两个一起变,缺口四倍。这就是为什么「先小规模验证账期,再放量」是纪律,不是保守。

真实情况还要复杂一点:平台常留一笔风险金(暂扣),结算时先扣一部分,隔一段再释放。这等于在账期之外再加一段小账期。模型里要把它显式建出来,否则会低估缺口。

S 层仿真:缺口曲线

下面这段是 S 层仿真脚本 sim/o12_cashflow.py。它读 config/o12_cashflow.json 的四条流水线,逐日累计「确认收入」与「到账现金」两条曲线,找出资金缺口的峰值与出现日,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o12_cashflow.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o12_cashflow.py --config config/ --out out/
"""现金流与收入确认仿真:把「收入确认的时点」与「现金到账的时点」拉开,算出资金缺口曲线。

读 config/o12_cashflow.json。每个流水线 s:

    dailyNetFen(s)  = dailyGrossFen * (1000 - sharePermille) // 1000   # 每日净收入
    lag(s)          = settleCycleDays + holdDays                       # 结算账期(天)
    settleNetFen(s) = dailyNetFen * (1000 - reservePermille) // 1000   # 结算先扣风险金
    reserveFen(s)   = dailyNetFen - settleNetFen                       # 风险金(延后释放)

第 d 天(0 基):
    recognized(d) = Σ_s dailyNetFen(s),当 d < activeDays(收入在赚到那天确认)
    cash(d)       = Σ_s settleNetFen(s),当 d-lag(s) 落在 [0, activeDays)
                  + Σ_s reserveFen(s),  当 d-lag(s)-reserveReleaseDays 落在 [0, activeDays)
    缺口(d)       = Σ_{t<=d} recognized(t) - Σ_{t<=d} cash(t)

peakGapFen = max_d 缺口(d)(= 需要垫付的营运资金峰值);peakGapDay 为首次取到峰值的天。
口径:真实账期与暂扣规则需真实业务环境,命令行不可复现(X)。
产出 out/o12_cashflow.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_cashflow.json"), encoding="utf-8") as f:
        return json.load(f)


def stream_params(s: dict, reserve_permille: int) -> dict:
    daily_net = s["dailyGrossFen"] * (1000 - s["sharePermille"]) // 1000
    settle_net = daily_net * (1000 - reserve_permille) // 1000
    return {"id": s["id"], "name": s["name"], "dailyNetFen": daily_net,
            "lag": s["settleCycleDays"] + s["holdDays"], "settleNetFen": settle_net,
            "reserveFen": daily_net - settle_net}


def simulate(cfg: dict) -> dict:
    horizon = cfg["horizonDays"]
    active = cfg["activeDays"]
    reserve_rel = cfg["reserveReleaseDays"]
    params = [stream_params(s, cfg["reservePermille"]) for s in cfg["streams"]]
    daily_net_total = sum(p["dailyNetFen"] for p in params)

    curve = []
    cum_rec = cum_cash = 0
    peak_gap, peak_day = 0, -1
    for d in range(0, horizon + 1):
        rec = daily_net_total if d < active else 0
        cash = 0
        for p in params:
            if 0 <= d - p["lag"] < active:
                cash += p["settleNetFen"]
            if 0 <= d - p["lag"] - reserve_rel < active:
                cash += p["reserveFen"]
        cum_rec += rec
        cum_cash += cash
        gap = cum_rec - cum_cash
        if gap > peak_gap:
            peak_gap, peak_day = gap, d
        curve.append({"day": d, "recognizedFen": rec, "cashFen": cash,
                      "cumRecognizedFen": cum_rec, "cumCashFen": cum_cash, "gapFen": gap})
    return {"params": params, "dailyNetTotalFen": daily_net_total, "curve": curve,
            "peakGapFen": peak_gap, "peakGapDay": peak_day,
            "totalRecognizedFen": cum_rec, "totalCashFen": cum_cash,
            "cashLags": sorted(p["lag"] for p in params),
            "maxLag": max(p["lag"] for p in params)}


def draw_svg(path: str, curve: list, peak_day: int) -> int:
    """折线图:累计收入确认 vs 累计现金到账,中间阴影即资金缺口;峰值画竖线。"""
    W, H, PAD_L, PAD_B, PAD_T, PAD_R = 760, 320, 60, 40, 30, 20
    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": "20", "font-size": "14",
                                 "fill": "#111827"}).text = "收入确认 vs 现金到账(累计,分):两线之差 = 资金缺口"
    plot_w, plot_h = W - PAD_L - PAD_R, H - PAD_T - PAD_B
    n = len(curve)
    max_v = max(c["cumRecognizedFen"] for c in curve) or 1
    x = lambda d: PAD_L + plot_w * d // max(1, n - 1)
    y = lambda v: PAD_T + plot_h - plot_h * v // max_v
    for c in curve:
        if c["cumRecognizedFen"] > c["cumCashFen"]:
            ET.SubElement(root, "rect", {"x": str(x(c["day"])), "y": str(y(c["cumRecognizedFen"])),
                                         "width": "1", "height": str(y(c["cumCashFen"]) - y(c["cumRecognizedFen"])),
                                         "fill": "#fde68a"})
    rec_pts = " ".join(f'{x(c["day"])},{y(c["cumRecognizedFen"])}' for c in curve)
    cash_pts = " ".join(f'{x(c["day"])},{y(c["cumCashFen"])}' for c in curve)
    ET.SubElement(root, "polyline", {"points": rec_pts, "fill": "none", "stroke": "#2563eb", "stroke-width": "2"})
    ET.SubElement(root, "polyline", {"points": cash_pts, "fill": "none", "stroke": "#16a34a", "stroke-width": "2"})
    ET.SubElement(root, "line", {"x1": str(x(peak_day)), "y1": str(PAD_T), "x2": str(x(peak_day)),
                                 "y2": str(H - PAD_B), "stroke": "#ef4444", "stroke-dasharray": "4 4"})
    ET.SubElement(root, "text", {"x": str(x(peak_day) + 4), "y": str(PAD_T + 12), "font-size": "11",
                                 "fill": "#ef4444"}).text = f"缺口峰值 D{peak_day}"
    ET.SubElement(root, "text", {"x": str(PAD_L), "y": str(H - 10), "font-size": "11",
                                 "fill": "#2563eb"}).text = "蓝=累计确认  绿=累计到账  黄=缺口"
    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)
    sim = simulate(cfg)

    digest = f"dailyNet={sim['dailyNetTotalFen']},peakGap={sim['peakGapFen']},peakDay={sim['peakGapDay']}," \
             f"totalRec={sim['totalRecognizedFen']},totalCash={sim['totalCashFen']}"

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "o12_cashflow.svg"), sim["curve"], sim["peakGapDay"])
    with open(os.path.join(a.out, "o12_cashflow.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["day", "recognizedFen", "cashFen", "cumRecognizedFen", "cumCashFen", "gapFen"])
        for c in sim["curve"]:
            w.writerow([c["day"], c["recognizedFen"], c["cashFen"], c["cumRecognizedFen"],
                        c["cumCashFen"], c["gapFen"]])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "horizonDays": cfg["horizonDays"], "activeDays": cfg["activeDays"],
           "reservePermille": cfg["reservePermille"], "reserveReleaseDays": cfg["reserveReleaseDays"],
           "streams": [{k: v for k, v in p.items()} for p in sim["params"]],
           "dailyNetTotalFen": sim["dailyNetTotalFen"], "cashLags": sim["cashLags"],
           "maxLag": sim["maxLag"], "peakGapFen": sim["peakGapFen"], "peakGapDay": sim["peakGapDay"],
           "totalRecognizedFen": sim["totalRecognizedFen"], "totalCashFen": sim["totalCashFen"],
           "digest": digest, "csv": os.path.join(a.out, "o12_cashflow.csv"),
           "svg": os.path.join(a.out, "o12_cashflow.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "o12_cashflow.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"dailyNetTotalFen": sim["dailyNetTotalFen"], "cashLags": sim["cashLags"],
                      "maxLag": sim["maxLag"], "peakGapFen": sim["peakGapFen"],
                      "peakGapDay": sim["peakGapDay"], "totalRecognizedFen": sim["totalRecognizedFen"],
                      "totalCashFen": sim["totalCashFen"],
                      "peakGapPerLagDayFen": sim["peakGapFen"] // max(1, sim["maxLag"]),
                      "digest": digest}, ensure_ascii=False, indent=2))
    return 0


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

参数放在 config/o12_cashflow.json。四条流水线覆盖微信 / 抖音的广告与内购,账期不同,风险金按结算额 10% 暂扣、30 天后释放:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "horizonDays": 120,
  "activeDays": 60,
  "reservePermille": 100,
  "reserveReleaseDays": 30,
  "note": "现金流与收入确认参数。口径是「公开可观察机制」的建模假设,不是任何平台结算单实测。dailyGrossFen=每毛收入(分/日);sharePermille=平台扣点千分比;settleCycleDays=结算周期(天);holdDays=冻结/账期(天);reservePermille=平台风险金暂扣千分比(结算时先扣,reserveReleaseDays 天后释放)。收入在「赚到那天」确认,现金在「账期到那天」到账。真实账期与暂扣规则需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "streams": [
    { "id": "iaa_wx", "name": "广告(微信)", "dailyGrossFen": 150000, "sharePermille": 400, "settleCycleDays": 30, "holdDays": 15 },
    { "id": "iap_wx", "name": "内购(微信)", "dailyGrossFen": 70000, "sharePermille": 300, "settleCycleDays": 30, "holdDays": 15 },
    { "id": "iaa_dy", "name": "广告(抖音)", "dailyGrossFen": 90000, "sharePermille": 350, "settleCycleDays": 15, "holdDays": 7 },
    { "id": "iap_dy", "name": "内购(抖音)", "dailyGrossFen": 30000, "sharePermille": 300, "settleCycleDays": 15, "holdDays": 7 }
  ]
}

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

指标数值
每日净收入合计(分)218500
账期集合(天)22 / 45
最长账期(天)45
累计确认收入(分)13110000
累计到账现金(分)12915400
资金缺口峰值(分)8451000
缺口峰值出现日D59

读这张表要读出三件事。第一,确认了 1311 万,到账只有 1291.5 万——差额 194,600 分(约 1946 元)正是账期还没走完的风险金尾款,在 horizon 内还没释放完。第二,缺口峰值 8451000 分(约 8.45 万元)出现在 D59——不是月底,而是「收入停止增长、现金还没跟上」的那一刻。第三,峰值缺口 ÷ 最长账期 = 8451000 // 45 = 187800 分/天,也就是每多压一天账期,要多垫约 1878 元。这个数就是下一节 B 基准要盯的敏感度。

B 基准:缺口对账期有多敏感

缺口峰值完全由账期决定,而账期是平台条款,你单方面改不了。所以要问一个稳健性问题:「如果每个流水的账期整体 ±5 天,缺口峰值会怎么变?」下面这段是 B 层基准脚本 sim/o12_cashflow_bench.py,把每个流水的 settleCycleDays 整体平移 ±5 天重跑仿真,软告警不硬失败。

#!/usr/bin/env python3
# file: sim/o12_cashflow_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/o12_cashflow_bench.py --config config/ --out out/
"""B 基准:资金缺口峰值对「结算账期」的敏感度。

现金流缺口峰值直接由账期决定——而账期是**平台条款**,不是你能单方面决定的常数。
所以要问:「如果每个流水的账期整体 ±N 天,缺口峰值会怎么变?每多压一天账期,要多垫多少钱?」
做法:把每个流水的 settleCycleDays 整体 ±N 天,重跑 simulate(),比较 peakGapFen。
软告警:任一方向缺口峰值变化超过 50% 即打 WARN。退出码恒为 0——这是基准,不是门禁。
全整数、地板除,无浮点。
"""
from __future__ import annotations

import argparse
import json
import os

from o12_cashflow import load, simulate

PERTURB_DAYS = 5


def shifted(cfg: dict, days: int) -> dict:
    out = json.loads(json.dumps(cfg))
    for s in out["streams"]:
        s["settleCycleDays"] = max(0, s["settleCycleDays"] + days)
    return out


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)
    base = simulate(cfg)["peakGapFen"]

    cases = []
    for days in (-PERTURB_DAYS, PERTURB_DAYS):
        sim = simulate(shifted(cfg, days))
        delta = sim["peakGapFen"] - base
        cases.append({"shiftDays": days, "peakGapFen": sim["peakGapFen"], "peakGapDay": sim["peakGapDay"],
                      "deltaFen": delta, "deltaPermille": delta * 1000 // base if base else 0})

    span_days = 2 * PERTURB_DAYS
    per_day = (cases[1]["peakGapFen"] - cases[0]["peakGapFen"]) // span_days
    warn = any(abs(c["deltaPermille"]) > 500 for c in cases)

    os.makedirs(a.out, exist_ok=True)
    rep = {"version": cfg["version"], "source": "sim/o12_cashflow_bench.py", "perturbDays": PERTURB_DAYS,
           "basePeakGapFen": base, "cases": cases, "gapPerLagDayFen": per_day,
           "verdict": "WARN(soft) 缺口对账期高度敏感" if warn else "PASS(soft) 缺口对账期不敏感"}
    with open(os.path.join(a.out, "o12_cashflow_bench.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"basePeakGapFen": base, "cases": cases, "gapPerLagDayFen": per_day,
                      "verdict": rep["verdict"]}, ensure_ascii=False, indent=2))
    return 0  # B 层软告警:恒返回 0


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

在本机跑它,结果是 PASS(soft),退出码仍为 0:

扰动缺口峰值(分)相对基准‰
账期 −5 天7428000−122
基准84510000
账期 +5 天9474000+121

每压一天账期,缺口峰值变化 (9474000 − 7428000) // 10 = 204600 分/天,也就是每多压一天账期多垫约 2046 元。注意这个数比上面稳态估算的 1878 元/天大——因为它同时叠加了风险金释放期的平移。B 基准给出的结论是:缺口对账期近似线性敏感,但敏感度不低(5 天就动 12%)。所以账期谈判里,每争到一天都是真金白银的营运资金;反过来,接受一个更长的账期,等于默默多垫一笔现金。

X 层:真实账期与暂扣规则不可复现

边界必须划清。上面算的是模型侧:给定每日净收入与账期,缺口曲线长什么样。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的结算账期与打款日(平台规则、节假日顺延、对账差异都会挪动实际到账日);
  • 真实的风险金比例与释放条件(触发条件、扣减情形由平台条款定义);
  • 真实的现金消耗节奏(工资、服务器、投放预算的付款时点未必与收入对齐,实际缺口可能比模型更大)。

工程上正确的做法是:把可复现的现金流模型与不可复现的真实付款节奏分开——模型给出「最低限度要垫多少」的下界,真实账本再叠加费用侧的时间差。这也是第 14 章月度经营账要跟本节字段对齐的原因。

自检表

检查项合格标准
双时点收入确认日与现金到账日分开建模
风险金暂扣与释放期显式建出,不并入账期蒙混
峰值给出缺口峰值金额与出现日,不只看总额
敏感度对账期做 ±N 天扰动,量化每元/天
边界清晰真实账期与暂扣规则标 X,不写成「实测」

现金流缺口算出来了,但它还停在「平台 → 你」这一段。真正落到银行账户之前,还有最后一道关:税、发票、跨境收款。下一节我们把这最后一段的净额算清楚。

阅读导航:上一节:12.1 平台分成与结算周期 · 下一节:12.3 税务、发票与跨境收款 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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