《移动游戏产品的架构·商业化与发行工程》13.3 卷三评审与进入卷四

卷三收口的最后一道门禁,把附录 C 的九组检查项写成可运行评审器:pass 无证据自动降级为 partial,partial 与 fail 都阻塞,空评审直接判失败。本节给出九组汇总、空白组与失败组的区别,以及进入平台化卷之前必须回答的四个问题。

「商业化这条线基本通了」

卷三读到最后一节,团队通常会给出一个模糊结论:「商业化这条线基本通了,可以看平台化了。」

这句话在评审会上很难被反驳,因为没有人能立刻说出「基本」缺什么。等到下个版本出事,复盘才会发现:账号合并没做冲突检查、退款没有回收资产、埋点口径改过两次、隐私政策没跟上 SDK 清单。

本节不接受「基本通了」。它只认三种状态,并且把没有证据的「通过」直接降级。

九组检查项

卷三的范围对应九组:

组核心问题对应章节
收入与口径赚了多少、口径是否一致第 1、5 章
账号与登录用户是谁、能不能找回第 2 章
渠道与包体包从哪来、是否可复现第 3 章
支付与商品钱能不能收到、商品是否正确第 4 章
订单与对账收到之后能不能对上第 5 章
广告与归因流量怎么变现、新增算给谁第 6、7 章
风控与数据作弊能不能挡住、数据是否可信第 8、10 章
运营与触达活动与消息是否可控第 9、11 章
合规与发行材料与红线是否齐备第 12 章

九组不是并列清单,而是商业化判断链的九个关节。任何一组全空,都说明那条链断了一节。

把门禁写成代码

评审器的规则只有三条:

  1. pass 必须有非空证据,否则降级为 partial;
  2. partial 与 fail 都是阻塞项;
  3. 空评审直接判失败,而不是「默认通过」。
// file: src/lab/review-gate-biz.ts
// tier: L1
// verify: node --test test/lab/review-gate-biz.test.ts
export type ReviewStatus = "pass" | "partial" | "fail";

export interface ReviewItem {
  readonly id: string;
  readonly group: string;
  readonly status: ReviewStatus;
  readonly evidence: string;
}

export interface GroupSummary {
  readonly group: string;
  readonly pass: number;
  readonly partial: number;
  readonly fail: number;
}

export interface GateResult {
  readonly passed: boolean;
  readonly total: number;
  readonly counts: Readonly<Record<ReviewStatus, number>>;
  readonly groups: readonly GroupSummary[];
  readonly blockers: readonly ReviewItem[];
  readonly openGroups: readonly string[];
  readonly reasons: readonly string[];
}

export const REVIEW_GROUPS = [
  "收入与口径",
  "账号与登录",
  "渠道与包体",
  "支付与商品",
  "订单与对账",
  "广告与归因",
  "风控与数据",
  "运营与触达",
  "合规与发行",
] as const;

export function evaluateGate(items: readonly ReviewItem[]): GateResult {
  const reasons: string[] = [];
  const normalized: ReviewItem[] = [];

  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 counts: Record<ReviewStatus, number> = { pass: 0, partial: 0, fail: 0 };
  for (const item of normalized) {
    counts[item.status] += 1;
  }

  const groups = REVIEW_GROUPS.map((group) => {
    const scoped = normalized.filter((item) => item.group === group);
    return {
      group,
      pass: scoped.filter((item) => item.status === "pass").length,
      partial: scoped.filter((item) => item.status === "partial").length,
      fail: scoped.filter((item) => item.status === "fail").length,
    };
  });

  const blockers = normalized.filter((item) => item.status !== "pass");
  const openGroups = groups.filter((group) => group.pass === 0).map((group) => group.group);

  if (normalized.length === 0) {
    reasons.push("no review items");
  }
  if (counts.partial > 0) {
    reasons.push(`${counts.partial} partial item(s) block the gate`);
  }
  if (counts.fail > 0) {
    reasons.push(`${counts.fail} failed item(s) block the gate`);
  }

  return {
    passed: normalized.length > 0 && blockers.length === 0,
    total: normalized.length,
    counts,
    groups,
    blockers,
    openGroups,
    reasons,
  };
}

export function failingGroups(result: GateResult): readonly string[] {
  return result.groups.filter((group) => group.fail > 0).map((group) => group.group);
}

export function formatGate(result: GateResult): string {
  const header = "group\tpass\tpartial\tfail";
  const rows = result.groups.map(
    (group) => `${group.group}\t${group.pass}\t${group.partial}\t${group.fail}`,
  );
  return [header, ...rows].join("\n");
}

export const DEFAULT_REVIEW: readonly ReviewItem[] = [
  { id: "revenue", group: "收入与口径", status: "pass", evidence: "src/biz/revenue-mix.ts" },
  { id: "unit-economics", group: "收入与口径", status: "pass", evidence: "src/biz/unit-economics.ts" },
  { id: "identity", group: "账号与登录", status: "pass", evidence: "src/biz/identity.ts" },
  { id: "session", group: "账号与登录", status: "pass", evidence: "src/biz/login-session.ts" },
  { id: "channel", group: "渠道与包体", status: "pass", evidence: "src/biz/channel-build.ts" },
  { id: "catalog", group: "支付与商品", status: "pass", evidence: "src/biz/catalog.ts" },
  { id: "receipt", group: "支付与商品", status: "pass", evidence: "src/biz/receipt-verify.ts" },
  { id: "order", group: "订单与对账", status: "pass", evidence: "src/biz/order-fsm.ts" },
  { id: "attribution", group: "广告与归因", status: "pass", evidence: "src/biz/attribution.ts" },
  { id: "risk", group: "风控与数据", status: "pass", evidence: "src/biz/risk-engine.ts" },
  { id: "campaign", group: "运营与触达", status: "pass", evidence: "src/biz/campaign.ts" },
  { id: "privacy", group: "合规与发行", status: "pass", evidence: "src/biz/privacy-manifest.ts" },
];

evaluateGate 返回四类信息:是否通过、状态计数、按组汇总、阻塞项列表。另外两个派生视图很有用:

  • openGroups:一项都没通过的组,用来识别「完全没开始」;
  • failingGroups:存在 fail 的组,用来识别「做了但坏了」。

两个口径不能混。一个组可能全是 partial(做了没证据),也可能有 pass 有 fail(部分可用部分坏)。前者是没验证,后者是验证出问题,处理方式完全不同。

一次方舟的评审记录

假设方舟在赛季前跑一次评审,12 项里有 2 项没过:

项组状态证据处理
receipt支付与商品partial只测了沙盒凭证补生产凭证校验与失败用例
refund订单与对账fail退款后资产未回收阻塞版本,先补回收链路
其余 10 项—pass文件路径与测试输出保持

结果:passed: false,blockers 是这两项;openGroups 为空(每组至少一项通过),failingGroups 是「订单与对账」。

这个区分决定处理顺序:订单与对账组有 fail,说明能力存在但坏了,优先级最高;支付与商品组只有 partial,说明验证不完整,可以排在其后。先修坏的,再补没验的。

与卷二评审器的关系

卷二的评审器与本节结构相似,但分组与证据类型不同:

维度卷二卷三
分组协议、连接、离线、同步、战报、流量、资源、热更、下载收入、账号、渠道、支付、订单、广告、风控、运营、合规
检查对象客户端与外部系统的技术契约客户端、渠道与中台之间的商业契约
典型证据契约测试、补丁测试、演练记录订单测试、对账报表、渠道材料
空白组含义跨边界技术能力缺失商业链路能力缺失

两卷的门禁都要求「证据可复现」,但卷三的证据更多来自报表与渠道后台,而不是单测。这不是标准降低了,而是检查对象变了:退款是否回收资产,无法只靠一个纯函数证明。

双案例的收口表

把九组门禁套到两款产品上,得到两张可复核的表:

组消消方舟
收入与口径passpass
账号与登录passpass
渠道与包体passpass
支付与商品passpartial(生产凭证待补)
订单与对账passfail(退款回收缺失)
广告与归因passpartial(归因回传延迟)
风控与数据passpass
运营与触达passpass
合规与发行passpass

「不适用」不是「通过」。消消的强联网战斗不适用不代表其同步能力通过;评审时应记录为范围外,并在汇总里单列。把不适用算成 pass,会让通过率虚高,也会掩盖真正缺失的组。

进入卷四之前

卷四讨论平台化与长线治理。进入之前,有四个问题必须能回答:

  1. 两款产品的复用边界在哪? 如果卷三还没区分「中台共用」与「产品专属」,卷四的组件库与配置平台就无从谈起。
  2. 配置归属谁? 商品目录、活动配置、渠道参数分别由哪个平台维护?归属不清,长线运营一定出现双份配置。
  3. 指标口径归口哪里? 商业化、增长、财务三套报表如果各算各的,卷四的指标治理会从负数开始。
  4. 事故责任怎么划? 支付掉单、活动超发、渠道包错误,分别由哪条线应急?没有责任人,平台化只会把问题集中得更快。

四个问题里,前两个直接依赖卷三;后两个是卷三向卷四的接口。答不上来,说明卷三的账号、渠道与数据章节还没落地。

带病进入卷四

如果只能回答其中两三个问题,也可以进入卷四,但必须把未答项登记为「带病进入」:

未答项影响修复版本
配置归属活动与商品出现双份配置卷四第 4 章前
指标归口三套报表口径不一致卷四第 6 章前

登记的意义不是免责,而是排期。允许带病进入,不允许假装没病——这句话在卷三同样适用。

与相邻章节的关系

本节依赖 13.1 的指标矩阵与 13.2 的发行门禁:前者提供数值证据,后者提供发布证据。附录 C 是本节检查项的来源,本节把清单变成可运行门禁。

读到这里,卷三的判断链已经闭合:从收入结构出发,经账号、渠道、支付、订单、广告、归因、风控、运营、埋点、推送与合规,最后收口为指标矩阵、发行门禁与评审器。卷四会在这条链上接入平台复用与长线治理。

小结

  • 卷三评审有九组:收入、账号、渠道、支付、订单、广告、风控、运营、合规。
  • 门禁只认 pass/partial/fail,pass 无证据自动降级,空评审判失败。
  • openGroups 识别完全没开始的组,failingGroups 识别做了但坏了的组。
  • 先修 fail,再补 partial;两类问题的处理顺序不能反。
  • 「不适用」要单列,不能算成通过,否则通过率虚高。
  • 进入卷四前回答四个问题:复用边界、配置归属、指标归口、事故责任。
  • 允许带病进入,但未答项必须登记并排期。

阅读导航:上一节:13.2 发行清单与上线门禁 · 下一节:目录与学习路径 。

常见错误与边界

  1. 把评审做成打分。 平均分掩盖空白组;门禁只认通过与否,不做加权。
  2. 允许「部分通过」。 设计有、证据无,等于没验证;按规则直接降级。
  3. 证据写成「已确认」。 证据必须是路径、命令输出或线上查询,能被人复现。
  4. 把不适用算成通过。 消消没有强联网战斗,不代表同步能力通过;应单列为范围外。
  5. 只看总分不看分组。 九组里一组全空,总分仍可能很高;openGroups 专门抓这个。
  6. 混淆空白组与失败组。 前者没开始,后者做坏了;处理优先级不同。
  7. 评审只做一次。 每个大版本重跑,保留历史,趋势比单次结论更有信息量。
  8. 把评审器当自动化测试。 它校验记录自洽,不替代订单测试、对账与渠道演练。
  9. 带病进入变成常态。 每个版本都带病,说明门禁没有约束力。
  10. 删除旧评审记录。 没有历史,就无法区分「一直如此」与「本版本退步」。
  11. 忽略渠道证据。 渠道后台截图与账单是证据,口头确认不是。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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