13.1 中台要解决什么问题

「受影响项目多」不等于「该中台化」。本节把中台判据写成一笔整数账:年节省人天×100、回本月数×100、是否值得做,除法一律地板除;再用两条负例证明单轴判定会误收只影响一个项目的问题。TS 侧独立复算 Python 落盘的 JSON,整数逐位一致,digest 对拍。

第 12 章的 CI 门禁把规则变成了闸门,但闸门只负责「检查」;真正难的是这些问题谁来长期维护、谁来跨项目统一。本章问的是更靠上的一层:哪些问题值得从项目组抽出来,交给一个专门的中台团队统一解决。站内 《游戏性能优化:从生产到落地》 讲的是性能优化在生产里怎么推行——那篇讲道理;本节讲「要不要中台化」这笔账怎么算。DevOps / CI 平台本身不是本节内容,本卷只算账、不重推平台实现。

13.1 中台要解决什么问题

13.1.1 中台不是「更大的项目组」

中台的定义可以写得很简单:把重复出现、跨项目复用、且需要专人长期维护的问题,从项目组收上来统一解决。三个条件缺一不可——

  • 重复出现:不同项目各自踩同一类坑,而不是某一个项目的特例;
  • 跨项目复用:解决方案能被多个项目同时消费,复用面决定摊薄效果;
  • 需专人维护:它会随引擎、DCC、规范持续变化,放着不管就会腐坏。

只满足「重要」而不满足这三条的,不该进中台——那是项目组自己的活。

13.1.2 判据:一笔整数账,不是一句口号

「该不该中台化」可以用人天算出来。设某问题影响 N 个项目,不中台化时每个项目每年要重复投入 r;中台化需要一次性建设 b,之后每年维护 m。则:

量公式含义
年节省N × r - m中台化后每年净省下的人天
回本月数b × 12 ÷ 年节省建设成本要多久摊平
值得做回本 ≤ 观察期观察期是团队能接受的回收窗口

全部用人天×100、月数×100 做整数,除法一律整数地板除。年节省 ≤ 0 时永不回本,回本记 null 而不是 0——0 会被误读成「立刻回本」。

13.1.3 D 层:判据配置

下面这段是 D 层配置:六个待判定问题与它们的示例参数(复用面、重复投入、建设、维护)。真实产能需真实团队环境,命令行不可复现(X)。

{
  "version": 1,
  "project": "stardust-inn",
  "note": "13.1 中台判据账。每个问题:projectsAffected = 受影响项目数;repeatPersonDaysX100 = 不中台化时每个项目每年重复投入(人天×100);buildPersonDaysX100 = 中台一次性建设成本;maintainPersonDaysX100PerYear = 中台化后每年维护成本。全部为本套构造的示例参数,真实产能需真实团队环境,命令行不可复现(X)。判据:annualSavingX100 = 受影响数×重复投入 - 年维护;paybackMonthsX100 = 建设×12×100 // 年节省(整数地板除,永不回本记 null);worthPlatform = 回本 ≤ horizonMonths×100。",
  "horizonMonths": 24,
  "problems": [
    { "id": "p-naming", "name": "命名与目录校验", "projectsAffected": 3, "repeatPersonDaysX100": 800, "buildPersonDaysX100": 1200, "maintainPersonDaysX100PerYear": 400 },
    { "id": "p-export", "name": "导出预设与单位对齐", "projectsAffected": 3, "repeatPersonDaysX100": 600, "buildPersonDaysX100": 900, "maintainPersonDaysX100PerYear": 300 },
    { "id": "p-manifest", "name": "资源清单差异核对", "projectsAffected": 3, "repeatPersonDaysX100": 700, "buildPersonDaysX100": 1100, "maintainPersonDaysX100PerYear": 350 },
    { "id": "p-atlas", "name": "图集装箱校验", "projectsAffected": 2, "repeatPersonDaysX100": 500, "buildPersonDaysX100": 1600, "maintainPersonDaysX100PerYear": 700 },
    { "id": "p-vfx", "name": "特效时长与屏占比检查", "projectsAffected": 1, "repeatPersonDaysX100": 900, "buildPersonDaysX100": 1000, "maintainPersonDaysX100PerYear": 500 },
    { "id": "p-onboard", "name": "新人上手文档与样例", "projectsAffected": 2, "repeatPersonDaysX100": 300, "buildPersonDaysX100": 1400, "maintainPersonDaysX100PerYear": 800 }
  ]
}

13.1.4 S 层:中台判据账

下面这段是 S 层脚本,读上面那份配置,逐个问题算年节省、回本月数与判定,落 out/ta13_platform.json,并另落 canonical 摘要与 sha256 前 16 位供 P 层对拍。

#!/usr/bin/env python3
# file: sim/ta13_platform.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta13_platform.py
"""13.1 中台要解决什么问题:把「要不要中台化」算成一笔整数账。

读 config/ta13_platform.json。对每个问题算:
  ① 年节省 annualSavingX100 = 受影响项目数 × 每项目年重复投入 - 中台年维护;
  ② 回本月数 paybackMonthsX100 = 建设成本 × 12 × 100 // 年节省(整数地板除,永不回本记 None);
  ③ worthPlatform = 回本 ≤ horizonMonths×100。
核心观点:**「受影响项目多」不等于「该中台化」**——p-atlas 影响 2 个项目却要 64 个月回本,
p-vfx 每项目重复投入最高却只影响 1 个项目。账要一起算,不能只挑一个轴。
产出 out/ta13_platform.json,另落 canonical 摘要与 sha256 前 16 位供 P 层对照。
真实产能需真实团队环境,命令行不可复现(X)。"""
import hashlib
import json
import os

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
CONFIG = os.path.join(ROOT, "config/ta13_platform.json")
OUT = os.path.join(ROOT, "out")


def annual_saving_x100(p):
    """人天×100:受影响数 × 每项目年重复投入 - 中台年维护。"""
    return p["projectsAffected"] * p["repeatPersonDaysX100"] - p["maintainPersonDaysX100PerYear"]


def payback_months_x100(p):
    """整数地板除。永不回本(年节省 ≤ 0)返回 None。"""
    saving = annual_saving_x100(p)
    if saving <= 0:
        return None
    return p["buildPersonDaysX100"] * 12 * 100 // saving


def is_worth(p, horizon_months):
    pb = payback_months_x100(p)
    return pb is not None and pb <= horizon_months * 100


def analyze(cfg):
    horizon = cfg["horizonMonths"]
    rows = [{"id": p["id"], "name": p["name"],
             "annualSavingX100": annual_saving_x100(p),
             "paybackMonthsX100": payback_months_x100(p),
             "worthPlatform": is_worth(p, horizon)} for p in cfg["problems"]]
    worth = [r for r in rows if r["worthPlatform"]]
    naive = [p["id"] for p in cfg["problems"] if p["repeatPersonDaysX100"] >= 500]
    canon = json.dumps(
        [{"id": r["id"], "annualSavingX100": r["annualSavingX100"],
          "paybackMonthsX100": r["paybackMonthsX100"],
          "worthPlatform": r["worthPlatform"]} for r in rows],
        separators=(",", ":"), ensure_ascii=False)
    return {
        "version": cfg["version"], "project": cfg["project"], "horizonMonths": horizon,
        "problemCount": len(rows), "rows": rows,
        "worthCount": len(worth), "notWorthCount": len(rows) - len(worth),
        "totalBuildX100": sum(p["buildPersonDaysX100"] for p in cfg["problems"]),
        "totalAnnualSavingX100": sum(r["annualSavingX100"] for r in rows),
        "worthBuildX100": sum(p["buildPersonDaysX100"] for p in cfg["problems"] if is_worth(p, horizon)),
        "worthAnnualSavingX100": sum(r["annualSavingX100"] for r in worth),
        "maxWorthPaybackMonthsX100": max((r["paybackMonthsX100"] for r in worth), default=0),
        "naiveRepeatRuleIds": naive, "naiveRepeatRuleCount": len(naive),
        "canonical": canon, "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16],
    }


def main():
    cfg = json.load(open(CONFIG, encoding="utf-8"))
    rep = analyze(cfg)
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "ta13_platform.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
        f.write("\n")
    print(f"problems={rep['problemCount']} worth={rep['worthCount']} notWorth={rep['notWorthCount']}")
    for r in rep["rows"]:
        pb = "never" if r["paybackMonthsX100"] is None else f"{r['paybackMonthsX100']}"
        print(f"  {r['id']:<10} savingX100={r['annualSavingX100']:<6} paybackX100={pb:<6} worth={r['worthPlatform']}")
    print(f"totalBuildX100={rep['totalBuildX100']} worthBuildX100={rep['worthBuildX100']} "
          f"totalAnnualSavingX100={rep['totalAnnualSavingX100']} worthAnnualSavingX100={rep['worthAnnualSavingX100']}")
    print(f"naiveRepeatRule={rep['naiveRepeatRuleCount']} {rep['naiveRepeatRuleIds']}")
    print(f"digest={rep['digest']}")
    print("wrote out/ta13_platform.json")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/ta13_platform.py:

problems=6 worth=3 notWorth=3
  p-naming   savingX100=2000   paybackX100=720    worth=True
  p-export   savingX100=1500   paybackX100=720    worth=True
  p-manifest savingX100=1750   paybackX100=754    worth=True
  p-atlas    savingX100=300    paybackX100=6400   worth=False
  p-vfx      savingX100=400    paybackX100=3000   worth=False
  p-onboard  savingX100=-200   paybackX100=never  worth=False
totalBuildX100=7200 worthBuildX100=3200 totalAnnualSavingX100=5750 worthAnnualSavingX100=5250
naiveRepeatRule=5 ['p-naming', 'p-export', 'p-manifest', 'p-atlas', 'p-vfx']
digest=0d4ac9d1b7b4411c
wrote out/ta13_platform.json

三个问题值得做(worth=True)、三个不值得。两处反直觉:p-atlas 影响 2 个项目却要 64 个月才回本(建设 16 人天、年维护 7 人天太贵);p-vfx 每项目重复投入最高(9 人天/项目/年)却只影响 1 个项目,年节省被维护吃掉。六个问题全做要 72 人天建设,只做值得的三个只要 32 人天。

13.1.5 P 层内核

同一份配置,TypeScript 侧独立实现一遍:

// file: core/src/ta13/platform.ts
// tier: P
// verify: node --test 'core/test/ta13_*.test.ts'
// 13.1 中台判据内核:把「要不要中台化」算成一笔可断言的整数账。
//
// 关键设计:**判定必须同时看复用面、节省与维护成本**。只看「每项目重复投入高」会把
// 只影响 1 个项目的 p-vfx 收进来;只看「受影响项目多」会把 64 个月回本的 p-atlas 收进来。
// 全部整数:人天×100、月数×100,除法一律整数地板除。永不回本(年节省 ≤ 0)返回 null。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type Problem = {
  id: string;
  name: string;
  projectsAffected: number;
  repeatPersonDaysX100: number;
  buildPersonDaysX100: number;
  maintainPersonDaysX100PerYear: number;
};

export type Row = {
  id: string;
  annualSavingX100: number;
  paybackMonthsX100: number | null;
  worthPlatform: boolean;
};

/** 年节省(人天×100):受影响数 × 每项目年重复投入 - 中台年维护。 */
export function annualSavingX100(p: Problem): number {
  return p.projectsAffected * p.repeatPersonDaysX100 - p.maintainPersonDaysX100PerYear;
}

/** 回本月数 ×100,整数地板除;永不回本返回 null。 */
export function paybackMonthsX100(p: Problem): number | null {
  const saving = annualSavingX100(p);
  if (saving <= 0) return null;
  return Math.floor((p.buildPersonDaysX100 * 12 * 100) / saving);
}

/** 回本 ≤ 观察期(horizonMonths × 100)才算值得中台化。 */
export function isWorth(p: Problem, horizonMonths: number): boolean {
  const pb = paybackMonthsX100(p);
  return pb !== null && pb <= horizonMonths * 100;
}

/** canonical 摘要:键序与 Python 侧一致,供 digest 对拍。 */
export function canonical(rows: Row[]): string {
  return JSON.stringify(rows.map((r) => ({
    id: r.id,
    annualSavingX100: r.annualSavingX100,
    paybackMonthsX100: r.paybackMonthsX100,
    worthPlatform: r.worthPlatform,
  })));
}

13.1.6 P 层测试:跨语言与负例

// file: core/test/ta13_platform.test.ts
// tier: P
// verify: node --test 'core/test/ta13_*.test.ts'
// 中台判据断言:TS 侧独立复算 config/ta13_platform.json,与 Python(sim/ta13_platform.py)
// 落盘的 out/ta13_platform.json 逐位比对 + digest 对照。负例守住「单轴判定会误收」。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { annualSavingX100, paybackMonthsX100, isWorth, canonical } from "../src/ta13/platform.ts";
import type { Problem, Row } from "../src/ta13/platform.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta13_platform.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta13_platform.json"), "utf8"));
const problems = cfg.problems as Problem[];
const horizon = cfg.horizonMonths as number;
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
const rows: Row[] = problems.map((p) => ({
  id: p.id,
  annualSavingX100: annualSavingX100(p),
  paybackMonthsX100: paybackMonthsX100(p),
  worthPlatform: isWorth(p, horizon),
}));

test("跨语言:年节省 / 回本月数 / 判定逐位一致(整数类)", () => {
  assert.equal(rows.length, py.problemCount);
  for (const r of rows) {
    const t = py.rows.find((x: { id: string }) => x.id === r.id);
    assert.equal(r.annualSavingX100, t.annualSavingX100, r.id);
    assert.equal(r.paybackMonthsX100, t.paybackMonthsX100, r.id);
    assert.equal(r.worthPlatform, t.worthPlatform, r.id);
  }
  assert.equal(rows.filter((r) => r.worthPlatform).length, py.worthCount);
});

test("跨语言:canonical 与 digest 一致,汇总账对得上", () => {
  const canon = canonical(rows);
  assert.equal(canon, py.canonical);
  assert.equal(sha16(canon), py.digest);
  assert.equal(py.digest, "0d4ac9d1b7b4411c");
  assert.equal(py.totalBuildX100, 7200);
  assert.equal(py.worthBuildX100, 3200);
  assert.equal(py.totalAnnualSavingX100, 5750);
  assert.equal(py.worthAnnualSavingX100, 5250);
});

test("负例:只看「重复投入高」会把 p-vfx 误收(它只影响 1 个项目)", () => {
  const vfx = problems.find((p) => p.id === "p-vfx") as Problem;
  assert.equal(vfx.repeatPersonDaysX100, 900);
  assert.equal(vfx.projectsAffected, 1);
  const naive = problems.filter((p) => p.repeatPersonDaysX100 >= 500).map((p) => p.id);
  assert.equal(naive.length, py.naiveRepeatRuleCount);
  assert.equal(naive.length, 5);
  assert.equal(isWorth(vfx, horizon), false);
});

test("负例:只看「受影响项目多」会把 p-atlas 误收(它要 64 个月回本)", () => {
  const atlas = problems.find((p) => p.id === "p-atlas") as Problem;
  assert.ok(atlas.projectsAffected >= 2);
  assert.equal(paybackMonthsX100(atlas), 6400);
  assert.equal(isWorth(atlas, horizon), false);
});

test("边界:年节省 ≤ 0 时永不回本(回本月数记 null,不记 0)", () => {
  const onboard = problems.find((p) => p.id === "p-onboard") as Problem;
  assert.ok(annualSavingX100(onboard) <= 0);
  assert.equal(paybackMonthsX100(onboard), null);
  assert.equal(isWorth(onboard, horizon), false);
});

实跑 node --test 'core/test/ta13_platform.test.ts':

✔ 跨语言:年节省 / 回本月数 / 判定逐位一致(整数类)
✔ 跨语言:canonical 与 digest 一致,汇总账对得上
✔ 负例:只看「重复投入高」会把 p-vfx 误收(它只影响 1 个项目)
✔ 负例:只看「受影响项目多」会把 p-atlas 误收(它要 64 个月回本)
✔ 边界:年节省 ≤ 0 时永不回本(回本月数记 null,不记 0)
ℹ tests 5
ℹ pass 5
ℹ fail 0

digest=0d4ac9d1b7b4411c,两侧逐位一致。两条负例守住同一件事:只挑一个轴判定就会误收——只看「重复投入高」会把 p-vfx 收进来,只看「受影响项目多」会把 p-atlas 收进来,真账都要靠回本月数拦下。

下一节把中台里「工具」这一块拆开:一条工具链应该分几层、什么条件下才值得下沉复用。

阅读导航:上一节:12.3 误报与白名单治理 · 下一节:13.2 工具链的分层与复用 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则