9.3 上线检查清单

上线前的最后一道关,是一张覆盖包体、性能、功能、变现、合规、数据、运营七类的检查清单。这一节把清单做成一份带 schema 的配置——必填字段、状态取值、分类取值、门禁项必须通过、waived 必须带说明——再给一个可跑的校验脚本:逐条校验、判定整份清单的门禁是否通过,最后用一次「注入故障」的自检证明校验器真能抓住问题。

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)。但清单里那一条「资质与备案材料齐备」始终只是个勾选位——它背后的规则,是下一章的主题。下一章讲合规与风控:资质备案、内容安全红线、未成年人保护与防沉迷,把上线前必须守住的那条线说清楚。

阅读导航:上一节:灰度、放量与回滚 · 下一节:资质与备案规则 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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