第 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 工具链的分层与复用 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。