9.1 讲了发布流水线的阶段与门禁状态机,9.2 讲了灰度放量与回滚。两节都反复出现一个词——门禁:门禁过不过、该不该放量、要不要回滚。这一节把门禁落到实处:上线前到底要检查哪些项、每一项长什么样、怎么判定「整份清单通过」。它既是 9.1 门禁状态机的输入数据,也是 9.2 放量决策的前置条件——灰度计划本身就是清单里的一条门禁项。
先说边界。清单里会有「合规」这一类目,但合规的红线、资质、备案细则全部属于第 10 章,本节只在清单里给它留一个勾选位,写明「细则见第 10 章」,不展开任何合规判断。本节只讲清单本身的结构与校验方法:一份清单该有哪些字段、状态取值怎么定、门禁项怎么卡。
检查清单不是「打勾走流程」
很多团队的上线清单是一张 Excel 或一个文档,靠人工打勾。它的问题不在于「有没有」,而在于不可校验:打勾的人可能忘了某项、可能把「待办」勾成「完成」、可能整份清单里有个门禁项没过却照样上线。要让它真正成为门禁,清单必须结构化——每一项都有明确的字段、明确的状态取值、明确的负责人和证据,然后由一个脚本去逐条校验。
结构化清单有个关键设计:区分「门禁项」与「普通项」。门禁项(gate: true)是不上线就不能放过的硬条件,比如「主包体积不超预算」「核心链路端到端通过」「灰度计划与回滚预案就绪」;普通项是可以带条件通过的软条件,比如「版本公告素材」——首版不发公告,就可以标成 waived 并写明原因。**门禁项必须全过,清单才算通过;普通项允许 waived。**这条规则把「哪些必须做、哪些可以缓」写进了数据,而不是留在某个人脑子里。
清单里每一项都带 owner(负责人)与 evidence(证据)。这两列不是形式主义:owner 让「这项谁负责」不再含糊,evidence 让「这项凭什么算过」有据可查——证据可以是一份构建日志、一份测试报告、一张截图,关键是它能被复核。没有证据的 pass、没有负责人的 pass,都是不可信的勾。
四个状态各有明确语义:pass 是通过、fail 是明确不通过、pending 是还没做、waived 是「主动决定不做」并写明原因。pending 与 waived 的区别尤其重要——前者是「还没轮到」,后者是「决定缓一缓」;把两者混为一谈,就会让「没做完」悄悄伪装成「不打算做」,到上线那天谁也说不清到底漏没漏。
D 层:把清单与 schema 写进配置
下面这份是 D 层(静态层)配置:上半是 schema(清单的元规则),下半是 items(清单本体,14 条)。全整数、布尔与字符串枚举,禁浮点。
{
"version": 1,
"game": "merge-island",
"note": "上线检查清单:每条含 id / 标题 / 分类 / 是否门禁项 / 负责人 / 状态 / 证据。schema 规定必填字段、状态取值、分类取值、门禁项必须 pass、waived 必须带 note。合规类目只做清单勾选,细则与红线见卷三第 10 章。",
"schema": {
"requiredFields": ["id", "title", "category", "gate", "owner", "status", "evidence"],
"allowedStatus": ["pass", "fail", "pending", "waived"],
"allowedCategory": ["包体", "性能", "功能", "变现", "合规", "数据", "运营"],
"gateMustPass": true,
"waivedNeedsReason": true
},
"items": [
{ "id": "PKG-01", "title": "主包体积不超过预算", "category": "包体", "gate": true, "owner": "客户端", "status": "pass", "evidence": "out/l09_build.log" },
{ "id": "PKG-02", "title": "首包资源清单与分包核对", "category": "包体", "gate": false, "owner": "客户端", "status": "pass", "evidence": "out/l09_assets.csv" },
{ "id": "PERF-01", "title": "低端机首帧时间达标", "category": "性能", "gate": true, "owner": "客户端", "status": "pass", "evidence": "out/l09_firstframe.json" },
{ "id": "PERF-02", "title": "连续运行无持续掉帧", "category": "性能", "gate": false, "owner": "客户端", "status": "pass", "evidence": "out/l09_jank.json" },
{ "id": "FUNC-01", "title": "核心合成链路端到端通过", "category": "功能", "gate": true, "owner": "客户端", "status": "pass", "evidence": "out/l09_e2e.txt" },
{ "id": "FUNC-02", "title": "存档与云同步回归", "category": "功能", "gate": false, "owner": "服务端", "status": "pass", "evidence": "out/l09_save.txt" },
{ "id": "MON-01", "title": "广告位填充与回调联调", "category": "变现", "gate": true, "owner": "商业化", "status": "pass", "evidence": "out/l09_ads.json" },
{ "id": "MON-02", "title": "内购沙箱下单验证", "category": "变现", "gate": false, "owner": "商业化", "status": "pass", "evidence": "out/l09_iap.json" },
{ "id": "COMP-01", "title": "资质与备案材料齐备", "category": "合规", "gate": true, "owner": "运营", "status": "pass", "evidence": "out/l09_filing.md", "note": "仅做清单勾选,红线细则见第 10 章" },
{ "id": "DATA-01", "title": "埋点上报链路打通", "category": "数据", "gate": true, "owner": "数据", "status": "pass", "evidence": "out/l09_tracking.json" },
{ "id": "DATA-02", "title": "看板与告警阈值配置", "category": "数据", "gate": false, "owner": "数据", "status": "pass", "evidence": "out/l09_dashboard.json" },
{ "id": "OPS-01", "title": "客服与反馈入口上线", "category": "运营", "gate": false, "owner": "运营", "status": "pass", "evidence": "out/l09_support.md" },
{ "id": "OPS-02", "title": "灰度放量计划与回滚预案就绪", "category": "运营", "gate": true, "owner": "运营", "status": "pass", "evidence": "out/l09_canary_ramp.json" },
{ "id": "OPS-03", "title": "版本公告与推广素材准备", "category": "运营", "gate": false, "owner": "运营", "status": "waived", "evidence": "out/l09_announce.md", "note": "首版不做外部公告,材料延后到首个活动版本" }
]
}
schema.requiredFields 列出每条必须带的字段;allowedStatus 是状态取值(pass / fail / pending / waived);allowedCategory 是分类取值(包体 / 性能 / 功能 / 变现 / 合规 / 数据 / 运营);gateMustPass 表示门禁项必须 pass;waivedNeedsReason 表示 waived 必须带 note。items 里 7 条门禁项全部 pass,1 条普通项 OPS-03 标成 waived 并写了原因——这是一份已经准备好上线的清单。
清单的写法也有讲究:id 用「类别前缀 + 序号」(如 PKG-01、OPS-03),这样一眼能看出它属于哪一类、也能在 CSV 里稳定排序;gate 用布尔而不是字符串,避免「是 / 否 / 也许」这类模糊取值混进来。
S 层:清单 schema 校验脚本
下面这段是 S 层校验脚本,读上面这份配置,逐条校验:必填字段是否齐全、状态与分类取值是否合法、门禁项是否全过、waived 是否带说明,最后判定整份清单能否上线。
#!/usr/bin/env python3
# file: sim/l09_checklist_check.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l09_checklist_check.py --config config/ --out out/
"""上线检查清单 schema 校验:读 config/l09_checklist.json。
对每条检查项逐条校验:
- 必填字段齐全且非空(id / 标题 / 分类 / 是否门禁 / 负责人 / 状态 / 证据);
- 状态取值在 allowedStatus 内、分类取值在 allowedCategory 内;
- 门禁项(gate=true)必须 pass,否则整份清单判「不可上线」;
- waived 状态必须带 note 说明。
再做一次自检:复制一份清单,把第一条门禁项改成 pending,重跑校验,
确认校验器真的能把「门禁未过」判出来(若判不出即视为校验器失效)。
产出 JSON + CSV + SVG。退出码:真实清单门禁未过则 1,否则 0。
"""
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, "l09_checklist.json"), encoding="utf-8") as f:
return json.load(f)
def validate(items: list, schema: dict) -> dict:
"""逐条校验,返回 {issues: [...], gateViolations: [...], counts: {...}}。"""
req = schema["requiredFields"]
allowed_status = set(schema["allowedStatus"])
allowed_cat = set(schema["allowedCategory"])
issues, gate_violations = [], []
counts = {s: 0 for s in schema["allowedStatus"]}
seen_ids = set()
for it in items:
iid = it.get("id", "<无 id>")
for f in req:
v = it.get(f)
if v is None or (isinstance(v, str) and v.strip() == ""):
issues.append({"id": iid, "field": f, "problem": "缺字段或为空"})
if iid in seen_ids:
issues.append({"id": iid, "field": "id", "problem": "id 重复"})
seen_ids.add(iid)
st = it.get("status")
if st not in allowed_status:
issues.append({"id": iid, "field": "status", "problem": f"非法状态 {st}"})
else:
counts[st] += 1
if it.get("category") not in allowed_cat:
issues.append({"id": iid, "field": "category", "problem": f"非法分类 {it.get('category')}"})
if it.get("gate") is True and st != "pass":
gate_violations.append({"id": iid, "status": st, "problem": "门禁项未通过"})
if st == "waived" and schema.get("waivedNeedsReason"):
if not (it.get("note") or "").strip():
issues.append({"id": iid, "field": "note", "problem": "waived 缺说明"})
return {"issues": issues, "gateViolations": gate_violations, "counts": counts,
"total": len(items), "gateCount": sum(1 for it in items if it.get("gate") is True)}
def draw_svg(path: str, counts: dict, gate_ok: bool, total: int) -> int:
W, H = 720, 90 + 50 * len(counts)
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15", "fill": "#111827"}).text = \
f"上线检查清单状态分布(共 {total} 项,门禁{'通过' if gate_ok else '未过'})"
colors = {"pass": "#16a34a", "fail": "#dc2626", "pending": "#f59e0b", "waived": "#6b7280"}
peak = max(counts.values()) or 1
for i, (st, c) in enumerate(counts.items()):
y = 50 + i * 50
w = 480 * c // peak
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 20), "font-size": "12",
"fill": "#374151"}).text = st
ET.SubElement(svg, "rect", {"x": "120", "y": str(y + 6), "width": str(w), "height": "22",
"rx": "4", "fill": colors.get(st, "#2563eb")})
ET.SubElement(svg, "text", {"x": str(128 + w), "y": str(y + 22), "font-size": "12",
"fill": "#6b7280"}).text = str(c)
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
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)
schema, items = cfg["schema"], cfg["items"]
rep = validate(items, schema)
gate_ok = (not rep["gateViolations"]) and (not rep["issues"])
# 自检:把第一条门禁项改成 pending,确认校验器能判出「门禁未过」。
mutated = [dict(it) for it in items]
first_gate = next((it for it in mutated if it.get("gate") is True), None)
if first_gate is not None:
first_gate["status"] = "pending"
mut_rep = validate(mutated, schema)
selfcheck_ok = len(mut_rep["gateViolations"]) >= 1
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "l09_checklist_check.svg"), rep["counts"], gate_ok, rep["total"])
with open(os.path.join(a.out, "l09_checklist_check.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["id", "category", "gate", "owner", "status", "issues"])
iss_ids = {i["id"] for i in rep["issues"]}
gv_ids = {g["id"] for g in rep["gateViolations"]}
for it in items:
flags = []
if it["id"] in iss_ids:
flags.append("schema")
if it["id"] in gv_ids:
flags.append("gate")
w.writerow([it["id"], it["category"], it["gate"], it["owner"], it["status"], "|".join(flags) or "ok"])
out = {"version": cfg["version"], "game": cfg["game"], "total": rep["total"],
"gateCount": rep["gateCount"], "counts": rep["counts"],
"issueCount": len(rep["issues"]), "issues": rep["issues"],
"gateViolations": rep["gateViolations"], "gatePass": gate_ok,
"selfcheck": {"mutatedGateViolations": len(mut_rep["gateViolations"]), "ok": selfcheck_ok},
"csv": os.path.join(a.out, "l09_checklist_check.csv"),
"svg": os.path.join(a.out, "l09_checklist_check.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "l09_checklist_check.json"), "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False, indent=2)
print(json.dumps({"total": rep["total"], "gateCount": rep["gateCount"], "counts": rep["counts"],
"issues": len(rep["issues"]), "gateViolations": len(rep["gateViolations"]),
"gatePass": gate_ok, "selfcheckOk": selfcheck_ok}, ensure_ascii=False))
return 0 if gate_ok else 1
if __name__ == "__main__":
raise SystemExit(main())
脚本的 validate 是纯校验函数:它不改清单,只产出一份「问题清单」和「门禁违例清单」。这一点在 CI 里很关键——校验必须无副作用,否则「跑一次校验」本身就可能改动被校验的数据,让门禁失去意义。在本机跑它,输出如下:
{"total": 14, "gateCount": 7, "counts": {"pass": 13, "fail": 0, "pending": 0, "waived": 1}, "issues": 0, "gateViolations": 0, "gatePass": true, "selfcheckOk": true}
14 条清单项里,13 条 pass、1 条 waived、0 条 fail / pending;issues 为 0(字段、取值、说明全部合规);7 条门禁项全过,gatePass 为 true——整份清单判定可以上线。脚本的退出码也据此返回:门禁未过返回 1,可用于 CI。
脚本同时产出三种格式:JSON 给 CI 读结论与退出码、CSV 给人逐条核对哪一项有问题、SVG 是状态分布图(pass / fail / pending / waived 各多少)。结论给机器、明细给人、分布给一眼扫——这是校验类脚本输出格式的一般原则,本套的每个 S 层校验脚本都遵循它。
门禁项与自检:校验器本身也要被校验
一份「全绿」的清单最可疑的地方是:你怎么知道校验器不是在假装工作?如果校验脚本写错了,把任何清单都判成通过,那这份「全绿」就毫无意义。所以脚本里加了一段自检:复制一份清单,把第一条门禁项的状态改成 pending,重跑校验,确认它真的能判出「门禁未过」。
selfcheck: 把首条门禁项改成 pending -> gateViolations 1, ok true
自检通过(selfcheckOk: true)意味着:校验器对同一份数据、只改一个门禁项状态会给出不同结论——它确实在读取门禁状态,而不是无脑放行。这是校验类脚本的通用做法:光验证「正常输入通过」不够,还要验证「异常输入被拦」,否则你无法区分「校验通过」和「校验失效」。
门禁项的判定顺序也值得说清:脚本先看门禁违例(门禁项未过),再看schema 问题(字段缺失、取值非法、waived 缺说明)。只要两者有其一,gatePass 就是 false。这样一条命令就能回答两个问题——清单填得对不对(schema)和能不能上线(门禁)。
顺带一提:把 gate 从「勾选」升级成「布尔字段」,本身就是一次小型的数据治理——能自动校验的前提,是先把模糊的人工判断变成明确的取值。
小结与下一步
把这份清单接进发布流程,就形成了第 9 章的三段闭环:清单校验(本节)回答「该查的查了没」,门禁状态机(9.1)回答「能不能提审」,灰度放量(9.2)回答「上线后怎么放大」。三者共用同一套「门禁项」概念——清单里的门禁项,就是状态机里的一条 GateCheck,也是放量计划的前置条件。同一份数据在三处被消费,这正是「一份配置、多处消费」在发布流程里的体现。
这一节把上线检查从一张打勾表变成了可校验的数据:14 条清单项、7 条门禁项,配上 schema(必填字段 / 状态取值 / 分类取值 / 门禁必须过 / waived 必带说明)和一个可跑的校验脚本。它输出三个结论——schema 是否合规、门禁是否全过、以及校验器自身是否有效(自检)。这三个结论合起来,才是「能不能上线」的完整回答。
到这里,第 9 章把「上线发布」这条线走完了:提审与发布流程(9.1)、灰度放量与回滚(9.2)、上线检查清单(9.3)。但清单里那一条「资质与备案材料齐备」始终只是个勾选位——它背后的规则,是下一章的主题。下一章讲合规与风控:资质备案、内容安全红线、未成年人保护与防沉迷,把上线前必须守住的那条线说清楚。
阅读导航:上一节:灰度、放量与回滚 · 下一节:资质与备案规则 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。