9.1 GDPR / CCPA 的工程落点

GDPR 与 CCPA 落到代码里,本质是三张表:数据流清单、字段级标记、主体权利请求的动作规划。本节把访问、可携带、删除三类请求写成可跑的 DSAR 规划内核,用 P 层 TypeScript 与 Python 参考实现逐字符对拍,删除权尊重法律义务与法律保全。本节只给工程实现,法律结论请咨询律师。

站内那篇独立游戏出海合规与税务风险清单 把「出海要遵守什么」讲透了:注册主体、税务登记、平台条款、未成年人保护。本节不重复那些结论,只做一件更窄的事——把合规要求翻译成代码与配置。先把边界说清楚:本节给的是工程实现,法律结论请咨询律师;法条怎么适用、监管怎么裁、你这款游戏到底算不算「定向广告」,都不在本文范围。

合规落到工程,只剩三张表

GDPR 与 CCPA 的法条读起来千头万绪,但真正要写进代码的只有三件事:你处理了哪些个人数据(数据流清单)、每条数据凭什么处理、能不能删(字段级标记)、玩家提了一个请求,系统该做什么(权利请求的动作规划)。把这三件事做成可机读的表,合规就从「一堆待办」变成了「一次校验」。

权利GDPRCCPA工程动作
访问 / 知情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 与儿童向游戏的限制 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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