站内那篇独立游戏出海合规与税务风险清单 把「出海要遵守什么」讲透了:注册主体、税务登记、平台条款、未成年人保护。本节不重复那些结论,只做一件更窄的事——把合规要求翻译成代码与配置。先把边界说清楚:本节给的是工程实现,法律结论请咨询律师;法条怎么适用、监管怎么裁、你这款游戏到底算不算「定向广告」,都不在本文范围。
合规落到工程,只剩三张表
GDPR 与 CCPA 的法条读起来千头万绪,但真正要写进代码的只有三件事:你处理了哪些个人数据(数据流清单)、每条数据凭什么处理、能不能删(字段级标记)、玩家提了一个请求,系统该做什么(权利请求的动作规划)。把这三件事做成可机读的表,合规就从「一堆待办」变成了「一次校验」。
| 权利 | GDPR | CCPA | 工程动作 |
|---|---|---|---|
| 访问 / 知情 | Art.15 | 知道权 | 汇总个人数据 → 导出包 |
| 可携带 | Art.20 | — | 机器可读导出,排除不可导出项 |
| 删除 / 被遗忘 | Art.17 | 删除权 | 按清单删除,法律义务项保留 |
| 更正 | Art.16 | 更正权 | 字段级更新,留审计轨迹 |
| 限制处理 / 拒绝 | Art.18/21 | 退出「出售」 | 停止用途,不删数据 |
数据流清单:把字段标成可判定的属性
下面是 D 层的清单表。每条数据项都带一组可判定属性:personal(是否个人数据)、sensitive(是否敏感)、basis(合法性基础:合同 / 同意 / 正当利益 / 法律义务)、exportable、erasable。requests 是三类主体权利请求,holds 是法律保全(例如税务审计期间不得删除)。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "个人数据清单与主体权利请求(DSAR)动作规划表(D 层)。每条数据项标注:存储系统 store、处理目的 purpose、是否个人数据 personal、是否敏感 sensitive、合法性基础 basis(contract/consent/legit/legal)、保留天数 retentionDays、能否导出 exportable、能否删除 erasable。requests 是访问/可携带/删除三类请求;holds 是法律保全(如税务审计)。口径为工程建模假设:真实法规适用、真实监管裁决与最终合规判定需真实业务环境与专业法务判断,命令行不可复现(X)。全整数,无浮点。",
"regimes": ["gdpr", "ccpa"],
"fields": [
{"id": "d_account_id", "store": "account", "purpose": "service", "personal": true, "sensitive": false, "basis": "contract", "retentionDays": 365, "exportable": true, "erasable": true},
{"id": "d_email", "store": "account", "purpose": "service", "personal": true, "sensitive": false, "basis": "contract", "retentionDays": 365, "exportable": true, "erasable": true},
{"id": "d_geo_ip", "store": "analytics", "purpose": "analytics", "personal": true, "sensitive": false, "basis": "consent", "retentionDays": 90, "exportable": true, "erasable": true},
{"id": "d_ad_id", "store": "ads", "purpose": "personalized_ads", "personal": true, "sensitive": false, "basis": "consent", "retentionDays": 180, "exportable": true, "erasable": true},
{"id": "d_purchase", "store": "billing", "purpose": "billing", "personal": true, "sensitive": false, "basis": "legal", "retentionDays": 3650, "exportable": true, "erasable": false},
{"id": "d_dob_child", "store": "account", "purpose": "age_gate", "personal": true, "sensitive": true, "basis": "legal", "retentionDays": 3650, "exportable": false, "erasable": false},
{"id": "d_crash_stack", "store": "crash", "purpose": "diagnostics", "personal": false, "sensitive": false, "basis": "legit", "retentionDays": 30, "exportable": false, "erasable": true},
{"id": "d_device_model", "store": "analytics", "purpose": "diagnostics", "personal": false, "sensitive": false, "basis": "legit", "retentionDays": 180, "exportable": false, "erasable": true}
],
"requests": [
{"id": "r1", "type": "access", "subject": "u1001", "atDay": 200},
{"id": "r2", "type": "portability", "subject": "u1001", "atDay": 200},
{"id": "r3", "type": "erasure", "subject": "u1002", "atDay": 200},
{"id": "r4", "type": "erasure", "subject": "u1003", "atDay": 400}
],
"holds": [
{"subject": "u1003", "field": "d_account_id", "reason": "tax_audit"}
]
}
P 层:DSAR 动作规划内核
关键设计是输出「动作」而不是「删除」。同一份清单,访问请求产出 include / skip,删除请求产出 erase / retain,且每个动作都带 reason——这样「为什么不删」是有据可查的,而不是一句「合规要求」。全整数、无随机:
// file: core/src/g09/datamap.ts
// tier: P
// verify: node --test 'core/test/g09_*.test.ts'
//
// 个人数据清单 + 主体权利请求(DSAR)动作规划内核:与 sim/g09_data_map.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g09_data_map.json;全整数、无浮点、无随机、禁 Math.random(纯确定性回放)。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。
export interface DataField {
readonly id: string; readonly store: string; readonly purpose: string;
readonly personal: boolean; readonly sensitive: boolean; readonly basis: string;
readonly retentionDays: number; readonly exportable: boolean; readonly erasable: boolean;
}
export interface DsarRequest {
readonly id: string; readonly type: string; readonly subject: string; readonly atDay: number;
}
export interface Hold { readonly subject: string; readonly field: string; readonly reason: string; }
export interface DataMapConfig {
readonly version: number; readonly game: string; readonly regimes: readonly string[];
readonly fields: readonly DataField[]; readonly requests: readonly DsarRequest[]; readonly holds: readonly Hold[];
}
export interface Action { readonly field: string; readonly action: string; readonly reason: string; }
export interface RequestPlan { readonly id: string; readonly type: string; readonly rows: readonly Action[]; }
/** 访问权:个人数据一律纳入;敏感数据额外标注需人工复核。 */
export function planAccess(field: DataField): Action {
if (!field.personal) return { field: field.id, action: "skip", reason: "notPersonal" };
if (field.sensitive) return { field: field.id, action: "include", reason: "sensitiveReview" };
return { field: field.id, action: "include", reason: "personal" };
}
/** 可携带权:只有「个人数据且可导出」才进机器可读包。 */
export function planPortability(field: DataField): Action {
if (!field.personal) return { field: field.id, action: "skip", reason: "notPersonal" };
if (!field.exportable) return { field: field.id, action: "skip", reason: "notExportable" };
return { field: field.id, action: "include", reason: "portable" };
}
/** 删除权:法律义务保留的数据不删;命中法律保全的数据暂缓删除。 */
export function planErasure(field: DataField, held: boolean): Action {
if (!field.erasable) return { field: field.id, action: "retain", reason: "legalBasis" };
if (held) return { field: field.id, action: "retain", reason: "legalHold" };
return { field: field.id, action: "erase", reason: "requested" };
}
/** 回放一个请求:对清单里每条数据项算出动作。 */
export function planRequest(cfg: DataMapConfig, req: DsarRequest): RequestPlan {
const held = new Set(cfg.holds.filter((h) => h.subject === req.subject).map((h) => h.field));
const rows = cfg.fields.map((f) =>
req.type === "access" ? planAccess(f)
: req.type === "portability" ? planPortability(f)
: planErasure(f, held.has(f.id)));
return { id: req.id, type: req.type, rows };
}
export function countAction(plan: RequestPlan, action: string): number {
return plan.rows.filter((r) => r.action === action).length;
}
/** 跨语言对拍摘要:与 sim/g09_data_map.py 的 digest 逐字符一致。 */
export function digest(cfg: DataMapConfig): string {
const plans = cfg.requests.map((r) => planRequest(cfg, r));
const primary = (p: RequestPlan): number =>
p.type === "erasure" ? countAction(p, "erase") : countAction(p, "include");
const secondary = (p: RequestPlan): number =>
p.type === "erasure" ? countAction(p, "retain") : countAction(p, "skip");
const head = plans.map((p) => `${p.id}:${p.type}:${primary(p)}:${secondary(p)}`).join(",");
const total = (a: string): number => plans.reduce((s, p) => s + countAction(p, a), 0);
return `${head};include=${total("include")},skip=${total("skip")}`
+ `,erase=${total("erase")},retain=${total("retain")}`;
}
硬断言与跨语言对拍
测试不测「看起来像不像」,测的是跨语言逐字符一致:TS 内核复算的摘要必须等于 Python 参考实现 sim/g09_data_map.py 落盘的金标准,外加访问纳入、可携带排除、法律义务保留、法律保全暂缓四项定点断言。
// file: core/test/g09_datamap.test.ts
// tier: P
// verify: node --test 'core/test/g09_*.test.ts'
//
// 硬断言:TS DSAR 动作规划内核复算的摘要,与 Python 侧 sim/g09_data_map.py 落盘的金标准逐字符一致;
// 并验访问权纳入个人数据、可携带权排除不可导出、删除权尊重法律义务与法律保全。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { planRequest, planErasure, countAction, digest,
type DataMapConfig, type DataField } from "../src/g09/datamap.ts";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "g09_data_map.json"), "utf8")) as DataMapConfig;
const field = (id: string): DataField => cfg.fields.find((f) => f.id === id)!;
// Python 侧同机实跑落盘的金标准摘要
const EXPECTED = "r1:access:6:2,r2:portability:5:3,r3:erasure:6:2,r4:erasure:5:3"
+ ";include=11,skip=5,erase=11,retain=5";
test("跨语言一致:TS DSAR 摘要 == Python 金标准", () => {
assert.equal(digest(cfg), EXPECTED);
});
test("访问权:个人数据全部纳入,非个人数据跳过", () => {
const p = planRequest(cfg, cfg.requests.find((r) => r.id === "r1")!);
assert.equal(countAction(p, "include"), 6);
assert.equal(countAction(p, "skip"), 2);
assert.equal(p.rows.find((r) => r.field === "d_crash_stack")!.reason, "notPersonal");
});
test("访问权:敏感数据标注需人工复核", () => {
const p = planRequest(cfg, cfg.requests.find((r) => r.id === "r1")!);
assert.equal(p.rows.find((r) => r.field === "d_dob_child")!.reason, "sensitiveReview");
});
test("可携带权:个人但不可导出的字段被排除", () => {
const p = planRequest(cfg, cfg.requests.find((r) => r.id === "r2")!);
assert.equal(countAction(p, "include"), 5);
assert.equal(p.rows.find((r) => r.field === "d_dob_child")!.reason, "notExportable");
});
test("删除权:法律义务保留的数据不删", () => {
assert.equal(planErasure(field("d_purchase"), false).action, "retain");
assert.equal(planErasure(field("d_purchase"), false).reason, "legalBasis");
});
test("删除权:命中法律保全的数据暂缓删除", () => {
const p = planRequest(cfg, cfg.requests.find((r) => r.id === "r4")!);
assert.equal(p.rows.find((r) => r.field === "d_account_id")!.reason, "legalHold");
assert.equal(countAction(p, "erase"), 5);
});
同机实跑(Node 26,strip-only 直跑 .ts):
$ node --test core/test/g09_datamap.test.ts
✔ 跨语言一致:TS DSAR 摘要 == Python 金标准
✔ 访问权:个人数据全部纳入,非个人数据跳过
✔ 访问权:敏感数据标注需人工复核
✔ 可携带权:个人但不可导出的字段被排除
✔ 删除权:法律义务保留的数据不删
✔ 删除权:命中法律保全的数据暂缓删除
ℹ tests 6
ℹ pass 6
ℹ fail 0
Python 参考实现(sim/g09_data_map.py)对同一份配置产出的金标准摘要:
digest=r1:access:6:2,r2:portability:5:3,r3:erasure:6:2,r4:erasure:5:3;include=11,skip=5,erase=11,retain=5
为什么输出「动作」而不是「结果」
四个请求的摘要一眼能读出关键差别:r3 删除两个请求(无保全)与 r4(d_account_id 命中法律保全)看起来只差一个字段,但 r4 的 retain 从 2 涨到 3——多出来的那一条正是 legalHold,而不是「法律义务」。把这两个理由分开,审计时才回答得了「这条数据为什么还在」:是法定保留,还是暂时保全。r1(访问)里 d_dob_child 被标 sensitiveReview,它照样纳入导出,但额外提示人工复核——敏感不等于不可给,只是要多一道确认。
这些数不能靠命令行复现(X)
清单表与请求序列是工程建模假设:真实法规适用(你的游戏到底受不受 GDPR 约束)、真实监管裁决、真实平台条款与最终合规判定,都需要真实业务环境与专业法务判断,命令行无法复现,标为 X。真正能复现的,是给定这张表之后的动作规划结果——那部分 TS 内核与 Python 参考实现逐字符一致(6/6 通过)。
自检
| 检查项 | 本节结论 |
|---|---|
| 是否重复合规清单的结论 | 否,只做字段级工程落点,首段外链既有长文 |
| 是否区分「法律义务」与「法律保全」 | 是,两种 retain 理由分开 |
| 敏感数据是否直接拒绝 | 否,纳入但标 sensitiveReview |
| 跨语言是否一致 | 是,TS 摘要 == Python 金标准,6/6 通过 |
| 是否提供法律意见 | 否,法律结论请咨询律师 |
本节把「合规」从「一堆待办」变成了一张可机读、可对拍的清单。下一节进入 9.2,处理一个更敏感的场景:儿童向游戏——未满 13 岁、家庭专区上架,能力矩阵要整体收紧。
阅读导航:上一节:8.3 小语种取舍 · 下一节:9.2 COPPA 与儿童向游戏的限制 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。