《互联网产品的客户端架构·规模化与架构演进》8.3 隐私合规的工程落地

同意不是一个布尔值,而是一台有状态、有版本、可撤回的机器。本节实现未询问、已同意、已拒绝、已撤回四态状态机,演示采集前闸门、目的绑定、版本过期重问与撤回后的分目的清理,并给出可审计的判据与常见错误边界。同意管理不是弹窗文案问题,而是采集链路上的硬闸门。

用户点了「不同意」,埋点还在跑

某工具类 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 文案、时区与货币 。

常见错误与边界

  1. 用布尔值存同意:无法区分未询问、拒绝与撤回,后续只能不断加字段。
  2. 同意做成全局开关:分析、个性化、广告必须分目的记录与撤回。
  3. 采集函数不读状态:埋点、缓存上报、SDK 回调都可能绕过 UI。
  4. 撤回只改界面:必须停止采集、清理存储并记录执行结果。
  5. 忽略版本:隐私政策改版后旧同意失效,应重新询问。
  6. 滥用必要用途:necessary 需要法务确认,不能由调用方自行声明。
  7. 清理任务不重试:下发不等于完成,失败项要有重试与告警。
  8. 审计阻塞启动:审计应异步执行,发现脏记录先告警再灰度修复。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查