先划边界。站内那篇出海合规与税务风险清单 给的是法务与税务判断:哪些地区有法律风险、哪些条款要留意。本节不重写它,只做工程落点:把「哪些地区能进、进去之后哪些功能要关」变成一张可维护的登记表与一组可复算的开关。本节不提供法律意见,制裁与地区限制只作公开可观察机制引用(观察时点 2026-10),不可复现处标 X。
上一节处理的是内容侧的可用性:一份素材在哪些地区能发。这一节处理地区侧的可用性:这个地区本身能不能进、进去了哪些功能要关。两者是正交的——一个地区可能对内容没限制,但整区因制裁不可发;也可能整区可发,却必须关掉支付或社交。
地区可用性分三层:清单、档位、开关
地区可用性不是一个「能 / 不能」的开关,而是一套三层结构。第一层是清单:哪些地区在发行组合里。第二层是档位:每个地区分三档——tier=1 常规(全功能)、tier=2 受限(部分功能关闭,需人工复核)、tier=3 封锁(整区不可发)。第三层是功能开关:每档给一组默认开关(支付、广告、排行榜、社交、聊天、云存档、实名),再允许按地区覆盖个别功能。
这套结构的价值在于:档位决定默认,覆盖处理例外。中国大陆是常规档但单独关掉聊天;中东是受限档但单独打开聊天。把这些写进配置,地区策略就从「散落在代码里的 if」变成「一份可 review 的表」。
档位与覆盖:tierPolicy + featureOverrides
登记表是纯数据。tierPolicy 给出各档默认功能开关,featureOverrides 按地区覆盖:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "制裁与地区可用性登记表。口径是「公开可观察机制」的建模假设(观察时点 2026-10,非实测):把各地区的发行可用性与功能开关整理成可机读的登记表,不是任何监管机构或平台的逐条法律结论,也不构成法律意见。tier=1 常规(全功能)、2 受限(部分功能关闭,需人工复核)、3 封锁(整区不可发)。tierPolicy 给出各档的默认功能开关;featureOverrides 按地区覆盖默认值。available = tier != 3;feature(region, f) = tierPolicy[tier][f] 再被 featureOverrides 覆盖。真实制裁清单逐季变动、真实地区封禁结果需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"features": ["payments", "ads", "leaderboard", "social", "chat", "cloudSave", "realName"],
"tierPolicy": {
"1": { "payments": 1, "ads": 1, "leaderboard": 1, "social": 1, "chat": 1, "cloudSave": 1, "realName": 1 },
"2": { "payments": 1, "ads": 1, "leaderboard": 1, "social": 0, "chat": 0, "cloudSave": 1, "realName": 1 },
"3": { "payments": 0, "ads": 0, "leaderboard": 0, "social": 0, "chat": 0, "cloudSave": 0, "realName": 0 }
},
"regions": [
{ "id": "us", "name": "美国", "tier": 1, "featureOverrides": {} },
{ "id": "eu", "name": "欧盟", "tier": 1, "featureOverrides": {} },
{ "id": "jp", "name": "日本", "tier": 1, "featureOverrides": {} },
{ "id": "kr", "name": "韩国", "tier": 1, "featureOverrides": {} },
{ "id": "br", "name": "巴西", "tier": 1, "featureOverrides": {} },
{ "id": "cn", "name": "中国大陆", "tier": 1, "featureOverrides": { "chat": 0 } },
{ "id": "in", "name": "印度", "tier": 2, "featureOverrides": {} },
{ "id": "mena", "name": "中东", "tier": 2, "featureOverrides": { "chat": 1 } },
{ "id": "ru", "name": "俄罗斯", "tier": 3, "featureOverrides": {} },
{ "id": "ir", "name": "伊朗", "tier": 3, "featureOverrides": {} },
{ "id": "kp", "name": "朝鲜", "tier": 3, "featureOverrides": {} },
{ "id": "sy", "name": "叙利亚", "tier": 3, "featureOverrides": {} }
]
}
D 层:把地区清单扫成功能开关表
下面这段是 D 层校验脚本 sim/g12_sanction_gate.py。它读登记表,把「地区」与「功能开关」叉乘成一张 gate 表,算出封锁区、收款地区集合、功能覆盖度,以及开启 / 关闭的格子总数:
#!/usr/bin/env python3
# file: sim/g12_sanction_gate.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g12_sanction_gate.py --config config/ --out out/
"""制裁与地区可用性开关校验(D 层:清单结构 / lint 校验)。
读 config/g12_sanction_regions.json,把「地区」与「功能开关」叉乘成一张 gate 表:
available(region) = 1 if tier != 3 else 0
feature(region, f) = tierPolicy[tier][f],再被 region.featureOverrides 覆盖
enabledCount(region) = 该地区开启的功能数
enabledCells = Σ enabledCount(所有地区)
disabledCells = regions × features − enabledCells
paymentRegions = 可用且开启 payments 的地区(能收款的地区)
口径:登记表是「公开可观察机制」的建模假设,不是监管机构或平台的逐条法律结论,
也不构成法律意见。真实制裁清单逐季变动、真实地区封禁结果需真实业务环境,
命令行不可复现(X)。产出 out/g12_sanction_gate.json + .csv + .svg。全整数,无浮点。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def gate(cfg: dict, region: dict) -> dict:
tier = str(region["tier"])
policy = cfg["tierPolicy"][tier]
overrides = region.get("featureOverrides", {})
feats = {f: int(overrides.get(f, policy[f])) for f in cfg["features"]}
return {"id": region["id"], "name": region["name"], "tier": region["tier"],
"available": 0 if region["tier"] == 3 else 1, "features": feats,
"enabledCount": sum(feats.values())}
def draw_svg(path: str, rows: list[dict], features: list[str]) -> int:
"""纯 Python SVG:region × feature 开关格(绿=开、灰=关、封锁区整行红底)。"""
CW, CH, LEFT, TOP = 78, 30, 150, 74
W = LEFT + CW * len(features) + 20
H = TOP + CH * len(rows) + 20
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(svg, "text", {"x": "16", "y": "28", "font-size": "15",
"fill": "#111827"}).text = "地区 × 功能开关(绿=开 / 灰=关 / 红=整区封锁)"
for k, f in enumerate(features):
ET.SubElement(svg, "text", {"x": str(LEFT + k * CW + 4), "y": str(TOP - 10),
"font-size": "10", "fill": "#6b7280"}).text = f
for i, r in enumerate(rows):
y = TOP + i * CH
ET.SubElement(svg, "text", {"x": "16", "y": str(y + 19), "font-size": "11",
"fill": "#374151"}).text = f'{r["name"]}(tier{r["tier"]})'
for k, f in enumerate(features):
on = r["features"][f]
fill = "#16a34a" if on else ("#ef4444" if r["available"] == 0 else "#e5e7eb")
ET.SubElement(svg, "rect", {"x": str(LEFT + k * CW + 2), "y": str(y + 4),
"width": str(CW - 4), "height": str(CH - 8), "rx": "3",
"fill": fill})
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(svg))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
a = ap.parse_args()
with open(os.path.join(a.config, "g12_sanction_regions.json"), encoding="utf-8") as f:
cfg = json.load(f)
features = cfg["features"]
rows = [gate(cfg, r) for r in cfg["regions"]]
available = [r["id"] for r in rows if r["available"] == 1]
blocked = [r["id"] for r in rows if r["available"] == 0]
enabled_cells = sum(r["enabledCount"] for r in rows)
disabled_cells = len(rows) * len(features) - enabled_cells
payment_regions = [r["id"] for r in rows if r["available"] == 1 and r["features"]["payments"] == 1]
per_feature = [{"feature": f, "enabledRegions": sum(1 for r in rows if r["features"][f] == 1)}
for f in features]
digest = (f"regions={len(rows)},available={len(available)},blocked={len(blocked)},"
f"enabledCells={enabled_cells},disabledCells={disabled_cells}")
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "g12_sanction_gate.svg"), rows, features)
with open(os.path.join(a.out, "g12_sanction_gate.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["region", "name", "tier", "available", "enabledCount", *features])
for r in rows:
w.writerow([r["id"], r["name"], r["tier"], r["available"], r["enabledCount"],
*[r["features"][ft] for ft in features]])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"features": features, "regions": rows, "available": available, "blocked": blocked,
"enabledCells": enabled_cells, "disabledCells": disabled_cells,
"paymentRegions": payment_regions, "perFeature": per_feature,
"digest": digest, "svg": os.path.join(a.out, "g12_sanction_gate.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "g12_sanction_gate.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "available": available, "blocked": blocked,
"enabledCells": enabled_cells, "disabledCells": disabled_cells,
"paymentRegions": payment_regions, "perFeature": per_feature},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑 /opt/homebrew/bin/python3 sim/g12_sanction_gate.py --config config/ --out out/,12 个地区 × 7 个功能 = 84 格的结果是(同机相对量级,绝对数值不可移植):
digest = regions=12,available=8,blocked=4,enabledCells=52,disabledCells=32
逐地区看(enabledCount 是该地区开启的功能数):
| 地区 | 档位 | 可用 | 开启功能 | 读法 |
|---|---|---|---|---|
| 美国 / 欧盟 / 日本 / 韩国 / 巴西 | 1 | 是 | 7 | 全功能 |
| 中国大陆 | 1 | 是 | 6 | 常规档,单独关掉聊天 |
| 印度 | 2 | 是 | 5 | 受限档,社交与聊天默认关 |
| 中东 | 2 | 是 | 6 | 受限档,但单独打开聊天 |
| 俄罗斯 / 伊朗 / 朝鲜 / 叙利亚 | 3 | 否 | 0 | 封锁,整行关闭 |
这张表最该记住的是两条分界线。第一条是可用 / 封锁:4 个地区(ru / ir / kp / sy)整区不可发,功能全关——它们不出现在 paymentRegions 里,所以连支付通道都不必接。第二条是覆盖优先级:中国大陆是常规档却关掉了聊天,中东是受限档却打开了聊天——档位给默认,覆盖处理例外。功能覆盖度也一目了然:social 与 chat 只覆盖 6 个地区,其余功能覆盖 8 个。
P 层内核:跨语言对拍,证明开关没配错
一张表看不出「是判定对」还是「碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/g12/region_gate.ts 复算同一套开关:同样的档位默认、同样的覆盖优先级、同样的封锁判定。它读同一份 config/g12_sanction_regions.json,产出与 Python 逐字符一致的摘要。
// file: core/src/g12/region_gate.ts
// tier: P
// verify: node --test 'core/test/g12_*.test.ts'
//
// 制裁与地区可用性内核:与 sim/g12_sanction_gate.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g12_sanction_regions.json;全整数、无浮点、无随机、禁 Math.random。
// available = tier != 3;feature(region, f) = tierPolicy[tier][f] 再被 featureOverrides 覆盖。
export interface RawRegion {
readonly id: string; readonly name: string; readonly tier: number;
readonly featureOverrides: Readonly<Record<string, number>>;
}
export interface GateConfig {
readonly version: number; readonly game: string; readonly observedAt: string;
readonly features: readonly string[];
readonly tierPolicy: Readonly<Record<string, Readonly<Record<string, number>>>>;
readonly regions: readonly RawRegion[];
}
export interface GateRow {
readonly id: string; readonly name: string; readonly tier: number; readonly available: number;
readonly features: Readonly<Record<string, number>>; readonly enabledCount: number;
}
export interface GateResult { readonly rows: readonly GateRow[]; readonly blocked: readonly string[];
readonly paymentRegions: readonly string[] }
/** 单地区 gate:先取 tier 默认,再用 featureOverrides 覆盖。 */
export function gate(cfg: GateConfig, region: RawRegion): GateRow {
const policy = cfg.tierPolicy[String(region.tier)];
const features: Record<string, number> = {};
for (const f of cfg.features) {
const override = region.featureOverrides[f];
features[f] = override === undefined ? policy[f] : override;
}
let enabled = 0;
for (const f of cfg.features) enabled += features[f];
return { id: region.id, name: region.name, tier: region.tier,
available: region.tier === 3 ? 0 : 1, features, enabledCount: enabled };
}
/** 全量 gate:逐地区求开关、可用性与功能计数。 */
export function gateAll(cfg: GateConfig): GateResult {
const rows = cfg.regions.map((r) => gate(cfg, r));
return {
rows,
blocked: rows.filter((r) => r.available === 0).map((r) => r.id),
paymentRegions: rows.filter((r) => r.available === 1 && r.features["payments"] === 1).map((r) => r.id),
};
}
/** 跨语言对拍摘要:与 sim/g12_sanction_gate.py 的 digest 逐字符一致。 */
export function digest(cfg: GateConfig): string {
const { rows } = gateAll(cfg);
const enabled = rows.reduce((s, r) => s + r.enabledCount, 0);
const cells = rows.length * cfg.features.length;
const available = rows.filter((r) => r.available === 1).length;
return `regions=${rows.length},available=${available},blocked=${rows.length - available},`
+ `enabledCells=${enabled},disabledCells=${cells - enabled}`;
}
/** 某功能在多少个地区开启(跨地区功能覆盖度)。 */
export function enabledRegionCount(cfg: GateConfig, feature: string): number {
return gateAll(cfg).rows.filter((r) => r.features[feature] === 1).length;
}
配套测试 core/test/g12_region_gate.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并验封锁区整行关闭、覆盖优先级与收款地区集合。在本机跑 node --test 'core/test/g12_*.test.ts',本节 6 个用例全过:
✔ 跨语言一致:TS 地区 gate 摘要 == Python 金标准
✔ 封锁区整行关闭:4 个 tier3 地区全部功能为 0 且不可用
✔ 覆盖优先:中国大陆默认全开但单独关掉 chat;中东受限但单独打开 chat
✔ 受限区默认关闭社交与聊天:印度 enabledCount=5
✔ 收款地区集合:8 个可用地区全部开启 payments
✔ 功能覆盖度:社交与聊天只覆盖 6 个地区,其余功能 8 个
ℹ tests 6 / pass 6 / fail 0
只要有一边的档位默认、覆盖优先级或封锁判定写错,摘要立刻分叉。这与「GDPR / CCPA 的工程落点」 是同一种思路:把随地区变化的策略关进配置,让代码只负责「按表执行」。
X 层:真实制裁清单与封禁结果不可复现
必须把边界划清。上面算的是策略侧:给定一张地区表,开关矩阵是什么。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 各地区的真实制裁清单(清单逐季变动、口径各异,本节只作「公开可观察机制」引用并标注观察时点 2026-10,不构成法律意见);
- 各平台的真实地区封禁结果(同一地区在不同平台可能可发 / 不可发,且平台侧不透明);
- 各支付 / 广告网络的真实准入地区(与平台可用性不一致,需逐个对接)。
工程上正确的做法是:把策略层与事实层分开——策略层(tierPolicy / featureOverrides)是数据,可 review、可版本化;事实层(真实清单与准入结果)靠法务与商务反标定登记表(某地区被列入清单就改一行 tier,矩阵立刻更新)。这也是本节与「平台分成与结算条款」
的接口:地区可用性决定结算对象——封锁区的收入为零,收款地区才进分成账。
自检表
| 检查项 | 合格标准 |
|---|---|
| 三层结构 | 清单、档位、开关分开,档位给默认、覆盖处理例外 |
| 封锁可查 | tier=3 的地区整行关闭,不出现在收款集合 |
| 覆盖可复算 | 覆盖优先级唯一确定开关,Python / TS 结果一致 |
| 边界清晰 | 真实清单 / 封禁标 X,标观察时点,不写成「实测」 |
| 与法务分界 | 只做工程落点,不提供法律意见 |
到这里,地区侧的可控性就立起来了:先有地区表与开关,才谈得上多地区运营。下一节换一个完全不同的维度——不再是「地区能不能进」,而是「进来的流量是不是真人」:给出一套可播种的刷量检测内核,用正常 / 刷量两类流量算出检测率与误伤率。
阅读导航:上一节:12.1 内容与版权的地区差异 · 下一节:12.3 反作弊与刷量 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。