2.2 上架资料、审核与驳回

九个小游戏平台的资料门槛,把「能不能提审」变成一张可算的清单:国内平台普遍要版号,海外 H5 平台只要隐私政策与年龄分级。本节用可跑的 Python 仿真算出《合成小岛》工作室在九平台上的资料就绪度、缺口与审核风险,配 TypeScript 门槛内核跨语言对拍,并把真实审核结果标为不可复现的 X 层。

站内有一篇出海合规与税务风险清单 ,那是法务与税务口径:哪些地区要交什么税、哪些条款有法律风险。本节不重写那些法律意见,只做工程侧的落点:把「上架需要哪些资料」变成一张可维护的清单,把「能不能提审」变成一次可复算的判定。换句话说,那篇回答「合不合法」,本节回答「资料齐不齐、卡在哪一步」。2.1 解决了「代码能不能跑」,这一节解决「产品能不能上」。

上架不是「上传一个包」

一个常见的误判是:小游戏无非是网页,打包上传就完了。真到了国内平台你会发现,提审的入口根本不在你手里——它由一份资料清单把守。少一样,连提交按钮都点不了。这份清单里最硬的一项是版号(游戏出版物号):国内平台对含内购或广告变现的小游戏普遍要求版号,而版号的申请周期以月计;海外 H5 平台则基本不看版号,只要隐私政策与年龄分级。

这就形成了一个结构性事实:同一款游戏,海外三平台能立刻提审,国内六平台全被同一份资料卡住。本节就是把这件事量化。

资料门槛与审核风险

平台要求不是「一个包」,而是一组资料键的集合。本节登记七类:软件著作权(softCopyright)、版号(isbn)、隐私政策(privacyPolicy)、年龄分级(ageRating)、内容自评(contentSelfReview)、支付资质(paymentQualification)、实名认证(realNameAuth)。每个平台要求其中若干项,工作室已有的项打 1、没有的打 0,就能算出就绪度与缺口:

present       = [m for m in requiredMaterials if studio[m] == 1]
gapCount      = len(requiredMaterials) - len(present)
submittable   = 1 if gapCount == 0 else 0
readiness‰    = len(present) * 1000 / len(requiredMaterials)
rejectionRisk = Σ rejectionReasons[r].weight × platform.riskFactors[r]

rejectionRisk 是审核风险分:把六类驳回原因(资料缺失、内容政策、知识产权、年龄分级、支付、隐私)的全局权重,乘上该平台对各类原因的敏感度,得到「这家审得有多严」的一个可比较整数。它不预测你会不会被驳,只用于排期时给严格平台留缓冲。

S 层:资料门槛仿真

下面这段是 S 层仿真脚本 sim/g02_submission_gate.py。它读 config/g02_submission.json,对九个平台算出各自的可提审状态、资料缺口、就绪度与审核风险,并额外算出「补齐某份资料能解锁几个平台」,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/g02_submission_gate.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g02_submission_gate.py --config config/ --out out/
"""上架资料门槛仿真:给定工作室已有的资料清单,算「九平台里哪些能提审、卡在哪、风险多高」。

读 config/g02_submission.json:

    present       = [m for m in requiredMaterials if studio[m] == 1]
    gapCount      = len(requiredMaterials) - len(present)
    submittable   = 1 if gapCount == 0 else 0
    readiness‰    = len(present) * 1000 // len(requiredMaterials)
    rejectionRisk = Σ rejectionReasons[r].weight * platform.riskFactors[r]

再算「补齐某份资料能解锁几个平台」:对每份缺失资料,统计有多少个当前被卡的平台
把它列为必备。产出 out/g02_submission_gate.json + .csv + .svg。

全程整数、地板除,无浮点。资料要求与审核风险是「公开可观察机制」口径的建模假设;
真实审核结果、真实驳回通知需真实业务环境,命令行不可复现(X)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET


def evaluate(p: dict, studio: dict, reasons: dict) -> dict:
    req = p["requiredMaterials"]
    present = [m for m in req if studio.get(m, 0) == 1]
    gap = len(req) - len(present)
    risk = sum(reasons[r]["weight"] * p["riskFactors"].get(r, 0) for r in reasons)
    return {
        "id": p["id"], "name": p["name"], "region": p["region"],
        "required": len(req), "present": len(present), "gapCount": gap,
        "submittable": 1 if gap == 0 else 0,
        "readinessPermille": len(present) * 1000 // len(req) if req else 1000,
        "reviewDaysP50": p["reviewDaysP50"], "rejectionRisk": risk,
    }


def unlock_counts(cfg: dict, rows: list[dict]) -> list[dict]:
    """对每份缺失资料,统计它能为多少个当前被卡的平台解锁。"""
    studio = cfg["studio"]
    blocked = [p for p, r in zip(cfg["platforms"], rows) if r["submittable"] == 0]
    out = []
    for m in cfg["materialKeys"]:
        if studio.get(m, 0) == 1:
            continue
        n = sum(1 for p in blocked if m in p["requiredMaterials"])
        out.append({"material": m, "unlocks": n})
    out.sort(key=lambda x: (-x["unlocks"], x["material"]))
    return out


def draw_svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每个平台的资料就绪度(千分比)横条 + 能否提审 + 风险分。"""
    W, H, PAD, ROW = 780, 92 + 40 * len(rows), 190, 40
    root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                              "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15", "fill": "#111827"}).text = (
        "《合成小岛》九平台上架资料就绪度(千分比,1000=可提审)")
    span = W - PAD - 24
    for i, r in enumerate(rows):
        y = 56 + i * ROW
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 18), "font-size": "12",
                                     "fill": "#374151"}).text = f'{r["name"]}({r["region"]})'
        w = span * r["readinessPermille"] // 1000
        color = "#16a34a" if r["submittable"] == 1 else "#ef4444"
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 4), "width": str(w),
                                     "height": "20", "rx": "3", "fill": color})
        ET.SubElement(root, "text", {"x": str(PAD + w + 6), "y": str(y + 18), "font-size": "11",
                                     "fill": "#6b7280"}).text = (
            f'{"可提审" if r["submittable"] == 1 else f"缺 {r["gapCount"]} 份"} · '
            f'{r["readinessPermille"]}‰ · 风险 {r["rejectionRisk"]}')
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(root))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()

    with open(os.path.join(args.config, "g02_submission.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    reasons = {r["code"]: r for r in cfg["rejectionReasons"]}
    rows = [evaluate(p, cfg["studio"], reasons) for p in cfg["platforms"]]
    unlocks = unlock_counts(cfg, rows)
    submittable = [r["id"] for r in rows if r["submittable"] == 1]
    blocked = [r["id"] for r in rows if r["submittable"] == 0]

    digest = ",".join(f'{r["id"]}:{r["submittable"]}:{r["gapCount"]}:'
                      f'{r["reviewDaysP50"]}:{r["rejectionRisk"]}' for r in rows)

    os.makedirs(args.out, exist_ok=True)
    svg_bytes = draw_svg(os.path.join(args.out, "g02_submission_gate.svg"), rows)
    report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
              "materialKeys": cfg["materialKeys"], "studio": cfg["studio"],
              "platforms": rows, "unlocks": unlocks, "submittable": submittable, "blocked": blocked,
              "digest": digest, "svg": os.path.join(args.out, "g02_submission_gate.svg"), "svgBytes": svg_bytes}
    with open(os.path.join(args.out, "g02_submission_gate.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "g02_submission_gate.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        cols = ("id", "name", "region", "required", "present", "gapCount", "submittable",
                "readinessPermille", "reviewDaysP50", "rejectionRisk")
        w.writerow(cols)
        for r in rows:
            w.writerow([r[k] for k in cols])

    print(json.dumps({"digest": digest, "submittable": submittable, "blocked": blocked,
                      "unlocks": unlocks, "platforms": rows}, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

参数放在 config/g02_submission.json(D 层配置)。工作室资料清单、平台要求与审核敏感度都是「公开可观察机制」口径的建模假设,不是任何平台的实测。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "九平台上架资料门槛与审核风险登记表。口径是「公开可观察机制」的建模假设(观察时点 2026-10,非实测):requiredMaterials=该平台提审前必须具备的资料键;reviewDaysP50=从提审到出结果的中位天数;riskFactors=该平台对各类驳回原因的敏感度(0/1)。rejectionReasons 是驳回原因的全局权重。submittable/gapCount/readinessPermille/rejectionRisk 由 studio 资料清单与平台要求推导,仅用于工程排期,不代表任何平台官方审核结论。真实审核结果、真实驳回通知需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "materialKeys": ["softCopyright", "isbn", "privacyPolicy", "ageRating", "contentSelfReview", "paymentQualification", "realNameAuth"],
  "studio": { "softCopyright": 1, "isbn": 0, "privacyPolicy": 1, "ageRating": 1, "contentSelfReview": 1, "paymentQualification": 0, "realNameAuth": 1 },
  "rejectionReasons": [
    { "code": "material-missing", "weight": 5 },
    { "code": "content-policy", "weight": 4 },
    { "code": "ip-infringement", "weight": 5 },
    { "code": "age-rating", "weight": 3 },
    { "code": "payment", "weight": 3 },
    { "code": "privacy", "weight": 4 }
  ],
  "platforms": [
    { "id": "wechat", "name": "微信小游戏", "region": "cn", "reviewDaysP50": 7, "requiredMaterials": ["softCopyright", "isbn", "privacyPolicy", "ageRating", "contentSelfReview", "realNameAuth"], "riskFactors": { "content-policy": 1, "ip-infringement": 1, "age-rating": 1, "payment": 0, "privacy": 1 } },
    { "id": "douyin", "name": "抖音小游戏", "region": "cn", "reviewDaysP50": 5, "requiredMaterials": ["softCopyright", "isbn", "privacyPolicy", "ageRating", "contentSelfReview", "paymentQualification", "realNameAuth"], "riskFactors": { "content-policy": 1, "ip-infringement": 1, "age-rating": 1, "payment": 1, "privacy": 1 } },
    { "id": "kuaishou", "name": "快手小游戏", "region": "cn", "reviewDaysP50": 5, "requiredMaterials": ["softCopyright", "isbn", "privacyPolicy", "ageRating", "contentSelfReview", "realNameAuth"], "riskFactors": { "content-policy": 1, "ip-infringement": 1, "age-rating": 0, "payment": 1, "privacy": 1 } },
    { "id": "qq", "name": "QQ 小游戏", "region": "cn", "reviewDaysP50": 7, "requiredMaterials": ["softCopyright", "isbn", "privacyPolicy", "ageRating", "contentSelfReview", "realNameAuth"], "riskFactors": { "content-policy": 1, "ip-infringement": 0, "age-rating": 1, "payment": 0, "privacy": 1 } },
    { "id": "baidu", "name": "百度小游戏", "region": "cn", "reviewDaysP50": 6, "requiredMaterials": ["softCopyright", "isbn", "privacyPolicy", "ageRating", "contentSelfReview", "realNameAuth"], "riskFactors": { "content-policy": 1, "ip-infringement": 0, "age-rating": 0, "payment": 0, "privacy": 1 } },
    { "id": "alipay", "name": "支付宝小游戏", "region": "cn", "reviewDaysP50": 8, "requiredMaterials": ["softCopyright", "isbn", "privacyPolicy", "ageRating", "contentSelfReview", "paymentQualification", "realNameAuth"], "riskFactors": { "content-policy": 1, "ip-infringement": 1, "age-rating": 0, "payment": 1, "privacy": 1 } },
    { "id": "poki", "name": "Poki", "region": "global", "reviewDaysP50": 3, "requiredMaterials": ["privacyPolicy", "ageRating"], "riskFactors": { "content-policy": 1, "ip-infringement": 1, "age-rating": 1, "payment": 0, "privacy": 1 } },
    { "id": "crazygames", "name": "CrazyGames", "region": "global", "reviewDaysP50": 2, "requiredMaterials": ["privacyPolicy", "ageRating"], "riskFactors": { "content-policy": 1, "ip-infringement": 1, "age-rating": 1, "payment": 0, "privacy": 1 } },
    { "id": "gamedistribution", "name": "GameDistribution", "region": "global", "reviewDaysP50": 2, "requiredMaterials": ["privacyPolicy", "ageRating"], "riskFactors": { "content-policy": 1, "ip-infringement": 1, "age-rating": 0, "payment": 0, "privacy": 1 } }
  ]
}

在本机跑 /opt/homebrew/bin/python3 sim/g02_submission_gate.py --config config/ --out out/,结果如下(同机相对量级,绝对数值不可移植):

平台必备资料已有缺口可提审就绪度‰审核风险
微信小游戏651否83316
抖音小游戏752否71419
快手小游戏651否83316
QQ 小游戏651否83311
百度小游戏651否8338
支付宝小游戏752否71416
Poki220是100016
CrazyGames220是100016
GameDistribution220是100013

这张表最该记住的是两条分界线。第一条是可提审 / 被卡:海外三平台资料齐、立刻能提;国内六平台全被同一份资料(版号)卡住。脚本还算了一笔「解锁账」:在工作室缺的两份资料里,补上版号能一次性解锁 6 个平台,补上支付资质只能解锁 2 个——先办版号是收益最高的动作。第二条是审核风险:即便资料补齐,抖音的风险分(19)也最高,排期时要给它留更多缓冲。两条线合起来就是一句话:国内上架的时间瓶颈不在代码,在资料。

P 层:门槛内核与驳回分类

S 层算出了就绪度,但落地时你要的是一个能嵌进工程流水线的门槛内核:给一份资料清单,立刻回答「哪些平台可提、卡在哪、驳回原因怎么整改」。下面这段是 P 层 TypeScript 内核 core/src/g02/submission_gate.ts,它读同一份 config/g02_submission.json,复算同一套结果——和 Python 逐字符一致。

// file: core/src/g02/submission_gate.ts
// tier: P
// verify: node --test 'core/test/g02_submission_gate.test.ts'
//
// 上架资料门槛内核:给定工作室资料清单,算出九平台的 submittable / gapCount / 就绪度 / 审核风险,
// 与 sim/g02_submission_gate.py 逐位等价;并把驳回原因分类成可执行的整改动作。
// 读同一份 config/g02_submission.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。

export interface RejectionReason { readonly code: string; readonly weight: number; }
export interface RawPlatformSub {
  readonly id: string; readonly name: string; readonly region: string;
  readonly reviewDaysP50: number;
  readonly requiredMaterials: readonly string[];
  readonly riskFactors: Readonly<Record<string, number>>;
}
export interface SubmissionConfig {
  readonly version: number; readonly game: string; readonly observedAt: string;
  readonly materialKeys: readonly string[];
  readonly studio: Readonly<Record<string, number>>;
  readonly rejectionReasons: readonly RejectionReason[];
  readonly platforms: readonly RawPlatformSub[];
}

export interface GateResult {
  readonly id: string; readonly name: string; readonly region: string;
  readonly required: number; readonly present: number; readonly gapCount: number;
  readonly submittable: number; readonly readinessPermille: number;
  readonly reviewDaysP50: number; readonly rejectionRisk: number;
}

export function evaluate(p: RawPlatformSub, studio: Readonly<Record<string, number>>,
                         reasons: Readonly<Record<string, RejectionReason>>): GateResult {
  const req = p.requiredMaterials;
  const present = req.filter((m) => (studio[m] ?? 0) === 1).length;
  const gap = req.length - present;
  let risk = 0;
  for (const code of Object.keys(reasons)) {
    risk += reasons[code].weight * (p.riskFactors[code] ?? 0);
  }
  return { id: p.id, name: p.name, region: p.region, required: req.length, present, gapCount: gap,
    submittable: gap === 0 ? 1 : 0,
    readinessPermille: req.length === 0 ? 1000 : Math.floor((present * 1000) / req.length),
    reviewDaysP50: p.reviewDaysP50, rejectionRisk: risk };
}

export function evaluateAll(cfg: SubmissionConfig): GateResult[] {
  const reasons: Record<string, RejectionReason> = {};
  for (const r of cfg.rejectionReasons) reasons[r.code] = r;
  return cfg.platforms.map((p) => evaluate(p, cfg.studio, reasons));
}

/** 跨语言对拍摘要:与 sim/g02_submission_gate.py 的 digest 逐字符一致。 */
export function digest(rows: readonly GateResult[]): string {
  return rows.map((r) => `${r.id}:${r.submittable}:${r.gapCount}:${r.reviewDaysP50}:${r.rejectionRisk}`)
    .join(",");
}

/** 补齐某份资料能为多少个「当前被卡」的平台解锁。 */
export function unlockCounts(cfg: SubmissionConfig, rows: readonly GateResult[]): { material: string; unlocks: number }[] {
  const blocked = cfg.platforms.filter((p, i) => rows[i].submittable === 0);
  const out: { material: string; unlocks: number }[] = [];
  for (const m of cfg.materialKeys) {
    if ((cfg.studio[m] ?? 0) === 1) continue;
    const n = blocked.filter((p) => p.requiredMaterials.includes(m)).length;
    out.push({ material: m, unlocks: n });
  }
  return out.sort((a, b) => (b.unlocks - a.unlocks) || (a.material < b.material ? -1 : 1));
}

/** 驳回原因 → 整改动作分类(工程侧的可执行映射)。 */
export const REMEDIATION: Readonly<Record<string, string>> = {
  "material-missing": "补资料",
  "content-policy": "改内容",
  "ip-infringement": "换素材或补授权",
  "age-rating": "补年龄分级",
  "payment": "补支付资质",
  "privacy": "改隐私声明与埋点",
};

export function classifyRejection(code: string): string {
  return REMEDIATION[code] ?? "人工复核";
}

配套测试 core/test/g02_submission_gate.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算结果逐字符相等,并验证「版号解锁 6 个平台」与驳回原因分类。在本机跑 node --test 'core/test/g02_submission_gate.test.ts',7 个用例全过;两套独立实现给出同一个摘要:

digest = wechat:0:1:7:16,douyin:0:2:5:19,kuaishou:0:1:5:16,qq:0:1:7:11,baidu:0:1:6:8,alipay:0:2:8:16,poki:1:0:3:16,crazygames:1:0:2:16,gamedistribution:1:0:2:13

classifyRejection 是这份内核里最「工程」的一环:它把一条驳回原因(比如 ip-infringement)直接映射成整改动作(「换素材或补授权」),让审核驳回从「一段客服话术」变成「一条可派工的任务」。这正是本节与那篇合规清单 的分工:那篇给你法律判断,这份内核给你可执行的任务流。

X 层:真实审核结果不可复现

上面算的都是模型侧:给定资料清单,能不能提、卡在哪。而下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的审核结果与驳回通知(每条平台的审核尺度会随版本、类目、账号历史变化,且部分反馈只在后台可见);
  • 真实的版号申请周期(以月计,且受申报批次影响,命令行无法建模到「天」的精度);
  • 真实的平台要求清单(会随平台政策更新而增删,本节登记表须按观察时点维护)。

工程上正确的做法是把清单与判定分层:清单层(requiredMaterials)是随平台政策变动的数据,判定层(evaluate)是稳定的代码。平台改要求时只改配置、不改代码,重跑一次就能看到「哪个平台从可提变成被卡」。这与 2.1 的适配层是同一个思路——把易变的平台差异关进配置。

自检表

检查项合格标准
口径清晰资料要求标「公开可观察机制」+ 观察时点,不写「实测」
就绪度可复算readiness 由清单唯一确定,Python / TS 结果一致
缺口可排序补齐哪份资料解锁最多平台,脚本直接给出
驳回可派工驳回原因映射到整改动作,不停留在话术
边界清晰真实审核结果 / 版号周期标 X,不写成「实测」
与法务分界只做工程落点,不重写合规清单的法律意见

资料备齐、审核通过,游戏就上线了。但上线之后你会发现,平台能给你的不只是广告位——云存档、排行榜、实时多人这些「专属能力」,有的平台全有、有的几乎全无。下一节就把九平台的能力矩阵摊开,并给出缺失能力的降级链。

阅读导航:上一节:2.1 SDK 与广告 API 接入差异 · 下一节:2.3 平台专属能力:存档、排行榜与多人 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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