站内有一篇出海合规与税务风险清单 ,那是法务与税务口径:哪些地区要交什么税、哪些条款有法律风险。本节不重写那些法律意见,只做工程侧的落点:把「上架需要哪些资料」变成一张可维护的清单,把「能不能提审」变成一次可复算的判定。换句话说,那篇回答「合不合法」,本节回答「资料齐不齐、卡在哪一步」。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/,结果如下(同机相对量级,绝对数值不可移植):
| 平台 | 必备资料 | 已有 | 缺口 | 可提审 | 就绪度‰ | 审核风险 |
|---|---|---|---|---|---|---|
| 微信小游戏 | 6 | 5 | 1 | 否 | 833 | 16 |
| 抖音小游戏 | 7 | 5 | 2 | 否 | 714 | 19 |
| 快手小游戏 | 6 | 5 | 1 | 否 | 833 | 16 |
| QQ 小游戏 | 6 | 5 | 1 | 否 | 833 | 11 |
| 百度小游戏 | 6 | 5 | 1 | 否 | 833 | 8 |
| 支付宝小游戏 | 7 | 5 | 2 | 否 | 714 | 16 |
| Poki | 2 | 2 | 0 | 是 | 1000 | 16 |
| CrazyGames | 2 | 2 | 0 | 是 | 1000 | 16 |
| GameDistribution | 2 | 2 | 0 | 是 | 1000 | 13 |
这张表最该记住的是两条分界线。第一条是可提审 / 被卡:海外三平台资料齐、立刻能提;国内六平台全被同一份资料(版号)卡住。脚本还算了一笔「解锁账」:在工作室缺的两份资料里,补上版号能一次性解锁 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 平台专属能力:存档、排行榜与多人 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。