15.2 投放、结算与资质的典型失败

投放、结算与资质三类典型失败的判定清单:每条失败模式给出可观测指标、阈值与比较方向,用可跑的 Python 仿真逐条判定是否越界,量化越界幅度并按域汇总成一份体检报告,让「这次是不是踩坑了」从感觉变成一条条可对照阈值的判定;真实结算差异与审批结果一律标 X。

站内游戏产业专题 有 81 篇,覆盖客服、社区、舆情、团队、外包、排期与复盘——那是「运营团队怎么组织、舆情怎么应对」的口径。本节不碰组织,只做一件更窄的事:把投放、结算、资质三条线上最常见的失败模式列成一张可判定的清单,让「这次是不是踩坑了」从一句感觉变成一条条能对照阈值的判定。它与卷三失败模式与避坑 那章分工不同:卷三讲的是玩法数值、买量留存、运营合规的失败,本节聚焦卷四自己这条线——投放后台、结算对账、资质办理。

三类失败,各有各的死法

失败不是一种,而是三类,每类的责任人和救命手段都不同:

域谁负责典型死法救命手段
投放失败投放操盘手CAC 高于 LTV、放量过猛、素材疲劳关停、降量、换素材
结算失败财务 / 商务账期超现金缓冲、对账差异过大砍账期、对账、调整渠道
资质失败法务 / 发行倒排不足、材料缺项提前办、补材料

三类的共同点是:它们都不是「突然发生」,而是「指标早就越界了,只是没人看」。所以对付它们的办法不是事后救火,而是把每条失败模式写成一个可观测指标 + 一条阈值,定期体检。

失败判定:一个指标、一条阈值、一个方向

每条失败模式都写成同一个结构:一个可观测指标 observed、一条阈值 threshold、一个比较方向 comparator。判定规则简单到可以背下来:

triggered   = (comparator == "gt") ? observed > threshold : observed < threshold
gap         = |observed − threshold|            # 越过阈值的绝对量
gapPermille = gap × 1000 // max(threshold, 1)   # 越界幅度(千分比)

gt 是「越高越糟」(如 CPI、账期、对账差异),lt 是「越低越糟」(如点击到安装转化率、资质倒排天数)。一条场景触发失败,就说明这个指标已经越过红线;gapPermille 告诉你越了多少,用来排优先级。

S 层仿真:把清单跑成一份体检报告

下面这段是 S 层仿真脚本 sim/o15_failure.py。它读 config/o15_failure.json 的十条场景,逐条判定是否触发失败,算出越界幅度,再按域汇总成一份「失败体检报告」,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o15_failure.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o15_failure.py --config config/ --out out/
"""投放、结算与资质三类典型失败的判定仿真:把「失败模式清单」变成可复算的布尔判定。

读 config/o15_failure.json 的每条场景,按比较方向判定是否触发失败:

    triggered(s)   = (comparator == "gt") ? observed > threshold : observed < threshold
    gap(s)         = |observed - threshold|                        # 越过阈值的绝对量
    gapPermille(s) = gap * 1000 // max(threshold, 1)               # 相对阈值的越界幅度
    severity(s)    = triggered ? "HIGH" : "OK"

再按 domain 汇总:某域内任一条触发,则该域判为 RISK,否则 OK。给出一份「失败体检报告」——
它不预测未来,只把复盘时填进去的观测值逐条对照阈值,把「拍脑袋觉得有问题」变成「这一条越界了多少」。

口径:observed 是模型侧填值,不是任何真实后台 / 结算单 / 审批结果的实测;真实结算差异与
真实审批结果需真实业务环境,命令行不可复现(X)。产出 out/o15_failure.json + .csv + .svg。
全整数、地板除,无浮点。
"""
from __future__ import annotations

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


def judge(s: dict) -> dict:
    obs, th, cmp = s["observed"], s["threshold"], s["comparator"]
    triggered = (obs > th) if cmp == "gt" else (obs < th)
    gap = abs(obs - th)
    gap_permille = gap * 1000 // max(th, 1)
    return dict(s, triggered=triggered, gap=gap, gapPermille=gap_permille,
                severity="HIGH" if triggered else "OK")


def draw_svg(path: str, rows: list, domains: list) -> int:
    """纯 Python SVG:每个域一根横条——该域触发的失败条数 / 该域场景总数。"""
    W, H, PAD, ROW = 760, 90 + 48 * len(domains), 200, 48
    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 = "三类失败的体检:各域触发条数 / 场景总数"
    span = W - PAD - 160
    for i, d in enumerate(domains):
        total = sum(1 for r in rows if r["domain"] == d["id"])
        hit = sum(1 for r in rows if r["domain"] == d["id"] and r["triggered"])
        y = 56 + i * ROW
        w = span * hit // max(total, 1)
        color = "#ef4444" if hit else "#16a34a"
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
                                     "fill": "#374151"}).text = d["name"]
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(max(w, 1)),
                                     "height": "22", "rx": "4", "fill": color})
        ET.SubElement(root, "text", {"x": str(PAD + max(w, 1) + 8), "y": str(y + 22),
                                     "font-size": "12", "fill": "#6b7280"}).text = f"{hit} / {total} 触发"
    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()

    with open(os.path.join(a.config, "o15_failure.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    rows = [judge(s) for s in cfg["scenarios"]]

    domain_summary = []
    for d in cfg["domains"]:
        in_domain = [r for r in rows if r["domain"] == d["id"]]
        hit = sum(1 for r in in_domain if r["triggered"])
        domain_summary.append({"id": d["id"], "name": d["name"], "total": len(in_domain),
                               "triggered": hit, "verdict": "RISK" if hit else "OK"})
    triggered_ids = [r["id"] for r in rows if r["triggered"]]
    digest = ",".join(f'{r["id"]}:{"1" if r["triggered"] else "0"}:{r["gapPermille"]}'
                      for r in rows) + f';hit={len(triggered_ids)}'

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "o15_failure.svg"), rows, cfg["domains"])
    with open(os.path.join(a.out, "o15_failure.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "domain", "name", "metric", "observed", "threshold", "comparator",
                "unit", "triggered", "gap", "gapPermille", "severity", "note")
        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"],
           "scenarioCount": len(rows), "triggeredCount": len(triggered_ids),
           "triggeredIds": triggered_ids, "domainSummary": domain_summary, "scenarios": rows,
           "worstScenario": max(rows, key=lambda r: (r["triggered"], r["gapPermille"]))["id"],
           "digest": digest, "csv": os.path.join(a.out, "o15_failure.csv"),
           "svg": os.path.join(a.out, "o15_failure.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "o15_failure.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"domainSummary": domain_summary, "triggeredCount": len(triggered_ids),
                      "triggeredIds": triggered_ids,
                      "scenarios": [(r["id"], "FAIL" if r["triggered"] else "OK", r["gapPermille"])
                                    for r in rows],
                      "worstScenario": rep["worstScenario"], "digest": digest},
                     ensure_ascii=False, indent=2))
    return 0


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

失败清单放在 config/o15_failure.json。observed 是复盘时在模型侧填进去的值,不是任何真实后台、结算单或审批结果的实测:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "投放、结算与资质三类典型失败的判定清单。口径是「公开可观察机制」的建模假设:每条失败模式给出一个可观测指标 observed、一条阈值 threshold 与比较方向 comparator(gt=observed 超过阈值即判失败;lt=observed 低于阈值即判失败)。observed 是「复盘时在模型侧填进去的值」,不是任何真实后台、结算单或审批结果的实测;真实结算差异、真实审批结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "domains": [
    { "id": "ua", "name": "投放失败", "owner": "投放操盘手" },
    { "id": "settle", "name": "结算失败", "owner": "财务 / 商务" },
    { "id": "qual", "name": "资质失败", "owner": "法务 / 发行" }
  ],
  "scenarios": [
    { "id": "ua-cac-over-ltv", "domain": "ua", "name": "单用户成本高于生命周期价值", "metric": "cacFen", "observed": 520, "threshold": 430, "comparator": "gt", "unit": "分", "note": "CAC 高于 LTV30,越投越亏;应关停或降量" },
    { "id": "ua-ramp-too-fast", "domain": "ua", "name": "放量节奏过猛", "metric": "rampPct", "observed": 200, "threshold": 130, "comparator": "gt", "unit": "%", "note": "单次预算爬坡超过学习期承载,易触发重新学习" },
    { "id": "ua-creative-fatigue", "domain": "ua", "name": "素材疲劳未及时替换", "metric": "creativeAgeDays", "observed": 21, "threshold": 14, "comparator": "gt", "unit": "天", "note": "同组素材在线超过衰减窗口,CPI 抬头" },
    { "id": "ua-funnel-drop", "domain": "ua", "name": "点击到安装漏斗异常", "metric": "ctiPermille", "observed": 145, "threshold": 180, "comparator": "lt", "unit": "‰", "note": "点击到安装转化率偏低,落地页或包体有问题" },
    { "id": "settle-long-cash-cycle", "domain": "settle", "name": "账期超过现金缓冲", "metric": "cashDays", "observed": 75, "threshold": 45, "comparator": "gt", "unit": "天", "note": "结算周期加冻结期超过可垫付天数,现金流断裂风险" },
    { "id": "settle-recon-gap", "domain": "settle", "name": "三方对账差异超阈", "metric": "reconDiffPermille", "observed": 85, "threshold": 30, "comparator": "gt", "unit": "‰", "note": "归因口径差异过大,结算金额对不上" },
    { "id": "settle-refund-spike", "domain": "settle", "name": "退款率异常", "metric": "refundPermille", "observed": 45, "threshold": 80, "comparator": "gt", "unit": "‰", "note": "退款率在阈值内,本项不判失败" },
    { "id": "qual-lead-short", "domain": "qual", "name": "资质倒排时间不足", "metric": "qualLeadDays", "observed": 45, "threshold": 90, "comparator": "lt", "unit": "天", "note": "距上线预留的资质办理天数不足,会卡上线" },
    { "id": "qual-missing-material", "domain": "qual", "name": "必填材料缺项", "metric": "missingMandatory", "observed": 2, "threshold": 0, "comparator": "gt", "unit": "项", "note": "版号/文网文/软著必填材料缺项,提审必被退回" },
    { "id": "qual-ready", "domain": "qual", "name": "资质齐备", "metric": "missingMandatory", "observed": 0, "threshold": 0, "comparator": "gt", "unit": "项", "note": "材料齐备,本项不判失败" }
  ]
}

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

域场景数触发判定
投放失败44RISK
结算失败32RISK
资质失败32RISK

十条场景里 8 条触发失败,只有 settle-refund-spike(退款率 45‰,低于 80‰ 阈值)与 qual-ready(材料齐备)两条安全。越界最狠的是 qual-missing-material:阈值 0、观测 2,gapPermille 达到 2000‰——必填材料缺一项就是硬失败,因为提审必被退回,没有商量余地。其次是 settle-recon-gap(对账差异 85‰ 对阈值 30‰,越界 1833‰),这条最隐蔽:它不直接让你亏钱,但会让「后台收入」和「到手现金」对不上,第 4.3 节的对账工具就是为它准备的。投放域的 4 条全中,说明一个不设止损的投放计划,几乎必然同时踩中 CPI、放量、素材三条线。

逐条摊开看,体检报告长这样(同机相对量级,绝对数值不可移植):

场景指标观测阈值方向越界‰判定
ua-cac-over-ltvcacFen520430gt209FAIL
ua-ramp-too-fastrampPct200130gt538FAIL
ua-creative-fatiguecreativeAgeDays2114gt500FAIL
ua-funnel-dropctiPermille145180lt194FAIL
settle-long-cash-cyclecashDays7545gt666FAIL
settle-recon-gapreconDiffPermille8530gt1833FAIL
settle-refund-spikerefundPermille4580gt437OK
qual-lead-shortqualLeadDays4590lt500FAIL
qual-missing-materialmissingMandatory20gt2000FAIL
qual-readymissingMandatory00gt0OK

这张表最有用的不是「谁 FAIL」,而是方向一栏:ua-funnel-drop 与 qual-lead-short 是 lt(越低越糟),其余都是 gt(越高越糟)。同一个「变差了」的感觉,在指标上可能是变大也可能是变小——方向写错,体检结论会完全反过来。所以清单里每条都必须显式写下 comparator,不能靠默认。

X 层:真实结算与审批结果不可复现

边界必须划清。上面算的是判定侧:给定观测值,某条失败模式是否触发。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的结算差异(由平台侧逐笔核算、退款扣减与作弊扣减共同决定,不同账期对不上是常态);
  • 真实的资质审批结果与时长(版号、文网文、软著的受理与审批节奏由主管部门决定);
  • 真实的投放后台数据(学习期状态、计划消耗、素材衰减曲线都在平台侧,且口径常变)。

工程上正确的做法是:把可复现的判定规则与不可复现的真实数据分开——判定规则是清单(本节),真实观测值靠后台报表反标定(把实际 CPI、账期、对账差异、审批时长填回 config,体检立刻反映现实)。

自检表

检查项合格标准
一指标一阈值每条失败模式都有可观测指标 + 阈值,不含糊
方向明确gt / lt 写死在配置,不靠口头约定
越界量化给出 gapPermille,能排优先级
分域汇总按投放 / 结算 / 资质分别判 RISK / OK
全整数判定与越界幅度全为整数地板除,无浮点
边界清晰真实结算与审批结果标 X,不写成「实测」

清单把「哪里会失败」摊开了,但同一个岔路口,往往有多个选择,而每个选择都各有代价。下一节我们把全卷的关键决策收成一张对照表,用一段可查询的 TS 内核把「该选哪个」变成可复算的加权打分。

阅读导航:上一节:15.1 《合成小岛》投放与变现复盘 · 下一节:15.3 关键决策对照表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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