站内那篇出海合规与税务风险清单 给的是法律与表单口径——哪些地区要什么资质、资金怎么走。本节不碰那些,只做一件更工程的事:把 iOS 归因链路接对。而在站内另一个相邻主题上,卷四 4.2 已经算过「隐私新政后你还能看见多少」的可见度模型。本节与它明确分工:卷四讲归因窗口、匹配率与量化损失这条「算可见度」的线;本节讲 iOS 侧的链路本身——ATT 状态机怎么流转、IDFA 在哪个状态才拿得到、SKAdNetwork 回传字段长什么样、6 位转化值该怎么定义。
「链路」不是一条线,是一台状态机
很多人把 ATT 理解成一个「同意 / 不同意」的弹窗。工程上它不是弹窗,是一台只有四个状态的状态机,而每个状态同时决定三件事:还能不能拿到 IDFA、还能不能再弹、未授权侧走不走聚合回传。这三件事互相耦合——比如 restricted(系统受限,常见于家长控制)既拿不到 IDFA 也不能弹,但它仍然走聚合回传。把状态、用途、回传三张表分开看,链路就会漏掉分支。
| 状态 | 能拿 IDFA | 能再弹窗 | 走聚合回传 |
|---|---|---|---|
not_determined 未决定 | 否 | 是 | 否 |
denied 已拒绝 | 否 | 否 | 是 |
authorized 已授权 | 是 | 否 | 是 |
restricted 系统受限 | 否 | 否 | 是 |
追踪用途也要单独门控
第二个容易漏的点:状态允许 ≠ 用途允许。同一个 denied 状态,「归因测量」通常还能靠聚合数据做,而「个性化广告」必须已授权才合法。所以链路决策是状态与用途的笛卡尔积,不是只看状态:
| 用途 | 需要已授权 | 未授权时的处置 |
|---|---|---|
归因测量 measurement | 否 | 可走聚合回传 |
产品分析 analytics | 否 | 可走聚合回传 |
个性化推荐 personalization | 是 | 禁用 |
个性化广告 ads | 是 | 禁用 |
回传字段是合同,转化值是接口
SKAdNetwork 的回传不是「把安装明细发给你」,而是一组合法字段 + 一个整数转化值。字段表本身就是一份合同:每个字段有位宽与合法区间(conversion_value 是 6 位、上限 63;source_identifier 是 4 位十进制)。你要做的是把「应用内发生了什么」编码进那个整数。本节用的编码是位掩码——每个里程碑占一个比特位,达成即置位,cv = OR(1 << bit)。这样 6 位能同时表达 6 个独立里程碑,比「按收入分桶」更能保留「到底做了哪几件事」的信息。
D 层:链路清单与 schema 校验
下面这份是 D 层配置 config/g10_att_link.json,把状态机、用途门控、回传字段 schema、里程碑位与场景全部写成可机读的整数表:
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "ATT / IDFA / SKAdNetwork 链路清单。口径是「公开可观察机制」的建模假设(观察时点 2026-10),不是任一平台后台或归因服务商实测。attStates:idfaAvailable=该状态能否拿到 IDFA;canPrompt=该状态能否再次请求授权;skanPath=该状态是否走聚合回传。purposes:needsConsent=该用途是否必须已授权。skanFields:bits=字段位宽(0 表示非数值字段);min/max=合法取值区间。milestones:bit=里程碑占用的转化值比特位。coarseBands:粗粒度档位按 popcount 落带。真实同意率 / 回传门槛 / 平台版本需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
"attStates": [
{ "id": "not_determined", "label": "未决定", "idfaAvailable": 0, "canPrompt": 1, "skanPath": 0 },
{ "id": "denied", "label": "已拒绝", "idfaAvailable": 0, "canPrompt": 0, "skanPath": 1 },
{ "id": "authorized", "label": "已授权", "idfaAvailable": 1, "canPrompt": 0, "skanPath": 1 },
{ "id": "restricted", "label": "系统受限", "idfaAvailable": 0, "canPrompt": 0, "skanPath": 1 }
],
"purposes": [
{ "id": "measurement", "label": "归因测量", "needsConsent": 0 },
{ "id": "analytics", "label": "产品分析", "needsConsent": 0 },
{ "id": "personalization", "label": "个性化推荐", "needsConsent": 1 },
{ "id": "ads", "label": "个性化广告", "needsConsent": 1 }
],
"skanFields": [
{ "name": "version", "bits": 0, "min": 0, "max": 0 },
{ "name": "conversion_value", "bits": 6, "min": 0, "max": 63 },
{ "name": "coarse_conversion_value", "bits": 2, "min": 0, "max": 3 },
{ "name": "source_identifier", "bits": 14, "min": 1000, "max": 9999 },
{ "name": "campaign_id", "bits": 64, "min": 0, "max": 0 }
],
"milestones": [
{ "id": "install", "bit": 0, "revenueDeciFen": 0 },
{ "id": "tutorial", "bit": 1, "revenueDeciFen": 0 },
{ "id": "d1_return", "bit": 2, "revenueDeciFen": 0 },
{ "id": "first_ad", "bit": 3, "revenueDeciFen": 0 },
{ "id": "first_pay", "bit": 4, "revenueDeciFen": 600 },
{ "id": "level10", "bit": 5, "revenueDeciFen": 0 }
],
"coarseBands": [
{ "id": "low", "minPopcount": 0, "maxPopcount": 1 },
{ "id": "medium", "minPopcount": 2, "maxPopcount": 3 },
{ "id": "high", "minPopcount": 4, "maxPopcount": 6 }
],
"scenarios": [
{ "id": "s1", "state": "not_determined", "purpose": "measurement", "milestones": ["install", "tutorial", "d1_return"] },
{ "id": "s2", "state": "denied", "purpose": "ads", "milestones": ["install", "tutorial"] },
{ "id": "s3", "state": "authorized", "purpose": "measurement", "milestones": ["install", "tutorial", "d1_return", "first_ad", "first_pay"] },
{ "id": "s4", "state": "restricted", "purpose": "personalization", "milestones": ["install"] },
{ "id": "s5", "state": "authorized", "purpose": "ads", "milestones": ["install", "tutorial", "d1_return", "first_ad", "first_pay", "level10"] }
]
}
下面这段是 D 层校验脚本 sim/g10_att_link.py。它读上面那份配置,把「状态 × 用途」解析成链路决策,把里程碑编码成转化值并反解回里程碑列表证明可逆,再对字段表做 schema 校验:
#!/usr/bin/env python3
# file: sim/g10_att_link.py
# tier: D
# verify: /opt/homebrew/bin/python3 sim/g10_att_link.py --config config/ --out out/
"""ATT / IDFA / SKAdNetwork 链路的清单解析与 schema 校验(D 层:清单结构 / lint)。
读 config/g10_att_link.json,把「ATT 状态 × 追踪用途」解析成链路决策:
idfa = state.idfaAvailable;prompt = state.canPrompt;skan = state.skanPath
allowed = purpose.needsConsent == 0 or idfa == 1;link = idfa / skan / none
encode: cv = OR(1 << bit),coarse = 按 popcount 落带;decode 把 cv 解回里程碑
schema_ok: bits>0 的字段必须满足 max < 2^bits
产出 out/g10_att_link.json + .csv。全整数、无浮点、无随机。
口径:ATT 状态机与回传字段是「公开可观察机制」的建模假设(观察时点 2026-10);真实
同意率 / 回传门槛 / 平台版本需真实业务环境,命令行不可复现(X)。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
def resolve(state, purpose):
idfa, prompt, skan = state["idfaAvailable"], state["canPrompt"], state["skanPath"]
allowed = 1 if (purpose["needsConsent"] == 0 or idfa == 1) else 0
return {"idfa": idfa, "prompt": prompt, "skan": skan, "allowed": allowed,
"link": "idfa" if idfa else ("skan" if skan else "none")}
def encode(cfg, achieved):
bit_of = {m["id"]: m["bit"] for m in cfg["milestones"]}
cv = 0
for mid in achieved:
cv |= 1 << bit_of[mid]
pc = bin(cv).count("1")
band = next(b["id"] for b in cfg["coarseBands"] if b["minPopcount"] <= pc <= b["maxPopcount"])
return {"cv": cv, "popcount": pc, "coarse": band}
def decode(cfg, cv):
return [m["id"] for m in cfg["milestones"] if (cv >> m["bit"]) & 1]
def schema_ok(fields):
return 0 if any(f["bits"] > 0 and f["max"] >= (1 << f["bits"]) for f in fields) else 1
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, "g10_att_link.json"), encoding="utf-8") as f:
cfg = json.load(f)
states = {s["id"]: s for s in cfg["attStates"]}
purposes = {p["id"]: p for p in cfg["purposes"]}
rows, parts = [], []
for s in cfg["scenarios"]:
r = resolve(states[s["state"]], purposes[s["purpose"]])
e = encode(cfg, s["milestones"])
rows.append({"id": s["id"], "state": s["state"], "purpose": s["purpose"], **r, **e,
"roundTrip": 1 if decode(cfg, e["cv"]) == list(s["milestones"]) else 0})
parts.append(f'{s["id"]}:{s["state"]}:{s["purpose"]}:{r["idfa"]}:{r["prompt"]}:'
f'{r["skan"]}:{r["allowed"]}:{r["link"]}:{e["cv"]}:{e["coarse"]}')
ok, bits = schema_ok(cfg["skanFields"]), sum(f["bits"] for f in cfg["skanFields"])
cnt = lambda key: sum(1 for r in rows if r["link"] == key) # noqa: E731
digest = (f'fields={len(cfg["skanFields"])},bits={bits},schemaOk={ok},idfaPath={cnt("idfa")},'
f'skanPath={cnt("skan")},nonePath={cnt("none")},'
f'blocked={sum(1 for r in rows if r["allowed"] == 0)},'
f'roundTrip={sum(1 for r in rows if r["roundTrip"] == 1)};' + ";".join(parts))
os.makedirs(a.out, exist_ok=True)
cols = ("id", "state", "purpose", "idfa", "prompt", "skan", "allowed", "link", "cv", "coarse", "roundTrip")
with open(os.path.join(a.out, "g10_att_link.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(cols)
for r in rows:
w.writerow([r[c] for c in cols])
rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
"fieldCount": len(cfg["skanFields"]), "totalBits": bits, "schemaOk": ok,
"scenarios": rows, "digest": digest, "csv": os.path.join(a.out, "g10_att_link.csv")}
with open(os.path.join(a.out, "g10_att_link.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"digest": digest, "schemaOk": ok, "totalBits": bits,
"scenarios": [{"id": r["id"], "link": r["link"], "cv": r["cv"],
"coarse": r["coarse"], "allowed": r["allowed"]} for r in rows]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑 /opt/homebrew/bin/python3 sim/g10_att_link.py --config config/ --out out/,五个场景的链路决策如下(同机相对量级,绝对数值不可移植):
| 场景 | 状态 | 用途 | IDFA | 可弹窗 | 聚合 | 用途放行 | 链路 | 转化值 | 粗档 |
|---|---|---|---|---|---|---|---|---|---|
| s1 | not_determined | measurement | 0 | 1 | 0 | 1 | none | 7 | medium |
| s2 | denied | ads | 0 | 0 | 1 | 0 | skan | 3 | medium |
| s3 | authorized | measurement | 1 | 0 | 1 | 1 | idfa | 31 | high |
| s4 | restricted | personalization | 0 | 0 | 1 | 0 | skan | 1 | low |
| s5 | authorized | ads | 1 | 0 | 1 | 1 | idfa | 63 | high |
两处 0 就是「用途门控」拦下来的:s2 是未授权用户想做个性化广告、s4 是受限用户想做个性化推荐,两条链路都只能降级到聚合,不能按用户级投放。而 s3 / s5 拿到 IDFA,走的是精确归因。字段表 schema 校验通过(schemaOk=1,五个字段合计 bits=86),roundTrip=5 说明五个场景的位掩码编码全部可逆。
P 层内核:跨语言对拍,证明编码没写错
下面这段是 P 层 TypeScript 内核 core/src/g10/att_link.ts,复算同一套链路决策与位掩码编解码。它读同一份 config/g10_att_link.json,产出和 Python 逐字符一致的摘要。注意位宽校验在 TS 侧必须用 BigInt——campaign_id 是 64 位,1 << 64 在普通 number 下会静默溢出成 0,schema 校验会假通过:
// file: core/src/g10/att_link.ts
// tier: P
// verify: node --test 'core/test/g10_*.test.ts'
// ATT/IDFA/SKAdNetwork 链路内核:与 sim/g10_att_link.py 逐位等价的对偶实现(全整数、无浮点、
// 无随机、只用可擦除语法;位宽校验用 BigInt,因 campaign_id 是 64 位)。
export interface AttState {
readonly id: string; readonly label: string; readonly idfaAvailable: number;
readonly canPrompt: number; readonly skanPath: number;
}
export interface Purpose { readonly id: string; readonly label: string; readonly needsConsent: number }
export interface SkanField { readonly name: string; readonly bits: number; readonly min: number; readonly max: number }
export interface Milestone { readonly id: string; readonly bit: number; readonly revenueDeciFen: number }
export interface CoarseBand { readonly id: string; readonly minPopcount: number; readonly maxPopcount: number }
export interface Scenario {
readonly id: string; readonly state: string; readonly purpose: string; readonly milestones: readonly string[];
}
export interface LinkConfig {
readonly version: number; readonly game: string; readonly attStates: readonly AttState[];
readonly purposes: readonly Purpose[]; readonly skanFields: readonly SkanField[];
readonly milestones: readonly Milestone[]; readonly coarseBands: readonly CoarseBand[];
readonly scenarios: readonly Scenario[];
}
export interface Decision {
readonly idfa: number; readonly prompt: number; readonly skan: number;
readonly allowed: number; readonly link: string;
}
export interface Encoded { readonly cv: number; readonly popcount: number; readonly coarse: string }
export interface ScenarioRow extends Decision, Encoded {
readonly id: string; readonly state: string; readonly purpose: string; readonly roundTrip: number;
}
export function resolve(state: AttState, purpose: Purpose): Decision {
const idfa = state.idfaAvailable, prompt = state.canPrompt, skan = state.skanPath;
const allowed = purpose.needsConsent === 0 || idfa === 1 ? 1 : 0;
const link = idfa ? "idfa" : skan ? "skan" : "none";
return { idfa, prompt, skan, allowed, link };
}
/** 把已达成里程碑集合编码成转化值位掩码与粗粒度档位。 */
export function encode(cfg: LinkConfig, achieved: readonly string[]): Encoded {
const bitOf = new Map(cfg.milestones.map((m) => [m.id, m.bit]));
let cv = 0;
for (const mid of achieved) cv |= 1 << (bitOf.get(mid) ?? 0);
let popcount = 0;
for (let v = cv; v > 0; v >>= 1) popcount += v & 1;
const band = cfg.coarseBands.find((b) => popcount >= b.minPopcount && popcount <= b.maxPopcount)!;
return { cv, popcount, coarse: band.id };
}
export function decode(cfg: LinkConfig, cv: number): string[] {
return cfg.milestones.filter((m) => ((cv >> m.bit) & 1) === 1).map((m) => m.id);
}
export function schemaOk(fields: readonly SkanField[]): number {
for (const f of fields) {
if (f.bits > 0 && BigInt(f.max) >= 1n << BigInt(f.bits)) return 0;
}
return 1;
}
export function rows(cfg: LinkConfig): ScenarioRow[] {
const st = new Map(cfg.attStates.map((s) => [s.id, s]));
const pu = new Map(cfg.purposes.map((p) => [p.id, p]));
return cfg.scenarios.map((s) => {
const d = resolve(st.get(s.state)!, pu.get(s.purpose)!);
const e = encode(cfg, s.milestones);
const back = decode(cfg, e.cv);
const rt = back.length === s.milestones.length && back.every((v, i) => v === s.milestones[i]);
return { id: s.id, state: s.state, purpose: s.purpose, ...d, ...e, roundTrip: rt ? 1 : 0 };
});
}
/** 跨语言对拍摘要:与 sim/g10_att_link.py 的 digest 逐字符一致。 */
export function digest(cfg: LinkConfig): string {
const rs = rows(cfg);
const ok = schemaOk(cfg.skanFields);
const totalBits = cfg.skanFields.reduce((a, f) => a + f.bits, 0);
const nIdfa = rs.filter((r) => r.link === "idfa").length;
const nSkan = rs.filter((r) => r.link === "skan").length;
const nNone = rs.filter((r) => r.link === "none").length;
const nBlocked = rs.filter((r) => r.allowed === 0).length;
const nRound = rs.filter((r) => r.roundTrip === 1).length;
const head = `fields=${cfg.skanFields.length},bits=${totalBits},schemaOk=${ok},`
+ `idfaPath=${nIdfa},skanPath=${nSkan},nonePath=${nNone},blocked=${nBlocked},roundTrip=${nRound};`;
return head + rs.map((r) => `${r.id}:${r.state}:${r.purpose}:${r.idfa}:${r.prompt}:`
+ `${r.skan}:${r.allowed}:${r.link}:${r.cv}:${r.coarse}`).join(";");
}
配套测试 core/test/g10_att_link.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算结果逐字符相等,并顺手验状态机门控、用途门控、位掩码可逆与 64 位边界。在本机跑 node --test 'core/test/g10_*.test.ts',本节 6 个用例全过:
✔ 跨语言一致:TS 链路摘要 == Python 金标准
✔ ATT 状态机:只有 authorized 有 IDFA,只有 not_determined 能弹窗
✔ 用途同意门控:需要同意的用途在未授权时被禁用
✔ 转化值位掩码:编码 / 解码可逆
✔ 字段表 schema:6 位转化值上限 63,64 位字段不溢出
✔ 场景表:两条 IDFA 路径、两条聚合路径、一条无路径
ℹ tests 6 / pass 6 / fail 0(本节 6 个;该 glob 连同 10.3 的 5 个共 11 个)
两套独立实现给出同一个摘要(截取首段):fields=5,bits=86,schemaOk=1,idfaPath=2,skanPath=2,nonePath=1,blocked=2,roundTrip=5;s1:not_determined:measurement:0:1:0:1:none:7:medium;s2:denied:ads:0:0:1:0:skan:3:medium;…。注意 TS 侧若用普通 number 做 1 << bits,位移计数按 32 取模,1 << 64 会静默等于 1,64 位字段的上限校验等于形同虚设——所以那里必须用 BigInt。
X 层:真实同意率与回传门槛不可复现
必须把边界划清。上面算的是模型侧:给定状态与用途,链路决策是什么、转化值怎么编码。而下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的 ATT 同意率(随地区、品类、弹窗时机剧烈变化,且平台不允许诱导);
- 真实的 SKAdNetwork 回传门槛与延迟(隐私阈值、回传窗口、转化值更新规则由平台定义);
- 真实的字段版本与位宽(平台会随版本调整字段集,本节按观察时点 2026-10 冻结)。
工程上的正确做法依旧是把模型层与真实数据层分开:模型层回答「用途门控会不会把这条链路拦下来」,真实数据层用来在投放后反标定。这也是为什么链路决策必须与埋点口径一一对应——口径对不上,反标定就无从谈起。
自检表
| 检查项 | 合格标准 |
|---|---|
| 状态机完整 | 四个 ATT 状态都有分支,不把 restricted 与 denied 混为一谈 |
| 用途单独门控 | 需要已授权的用途在未授权时显式禁用,不靠默认值 |
| 转化值定义 | 位掩码可逆(编码后能解回里程碑),不是只报一个数 |
| 字段 schema | 位宽与上限显式校验,64 位字段用 BigInt 不溢出 |
| 跨语言一致 | Python 与 TS 摘要逐字符相同 |
| 边界清晰 | 真实同意率与回传门槛标 X,不写成「实测」 |
把状态机与字段 schema 冻结下来,你才不会把「未授权用户的聚合回传」当成全量数据去优化。下一节处理链路断了之后的事:没有 IDFA,还能靠什么把渠道贡献估出来。
阅读导航:上一节:9.3 同意管理与数据出境 · 下一节:10.2 无 IDFA 时代的归因替代 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。