15.2 经济事故案例库

上一节教会我们认出可疑账号,这一节处理已经发生的事故。SLG 的经济事故翻来覆去就那么几类:产出漏洞、复制 bug、汇率套利、赛季重置算错、补偿发多。每一类都给「症状 / 根因 / 影响面 / 处置」四段,并把案例库结构化成一份可校验的配置——真实事故一律标 X(需真实业务环境,命令行不可复现),只归纳公开可观察的机制。

15.2 经济事故案例库

上一节把「谁可疑」算清楚了,但检测器只负责发现。真正把经济搞崩的,往往不是慢慢薅的工作室,而是一次爆发式的事故:一封邮件被并发领取了一百次、一次赛季重置把折算比例写错了、一批补偿发给了全服。站内的赛季重置原则 给的是「重置要注意什么」的通用清单,这一节把口径收紧到 SLG 的经济事故:把它们归成五类,每类给出「症状 / 根因 / 影响面 / 处置」四段,并把整本案例库结构化成一份可以校验的配置。

X 层声明:本节所有事故都来自公开可观察的机制归纳(版本公告、玩家反馈、公开复盘文),不包含任何公司的后台数据,也不写成「实测」。它们标为 X 层——需真实业务环境,命令行不可复现。可复现的只有案例库的结构,也就是下面的校验脚本。

五类事故模式

事故看起来千奇百怪,按资源从哪里凭空多出来归类,只有五种:

类别一句话典型触发点
产出漏洞 production_leak产量算多了离线结算、增益叠加
复制 bug duplication_bug同一份资源出现了两次并发领取、跨角色事务
汇率套利 fx_arbitrage两个价之间来回走一圈就变多官方比价与市场价脱节
赛季重置算错 season_reset_error批量折算写错了比例或顺序折算比例、发奖与清零的顺序
补偿发多 compensation_overissue一次大规模货币注入全服补偿的重试与幂等

五类的共同点是:多出来的资源都进入了玩家存量。区别在于「多出来」发生在哪一层——产出层、结算层、兑换层、重置层、运营层。下面逐类给一个代表性案例。

产出漏洞:离线产出绕过库存上限

症状:版本更新后,全服资源存量一周内单调抬升,头部玩家仓库常年贴顶却仍在增长,拍卖行原材料价格缓慢下滑。
根因:离线结算补算时用「上限前」的产量入账,而不是先按资源上限截断再入账——在线路径有截断、离线路径漏了。
影响面:全服所有用离线收益的账号;上线越久、离线越长,超发越多,对材料价格形成持续下行压力。
处置:止血先关掉离线补算开关、只结算最近一次在线时间;修复让离线与在线共用同一个截断函数;已入库的资源不追缴,改由后续回收活动吸收。

复制 bug:邮件附件并发领取重复发放

症状:单个账号短时间收到远超正常量的同一附件,领奖接口在弱网重试下出现并发,邮件「标记已读」与「扣减附件」不是原子操作。
根因:领取是「先查未领取、再发资源、最后标记」三步,缺少幂等键与行锁,并发请求都通过了第一步检查。
影响面:任何能制造并发的客户端;数分钟就能把资源翻若干倍,直接冲击货币供给。
处置:止血给领取接口加账号级串行化 + 幂等键;修复把「查—发—标」压成一次带条件的原子更新;对重复发放做定向追缴,追不回的记坏账。

汇率套利:买卖价差形成无损套利环

症状:某两种货币的兑换比与市场成交价长期背离,少数账号高频来回兑换且稳定盈利,该货币对的挂单被秒吃。
根因:官方兑换比率是固定值,市场价随供需浮动;两者出现正价差时,来回兑换即可无风险套利。
影响面:能写脚本盯价差的账号;价差被吃平前会持续抽走系统手续费与市场流动性。
处置:止血给兑换加双边手续费与冷却,消除正价差;修复让官方比率跟随市场价移动,或直接关掉该货币对;不追缴,靠摩擦把套利空间压到零。

赛季重置算错:积分折算比例写错

症状:新赛季开局普遍玩家起点远高于设计值,通用货币在第一天就泛滥,赛季榜前段被上一季分数直接顶满。
根因:赛季结算把「未使用积分」按固定比例折算成通用货币,比例单位从千分之一写成了百分之一,超发约 10 倍。
影响面:全服所有参与上赛季的账号;新赛季经济从第一天就偏离稳态,且不可逆。
处置:止血立即停折算脚本、冻结相关玩法;修复回滚折算、按正确比例重算并覆盖;重算后对受影响玩家发等值补偿券。

补偿发多:全服补偿重复广播

症状:同一封补偿邮件在部分账号上出现多封,补偿发放脚本被重试触发,全服货币供给当天阶跃抬升。
根因:全服补偿用「遍历全服账号发邮件」实现,脚本重试导致同一批账号被发多轮,缺少批次幂等键。
影响面:全服账号;一次性货币注入直接抬升物价,后续要额外回收活动对冲。
处置:止血暂停发放、按批次号去重;修复让补偿走带幂等键的批次表,重试命中即跳过;对多发账号只回收超出部分,不回收第一封。

把案例库结构化成配置

案例本身不可复现,但案例库的字段是结构化的。把它写成 config/ch15_incidents.json:每个案例有 symptom(症状)、rootCause(根因)、blastRadius(影响面)、handling(处置)、lesson(教训)五个字段,再加 category(分类)与 severity(严重度 1–5)。每条都带 reproducibility: "X"——这是一条纪律:凡是进不了命令行的,就必须在数据里显式标出来,不许含糊。

下面这段是 S 层校验脚本。它不检查事故本身(那不可复现),只检查案例库的结构:五段是否写全、分类是否合法、严重度是否在区间内、每个分类是否都有案例。校验器还会故意注入五类缺陷,证明它确实会报错。

# file: sim/ch15_incident_casebook.py
# tier: S
# verify: python3 sim/ch15_incident_casebook.py --config config/
"""经济事故案例库的结构化校验。

事故本身不可复现(reproducibility=X),但案例库的**结构**可以校验:每条案例必须
把「症状 / 根因 / 影响面 / 处置 / 教训」五段写全,分类必须合法,严重度必须在区间内,
分类不得有空档。本脚本读 config/ch15_incidents.json,逐条校验并产出
out/ch15_incidents.json(校验结论 + 分类统计)、out/ch15_incidents.csv(拍平的案例表)。
纯标准库,零依赖。
"""

from __future__ import annotations

import argparse
import copy
import csv
import json
import os

REQUIRED_STR = ["id", "name", "category", "rootCause", "blastRadius", "lesson"]
REQUIRED_LIST = ["symptom", "handling"]
REQUIRED_ALL = REQUIRED_STR + REQUIRED_LIST + ["severity", "reproducibility"]


def validate(cfg: dict) -> tuple[list[str], dict]:
    errs: list[str] = []
    cat_ids = [c["id"] for c in cfg["categories"]]
    cat_set = set(cat_ids)
    cases = cfg["cases"]

    seen: set[str] = set()
    by_cat: dict[str, int] = {c: 0 for c in cat_ids}
    by_sev: dict[int, int] = {}
    for case in cases:
        cid = case.get("id", "<no-id>")
        for f in REQUIRED_ALL:
            if f not in case:
                errs.append(f"MISSING_FIELD {cid}.{f}")
        if cid in seen:
            errs.append(f"DUP_ID {cid}")
        seen.add(cid)

        for f in REQUIRED_STR:
            if not str(case.get(f, "")).strip():
                errs.append(f"EMPTY_STR {cid}.{f}")
        for f in REQUIRED_LIST:
            v = case.get(f)
            if not isinstance(v, list) or len(v) == 0 or not all(str(x).strip() for x in v):
                errs.append(f"EMPTY_LIST {cid}.{f}")

        cat = case.get("category")
        if cat not in cat_set:
            errs.append(f"BAD_CATEGORY {cid}.{cat}")
        else:
            by_cat[cat] += 1

        sev = case.get("severity")
        if not isinstance(sev, int) or not (1 <= sev <= 5):
            errs.append(f"BAD_SEVERITY {cid}.{sev}")
        else:
            by_sev[sev] = by_sev.get(sev, 0) + 1

        if case.get("reproducibility") != "X":
            errs.append(f"BAD_REPRO {cid}.{case.get('reproducibility')}")

    for cat in cat_ids:
        if by_cat[cat] == 0:
            errs.append(f"EMPTY_CATEGORY {cat}")

    stats = {
        "caseCount": len(cases),
        "categoryCount": len(cat_ids),
        "byCategory": by_cat,
        "bySeverity": {str(k): by_sev.get(k, 0) for k in range(1, 6)},
        "avgSeverity": round(sum(c.get("severity", 0) for c in cases) / len(cases), 3) if cases else 0,
        "allX": all(c.get("reproducibility") == "X" for c in cases),
    }
    return errs, stats


def mutation_demo(cfg: dict) -> dict:
    """故意注入五类缺陷,证明校验器确实会报错(不改任何配置文件)。"""
    bad = copy.deepcopy(cfg)
    cases = bad["cases"]
    cases[0]["id"] = cases[1]["id"]                 # 重复 id
    cases[2]["rootCause"] = "   "                   # 空字符串
    cases[3]["handling"] = []                       # 空列表
    cases[4]["category"] = "not_a_category"         # 非法分类
    cases[5]["severity"] = 9                         # 严重度越界
    cases[6]["reproducibility"] = "S"               # 非 X
    errs, _ = validate(bad)
    codes = sorted({e.split()[0] for e in errs})
    return {"injected": ["DUP_ID", "EMPTY_STR", "EMPTY_LIST", "BAD_CATEGORY", "BAD_SEVERITY", "BAD_REPRO"],
            "caught": codes, "rawCount": len(errs)}


def write_csv(cfg: dict, path: str) -> None:
    rows = []
    for c in cfg["cases"]:
        rows.append({
            "id": c["id"], "name": c["name"], "category": c["category"],
            "severity": c["severity"], "reproducibility": c["reproducibility"],
            "symptomCount": len(c["symptom"]), "handlingCount": len(c["handling"]),
            "symptom": " | ".join(c["symptom"]), "rootCause": c["rootCause"],
            "blastRadius": c["blastRadius"], "handling": " | ".join(c["handling"]),
            "lesson": c["lesson"],
        })
    rows.sort(key=lambda r: (-r["severity"], r["category"], r["id"]))
    with open(path, "w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        w.writeheader()
        w.writerows(rows)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    with open(os.path.join(args.config, "ch15_incidents.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    errs, stats = validate(cfg)
    demo = mutation_demo(cfg)
    report = {"version": cfg["version"], "game": cfg["game"], "ok": not errs,
              "errors": errs, "stats": stats, "mutationDemo": demo}

    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch15_incidents.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    write_csv(cfg, os.path.join(args.out, "ch15_incidents.csv"))
    print(json.dumps(report, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

本机实跑(python3 sim/ch15_incident_casebook.py --config config/)的输出:

ok=True
caseCount=8 categoryCount=5 avgSeverity=4.0 allX=True
byCategory production_leak=2 duplication_bug=2 fx_arbitrage=1 season_reset_error=2 compensation_overissue=1
bySeverity 3=2 4=4 5=2
mutationDemo injected=[DUP_ID, EMPTY_STR, EMPTY_LIST, BAD_CATEGORY, BAD_SEVERITY, BAD_REPRO]
mutationDemo caught=[BAD_CATEGORY, BAD_REPRO, BAD_SEVERITY, DUP_ID, EMPTY_CATEGORY, EMPTY_LIST, EMPTY_STR]

mutationDemo 那两行是这段脚本最值得看的地方。它注入了六类缺陷,校验器抓出了七条错误(比注入的还多一条——非法分类同时触发了「该分类没有案例」)。一个没被反例验证过的校验器,和一个永远返回 ok 的校验器,没有区别。

为什么案例库要「结构化」

把事故写成散文,读一遍就忘;写成有固定字段的数据,才能拿来做三件事:上线前逐条对照自查(这五类我各防住了没有)、事故发生后按四段填(症状/根因/影响面/处置,不许跳步)、统计哪一类最高发(byCategory 一眼看出哪类要重点加固)。上面这份案例库里,production_leak 与 duplication_bug 各两条、season_reset_error 两条,说明「多出来」最容易发生在结算路径与批量写上——这两处恰好也是测试最难覆盖的地方。

还有一条纪律值得单独讲:reproducibility: "X" 这个字段。它的作用不是分类,而是提醒写作者诚实。事故往往涉及真实后台、真实玩家、真实时间窗,命令行里复现不出来;把它标成 X,就是在告诉读者「这里我没有跑过,我引用的是公开机制」。凡是标了 X 又写了具体数字的,都要打问号。

本节小结

这一节把经济事故从「一团乱麻」归成「五类可对照的模式」:

  1. 五类模式:产出漏洞、复制 bug、汇率套利、赛季重置算错、补偿发多——按「资源从哪一层凭空多出来」归类,覆盖 SLG 绝大多数经济事故。
  2. 每类四段:症状(怎么发现)、根因(为什么会多)、影响面(坏了多少)、处置(先止血再修复)——四段缺一不可,缺了就会在真实事故里手忙脚乱。
  3. 结构可校验、纪律可检查:案例库是带 schema 的配置,reproducibility: "X" 是一条不能省的诚实标记。

案例库告诉你「通常怎么坏」。但真正出事的那一刻,没有人会按案例库的顺序出牌——资源还在源源不断地发,玩家在群里刷屏。下一节给出一套固定的处置动作:发现、止血、定位、修复、补偿、复盘六步,以及最难的决策——回滚的三条边界。

阅读导航:上一节:15.1 异常检测与打金识别 · 下一节:15.3 修复流程与回滚预案 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引