《移动游戏产品的架构·商业化与发行工程》13.2 发行清单与上线门禁

上线的安全不取决于最后一天的检查,而取决于每个阶段是否留下可复现的证据。本节把内容、构建、渠道、支付、合规、灰度、发布七个阶段写成门禁:必需项未通过即阻塞,无证据的通过自动降级为部分通过,并给出方舟赛季版本的一次检查记录与门禁维护成本的控制办法。

上线前一天才发现的问题

发行事故很少是「某个功能没做」,更多是「某份材料没交、某个环境没验、某个人以为别人验过」。

常见场景:

  • 渠道包已经提审,才发现支付回调地址还指向测试环境;
  • 灰度 5% 跑了两天,回滚脚本从未演练;
  • 隐私政策更新了,但 SDK 清单还是上个版本;
  • 所有人都说「测试过了」,但没有任何一份可复现的记录。

这些问题不能靠「上线前再检查一遍」解决,因为检查本身也需要顺序、责任人与证据。本节把这件事写成一个门禁。

七个阶段

发行不是一步,而是七步:

阶段要证明什么典型证据
content对外内容与素材齐备商店截图、描述、宣传素材
build构建可复现产物哈希、签名报告
channel渠道包正确包清单、SDK 初始化测试
payment收款链路可用商品目录版本、凭证校验测试、订单幂等测试
compliance合规材料齐备SDK 清单、权限清单、未成年人保护方案
gray小流量验证通过灰度报告、指标对比
launch全量与回滚就绪发布记录、回滚演练日志

七个阶段有依赖关系:构建不通过,渠道包没有意义;支付未验证,灰度放量只会放大损失;回滚未演练,全量发布就是一次没有退路的跳跃。

门禁的三条规则

规则一:必需项必须是 pass。

非必需项可以 partial 或 fail,但必需项不行。区分「必须有」与「最好有」,是门禁能执行的前提。

规则二:没有证据的 pass 降级为 partial。

「我确认过了」不是证据。证据必须能被第二个人复现:文件路径、测试输出、报表查询条件、演练日志。

规则三:空清单判失败,不默认通过。

这一条看起来多余,但它防的是最常见的事故:门禁脚本没接上,返回空列表,流程却显示「通过」。

把门禁写成代码

// file: src/release/release-gate.ts
// tier: L1
// verify: node --test test/release/release-gate.test.ts
export type ReleaseStatus = "pass" | "partial" | "fail";

export type ReleaseStage =
  | "content"
  | "build"
  | "channel"
  | "payment"
  | "compliance"
  | "gray"
  | "launch";

export interface ReleaseItem {
  readonly id: string;
  readonly stage: ReleaseStage;
  readonly required: boolean;
  readonly status: ReleaseStatus;
  readonly evidence: string;
}

export interface StageReport {
  readonly stage: ReleaseStage;
  readonly pass: number;
  readonly partial: number;
  readonly fail: number;
  readonly blocked: boolean;
}

export interface ReleaseReport {
  readonly ready: boolean;
  readonly total: number;
  readonly blocking: readonly ReleaseItem[];
  readonly missingEvidence: readonly string[];
  readonly stages: readonly StageReport[];
  readonly reasons: readonly string[];
}

export const RELEASE_STAGES: readonly ReleaseStage[] = [
  "content",
  "build",
  "channel",
  "payment",
  "compliance",
  "gray",
  "launch",
];

export function evaluateRelease(items: readonly ReleaseItem[]): ReleaseReport {
  const reasons: string[] = [];
  const normalized: ReleaseItem[] = [];

  for (const item of items) {
    if (item.status === "pass" && item.evidence.trim() === "") {
      reasons.push(`${item.id}: pass without evidence counts as partial`);
      normalized.push({ ...item, status: "partial" });
      continue;
    }
    normalized.push(item);
  }

  const blocking = normalized.filter(
    (item) => item.required && item.status !== "pass",
  );
  const missingEvidence = normalized
    .filter((item) => item.evidence.trim() === "")
    .map((item) => item.id);

  const stages: StageReport[] = RELEASE_STAGES.map((stage) => {
    const scoped = normalized.filter((item) => item.stage === stage);
    const pass = scoped.filter((item) => item.status === "pass").length;
    const partial = scoped.filter((item) => item.status === "partial").length;
    const fail = scoped.filter((item) => item.status === "fail").length;
    const blocked = scoped.some(
      (item) => item.required && item.status !== "pass",
    );
    return { stage, pass, partial, fail, blocked };
  });

  if (normalized.length === 0) {
    reasons.push("no release items");
  }
  if (blocking.length > 0) {
    reasons.push(`${blocking.length} required item(s) block release`);
  }

  return {
    ready: normalized.length > 0 && blocking.length === 0,
    total: normalized.length,
    blocking,
    missingEvidence,
    stages,
    reasons,
  };
}

export function blockingStages(report: ReleaseReport): readonly ReleaseStage[] {
  return report.stages.filter((stage) => stage.blocked).map((stage) => stage.stage);
}

export function openStages(report: ReleaseReport): readonly ReleaseStage[] {
  return report.stages
    .filter((stage) => stage.pass === 0)
    .map((stage) => stage.stage);
}

export function formatRelease(report: ReleaseReport): string {
  const header = "stage\tpass\tpartial\tfail\tblocked";
  const rows = report.stages.map(
    (stage) =>
      `${stage.stage}\t${stage.pass}\t${stage.partial}\t${stage.fail}\t${stage.blocked}`,
  );
  return [header, ...rows].join("\n");
}

export const DEFAULT_RELEASE: readonly ReleaseItem[] = [
  { id: "build-hash", stage: "build", required: true, status: "pass", evidence: "dist/latest.json" },
  { id: "build-sign", stage: "build", required: true, status: "pass", evidence: "sign/report.txt" },
  { id: "channel-packs", stage: "channel", required: true, status: "pass", evidence: "pack/manifest.json" },
  { id: "sdk-init-order", stage: "channel", required: true, status: "pass", evidence: "test/channel/sdk-contract.test.ts" },
  { id: "catalog-version", stage: "payment", required: true, status: "pass", evidence: "catalog/v42.json" },
  { id: "receipt-verify", stage: "payment", required: true, status: "pass", evidence: "test/biz/receipt-verify.test.ts" },
  { id: "order-idempotent", stage: "payment", required: true, status: "pass", evidence: "test/biz/order-fsm.test.ts" },
  { id: "privacy-sdk-list", stage: "compliance", required: true, status: "pass", evidence: "compliance/sdk-list.csv" },
  { id: "minor-protection", stage: "compliance", required: true, status: "pass", evidence: "compliance/minor-plan.md" },
  { id: "gray-5pct", stage: "gray", required: true, status: "pass", evidence: "gray/2026-11-05.json" },
  { id: "rollback-drill", stage: "launch", required: true, status: "pass", evidence: "drill/rollback-1105.log" },
  { id: "press-kit", stage: "content", required: false, status: "pass", evidence: "marketing/press-kit.zip" },
];

几个设计点值得说明。

为什么证据降级而不是直接判失败。 降级保留了「做了但没留证」与「完全没做」的区别。前者是流程问题,补记录即可;后者是能力缺失,需要停下来补工作。两者的处理优先级不同。

为什么按阶段汇总。 单个检查项失败,很难判断影响范围;按阶段汇总能直接回答「卡在哪一步」。blockingStages 返回被阻塞的阶段,openStages 返回一项都没通过的阶段。

为什么非必需项也要记录。 宣传素材今天不影响发布,但会影响发布后的买量。记录它,是为了在下一次迭代中补上,而不是假装它不存在。

一次方舟的上线检查

方舟准备赛季版本全量发布,门禁结果如下:

阶段passpartialfail是否阻塞
content100否
build200否
channel200否
payment300否
compliance110是
gray100否
launch100否

阻塞项是 minor-protection:方案写了,但没有人脸抽检的操作记录,状态是 partial。

这个结果会让发布停下来,但停得有价值:未成年人保护是合规红线,缺少操作记录意味着真出事时无法自证。补一份带时间与操作人的抽检记录,门禁即可通过。

如果门禁只输出「12 项中 11 项通过」,团队很可能直接发布;按阶段汇总后,问题位置一目了然。

与指标矩阵的分工

13.1 的矩阵检查「产品是否健康」,本节的门禁检查「这次发布是否安全」。

两者可能给出不同结论:

  • 指标全绿、门禁阻塞:产品没问题,但发行材料或流程缺失,不能发。
  • 门禁全绿、指标 fail:流程齐全,但产品数据不健康,不应该发。
  • 两者都绿:可以发。
  • 两者都红:先修产品,再补流程。

只有把两张表放在一起看,才能避免「流程正确地发布了一个错误版本」。

门禁的维护成本

门禁不是免费的。每增加一项检查,就多一份维护成本:

  • 证据会过期,需要定期重跑;
  • 渠道规则会变,检查项要跟着改;
  • 检查太多,团队会开始绕过。

控制成本的办法是分层:必需项只保留真正阻塞发布的,其他项标记为非必需并定期清理。一个 12 项的门禁如果能稳定执行,比一个 60 项但没人看的清单更有价值。

小结

  • 发行分七个阶段:内容、构建、渠道、支付、合规、灰度、发布。
  • 三条规则:必需项必须 pass、无证据的 pass 降级、空清单判失败。
  • blockingStages 定位卡住的阶段,openStages 定位完全没开始的阶段。
  • 必需项与非必需项要分开,否则门禁会因维护成本被绕过。
  • 门禁检查发布安全,指标矩阵检查产品健康,两者不能互相替代。

阅读导航:上一节:13.1 商业化指标矩阵 · 下一节:13.3 卷三评审与进入卷四 。

常见错误与边界

  1. 把门禁做成勾选清单。 没有证据的勾选等于没检查。
  2. 必需项过多。 所有项都必需,等于没有优先级,最后一定被绕过。
  3. 证据写成「已确认」。 证据必须是路径、输出或可查询的记录。
  4. 只跑一次门禁。 每次发布都要重跑,旧记录不能复用。
  5. 忽略非必需项。 它们不阻塞本次发布,但会影响下一次。
  6. 门禁与指标脱节。 流程通过不代表产品健康。
  7. 空清单默认通过。 脚本没接上时,这是最危险的结果。
  8. 回滚只写在文档里。 没有演练日志,回滚能力等于未知。
  9. 灰度没有退出条件。 只定放量比例,不定失败阈值。
  10. 把门禁交给一个人。 单点维护会随着人员变动失效。
  11. 证据不写版本。 同一个路径在不同版本下可能完全不同。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查