审核不是一次提交,而是一条可回滚的发布链
12.1 说明产品声明了什么,12.2 说明运行时执行了什么,12.3 要回答的是:怎样让外部审核相信前两件事。商店审核是发行链上唯一由外部决定的关卡,它不看你的内部文档,只看提交包、商店页面和审核备注是否自洽。
驳回通常不是「运气不好」,而是四类不一致之一:
| 不一致 | 典型表现 | 谁最容易漏 |
|---|---|---|
| 包与声明不一致 | 权限声明为空,安装后却弹定位 | 客户端 |
| 页面与包不一致 | 截图里有未上线的玩法 | 市场 |
| 账号与体验不一致 | 审核员拿不到可用的测试账号 | 运营 |
| 隐私与 SDK 不一致 | 政策没写某个广告 SDK | 法务与客户端 |
把审核当成一次「提交—等待—被拒—再提交」的循环,团队只能被动响应。把它当成发布门禁的一部分,才能在提交前发现大部分问题。
材料清单:十项,缺一项就重来
案例里的提交包按十项材料核对。前九项是必需项,第十项按平台要求决定是否提供。
| 材料 | 作用 | 缺失后果 | 责任方 |
|---|---|---|---|
| 隐私政策 | 说明数据采集与共享 | 直接驳回 | 法务 |
| 图标 | 商店展示与包内一致 | 展示不一致或驳回 | 市场 |
| 截图 | 证明真实玩法 | 误导性描述驳回 | 市场 |
| 描述 | 功能、付费、年龄说明 | 信息不完整驳回 | 市场 |
| 年龄分级 | 匹配内容与付费 | 分级错误驳回 | 运营 |
| 内购披露 | 列出档位与货币 | 未披露付费驳回 | 市场 |
| 账号删除入口 | 提供站内删除路径 | 账号政策驳回 | 客户端 |
| 权限用途文案 | 每项敏感权限一句说明 | 权限用途缺失驳回 | 客户端 |
| 测试账号 | 让审核员进入完整体验 | 无法验证驳回 | 运营 |
| 演示视频 | 说明复杂玩法或硬件依赖 | 视平台要求 | 运营 |
测试账号是最容易被低估的一项。它不能是一个空号,也不能要求审核员先充值。正确做法是提供一个带非空背包、跳过新手引导、能直接触发核心玩法与付费页的账号,并在审核备注里写清步骤。审核员没有义务替你猜入口。截图和描述则要遵守「所见即所得」。消消每 2 周更新一次商店素材,方舟每 4 周更新一次;更新频率高,容易出现截图已经下线、描述还写着旧活动的状况。素材过期本身通常只是告警,但如果截图展示了不存在的玩法,就会升级为误导性描述。
阻断项与告警项
审核检查单不能把所有问题都当成阻断项,否则每次提交都会被卡住;也不能把真正阻断的问题降级成提醒。案例里分成两级:
| 问题 | 级别 | 处理时机 | 负责人 |
|---|---|---|---|
| 必需材料缺失 | 阻断 | 提交前必须补齐 | 材料责任人 |
| 隐私政策版本不一致 | 阻断 | 提交前对齐 | 法务 |
| 内购未披露 | 阻断 | 提交前补页面 | 市场 |
| 未成年人保护未说明 | 阻断 | 提交前补审核备注 | 后端 |
| 权限用途文案为空 | 阻断 | 提交前补文案 | 客户端 |
| 商店素材超过刷新周期 | 告警 | 可提交,下个版本更新 | 市场 |
判断标准很简单:这个问题会不会让审核员无法验证核心体验,或者让玩家在不知情的情况下付费。会,就是阻断;不会,只是告警。把告警当阻断,会让发布节奏被素材更新拖死;把阻断当告警,会浪费一次提交机会。
审核窗口:24 小时、48 小时与 3–5 天
案例事实表给出的窗口很有限,但足够做排期:
| 平台 | 潮汐消消 | 潮汐方舟 |
|---|---|---|
| iOS | 24 小时 | 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 商业化指标矩阵 。
常见错误与边界
- 把测试账号当成形式。空背包、要求先充值、入口藏得深,都会让审核员无法验证,直接驳回。
- 隐私政策、商店声明、SDK 清单三处各写一套。版本或 SDK 列表对不上,是最常见的隐私类驳回。
- 截图使用未上线玩法。这属于误导性描述,不是素材过期,必须换回真实截图再提交。
- 内购档位只在包内披露。商店页面也要列出档位与货币,否则会被判定为未披露付费。
- 把素材过期当成阻断项。消消 2 周、方舟 4 周更新一次,过期很常见;阻断会让发布被素材流程拖死。
- 把审核窗口当成放量时间。审核通过只代表包可用,活动配置、支付和客服没就绪就开量,事故会在上线当天出现。
- 用 iOS 的 24 小时推算整体上线。国内安卓占消消 55%,3–5 天窗口才是主约束。
- 给未知平台套用已知窗口。Google Play 和 TapTap 不在事实表里,应按未知处理并单独留缓冲。
- 驳回后只改当前问题,不更新检查单。同类问题会反复出现,平均提交次数永远降不下来。
- 把 1.8 次或 2.4 次当成承诺。平均值包含一次过和三次过,单次发布只能控制提交前检查质量。
- 未知驳回码强行归入「其他」。新问题会被藏起来,应该进入人工队列,补充映射后再复用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。