《移动游戏产品的架构·商业化与发行工程》12.1 隐私政策、权限与数据采集清单

隐私合规不是上线前补一份政策,而是把每个 SDK、每项权限、每类数据登记成可核对的清单。本节以《潮汐消消》14 个第三方 SDK、8 项权限、v3.2 隐私政策为背景,给出清单字段、审计规则、同意前初始化边界与版本漂移检测,并对照《潮汐方舟》11 个 SDK、10 项权限说明差异。

隐私政策不是一份文档,而是一张对照表

玩家看到的是隐私政策,审核人员看到的是商店后台的权限声明,工程团队真正要维护的却是第三张表:哪个 SDK、在什么时机、采集了哪类数据、申请了哪项权限、依据是什么。三张表对不上,是发行审核里最常见也最难解释的一类问题。

《潮汐消消》的隐私政策版本是 v3.2,接入了 14 个第三方 SDK,申请 8 项权限;《潮汐方舟》是 v2.8、11 个 SDK、10 项权限。这两组数字看起来只是规模差异,实际反映的是两种完全不同的数据流:前者广告变现占月流水 38%,广告与归因 SDK 更多;后者内购占 97%,支付与账号链路的权限更重。

本节只回答一个问题:怎样把「我们采集了什么」变成一份可以逐项核对、可以进 CI、可以在版本升级时自动发现漂移的清单。未成年人保护属于运行时约束,放在 12.2;商店审核材料与驳回应对放在 12.3。三节分别处理「声明什么」「执行什么」「怎样证明」。

三张表:数据、权限、SDK

一份可用的清单至少要把三类对象分开记录,而不是写成一段散文。

对象要回答的问题典型取值归属
数据类别采了什么设备标识、账号标识、支付信息、位置、通讯录、使用行为、广告标识业务与法务共同确认
权限系统弹窗要什么网络、网络状态、存储、电话状态、位置、通讯录、通知、相机客户端与合规共同确认
SDK谁在采、为什么采登录、支付、广告、统计、推送、分享采购与客户端共同确认

三张表之间是多对多关系。一个广告 SDK 可能同时采集设备标识与广告标识,申请网络和电话状态;一个登录 SDK 只采集账号标识,但同样需要网络。把 SDK 当成唯一的记录单位,会漏掉「同一个数据被多个 SDK 重复采集」;把权限当成唯一单位,又会漏掉「权限背后的数据流向」。

清单的最小字段应该包括:

  • SDK 名称、供应商、用途、隐私政策地址。
  • 采集的数据类别、申请的权限。
  • 初始化时机:是否在玩家同意之前启动。
  • 权限依据:为什么需要、是否可选、由谁使用。
  • 政策版本:这份清单对应哪一版隐私政策。

最后两项经常被省略,却是审计时最容易被追问的。没有依据的权限,在审核眼里就是「过度申请」;没有版本号的清单,在政策更新后无法判断哪一份才是有效版本。

先问三个问题,再决定收不收

权限最小化不是「越少越好」,而是「每一项都能说清为什么」。在清单里新增一条记录前,先回答三个问题:

  1. 这项数据是不是完成当前功能的最小集合?能只用匿名设备标识,就不要用账号标识;能只用粗粒度位置,就不要用精确定位。
  2. 不授权时,玩家还能不能完成核心玩法?如果关闭通讯录就进不了游戏,那它不是可选权限,而是被包装成可选的强制权限。
  3. 关闭后会不会影响已付费权益?支付回调、订单恢复、已购内容校验属于必须保留的链路,不能为了减少权限把售后能力一起砍掉。

三个问题对应三种结论:删掉、改为可选、保留但写清依据。清单里的 optional 字段不是装饰,它决定了运行时能不能拒绝、拒绝后是否降级,而不是简单地弹一个「去设置里打开」。

《潮汐消消》的 8 项权限与《潮汐方舟》的 10 项权限,差距并不说明方舟更危险。消消的广告链路需要广告标识与网络状态,方舟的支付与人脸抽检链路需要更完整的身份与设备信息。判断风险要看权限的敏感度和数据的去向,不能只看数量。

同意之前,SDK 不应该初始化

清单是静态的,初始化是动态的。一个常见的错误是:政策里写「取得同意后才会初始化广告 SDK」,代码却在启动页就把广告 SDK 拉起来,先拿一次设备标识再说。审核人员不一定能抓到,但这正是最容易被投诉的点。

运行时门控要按用途分层:

SDK 用途同意前同意后说明
登录可初始化可初始化玩家需要先登录才能选择是否同意
支付可初始化可初始化已付费权益的恢复不能依赖广告同意
广告禁止允许未同意前不得采集广告标识
统计禁止允许匿名崩溃统计可按最小化原则单独处理
推送禁止允许通知权限与推送 SDK 都应后置
分享可延迟允许分享是主动行为,可在点击时再初始化

这张表的关键不是「哪些能开」,而是「哪些必须在同意后开」。登录和支付是玩家主动发起的核心链路,把它们也锁在同意之后,会让玩家连「不同意」的选项都看不到;广告和统计则相反,它们不是玩法必需,必须等同意。

清单校验只能发现「声明缺失」,不能发现「运行时提前初始化」。所以清单里要有一个 initBeforeConsent 字段,代码评审和自动化测试都要检查它。这个字段一旦为真,就不只是隐私问题,而是政策与实际行为不一致,属于发行阻断项。

版本漂移:v3.2 与 v2.8 之间

隐私政策不是一次性文档。每新增一个 SDK、每多申请一项权限、每改变一次数据用途,都应该产生新版本。问题是,版本号变了,清单有没有跟着变?

以消消为例,当前基线是 14 个 SDK、8 项权限、政策 v3.2;方舟是 11 个 SDK、10 项权限、政策 v2.8。基线的作用不是禁止变化,而是让变化可见。常见的漂移有四类:

漂移类型例子风险处理
新增 SDK接入一个新的归因 SDK数据去向未披露更新政策、清单、商店声明
新增权限为活动页申请位置过度申请先证明必要性,再决定是否保留
新增数据类别统计开始采集通讯录高敏感默认拒绝,除非有明确业务依据
政策地址失效供应商域名过期审核直接驳回换成可访问的 https 地址

漂移检测不需要很复杂:把当前清单与基线比较,SDK 数或权限数不一致就报警,再由人判断是「合理新增」还是「漏更新」。关键是不要让人靠记忆维护基线。14 个 SDK 的版本、政策地址、权限列表,靠表格手工同步,迟早会漏。

把清单做成可运行的门禁

下面的实现把清单校验压成纯函数:输入一份清单,输出问题列表和统计值;再提供同意前后的初始化计划,以及和基线的比较。它不读配置文件、不调用商店接口,因此可以用固定输入做确定性测试。

// file: src/biz/privacy-manifest.ts
// tier: L1
// verify: node --test test/biz/privacy-manifest.test.ts

export type DataCategory =
  | "device-id"
  | "account-id"
  | "payment"
  | "location"
  | "contacts"
  | "usage"
  | "advertising";

export type PermissionName =
  | "internet"
  | "network-state"
  | "storage"
  | "phone-state"
  | "location"
  | "contacts"
  | "notification"
  | "camera";

export type SdkPurpose = "login" | "payment" | "ads" | "analytics" | "push" | "share";

export interface SdkEntry {
  readonly name: string;
  readonly vendor: string;
  readonly purpose: SdkPurpose;
  readonly collects: readonly DataCategory[];
  readonly permissions: readonly PermissionName[];
  readonly policyUrl: string;
  readonly initBeforeConsent: boolean;
}

export interface PermissionJustification {
  readonly permission: PermissionName;
  readonly reason: string;
  readonly optional: boolean;
  readonly usedBy: readonly ("first-party" | "sdk")[];
}

export interface PrivacyManifest {
  readonly product: string;
  readonly policyVersion: string;
  readonly sdks: readonly SdkEntry[];
  readonly declaredPermissions: readonly PermissionJustification[];
  readonly policyCategories: readonly DataCategory[];
}

export type IssueCode =
  | "empty-sdk"
  | "missing-policy"
  | "undeclared-category"
  | "undeclared-permission"
  | "no-justification"
  | "unneeded-permission"
  | "init-before-consent";

export interface ManifestIssue {
  readonly code: IssueCode;
  readonly subject: string;
  readonly detail: string;
}

export interface ManifestReport {
  readonly product: string;
  readonly policyVersion: string;
  readonly sdkCount: number;
  readonly permissionCount: number;
  readonly optionalPermissionCount: number;
  readonly issues: readonly ManifestIssue[];
  readonly ok: boolean;
}

export interface ConsentPlan {
  readonly allowed: readonly string[];
  readonly blocked: readonly string[];
}

export interface ManifestBaseline {
  readonly product: string;
  readonly sdkCount: number;
  readonly permissionCount: number;
}

export const CASE_BASELINES: readonly ManifestBaseline[] = [
  { product: "潮汐消消", sdkCount: 14, permissionCount: 8 },
  { product: "潮汐方舟", sdkCount: 11, permissionCount: 10 },
];

export function auditManifest(manifest: PrivacyManifest): ManifestReport {
  const issues: ManifestIssue[] = [];
  const declared = new Set(manifest.declaredPermissions.map((item) => item.permission));
  const categories = new Set(manifest.policyCategories);

  for (const sdk of manifest.sdks) {
    if (sdk.name.trim() === "") {
      issues.push({ code: "empty-sdk", subject: sdk.vendor, detail: "SDK name is empty" });
    }
    if (!sdk.policyUrl.startsWith("https://")) {
      issues.push({
        code: "missing-policy",
        subject: sdk.name,
        detail: `policy url must be https, got ${sdk.policyUrl || "(empty)"}`,
      });
    }
    for (const category of sdk.collects) {
      if (!categories.has(category)) {
        issues.push({
          code: "undeclared-category",
          subject: sdk.name,
          detail: `collects ${category} but the privacy policy does not declare it`,
        });
      }
    }
    for (const permission of sdk.permissions) {
      if (!declared.has(permission)) {
        issues.push({
          code: "undeclared-permission",
          subject: sdk.name,
          detail: `requests ${permission} without a declared justification`,
        });
      }
    }
    if (sdk.initBeforeConsent) {
      issues.push({
        code: "init-before-consent",
        subject: sdk.name,
        detail: "SDK is initialized before the player grants consent",
      });
    }
  }

  for (const item of manifest.declaredPermissions) {
    if (item.reason.trim() === "") {
      issues.push({
        code: "no-justification",
        subject: item.permission,
        detail: "permission reason is empty",
      });
    }
    if (item.usedBy.length === 0) {
      issues.push({
        code: "unneeded-permission",
        subject: item.permission,
        detail: "permission is declared but unused",
      });
    }
  }

  return {
    product: manifest.product,
    policyVersion: manifest.policyVersion,
    sdkCount: manifest.sdks.length,
    permissionCount: manifest.declaredPermissions.length,
    optionalPermissionCount: manifest.declaredPermissions.filter((item) => item.optional).length,
    issues,
    ok: issues.length === 0,
  };
}

export function planSdkInit(manifest: PrivacyManifest, consentGranted: boolean): ConsentPlan {
  const allowed: string[] = [];
  const blocked: string[] = [];
  for (const sdk of manifest.sdks) {
    const needsConsent = sdk.purpose === "ads" || sdk.purpose === "analytics";
    if (needsConsent && !consentGranted) {
      blocked.push(sdk.name);
    } else {
      allowed.push(sdk.name);
    }
  }
  return { allowed, blocked };
}

export function compareWithBaseline(
  report: ManifestReport,
  baseline: ManifestBaseline,
): { matched: boolean; detail: string } {
  const drift: string[] = [];
  if (report.sdkCount !== baseline.sdkCount) {
    drift.push(`sdk ${report.sdkCount} != ${baseline.sdkCount}`);
  }
  if (report.permissionCount !== baseline.permissionCount) {
    drift.push(`permission ${report.permissionCount} != ${baseline.permissionCount}`);
  }
  if (drift.length === 0) {
    return { matched: true, detail: `${baseline.product} matches baseline` };
  }
  return { matched: false, detail: `${baseline.product} drift: ${drift.join("; ")}` };
}

export function formatManifestReport(report: ManifestReport): string {
  const codes = [...new Set(report.issues.map((issue) => issue.code))].join(",") || "none";
  const state = report.ok ? "ok" : "issues";
  return `${report.product} ${report.policyVersion} ${state} sdks=${report.sdkCount} permissions=${report.permissionCount} optional=${report.optionalPermissionCount} codes=${codes}`;
}

代码里有几个刻意的选择。

第一,declaredPermissions 是权限的唯一事实源。SDK 申请了未声明的权限,直接报 undeclared-permission;反过来,声明了却没有任何使用者的权限,报 unneeded-permission。两个方向都查,才能避免「声明一套、运行一套」。

第二,隐私政策地址必须是 https。这不是形式要求:http 地址在部分商店审核环境里会被判定为不可访问,供应商域名过期时也无法及时察觉。把协议写进校验,比在发布前人工点一遍可靠。

第三,initBeforeConsent 是独立的问题码,不和其他问题合并。它代表的是「政策承诺与运行时行为不一致」,性质比缺一个字段更严重,必须在报告里单独可见。

第四,planSdkInit 只按用途分层,不读取 initBeforeConsent。这两个检查解决的是不同阶段的问题:前者是运行时门控,后者是构建期审计。把它们混在一起,会让「代码写了门控但配置错了」这种情况被掩盖。

第五,compareWithBaseline 只比较 SDK 数和权限数,不比较具体名称。基线是漂移报警器,不是完整快照;具体差异仍然要人看。把比较做得太细,会在每次正常升级时产生大量噪音,最后没人看报警。

formatManifestReport 输出一行稳定文本,适合放进 CI 日志或发布检查单。它不面向玩家,也不应该出现在应用界面里。

双案例对照:消消与方舟

维度潮汐消消潮汐方舟
第三方 SDK 数14 个11 个
申请权限数8 项10 项
隐私政策版本v3.2v2.8
广告月流水占比38%3%
内购月流水占比62%97%

消消的 SDK 更多,因为广告、归因、频控、聚合广告位都需要独立的 SDK;方舟的权限更多,因为支付、账号安全与人脸抽检链路需要更完整的设备与身份信息。两张表放在一起看,结论不是「谁更合规」,而是「风险点在哪」:消消要重点管广告标识的采集与共享,方舟要重点管敏感权限的必要性与调用时机。

一个反直觉的结论是:SDK 数量少,不等于清单简单。方舟只有 11 个 SDK,但每个 SDK 背后的数据流更靠近支付与身份,一旦出错,影响的是订单和账号,而不是一次广告展示。清单的复杂度应该按数据敏感度算,不是按条目数算。

取舍:清单越细,维护成本越高

把清单做到可审计,代价是每次 SDK 升级都要走一遍更新流程:改清单、改政策、改商店声明、重新评审。消消每 2 周更新一次商店素材,方舟每 4 周更新一次;如果每次素材更新都触发全量隐私复核,团队会被流程拖住。

案例里的做法是分层维护:

  • 强门禁:新增权限、新增敏感数据类别、新增支付或广告 SDK,必须更新政策和清单,缺一不可。
  • 弱门禁:SDK 小版本升级、政策地址不变、权限不变,只记录版本号,不重新评审全文。
  • 自动报警:SDK 数或权限数相对基线变化时提醒,由负责人判断是否需要走强门禁。

代价是清单需要一个人负责,且这个人要能同时看懂客户端、法务和商店规则。收益是发行前不再靠「应该没问题」过关,而是能拿出逐项对应的证据。对一款月流水接近千万元的产品来说,这个人力成本远低于一次因隐私问题被下架或驳回的损失。

小结

  • 隐私政策是对外声明,数据采集清单是对内事实,两者必须逐项对应。
  • 清单要同时记录数据类别、权限和 SDK,三者是多对多关系,不能只维护其中一张表。
  • 权限最小化用三个问题判断:是否最小集合、不授权能否玩、关闭后是否影响已付费权益。
  • 同意前不得初始化广告与统计 SDK;initBeforeConsent 是发行阻断项,不是普通告警。
  • 用基线比较发现 SDK 数与权限数漂移,强门禁只覆盖新增敏感项,避免流程拖垮迭代。

阅读导航:上一节:11.3 召回活动的频控与退订合规 · 下一节:12.2 未成年人保护与防沉迷 。

常见错误与边界

  1. 把隐私政策当成合规的全部。政策只说明「声明了什么」,不证明「运行时时做了什么」,两者不一致时审核和投诉都会找上门。
  2. 只按 SDK 记录,不按数据类别记录。同一个设备标识可能被多个 SDK 采集,只记 SDK 会漏掉重复采集和共享链路。
  3. 声明了权限却没有使用者。这类权限在审核眼里就是过度申请,应该删除或补充明确的第一方用途。
  4. SDK 隐私政策地址用 http 或失效域名。商店审核环境可能直接判定不可访问,发布前必须逐条验证 https 可达。
  5. 在启动页提前初始化广告 SDK。政策承诺与代码行为不一致,属于阻断项,不是「先拿一次标识没关系」。
  6. 把登录和支付也锁在广告同意之后。玩家会连拒绝的入口都看不到,核心链路和可选采集必须分开。
  7. 基线只在文档里维护。14 个 SDK 的版本和权限靠手工同步,迟早漏更新;基线要能自动比较。
  8. 漂移报警做成全量差异。每次 SDK 小版本升级都报警,团队很快会无视;只报数量和敏感项变化。
  9. 用 SDK 数量判断风险高低。方舟 SDK 更少,但数据更靠近支付与身份,风险不能按条目数排序。
  10. 清单没有负责人。客户端、法务、采购都以为自己不用管,最后没有人能在发布前签字。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查