《移动游戏产品的架构·商业化与发行工程》12.3 商店审核材料与驳回应对

商店审核不是交完材料等结果,而是把版本、素材、隐私声明和审核窗口放进同一张发布检查单。本节以《潮汐消消》iOS 24 小时、国内安卓 3–5 天、首次平均提交 1.8 次,《潮汐方舟》48 小时、2.4 次为背景,给出材料清单、阻断项分级、驳回归因与重提节奏。

审核不是一次提交,而是一条可回滚的发布链

12.1 说明产品声明了什么,12.2 说明运行时执行了什么,12.3 要回答的是:怎样让外部审核相信前两件事。商店审核是发行链上唯一由外部决定的关卡,它不看你的内部文档,只看提交包、商店页面和审核备注是否自洽。

驳回通常不是「运气不好」,而是四类不一致之一:

不一致典型表现谁最容易漏
包与声明不一致权限声明为空,安装后却弹定位客户端
页面与包不一致截图里有未上线的玩法市场
账号与体验不一致审核员拿不到可用的测试账号运营
隐私与 SDK 不一致政策没写某个广告 SDK法务与客户端

把审核当成一次「提交—等待—被拒—再提交」的循环,团队只能被动响应。把它当成发布门禁的一部分,才能在提交前发现大部分问题。

材料清单:十项,缺一项就重来

案例里的提交包按十项材料核对。前九项是必需项,第十项按平台要求决定是否提供。

材料作用缺失后果责任方
隐私政策说明数据采集与共享直接驳回法务
图标商店展示与包内一致展示不一致或驳回市场
截图证明真实玩法误导性描述驳回市场
描述功能、付费、年龄说明信息不完整驳回市场
年龄分级匹配内容与付费分级错误驳回运营
内购披露列出档位与货币未披露付费驳回市场
账号删除入口提供站内删除路径账号政策驳回客户端
权限用途文案每项敏感权限一句说明权限用途缺失驳回客户端
测试账号让审核员进入完整体验无法验证驳回运营
演示视频说明复杂玩法或硬件依赖视平台要求运营

测试账号是最容易被低估的一项。它不能是一个空号,也不能要求审核员先充值。正确做法是提供一个带非空背包、跳过新手引导、能直接触发核心玩法与付费页的账号,并在审核备注里写清步骤。审核员没有义务替你猜入口。截图和描述则要遵守「所见即所得」。消消每 2 周更新一次商店素材,方舟每 4 周更新一次;更新频率高,容易出现截图已经下线、描述还写着旧活动的状况。素材过期本身通常只是告警,但如果截图展示了不存在的玩法,就会升级为误导性描述。

阻断项与告警项

审核检查单不能把所有问题都当成阻断项,否则每次提交都会被卡住;也不能把真正阻断的问题降级成提醒。案例里分成两级:

问题级别处理时机负责人
必需材料缺失阻断提交前必须补齐材料责任人
隐私政策版本不一致阻断提交前对齐法务
内购未披露阻断提交前补页面市场
未成年人保护未说明阻断提交前补审核备注后端
权限用途文案为空阻断提交前补文案客户端
商店素材超过刷新周期告警可提交,下个版本更新市场

判断标准很简单:这个问题会不会让审核员无法验证核心体验,或者让玩家在不知情的情况下付费。会,就是阻断;不会,只是告警。把告警当阻断,会让发布节奏被素材更新拖死;把阻断当告警,会浪费一次提交机会。

审核窗口:24 小时、48 小时与 3–5 天

案例事实表给出的窗口很有限,但足够做排期:

平台潮汐消消潮汐方舟
iOS24 小时48 小时
国内安卓3–5 天3–5 天

3–5 天按 72–120 小时表示。Google Play 与 TapTap 没有出现在事实表里,案例不做推测;排期时按「未知窗口」处理,单独留缓冲,而不是套用 iOS 或国内安卓的数字。窗口还要结合平台占比看:消消的国内安卓占 55%、iOS 占 30%、TapTap 占 8%、海外占 7%;方舟的 iOS 占 45%、国内安卓占 40%、Google Play 占 15%。国内安卓窗口最长、占比又高,它才是发布节奏的主约束;iOS 窗口短,适合做快速验证,但不能因为 24 小时就以为整体上线只需要一天。

排期时还要区分三个时间:

  • 提交时间:材料齐全、包上传完成的时间。
  • 审核时间:商店实际处理的窗口,存在排队和补充材料的可能。
  • 放量时间:审核通过后,由发行团队决定何时开量,通常还要留出监控和回滚窗口。

审核通过只代表包可用,不代表活动配置、支付通道和客服话术都已经就绪;把审核窗口当成放量时间,是发布事故的常见起点。

首次提交 1.8 次与 2.4 次意味着什么

案例设定里,消消首次过审平均提交 1.8 次,方舟 2.4 次,换算成「首提之后的补交次数」分别是 0.8 次和 1.4 次。这不是目标值,而是排期参数:方舟平均要多走一到两轮,原因不是审核更严,而是权限更多、支付链路更长、审核备注要解释的东西更多;消消的包更简单,但渠道包有 11 个、接入渠道 9 个,素材和配置的变体更多。

用它做两件事:给方舟至少预留一轮补交时间,以及复盘每次补交的根因。不要把平均值当成承诺,1.8 次意味着有的版本一次过,有的版本三次过;对单次发布来说,能控制的只有提交前的检查质量。

驳回归因:先分类,再改

收到驳回后,第一件事不是改代码,而是把驳回理由归到可处理的类别。案例里常见的七类:

驳回码含义首要责任方第一步
missing-demo-account审核员进不去或看不到完整内容运营补一个非空背包、免支付的测试账号
privacy-mismatch政策、商店声明、SDK 清单不一致法务三处对齐后再提交
iap-undisclosed付费档位或货币未披露市场在商店页列出全部档位
metadata-misleading截图或描述承诺了包内没有的功能市场换成真实截图与描述
permission-purpose敏感权限没有玩家可读的用途说明客户端每项权限补一句具体用途
crash-on-launch启动即崩溃客户端用干净设备与正式包复现
minor-protection未说明实名、时段或限额后端在审核备注中写清四道门

分类之后再看两件事:这个问题是否影响其他平台,以及是否需要改包。

  • 隐私声明、内购披露、误导性描述通常只需要改商店页面,不必重新出包。
  • 权限文案、启动崩溃、未成年人保护说明可能同时涉及包与页面,改完后要重新走构建与签名。
  • 测试账号问题通常只需更新审核备注,但如果账号依赖特定服务器环境,要确认审核环境可达。

每一次驳回都要留下根因和修复记录。连续两次因为同一类问题被拒,说明检查单有缺口,而不是审核员挑剔。

把检查单做成发布门禁

下面的实现把提交包压成一个可审计对象:材料是否齐全、政策版本是否一致、素材是否过期、内购与未成年人保护是否披露,最后给出是否可提交、阻断项数量和审核窗口。它不调用商店 API,也不上传文件,适合在 CI 或发布脚本里做提交前检查。

// file: src/biz/store-review.ts
// tier: L1
// verify: node --test test/biz/store-review.test.ts

export type Platform = "ios" | "android-cn" | "google-play" | "taptap";

export type MaterialKind =
  | "privacy-policy" | "icon" | "screenshots" | "description" | "age-rating"
  | "iap-disclosure" | "account-deletion" | "permission-strings" | "test-account" | "demo-video";

export const REQUIRED_MATERIALS: readonly MaterialKind[] = [
  "privacy-policy", "icon", "screenshots", "description", "age-rating",
  "iap-disclosure", "account-deletion", "permission-strings", "test-account",
];

export interface ReviewMaterial {
  readonly kind: MaterialKind;
  readonly present: boolean;
  readonly updatedAtMs: number;
  readonly note: string;
}

export interface ReviewPackage {
  readonly product: string;
  readonly platform: Platform;
  readonly version: string;
  readonly privacyPolicyVersion: string;
  readonly iapDisclosed: boolean;
  readonly minorProtection: boolean;
  readonly permissionPurposeComplete: boolean;
  readonly materials: readonly ReviewMaterial[];
}

export type ReviewIssueCode =
  | "missing-material" | "stale-asset" | "policy-mismatch"
  | "undisclosed-iap" | "minor-protection-missing" | "permission-purpose-missing";

export interface ReviewIssue {
  readonly code: ReviewIssueCode;
  readonly subject: string;
  readonly blocking: boolean;
  readonly detail: string;
}

export interface ReviewOptions {
  readonly nowMs: number;
  readonly maxAssetAgeMs: number;
}

export interface ReviewWindow {
  readonly minHours: number;
  readonly maxHours: number;
}

export interface ReviewReport {
  readonly product: string;
  readonly platform: Platform;
  readonly version: string;
  readonly ready: boolean;
  readonly blockingCount: number;
  readonly issues: readonly ReviewIssue[];
  readonly window: ReviewWindow | null;
}

export interface ReviewWindowEntry extends ReviewWindow {
  readonly product: string;
  readonly platform: Platform;
}

export const REVIEW_WINDOWS: readonly ReviewWindowEntry[] = [
  { product: "潮汐消消", platform: "ios", minHours: 24, maxHours: 24 },
  { product: "潮汐方舟", platform: "ios", minHours: 48, maxHours: 48 },
  { product: "潮汐消消", platform: "android-cn", minHours: 72, maxHours: 120 },
  { product: "潮汐方舟", platform: "android-cn", minHours: 72, maxHours: 120 },
];

export interface SubmissionBaseline {
  readonly product: string;
  readonly averageAttempts: number;
  readonly assetRefreshDays: number;
}

export const SUBMISSION_BASELINES: readonly SubmissionBaseline[] = [
  { product: "潮汐消消", averageAttempts: 1.8, assetRefreshDays: 14 },
  { product: "潮汐方舟", averageAttempts: 2.4, assetRefreshDays: 28 },
];

export function reviewWindow(product: string, platform: Platform): ReviewWindow | null {
  const entry = REVIEW_WINDOWS.find(
    (item) => item.product === product && item.platform === platform,
  );
  return entry === undefined ? null : { minHours: entry.minHours, maxHours: entry.maxHours };
}

export function resubmitBudget(averageAttempts: number): number {
  if (!Number.isFinite(averageAttempts) || averageAttempts < 1) {
    return 0;
  }
  return Math.round((averageAttempts - 1) * 100) / 100;
}

export function auditSubmission(pkg: ReviewPackage, options: ReviewOptions): ReviewReport {
  const issues: ReviewIssue[] = [];
  const byKind = new Map<MaterialKind, ReviewMaterial>();
  for (const material of pkg.materials) {
    byKind.set(material.kind, material);
  }

  for (const kind of REQUIRED_MATERIALS) {
    const material = byKind.get(kind);
    if (material === undefined || !material.present) {
      issues.push({
        code: "missing-material",
        subject: kind,
        blocking: true,
        detail: `${kind} is required but absent`,
      });
      continue;
    }
    const isAsset = kind === "screenshots" || kind === "icon" || kind === "description";
    if (isAsset && options.nowMs - material.updatedAtMs > options.maxAssetAgeMs) {
      const days = Math.round(options.maxAssetAgeMs / 86_400_000);
      issues.push({
        code: "stale-asset",
        subject: kind,
        blocking: false,
        detail: `${kind} is older than ${days} days`,
      });
    }
    if (kind === "privacy-policy" && material.note !== pkg.privacyPolicyVersion) {
      issues.push({
        code: "policy-mismatch",
        subject: kind,
        blocking: true,
        detail: `material ${material.note} != package ${pkg.privacyPolicyVersion}`,
      });
    }
  }

  if (!pkg.iapDisclosed) {
    issues.push({
      code: "undisclosed-iap",
      subject: "iap-disclosure",
      blocking: true,
      detail: "in-app purchases are not disclosed on the store page",
    });
  }
  if (!pkg.minorProtection) {
    issues.push({
      code: "minor-protection-missing",
      subject: "minor-protection",
      blocking: true,
      detail: "minor protection is not described in the submission",
    });
  }
  if (!pkg.permissionPurposeComplete) {
    issues.push({
      code: "permission-purpose-missing",
      subject: "permission-strings",
      blocking: true,
      detail: "one or more permission purpose strings are empty",
    });
  }

  const blockingCount = issues.filter((issue) => issue.blocking).length;
  return {
    product: pkg.product,
    platform: pkg.platform,
    version: pkg.version,
    ready: blockingCount === 0,
    blockingCount,
    issues,
    window: reviewWindow(pkg.product, pkg.platform),
  };
}

export type RejectionCode =
  | "missing-demo-account" | "privacy-mismatch" | "iap-undisclosed"
  | "metadata-misleading" | "permission-purpose" | "crash-on-launch" | "minor-protection";

export type Owner = "client" | "backend" | "ops" | "legal" | "marketing";

export interface RejectionGuide {
  readonly code: RejectionCode;
  readonly owner: Owner;
  readonly blocking: boolean;
  readonly firstAction: string;
}

const GUIDES: Readonly<Record<RejectionCode, RejectionGuide>> = {
  "missing-demo-account": { code: "missing-demo-account", owner: "ops", blocking: true,
    firstAction: "ship a demo account with a non-empty inventory and no payment step" },
  "privacy-mismatch": { code: "privacy-mismatch", owner: "legal", blocking: true,
    firstAction: "align the in-app policy, the store URL and the declared SDK list" },
  "iap-undisclosed": { code: "iap-undisclosed", owner: "marketing", blocking: true,
    firstAction: "disclose every paid tier and the currency on the store page" },
  "metadata-misleading": { code: "metadata-misleading", owner: "marketing", blocking: true,
    firstAction: "replace screenshots and copy that promise features the build does not have" },
  "permission-purpose": { code: "permission-purpose", owner: "client", blocking: true,
    firstAction: "write a player-facing purpose string for each sensitive permission" },
  "crash-on-launch": { code: "crash-on-launch", owner: "client", blocking: true,
    firstAction: "reproduce on a clean device with the submitted package, not a debug build" },
  "minor-protection": { code: "minor-protection", owner: "backend", blocking: true,
    firstAction: "expose real-name, play window and spend limit behavior in the review notes" },
};

export function classifyRejection(code: string): RejectionGuide | null {
  if (!Object.prototype.hasOwnProperty.call(GUIDES, code)) {
    return null;
  }
  return GUIDES[code as RejectionCode];
}

export function formatReviewReport(report: ReviewReport): string {
  const windowText =
    report.window === null ? "unknown" : `${report.window.minHours}-${report.window.maxHours}h`;
  const codes = [...new Set(report.issues.map((issue) => issue.code))].join(",") || "none";
  return `${report.product} ${report.version} ${report.platform} ${report.ready ? "ready" : "blocked"} blocking=${report.blockingCount} window=${windowText} codes=${codes}`;
}

代码里有几个刻意的选择。

第一,必需材料用常量列表维护,而不是散落在检查逻辑里。新增一项材料时只改一个地方,测试也能直接引用同一份列表,避免「检查单和测试各写一套」。第二,隐私政策材料用 note 存版本号,和提交包里的 privacyPolicyVersion 比较。版本不一致直接阻断,因为这是审核最容易抓、也最容易解释清楚的不一致。

第三,素材过期是告警而不是阻断。消消每 2 周更新素材、方舟每 4 周更新,过期很常见;把它做成阻断,会让每次小版本都被素材流程卡住。真正阻断的是「截图承诺了不存在的功能」,那属于误导性描述,不是单纯过期。第四,内购披露、未成年人保护、权限用途是三个独立布尔值,不合并成「合规就绪」。合并之后,报告只能告诉你「不通过」,无法告诉你是谁去修。

第五,审核窗口按产品和平台查表,查不到返回 null。事实表没有覆盖 Google Play 和 TapTap,代码就不猜;排期时把 null 当成未知窗口,单独留缓冲。第六,resubmitBudget 只做减法,不做预测。平均提交 1.8 次意味着首提后还有 0.8 次补交预算,它是排期输入,不是对下一次提交的承诺。

第七,驳回分类返回 null 表示未知码。未知码不应该被强行归入「其他」,否则复盘时会把新问题藏起来;应该进入人工队列,补充映射后再用。

双案例对照:消消与方舟

消消的发布约束在渠道数量:9 个渠道、11 个渠道包,国内安卓占 55%,审核窗口 3–5 天。它的材料检查重点是渠道素材和包内配置的一致性,iOS 24 小时窗口可以用来快速验证,但不能代表整体上线时间。

方舟的发布约束在审核深度:iOS 45%、国内安卓 40%,iOS 窗口 48 小时,首次平均提交 2.4 次。它的材料检查重点是权限用途、支付披露和未成年人保护说明,任何一项缺失都会让审核员无法验证核心体验。

两款产品共用发行中台,但检查单的参数不同:消消的素材刷新周期是 14 天,方舟是 28 天;消消的平均提交次数是 1.8,方舟是 2.4。把这些差异做成配置,比维护两份检查脚本更可控。

取舍:更早提交,还是更完整的材料

提交越早,越早进入审核队列;材料越完整,越少浪费提交机会。两者冲突时,案例的选择是「先内部预演,再正式提交」:

  • 用正式包、正式账号、正式商店页面做一次内部走查,模拟审核员的路径。
  • 把阻断项清零后再提交,告警项可以接受。
  • 国内安卓窗口最长,优先排期;iOS 窗口短,用来验证包和页面的自洽性。
  • 每次驳回都更新检查单,而不是只修当前版本。

代价是发布前多花半天到一天做走查,收益是少一次 3–5 天的重提等待。对国内安卓占 55% 的消消来说,一次重提的机会成本远高于走查成本;对方舟来说,2.4 次的平均提交次数已经说明「一次过」不现实,预演的价值更大。

还有一个边界:审核通过不等于发布完成。活动配置、支付通道、客服话术、灰度策略都要在放量前就绪,否则审核窗口省下来的时间会在上线当天还回去。

小结

  • 商店审核检查的是包、页面、账号和隐私声明是否自洽,不是文档写得多漂亮。
  • 十项材料里前九项必需,测试账号要能免支付进入完整玩法;素材过期是告警,误导性描述是阻断。
  • 审核窗口按产品和平台查表:消消 iOS 24 小时、方舟 48 小时,国内安卓均为 3–5 天;未知平台不猜。
  • 首次平均提交 1.8 次和 2.4 次是排期参数,首提后补交预算是 0.8 次和 1.4 次。
  • 驳回先归因再修复,同类问题连续出现说明检查单有缺口,而不是审核随机。

阅读导航:上一节:12.2 未成年人保护与防沉迷 · 下一节:13.1 商业化指标矩阵 。

常见错误与边界

  1. 把测试账号当成形式。空背包、要求先充值、入口藏得深,都会让审核员无法验证,直接驳回。
  2. 隐私政策、商店声明、SDK 清单三处各写一套。版本或 SDK 列表对不上,是最常见的隐私类驳回。
  3. 截图使用未上线玩法。这属于误导性描述,不是素材过期,必须换回真实截图再提交。
  4. 内购档位只在包内披露。商店页面也要列出档位与货币,否则会被判定为未披露付费。
  5. 把素材过期当成阻断项。消消 2 周、方舟 4 周更新一次,过期很常见;阻断会让发布被素材流程拖死。
  6. 把审核窗口当成放量时间。审核通过只代表包可用,活动配置、支付和客服没就绪就开量,事故会在上线当天出现。
  7. 用 iOS 的 24 小时推算整体上线。国内安卓占消消 55%,3–5 天窗口才是主约束。
  8. 给未知平台套用已知窗口。Google Play 和 TapTap 不在事实表里,应按未知处理并单独留缓冲。
  9. 驳回后只改当前问题,不更新检查单。同类问题会反复出现,平均提交次数永远降不下来。
  10. 把 1.8 次或 2.4 次当成承诺。平均值包含一次过和三次过,单次发布只能控制提交前检查质量。
  11. 未知驳回码强行归入「其他」。新问题会被藏起来,应该进入人工队列,补充映射后再复用。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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