8.3 广告数据与隐私

广告数据与隐私回答的是「哪些字段在什么条件下允许上报」:把同意状态机(未决/已同意/已拒绝/已撤回)与字段最小化清单串成一条可复现的判定链,逐事件回放上报请求——未列入清单的字段一律拒绝、必需字段免同意、需同意字段仅在其目的已授权时放行,配可跑的 Python 仿真与逐位等价的 TypeScript 内核。

站内《上线变现与长线运营》 与《小游戏广告变现设计》 分别讲过防沉迷联动与广告位设计,那是规则与商业口径;本节分工不同,它不提供具体法律意见、不重写隐私政策与备案文本,只做广告数据链路里可工程化的那一半:把「同意状态」与「字段最小化清单」做成一台可复现的状态机,逐事件判定哪些字段允许上报。

广告数据为什么是「工程」的事

前两节谈的是「广告内容能不能上」和「谁能看到广告」,这一节谈广告背后的数据能不能采集与上报。隐私合规落到工程上无非两条规则:同意决定用途——没有针对某用途的有效同意,就不能为该用途采集或上报相关字段;最小化决定字段——只处理完成该用途所必需的最少字段,未列入清单的字段默认拒绝。两条规则都不该是「上线时声明一次」,而要每个上报请求都重判:用户随时可能撤回同意,撤回之后同一批字段必须立刻被拦下。把这两条做成状态机,才能让「合规」变成可测的代码,而不是一句口号。

D 层:同意状态机与字段最小化清单

先定义数据。每个目的(purpose)有四个状态:unknown(未决)、granted(已同意)、denied(已拒绝)、withdrawn(已撤回);每个字段挂两个布尔口径:required(是否必需)与 needsConsent(是否需同意)。判定规则只有三条:未列入清单的字段一律拒绝;required 字段免同意即可上报;needsConsent 字段仅当其 purpose 处于 granted 时放行。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "广告数据与隐私的同意状态机与字段最小化清单(D 层)。目的、字段、必需性与「是否需同意」均为示例归纳,用于离线可复现的上报校验;真实法规要求、真实同意文案与最终合规判定需真实业务环境与专业法务判断,命令行不可复现(X)。校验口径:未列入清单的字段一律拒绝(默认拒绝);required 字段无需同意即可上报;needsConsent 字段仅在其 purpose 处于 granted 时放行。全整数,无浮点。",
  "purposes": ["personalized_ads", "attribution", "crash_analytics"],
  "initialState": "unknown",
  "states": ["unknown", "granted", "denied", "withdrawn"],
  "fields": [
    {"id": "f_device_model", "purpose": "crash_analytics", "required": true, "needsConsent": false},
    {"id": "f_oaid", "purpose": "personalized_ads", "required": false, "needsConsent": true},
    {"id": "f_idfa", "purpose": "attribution", "required": false, "needsConsent": true},
    {"id": "f_ad_click", "purpose": "personalized_ads", "required": false, "needsConsent": true},
    {"id": "f_crash_stack", "purpose": "crash_analytics", "required": false, "needsConsent": true},
    {"id": "f_geo_city", "purpose": "attribution", "required": false, "needsConsent": true}
  ],
  "events": [
    {"id": "e1", "op": "grant", "purpose": "personalized_ads"},
    {"id": "e2", "op": "report", "fields": ["f_device_model", "f_oaid", "f_unknown"]},
    {"id": "e3", "op": "report", "fields": ["f_idfa"]},
    {"id": "e4", "op": "grant", "purpose": "attribution"},
    {"id": "e5", "op": "report", "fields": ["f_idfa", "f_geo_city"]},
    {"id": "e6", "op": "withdraw", "purpose": "personalized_ads"},
    {"id": "e7", "op": "report", "fields": ["f_oaid", "f_ad_click"]},
    {"id": "e8", "op": "deny", "purpose": "crash_analytics"},
    {"id": "e9", "op": "report", "fields": ["f_crash_stack", "f_device_model"]}
  ]
}

S 层:逐事件回放上报判定

下面这段是 S 层仿真脚本 sim/o08_consent.py。它读上面那份配置,按事件顺序回放:grant/deny/withdraw 转移目的状态,report 则对每个字段查表判定,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/o08_consent.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o08_consent.py --config config/ --out out/
"""广告数据同意状态机 + 字段最小化校验:逐事件回放,判定每个字段能否上报。

读 config/o08_consent.json:目的集合 + 字段清单(用途 / 必需性 / 是否需同意)+ 事件序列。
状态机:每个 purpose 处于 unknown/granted/denied/withdrawn;grant/deny/withdraw 转移状态。
report 对每个字段:不在清单 -> reject(unknownField,默认拒绝);required -> allow;needsConsent
且 purpose==granted -> allow(consented);needsConsent 且未同意 -> reject(noConsent);其余 allow。
目的、字段与口径均为示例归纳;真实法规要求与最终合规判定需真实业务环境与专业法务判断,
命令行不可复现(X)。产出 out/o08_consent.json + .csv + .svg。全整数、无浮点、无随机数。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET

TRANS = {"grant": "granted", "deny": "denied", "withdraw": "withdrawn"}
ALLOW = ("required", "consented", "notNeedsConsent")


def validate_field(state: dict, field: dict) -> str:
    """单个字段的上报判定:返回 reason 字符串。"""
    if field["required"]:
        return "required"
    if field["needsConsent"]:
        return "consented" if state.get(field["purpose"]) == "granted" else "noConsent"
    return "notNeedsConsent"


def apply_op(state: dict, by_id: dict, ev: dict) -> dict:
    """回放一个事件:转移状态或校验字段;返回该事件的判定明细。"""
    op = ev["op"]
    if op in TRANS:
        state[ev["purpose"]] = TRANS[op]
        return {"id": ev["id"], "op": op, "purpose": ev["purpose"], "allowed": 0, "rejected": 0, "details": []}
    allowed = rejected = 0
    details = []
    for f in ev["fields"]:
        field = by_id.get(f)
        reason = validate_field(state, field) if field else "unknownField"
        ok = reason in ALLOW
        allowed, rejected = (allowed + 1, rejected) if ok else (allowed, rejected + 1)
        details.append({"field": f, "reason": reason, "allowed": ok})
    return {"id": ev["id"], "op": op, "allowed": allowed, "rejected": rejected, "details": details}


def svg(path: str, rows: list[dict]) -> int:
    """纯 Python SVG:每个事件一根堆叠条——放行(蓝)+ 拒绝(红)。"""
    W, PAD, ROW = 720, 210, 34
    H = 74 + ROW * len(rows)
    r = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                           "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(r, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(r, "text", {"x": "16", "y": "28", "font-size": "15",
                              "fill": "#111827"}).text = "《合成小岛》广告数据上报校验(放行 / 拒绝,示例口径)"
    bmax = W - PAD - 120
    hi = max((max(x["allowed"] + x["rejected"], 1) for x in rows), default=1) or 1
    for i, row in enumerate(rows):
        y = 74 + i * ROW
        ET.SubElement(r, "text", {"x": "16", "y": str(y + 16), "font-size": "12",
                                  "fill": "#374151"}).text = f'{row["id"]} {row["op"]}'
        x = PAD
        for cnt, color in ((row["allowed"], "#2563eb"), (row["rejected"], "#dc2626")):
            w = bmax * cnt // hi
            if cnt:
                ET.SubElement(r, "rect", {"x": str(x), "y": str(y + 4), "width": str(w),
                                          "height": "16", "rx": "3", "fill": color})
            x += w
        ET.SubElement(r, "text", {"x": str(PAD + bmax + 8), "y": str(y + 16), "font-size": "11",
                                  "fill": "#6b7280"}).text = f'{row["allowed"]}/{row["allowed"] + row["rejected"]}'
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(r).write(path, encoding="utf-8", xml_declaration=True)
    return os.path.getsize(path)


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, "o08_consent.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    by_id = {f["id"]: f for f in cfg["fields"]}
    state = {p: cfg["initialState"] for p in cfg["purposes"]}
    rows = [apply_op(state, by_id, ev) for ev in cfg["events"]]
    total_allowed = sum(r["allowed"] for r in rows)
    total_rejected = sum(r["rejected"] for r in rows)
    unknown_field = sum(1 for r in rows for d in r["details"] if d["reason"] == "unknownField")
    no_consent = sum(1 for r in rows for d in r["details"] if d["reason"] == "noConsent")
    digest = ",".join(f'{r["id"]}:{r["op"]}:{r["allowed"]}:{r["rejected"]}' for r in rows) + ";" + \
        f"allowed={total_allowed},rejected={total_rejected},unknownField={unknown_field}"
    os.makedirs(a.out, exist_ok=True)
    sb = svg(os.path.join(a.out, "o08_consent.svg"), rows)
    report = {"source": "sim/o08_consent.py", "tier": "S", "game": cfg["game"],
              "observedAt": cfg["observedAt"], "note": cfg["note"], "fieldCount": len(cfg["fields"]),
              "eventCount": len(rows), "finalState": state, "totalAllowed": total_allowed,
              "totalRejected": total_rejected,
              "rejectByReason": {"unknownField": unknown_field, "noConsent": no_consent},
              "digest": digest, "events": rows, "svgBytes": sb}
    with open(os.path.join(a.out, "o08_consent.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(a.out, "o08_consent.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["id", "op", "allowed", "rejected", "details"])
        for r in rows:
            w.writerow([r["id"], r["op"], r["allowed"], r["rejected"],
                        "|".join(f'{d["field"]}:{d["reason"]}' for d in r["details"])])
    for r in rows:
        det = " ".join(f'{d["field"]}={d["reason"]}' for d in r["details"]) or "-"
        print(f'{r["id"]:<3} {r["op"]:<8} 放行{r["allowed"]} 拒绝{r["rejected"]}  {det}')
    print(f"finalState={state}")
    print(f"digest={digest}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

在本机跑它,9 个事件回放完毕,最终状态与摘要如下:

e1  grant    放行0 拒绝0  -
e2  report   放行2 拒绝1  f_device_model=required f_oaid=consented f_unknown=unknownField
e3  report   放行0 拒绝1  f_idfa=noConsent
e4  grant    放行0 拒绝0  -
e5  report   放行2 拒绝0  f_idfa=consented f_geo_city=consented
e6  withdraw 放行0 拒绝0  -
e7  report   放行0 拒绝2  f_oaid=noConsent f_ad_click=noConsent
e8  deny     放行0 拒绝0  -
e9  report   放行1 拒绝1  f_crash_stack=noConsent f_device_model=required
finalState={'personalized_ads': 'withdrawn', 'attribution': 'granted', 'crash_analytics': 'denied'}
digest=e1:grant:0:0,e2:report:2:1,e3:report:0:1,e4:grant:0:0,e5:report:2:0,e6:withdraw:0:0,e7:report:0:2,e8:deny:0:0,e9:report:1:1;allowed=5,rejected=5,unknownField=1

把判定理由归一下类,能看清这台状态机到底拦住了什么:

reason含义本例次数
required必需字段,免同意即可上报2
consented需同意且目的已授权3
notNeedsConsent无需同意的字段0
noConsent需同意但目的未授权4
unknownField不在清单,默认拒绝1

两个细节值得留意。第一,noConsent 有 4 次,其中 2 次来自 e7——e1 授权、e6 撤回,同一个 personalized_ads 目的下的 f_oaid 与 f_ad_click 立刻由「可上报」变成「被拦」。这正是「撤回要即时生效」的工程含义:状态机记的是当前状态,不是「曾经同意过」。第二,required 字段照样放行(f_device_model 出现 2 次):崩溃分析所需的机型是功能必需,不必等同意——但 f_crash_stack 这类具体堆栈仍要同意(e9 因 crash_analytics 为 denied 被拦)。「必需」要精确到字段,不能糊到整条链路。

P 层:与 Python 逐位等价的 TS 内核

生产链路在客户端与服务端,通常是 TS。core/src/o08/consent.ts 是上面那台状态机的逐位等价对偶实现:读同一份 config/o08_consent.json,用同样的整数逻辑回放,产出逐字符相同的摘要。

// file: core/src/o08/consent.ts
// tier: P
// verify: node --test 'core/test/o08_*.test.ts'
//
// 广告数据同意状态机 + 字段最小化内核:与 sim/o08_consent.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o08_consent.json;全整数、无浮点、无 Math.random(纯确定性回放)。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface ConsentField { readonly id: string; readonly purpose: string; readonly required: boolean; readonly needsConsent: boolean; }
export interface ConsentEvent { readonly id: string; readonly op: string; readonly purpose?: string; readonly fields?: readonly string[]; }
export interface ConsentConfig {
  readonly version: number; readonly game: string; readonly purposes: readonly string[];
  readonly initialState: string; readonly fields: readonly ConsentField[]; readonly events: readonly ConsentEvent[];
}
export interface Detail { readonly field: string; readonly reason: string; readonly allowed: boolean; }
export interface EventResult {
  readonly id: string; readonly op: string; readonly purpose?: string;
  readonly allowed: number; readonly rejected: number; readonly details: readonly Detail[];
}

const TRANSITION: Record<string, string> = { grant: "granted", deny: "denied", withdraw: "withdrawn" };
const ALLOW_REASONS = new Set(["required", "consented", "notNeedsConsent"]);

/** 单个字段的上报判定:返回 reason 字符串。 */
export function validateField(state: Record<string, string>, field: ConsentField): string {
  if (field.required) return "required";
  if (field.needsConsent) return state[field.purpose] === "granted" ? "consented" : "noConsent";
  return "notNeedsConsent";
}

/** 回放一个事件:转移状态或校验字段;返回该事件的判定明细。 */
export function applyOp(state: Record<string, string>, byId: Record<string, ConsentField>, ev: ConsentEvent): EventResult {
  if (ev.op in TRANSITION) {
    state[ev.purpose as string] = TRANSITION[ev.op];
    return { id: ev.id, op: ev.op, purpose: ev.purpose, allowed: 0, rejected: 0, details: [] };
  }
  let allowed = 0; let rejected = 0;
  const details: Detail[] = [];
  for (const f of ev.fields ?? []) {
    const field = byId[f];
    const reason = field ? validateField(state, field) : "unknownField";
    const ok = ALLOW_REASONS.has(reason);
    if (ok) allowed += 1; else rejected += 1;
    details.push({ field: f, reason, allowed: ok });
  }
  return { id: ev.id, op: ev.op, allowed, rejected, details };
}

export function runAll(cfg: ConsentConfig): { state: Record<string, string>; events: EventResult[] } {
  const byId: Record<string, ConsentField> = Object.fromEntries(cfg.fields.map((f) => [f.id, f]));
  const state: Record<string, string> = {};
  for (const p of cfg.purposes) state[p] = cfg.initialState;
  return { state, events: cfg.events.map((ev) => applyOp(state, byId, ev)) };
}

/** 跨语言对拍摘要:与 sim/o08_consent.py 的 digest 逐字符一致。 */
export function digest(events: readonly EventResult[]): string {
  let totalAllowed = 0; let totalRejected = 0; let unknownField = 0;
  for (const r of events) {
    totalAllowed += r.allowed; totalRejected += r.rejected;
    for (const d of r.details) if (d.reason === "unknownField") unknownField += 1;
  }
  return events.map((r) => `${r.id}:${r.op}:${r.allowed}:${r.rejected}`).join(",")
    + `;allowed=${totalAllowed},rejected=${totalRejected},unknownField=${unknownField}`;
}

用 node --test 跑 core/test/o08_consent.test.ts(通配符同时带上 8.1 的用例,共 10 条),本机输出:

✔ 跨语言一致:TS 同意摘要 == Python 金标准 (0.657709ms)
✔ 状态机转移:grant→granted,withdraw→withdrawn,deny→denied (0.103541ms)
✔ 默认拒绝:未列入清单的字段一律拒绝 (0.096333ms)
✔ required 字段免同意:未同意也可上报 (0.076792ms)
✔ 撤回后需同意字段被拒:e7 两个字段都因 noConsent 被拦 (0.506667ms)
ℹ tests 10
ℹ pass 10
ℹ fail 0

5 条用例全绿,其中第一条就是跨语言对拍:TS 侧复算的摘要必须等于 Python 侧落盘的金标准 e1:grant:0:0,e2:report:2:1,e3:report:0:1,e4:grant:0:0,e5:report:2:0,e6:withdraw:0:0,e7:report:0:2,e8:deny:0:0,e9:report:1:1;allowed=5,rejected=5,unknownField=1。两边读同一份配置、走同一套规则,摘要逐字符一致——说明这套「同意 + 最小化」判定不依赖实现语言,换栈不会换结论。

X 层:真实法规与真实同意文案不可复现

上面算的是模型侧:给定时点、状态与字段清单,某个字段是否放行。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的法规条款与监管口径(具体条文的版本、生效时间与解释,且会更新);
  • 真实的同意文案、弹窗交互与「拒绝后仍可使用」的边界(各渠道审核口径不同);
  • 真实的字段清单与用途映射(各 SDK 与各广告平台的实际采集项、各地区的差异化要求)。

本节给的是判定骨架,不是合规结论;任何一条真实字段该不该收、该配哪个 purpose,都要回到真实业务与专业法务判断。

自检表

检查项合格标准
状态机完整未决/已同意/已拒绝/已撤回四态,且转移路径明确
默认拒绝未列入清单的字段一律拒绝,不靠白名单兜底
用途绑定需同意字段与 purpose 绑定,撤回后立即失效
最小化可验每个字段都有 required / needsConsent 两个布尔口径
跨语言一致Python 与 TS 对同一配置算出逐字符相同的摘要
边界清晰真实法规与同意文案标 X,不写成「实测」,不给法律意见

广告数据合规最危险的做法,是把「同意」当成一次性开关:上线时弹一次、之后再不重判。真实链路里用户随时撤回、目的随时新增,只有把状态机与最小化清单写进代码、让每个上报请求都过一遍判定,合规才是可测的。本节处理的是数据能不能上报;从下一节起,我们把视角从「单个环节的合规」抬到「渠道与商业结构」——先看怎么分级对接渠道,再逐层往收入与成本走。

阅读导航:上一节:8.2 未成年人广告与防沉迷联动 · 下一节:9.1 渠道分级与对接清单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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