用户点了「不同意」,埋点还在跑
某工具类 App 上线新版同意弹窗。用户点「不同意」后,弹窗消失,功能照常可用。一周后数据分析平台发现,拒绝用户的埋点量与同意用户几乎一样。
排查结果不复杂:弹窗只写了一个 consented = false,埋点 SDK 在应用启动时就初始化,采集函数从来不读这个布尔值。团队以为「不同意」是一个 UI 状态,工程里却没有任何闸门。
同意是状态机,不是布尔值
布尔值只能表达「同意/不同意」,表达不了「还没问」「问过但拒绝了」「同意后又撤回」。这四种状态对应的采集权限不同,也对应不同的清理动作:
| 状态 | 含义 | 允许采集 | 可执行事件 |
|---|---|---|---|
unasked | 尚未询问 | 否 | 同意、拒绝、重置 |
granted | 已同意 | 是(版本一致) | 撤回、重复同意、重置 |
denied | 已拒绝 | 否 | 重新同意、重复拒绝、重置 |
withdrawn | 同意后撤回 | 否 | 重新同意、重复撤回、重置 |
注意两个刻意收紧的边:granted 不能直接变 denied,必须先撤回;denied 不能直接撤回,因为从来没有授予过。把非法转移显式拒绝,比默默接受再产生一条语义不明的记录可靠。
每个目的各自记账
同意不是全局开关。分析、个性化、广告必须分别记录,因为用户可能同意分析、拒绝广告。撤回广告时也只清理广告相关的存储:
| 目的 | 数据存储 | 撤回动作 |
|---|---|---|
core-service | 订单缓存 | 保留(合同必要) |
analytics | 分析缓冲、设备画像 | 清除 |
personalization | 画像库、推荐缓存 | 清除 |
ads | 广告标识、归因队列 | 清除 |
「核心服务保留」不是工程判断,而是法律判断。代码只能把结论表达成 retain,不能替法务下结论。
状态机与采集闸门
下面的模块把状态转移、采集前校验、版本绑定和撤回清理放在一起:
// file: src/security/privacy-consent.ts
// tier: L2
// verify: node --test test/security/privacy-consent.test.ts
export type ConsentState = "unasked" | "granted" | "denied" | "withdrawn";
export type ConsentPurpose = "core-service" | "analytics" | "personalization" | "ads";
export type ConsentEvent = "grant" | "deny" | "withdraw" | "reset";
export type ConsentRecord = {
purpose: ConsentPurpose;
state: ConsentState;
version: string;
updatedAt: string;
};
export type TransitionResult =
| { ok: true; record: ConsentRecord }
| { ok: false; reason: string };
export type CollectRequest = {
purpose: ConsentPurpose;
version: string;
necessary?: boolean;
};
export type CollectDecision = {
allowed: boolean;
reason: string;
};
export type CleanupAction = "purge" | "retain";
export type CleanupTask = {
store: string;
action: CleanupAction;
};
export type WithdrawResult =
| { ok: true; record: ConsentRecord; tasks: CleanupTask[] }
| { ok: false; reason: string };
export const CONSENT_STATES: ConsentState[] = [
"unasked",
"granted",
"denied",
"withdrawn",
];
const TRANSITIONS: Record<ConsentState, Partial<Record<ConsentEvent, ConsentState>>> = {
unasked: { grant: "granted", deny: "denied", reset: "unasked" },
granted: { grant: "granted", withdraw: "withdrawn", reset: "unasked" },
denied: { deny: "denied", grant: "granted", reset: "unasked" },
withdrawn: { withdraw: "withdrawn", grant: "granted", reset: "unasked" },
};
const PURPOSE_STORES: Record<ConsentPurpose, CleanupTask[]> = {
"core-service": [{ store: "order-cache", action: "retain" }],
analytics: [
{ store: "analytics-buffer", action: "purge" },
{ store: "device-profile", action: "purge" },
],
personalization: [
{ store: "profile-store", action: "purge" },
{ store: "recommend-cache", action: "purge" },
],
ads: [
{ store: "ad-id", action: "purge" },
{ store: "attribution-queue", action: "purge" },
],
};
export function initialState(purpose: ConsentPurpose, version: string): ConsentRecord {
return { purpose, state: "unasked", version, updatedAt: "" };
}
export function transition(
record: ConsentRecord,
event: ConsentEvent,
updatedAt: string,
version: string = record.version,
): TransitionResult {
const next = TRANSITIONS[record.state][event];
if (next === undefined) {
return { ok: false, reason: `invalid:${record.state}:${event}` };
}
return {
ok: true,
record: { purpose: record.purpose, state: next, version, updatedAt },
};
}
export function stateOf(records: ConsentRecord[], purpose: ConsentPurpose): ConsentState {
return records.find((record) => record.purpose === purpose)?.state ?? "unasked";
}
export function needsReprompt(record: ConsentRecord, currentVersion: string): boolean {
const decided = record.state === "granted" || record.state === "denied";
return decided && record.version !== currentVersion;
}
export function collectGate(
records: ConsentRecord[],
request: CollectRequest,
): CollectDecision {
if (request.purpose === "core-service" && request.necessary === true) {
return { allowed: true, reason: "necessary" };
}
const record = records.find((item) => item.purpose === request.purpose);
if (!record) {
return { allowed: false, reason: "not-asked" };
}
if (record.state === "granted") {
if (record.version !== request.version) {
return { allowed: false, reason: "stale-version" };
}
return { allowed: true, reason: "granted" };
}
return { allowed: false, reason: record.state };
}
export function activePurposes(records: ConsentRecord[]): ConsentPurpose[] {
return records
.filter((record) => record.state === "granted")
.map((record) => record.purpose);
}
export function cleanupTasks(purposes: ConsentPurpose[]): CleanupTask[] {
const tasks: CleanupTask[] = [];
const seen = new Set<string>();
for (const purpose of purposes) {
for (const task of PURPOSE_STORES[purpose]) {
if (!seen.has(task.store)) {
seen.add(task.store);
tasks.push({ ...task });
}
}
}
return tasks;
}
export function withdrawAndCleanup(
records: ConsentRecord[],
purpose: ConsentPurpose,
updatedAt: string,
): WithdrawResult {
const record = records.find((item) => item.purpose === purpose);
if (!record) {
return { ok: false, reason: `no-record:${purpose}` };
}
const result = transition(record, "withdraw", updatedAt);
if (!result.ok) {
return result;
}
return { ok: true, record: result.record, tasks: cleanupTasks([purpose]) };
}
export function auditRecords(
records: ConsentRecord[],
currentVersion: string,
): string[] {
const issues: string[] = [];
const counts = new Map<ConsentPurpose, number>();
for (const record of records) {
counts.set(record.purpose, (counts.get(record.purpose) ?? 0) + 1);
}
for (const record of records) {
const { purpose } = record;
if ((counts.get(purpose) ?? 0) > 1) {
issues.push(`${purpose}:duplicate`);
}
if (!CONSENT_STATES.includes(record.state)) {
issues.push(`${purpose}:invalid-state`);
}
if (record.updatedAt.length === 0) {
issues.push(`${purpose}:missing-timestamp`);
}
if (record.state === "granted" && record.version !== currentVersion) {
issues.push(`${purpose}:stale-version`);
}
}
return issues;
}
collectGate 是所有采集调用的必经入口。它先看必要性声明,再看记录是否存在、状态是否允许、版本是否一致。任何一步不通过都返回具体原因,调用方没有「默认放行」的选项。
测试与真实输出
✔ 四个同意状态全部登记 (0.894375ms)
✔ 没有记录时视为未询问 (0.098458ms)
✔ 未询问可以直接同意 (0.094916ms)
✔ 未询问可以直接拒绝 (0.053916ms)
✔ 已同意可以撤回 (0.057625ms)
✔ 已拒绝不能直接撤回 (0.070833ms)
✔ 已同意不能直接拒绝,必须先撤回 (0.061459ms)
✔ 已拒绝可以在设置页重新同意 (0.048083ms)
✔ 已撤回可以重新同意 (0.067833ms)
✔ 重复同意保持已同意并刷新时间 (0.094959ms)
✔ 重置把已同意退回未询问 (0.066167ms)
✔ 转移不修改输入记录 (0.076917ms)
✔ 同意版本过期时需要重新询问 (0.057208ms)
✔ 同意版本一致时不需要重新询问 (0.031042ms)
✔ 已撤回与未询问都不算待重新询问 (0.039458ms)
✔ 已同意且版本一致时放行采集 (0.067ms)
✔ 未询问时拒绝采集 (0.047625ms)
✔ 已拒绝时拒绝采集 (0.035417ms)
✔ 已撤回时拒绝采集 (0.046792ms)
✔ 版本不一致时拒绝采集并提示过期 (0.05625ms)
✔ 必要性用途无需记录即可放行 (0.041708ms)
✔ 必要性用途未声明时仍按同意状态判定 (0.036541ms)
✔ 广告撤回后的清理任务只包含清除动作 (0.061916ms)
✔ 核心服务清理保留订单缓存 (0.02975ms)
✔ 多个目的的清理任务按店铺去重 (0.029834ms)
✔ 撤回成功时同时返回状态与清理任务 (0.0885ms)
✔ 缺少记录时撤回失败 (0.028917ms)
✔ 已拒绝的记录撤回失败且不产生清理任务 (0.02975ms)
✔ 审计发现重复的目的记录 (0.077708ms)
✔ 审计发现已同意但版本过期的记录 (0.029292ms)
✔ 审计发现缺失时间戳的记录 (0.035125ms)
✔ 审计发现非法状态 (0.031209ms)
✔ 活跃目的只包含已同意项 (0.045208ms)
ℹ tests 33
ℹ suites 0
ℹ pass 33
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 116.0995
判据与反例
判据一:状态只能沿边转移
非法转移返回 invalid:<state>:<event>,而不是抛异常或静默成功。调用方能区分「用户操作不合法」与「存储损坏」,审计也能从原因码里还原操作序列。
反例一:用布尔值代替状态机
consented: false 同时代表未询问、拒绝、撤回三种情况。产品要区分「还没弹过」和「弹过被拒」时只能再加字段,最后变成一组互相矛盾的布尔值。
判据二:采集前必须过闸门
采集函数的第一行应该是 collectGate,而不是 if (consented)。闸门检查目的、状态与版本,布尔值只检查了其中一个维度。
反例二:撤回只改 UI
把设置页开关置灰,不等于停止采集。已经在跑的定时上报、缓存队列、第三方 SDK 回调都要在撤回时停掉并清理,否则数据仍在流出。
判据三:同意与版本文本绑定
隐私政策改版后,旧同意不能自动延续。needsReprompt 在状态为同意或拒绝且版本不一致时返回真,客户端应重新询问;collectGate 对过期版本直接拒绝,避免「先采集、后补弹窗」。
反例三:把「必要」当万能豁免
necessary: true 只在核心服务且经过法务确认时成立,不是调用方随手传的布尔值。代码允许这个参数,是为了表达已确认的结论,不是为了给所有采集开后门。
撤回后的清理
撤回是一个事务,包含两步:状态转成 withdrawn,同时生成清理任务。两步必须一起返回,避免出现「状态已撤回、数据还在」的中间态。
清理任务按目的映射到具体存储,并按存储去重。调用方拿到任务后应逐项执行并记录结果;失败项要重试,不能把「已下发」当成「已清理」。
审计要能发现脏记录
auditRecords 检查四类问题:重复目的、非法状态、缺失时间戳、同意版本过期。它们不会让应用崩溃,却会让合规结论失真。
审计应在启动后异步执行,结果上报到监控,而不是阻塞首屏。发现问题时先告警,再按版本灰度修复;直接重置用户同意状态是更严重的违规。
小结
隐私合规的工程落点是三件事:
- 用四态状态机表达同意,拒绝非法转移;
- 每次采集先过闸门,目的、状态、版本三者同时满足;
- 撤回是状态变更加清理任务,二者不可拆开。
同意弹窗只是入口,真正决定合规的是采集链路上的那道闸门。
阅读导航:上一节:8.2 第三方 SDK 治理 · 下一节:9.1 文案、时区与货币 。
常见错误与边界
- 用布尔值存同意:无法区分未询问、拒绝与撤回,后续只能不断加字段。
- 同意做成全局开关:分析、个性化、广告必须分目的记录与撤回。
- 采集函数不读状态:埋点、缓存上报、SDK 回调都可能绕过 UI。
- 撤回只改界面:必须停止采集、清理存储并记录执行结果。
- 忽略版本:隐私政策改版后旧同意失效,应重新询问。
- 滥用必要用途:
necessary需要法务确认,不能由调用方自行声明。 - 清理任务不重试:下发不等于完成,失败项要有重试与告警。
- 审计阻塞启动:审计应异步执行,发现脏记录先告警再灰度修复。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。