9.3 同意管理与数据出境

同意不是一次勾选,而是一组带版本、可传播、可撤销的凭证;数据出境也不是一句「已加密」,而是每条传输都要有机制。本节把同意凭证与出境传输门写成可跑内核,用 P 层 TypeScript 与 Python 参考实现逐字符对拍,并区分「版本落后需重新同意」与「撤销立即生效」。本节只给工程实现,法律结论请咨询律师。

站内那篇独立游戏出海合规与税务风险清单 把跨境这件事的法律与税务面讲全了,本节不重复它的结论,只做工程侧的两件事:同意怎么被记录、传播与失效,以及数据要出境时传输门怎么判。边界照旧先说清楚:本节给的是工程实现,法律结论请咨询律师;充分性认定、标准合同条款怎么签,都不在本文范围。

同卷四的广告数据与隐私 相比,那节讲的是广告上报时的字段最小化(一份清单、默认拒绝);本节讲的是凭证的生命周期(版本、传播、撤销)与出境传输,两者互补,不重叠。

同意不是一个布尔值

最容易被写坏的假设是「同意就是一个 true/false」。真实情况是:同意是一组带版本的凭证,每条凭证记录「谁、对哪个目的、在什么时候、依据哪一版隐私政策」给出的状态。由此长出两个工程分支:

  • 版本落后:玩家当初同意的是政策 v2,现在是 v3,必须重新征求同意(reConsent),不能默认沿用。
  • 撤销立即生效:withdrawn 一旦成为最新凭证,所有绑定该目的的下游服务立即停止处理。

数据出境:每条传输都要有「机制」

跨境传输不是「加密了就行」。工程上给每条传输标一个 mechanism:adequacy(充分性认定)、scc(标准合同条款)、bcr(约束性公司规则)、consent(单独同意)、none(无机制)。传输门只认一条规则:none 一律阻断;consent 机制必须该目的处于 granted。

mechanism含义传输门判定
adequacy目的地被认定为充分保护放行
scc / bcr标准合同条款 / 约束性规则放行
consent需针对出境单独同意目的 granted 才放行
none没有任何机制一律阻断

配置:凭证 + 下游 + 传输条目

D 层配置把 policyVersion、处理目的、下游服务、同意凭证、出境传输条目全部写成数据。每条 receipt 带 policyVersion 与 atDay,传输条目带 field / to / mechanism / purpose:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "同意凭证与数据出境传输门(D 层)。policyVersion 是当前隐私政策版本;purposes 是处理目的;downstream 是下游服务(各自绑定一个 purpose);receipts 是同意凭证(含签发时的 policyVersion 与天数);transfers 是出境传输条目(field / to / mechanism=scc|adequacy|bcr|consent|none / purpose)。口径为工程建模假设:真实同意文案、真实监管充分性认定与最终合规判定需真实业务环境与专业法务判断,命令行不可复现(X)。全整数,无浮点。",
  "policyVersion": 3,
  "purposes": ["analytics", "personalized_ads", "cross_border"],
  "downstream": [
    {"id": "ads_sdk", "purpose": "personalized_ads"},
    {"id": "analytics_warehouse", "purpose": "analytics"},
    {"id": "crash_service", "purpose": "analytics"},
    {"id": "cdn_us", "purpose": "cross_border"}
  ],
  "receipts": [
    {"id": "c1", "subject": "u1001", "purpose": "personalized_ads", "state": "granted", "policyVersion": 3, "atDay": 100},
    {"id": "c2", "subject": "u1001", "purpose": "analytics", "state": "granted", "policyVersion": 2, "atDay": 50},
    {"id": "c3", "subject": "u1001", "purpose": "cross_border", "state": "withdrawn", "policyVersion": 3, "atDay": 120},
    {"id": "c4", "subject": "u1002", "purpose": "analytics", "state": "denied", "policyVersion": 3, "atDay": 30}
  ],
  "transfers": [
    {"id": "t1", "field": "d_ad_id", "to": "US", "mechanism": "scc", "purpose": "cross_border"},
    {"id": "t2", "field": "d_geo_ip", "to": "US", "mechanism": "none", "purpose": "cross_border"},
    {"id": "t3", "field": "d_account_id", "to": "SG", "mechanism": "adequacy", "purpose": "cross_border"},
    {"id": "t4", "field": "d_dob_child", "to": "US", "mechanism": "consent", "purpose": "cross_border"},
    {"id": "t5", "field": "d_crash_stack", "to": "US", "mechanism": "scc", "purpose": "cross_border"}
  ]
}

P 层:凭证与传输门内核

内核两个函数:effectiveState 取某主体某目的的最新凭证再按版本判定;transferDecision 按机制判传输门。注意排序用的是 atDay——最新凭证才算数,这是「撤销立即生效」的实现基础。全整数、无随机:

// file: core/src/g09/consent.ts
// tier: P
// verify: node --test 'core/test/g09_*.test.ts'
//
// 同意凭证(含版本)与数据出境传输门内核:与 sim/g09_consent_transfer.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g09_consent_transfer.json;全整数、无浮点、无随机、禁 Math.random(纯确定性回放)。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface Receipt {
  readonly id: string; readonly subject: string; readonly purpose: string;
  readonly state: string; readonly policyVersion: number; readonly atDay: number;
}
export interface Downstream { readonly id: string; readonly purpose: string; }
export interface Transfer {
  readonly id: string; readonly field: string; readonly to: string;
  readonly mechanism: string; readonly purpose: string;
}
export interface ConsentConfig {
  readonly version: number; readonly game: string; readonly policyVersion: number;
  readonly purposes: readonly string[]; readonly downstream: readonly Downstream[];
  readonly receipts: readonly Receipt[]; readonly transfers: readonly Transfer[];
}
export interface Decision { readonly id: string; readonly allowed: boolean; readonly reason: string; }

/** 某主体某目的的有效同意状态:取最新凭证;撤销/拒绝直接生效;签发版本落后于当前政策则需重新同意。 */
export function effectiveState(cfg: ConsentConfig, purpose: string, subject: string): string {
  const rs = cfg.receipts
    .filter((r) => r.subject === subject && r.purpose === purpose)
    .sort((a, b) => a.atDay - b.atDay);
  if (rs.length === 0) return "unknown";
  const last = rs[rs.length - 1];
  if (last.state === "withdrawn") return "withdrawn";
  if (last.state === "denied") return "denied";
  return last.policyVersion === cfg.policyVersion ? "granted" : "reConsent";
}

/** 下游服务有效状态 = 其绑定目的的有效状态。 */
export function downstreamState(cfg: ConsentConfig, d: Downstream, subject: string): string {
  return effectiveState(cfg, d.purpose, subject);
}

/** 出境传输门:无机制一律阻断;机制为 consent 时要求该目的处于 granted。 */
export function transferDecision(cfg: ConsentConfig, t: Transfer, subject: string): Decision {
  if (t.mechanism === "none") return { id: t.id, allowed: false, reason: "noMechanism" };
  if (t.mechanism === "consent" && effectiveState(cfg, t.purpose, subject) !== "granted") {
    return { id: t.id, allowed: false, reason: "noConsent" };
  }
  return { id: t.id, allowed: true, reason: t.mechanism };
}

/** 跨语言对拍摘要:与 sim/g09_consent_transfer.py 的 digest 逐字符一致。 */
export function digest(cfg: ConsentConfig, subject: string): string {
  const state = cfg.purposes.map((p) => `${p}:${effectiveState(cfg, p, subject)}`).join(",");
  const down = cfg.downstream.map((d) => `${d.id}:${downstreamState(cfg, d, subject)}`).join(",");
  const dec = cfg.transfers.map((t) => transferDecision(cfg, t, subject));
  const xfer = dec.map((d) => `${d.id}:${d.allowed ? "allow" : "block"}`).join(",");
  const allowed = dec.filter((d) => d.allowed).length;
  return `state=${state};down=${down};xfer=${xfer};allowed=${allowed},blocked=${dec.length - allowed}`;
}

硬断言与跨语言对拍

测试测的是跨语言逐字符一致,外加三条定点断言:版本落后触发 reConsent、撤销立即生效、无机制阻断出境。

// file: core/test/g09_consent.test.ts
// tier: P
// verify: node --test 'core/test/g09_*.test.ts'
//
// 硬断言:TS 同意/出境内核复算的摘要,与 Python 侧 sim/g09_consent_transfer.py 落盘的金标准逐字符一致;
// 并验版本落后触发重新同意、撤销立即生效、无机制阻断出境、consent 机制缺同意被拦。

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 { effectiveState, downstreamState, transferDecision, digest,
  type ConsentConfig, type Transfer } from "../src/g09/consent.ts";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "g09_consent_transfer.json"), "utf8")) as ConsentConfig;
const tr = (id: string): Transfer => cfg.transfers.find((t) => t.id === id)!;

// Python 侧同机实跑落盘的金标准摘要
const EXPECTED = "state=analytics:reConsent,personalized_ads:granted,cross_border:withdrawn;"
  + "down=ads_sdk:granted,analytics_warehouse:reConsent,crash_service:reConsent,cdn_us:withdrawn;"
  + "xfer=t1:allow,t2:block,t3:allow,t4:block,t5:allow;allowed=3,blocked=2";

test("跨语言一致:TS 同意/出境摘要 == Python 金标准", () => {
  assert.equal(digest(cfg, "u1001"), EXPECTED);
});

test("版本落后触发重新同意:签发版本 < 当前政策版本 -> reConsent", () => {
  assert.equal(effectiveState(cfg, "analytics", "u1001"), "reConsent");
  assert.equal(effectiveState(cfg, "personalized_ads", "u1001"), "granted");
});

test("撤销立即生效:最新凭证为 withdrawn -> withdrawn", () => {
  assert.equal(effectiveState(cfg, "cross_border", "u1001"), "withdrawn");
});

test("下游服务状态跟随其绑定目的", () => {
  assert.equal(downstreamState(cfg, cfg.downstream.find((d) => d.id === "ads_sdk")!, "u1001"), "granted");
  assert.equal(downstreamState(cfg, cfg.downstream.find((d) => d.id === "cdn_us")!, "u1001"), "withdrawn");
});

test("出境传输门:无机制阻断,consent 机制缺同意被拦", () => {
  assert.equal(transferDecision(cfg, tr("t2"), "u1001").reason, "noMechanism");
  assert.equal(transferDecision(cfg, tr("t4"), "u1001").reason, "noConsent");
  assert.equal(transferDecision(cfg, tr("t1"), "u1001").allowed, true);
});

同机实跑(Node 26,strip-only 直跑 .ts):

$ node --test core/test/g09_consent.test.ts
✔ 跨语言一致:TS 同意/出境摘要 == Python 金标准
✔ 版本落后触发重新同意:签发版本 < 当前政策版本 -> reConsent
✔ 撤销立即生效:最新凭证为 withdrawn -> withdrawn
✔ 下游服务状态跟随其绑定目的
✔ 出境传输门:无机制阻断,consent 机制缺同意被拦
ℹ tests 5
ℹ pass 5
ℹ fail 0

Python 参考实现(sim/g09_consent_transfer.py)对同一份配置产出的金标准摘要:

digest=state=analytics:reConsent,personalized_ads:granted,cross_border:withdrawn;down=ads_sdk:granted,analytics_warehouse:reConsent,crash_service:reConsent,cdn_us:withdrawn;xfer=t1:allow,t2:block,t3:allow,t4:block,t5:allow;allowed=3,blocked=2

版本落后与撤销:两种「不能上报」

摘要里有两个看起来都不能上报、但性质完全不同的状态,必须分开处理:

  • analytics:reConsent(c2 同意的是政策 v2):不是拒绝,是过期。正确动作是弹一次重新同意,玩家点了同意就恢复——下游 analytics_warehouse 与 crash_service 因此处于 reConsent,不能静默继续,也不该当成永久关闭。
  • cross_border:withdrawn(c3 主动撤销):是拒绝,立即生效。下游 cdn_us 立刻停处理,且不得再弹窗骚扰——撤销就是撤销。

出境门上同样是两种阻断:t2 是 noMechanism(这条传输根本没有合法机制,属于要补合规动作),t4 是 noConsent(机制有了,但该目的没同意)。把 reason 分开,整改方向才明确:前者去补机制,后者去补同意。

这些数不能靠命令行复现(X)

隐私政策版本、真实同意文案、目的地是否被认定为「充分保护」、真实监管裁决与最终合规判定,都需要真实业务环境与专业法务判断,命令行无法复现,标为 X。真正能复现的,是给定这份凭证与传输表之后的状态传播与传输门判定——那部分 TS 内核与 Python 参考实现逐字符一致(5/5 通过)。

自检

检查项本节结论
是否重复合规清单的法律结论否,只做凭证与传输门,首段外链既有长文
是否与卷四广告隐私节重叠否,那节做字段最小化,本节做凭证生命周期与出境
版本落后与撤销是否区分是,reConsent 与 withdrawn 分开
撤销后是否立即生效是,取 atDay 最新凭证判定
无机制传输是否放行否,一律阻断
跨语言是否一致是,TS 摘要 == Python 金标准,5/5 通过
是否提供法律意见否,法律结论请咨询律师

本节把「同意」从一个布尔值变成了一组带版本、可传播、可撤销的凭证,把「出境」从一句承诺变成了每条传输一个机制。下一节进入第 10 章,处理同样以「隐私新政」为核心的另一条线:ATT、IDFA 与 SKAdNetwork——当设备标识被系统性收紧之后,归因该怎么做。

阅读导航:上一节:9.2 COPPA 与儿童向游戏的限制 · 下一节:10.1 ATT、IDFA 与 SKAdNetwork 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.2 COPPA 与儿童向游戏的限制
  3. 9.1 GDPR / CCPA 的工程落点