站内《上线变现与长线运营》 与《小游戏广告变现设计》 分别讲过防沉迷联动与广告位设计,那是规则与商业口径;本节分工不同,它不提供具体法律意见、不重写隐私政策与备案文本,只做广告数据链路里可工程化的那一半:把「同意状态」与「字段最小化清单」做成一台可复现的状态机,逐事件判定哪些字段允许上报。
广告数据为什么是「工程」的事
前两节谈的是「广告内容能不能上」和「谁能看到广告」,这一节谈广告背后的数据能不能采集与上报。隐私合规落到工程上无非两条规则:同意决定用途——没有针对某用途的有效同意,就不能为该用途采集或上报相关字段;最小化决定字段——只处理完成该用途所必需的最少字段,未列入清单的字段默认拒绝。两条规则都不该是「上线时声明一次」,而要每个上报请求都重判:用户随时可能撤回同意,撤回之后同一批字段必须立刻被拦下。把这两条做成状态机,才能让「合规」变成可测的代码,而不是一句口号。
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 渠道分级与对接清单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。