《移动游戏产品的架构·商业化与发行工程》7.2 归因窗口、去重与隐私限制

归因窗口决定哪些点击有资格算数,去重决定同一笔收入被记几次,隐私限制决定还能看见什么。本节以两款产品共用的 7 天点击、1 天浏览窗口为背景,给出边界判定、触点选择、三层去重与标识降级的可运行规则,并算清窗口放长换来的报表好看与决策滞后各值多少。

窗口回答的是「哪次点击有资格」

7.1 把点击、安装、激活、回传连成了一条记录,但没有回答一个前置问题:玩家可能在一周内点过五个广告、看过三次开屏,最后装了游戏,哪一次算数?归因窗口就是这条资格线。

案例里两款产品使用同一套窗口设定:点击后 7 天、浏览后 1 天。点击是主动行为,玩家看过素材并点了进来,记忆和意图更强,给 7 天;浏览只是素材曝光,没有交互,给 1 天。超过窗口的点击不是「无效点击」,它仍然计费,只是不再具备把安装记到自己名下的资格。

窗口不是越长越好。把点击窗口从 7 天继续拉长,会有更多安装被归到买量,CPI 立刻变好看,ROI 曲线也提前;代价是归因结果来得更晚,等更晚的点击被匹配时,对应的投放早就结束了,优化动作只能事后复盘。窗口是一种「用决策时效换归因覆盖率」的交易,不是单纯的参数。

四条判定规则

窗口判定看起来只是比较两个时间戳,实际有四条规则,顺序不能乱:

顺序规则判定处理
1触点时间晚于安装时间future拒绝,通常是时钟漂移或伪造
2按触点类型取窗口点击 7 天、浏览 1 天不能用同一个窗口一刀切
3年龄恰好等于窗口in-window接受;边界取闭区间
4年龄大于窗口too-old拒绝,但保留在 rejected 列表

第一条必须先判。若触点时间在未来,年龄是负数,直接拿去和窗口比较会永远落在窗口内。时钟漂移在移动端很常见:设备时区错误、系统时间被手动修改、服务端与客户端时钟不同步,都会制造未来时间。把 future 单独一个 code,才能和「过期」区分开。

第三条的闭区间是刻意选择。ageMs > windowMs 才拒绝,意味着恰好 7 天整仍然有效。如果写成 >=,跨时区、跨秒级截断的边界样本会随机丢失,而且这种丢失很难复现。边界规则要写死在一个函数里,不能让每个渠道各自实现一遍。

第四条要求保留 rejected 列表,而不是简单丢弃。运营需要知道「有多少点击因为过期没算」,这个比例突然上升,通常意味着渠道延迟回传、客户端时钟异常或窗口配置被改动。

多触点:最后触点与首次触点

一次安装可能有多条合格触点。选哪一条,是归因模型,不是窗口规则:

模型规则适合回答的问题偏向
最后触点取窗口内时间最晚的触点哪次投放促成了这次安装效果广告、临门一脚
首次触点取窗口内时间最早的触点哪个渠道最先带来这个用户品牌曝光、拉新归因

本卷案例按最后触点结算,首次触点只作为观察口径并行计算。这样做的原因是买量预算按渠道分配,渠道需要看到「最后一次说服玩家」的功劳;如果按首次触点结算,品牌广告会拿走大量功劳,效果渠道的 ROI 被压低,预算分配会失真。

同一毫秒的并列需要确定性的 tie-break。代码里的规则是:时间相同时点击优先于浏览,类型也相同时按 touch_id 字典序。这不是业务真理,而是为了让重放结果稳定。没有确定的 tie-break,同一批数据跑两次可能给出不同归属,对账时无法解释。

窗口外的触点不参与选择,但也不应该被静默丢弃。Selection 里同时返回 eligibleCount 和 rejected,前者说明这次归属有多少候选,后者说明有多少条因为过期或未来时间被排除。只看最终 channel,无法判断这个结果是「唯一候选」还是「五个候选里的最后一个」。

去重三层,缺一层就会重复记账

归因链路上有三种不同的重复,必须分别处理:

层键重复来源处理
点击层click_id广告平台重试、落地页刷新同一 click_id 只保留最早一条
安装层install_id 或设备键重装、商店重复通知重装单独记账,不产生新安装归因
事件层install_id + event_id回传超时重试、服务重启值相同记 duplicate,值不同记 conflict

三层里最容易漏的是事件层。很多实现只用 event_id 去重,理由是「事件 id 全局唯一」;但客户端生成的 id 可能只在本机唯一,两个安装完全可能撞出同一个字符串。键里必须带 install_id,才能把不同安装的同名事件分开。

冲突比重复更值得警惕。同一个 install_id + event_id 到达两次,事件名或金额却不同,通常意味着三种情况之一:客户端重试时改了金额、服务端两次读取了不同的订单状态、或者有人在篡改回传。无论哪一种,都不能用后到的值覆盖先到的值,而应该保留两条证据并告警。

案例里的重复量并不小。《潮汐消消》的埋点事件重复率是 0.8%,《潮汐方舟》是 0.4%。消消每月 42 万笔订单、方舟 15 万笔订单,事件量只会比订单更大,重复事件不是边角料;没有事件层去重,回传收入会被系统性地高估。

隐私限制:标识降级是一条决策树

窗口和去重都假设「能看见标识」。现实是标识的可得性在持续下降:用户拒绝追踪、系统限制广告标识、平台只提供聚合结果。归因系统必须把「用哪种标识」做成一条显式决策,而不是在代码里到处写 if (id) ... else ...。

案例里的决策顺序是:

条件可用模式结果用途
用户明确拒绝none不做个体归因,只保留自然量统计
需要同意但状态未知aggregated只看聚合报表,不回传个体事件
设备标识可用device-id正常归因与回传
标识受限且允许指纹fingerprint低置信度归因,只进观察通道
标识受限且不允许指纹aggregated聚合归因,不进入单条结算
标识不可用且不允许聚合none放弃归因,记录原因

这条决策树有两个刻意的保守点。第一,用户拒绝后不做任何个体归因,即使指纹在技术上仍能猜出设备;合规风险高于归因收益。第二,指纹永远不进入结算通道,只作为观察值,原因在 7.1 已经说过:误配会把自然量和买量互相污染。

聚合口径不是「退而求其次」的废数据。平台级聚合结果能回答「这个渠道本周带来多少安装、多少收入」,只是不能回答「哪一条安装属于哪一次点击」。用它校准总量、发现渠道异常足够,用它做单用户召回或客服查询不够。系统要明确区分这两种用途,不能让聚合数据悄悄流进个体结算。

双案例:标识可得性的差异

《潮汐消消》买量占 45%、国内安卓占 55%,设备标识可得率相对高,但弱联网下的时钟漂移更多,future 判定要把设备时间与服务端时间都记下来。

《潮汐方舟》iOS 占 45%、买量占 65%,标识受限的比例更高,聚合口径承担了更大的校准职责;它的付费率高,单条归因错误的金额影响也更大。

把窗口、去重与隐私写成可运行的规则

同一个决策树,消消更多落在 device-id,方舟更多落在 aggregated。这不是实现差异,而是平台结构差异,不能用一套覆盖率指标横向比较两款产品,也不能靠一套硬编码的分支同时服务两者。

下面三个函数分别对应本节的三块规则。窗口判定按触点类型取不同长度,触点选择支持最后触点与首次触点,去重按 install_id 与 event_id 组合,标识决策按同意状态与标识可得性逐级降级。

// file: src/biz/attribution-window.ts
// tier: L1
// verify: node --test test/biz/attribution-window.test.ts

export type TouchKind = "click" | "view";
export type ChannelId = "adnet-alpha" | "adnet-beta" | "adnet-gamma";
export type AttributionModel = "last-touch" | "first-touch";

export interface Touch {
  readonly touchId: string;
  readonly channel: ChannelId;
  readonly kind: TouchKind;
  readonly touchedAtMs: number;
}

export interface WindowPolicy {
  readonly clickWindowMs: number;
  readonly viewWindowMs: number;
  readonly model: AttributionModel;
}

export type WindowCode = "in-window" | "too-old" | "future";

export interface WindowCheck {
  readonly code: WindowCode;
  readonly ageMs: number;
  readonly windowMs: number;
}

export function checkTouch(touch: Touch, installAtMs: number, policy: WindowPolicy): WindowCheck {
  const windowMs = touch.kind === "click" ? policy.clickWindowMs : policy.viewWindowMs;
  const ageMs = installAtMs - touch.touchedAtMs;
  if (ageMs < 0) {
    return { code: "future", ageMs, windowMs };
  }
  if (ageMs > windowMs) {
    return { code: "too-old", ageMs, windowMs };
  }
  return { code: "in-window", ageMs, windowMs };
}

export interface RejectedTouch {
  readonly touchId: string;
  readonly code: WindowCode;
}

export interface Selection {
  readonly status: "attributed" | "unattributed";
  readonly touch: Touch | null;
  readonly eligibleCount: number;
  readonly rejected: readonly RejectedTouch[];
  readonly detail: string;
}

function isBetter(candidate: Touch, current: Touch, model: AttributionModel): boolean {
  if (candidate.touchedAtMs !== current.touchedAtMs) {
    return model === "last-touch"
      ? candidate.touchedAtMs > current.touchedAtMs
      : candidate.touchedAtMs < current.touchedAtMs;
  }
  if (candidate.kind !== current.kind) {
    return candidate.kind === "click";
  }
  return candidate.touchId < current.touchId;
}

export function selectTouch(
  touches: readonly Touch[],
  installAtMs: number,
  policy: WindowPolicy,
): Selection {
  const eligible: Touch[] = [];
  const rejected: RejectedTouch[] = [];
  for (const touch of touches) {
    const check = checkTouch(touch, installAtMs, policy);
    if (check.code === "in-window") {
      eligible.push(touch);
    } else {
      rejected.push({ touchId: touch.touchId, code: check.code });
    }
  }
  if (eligible.length === 0) {
    return {
      status: "unattributed",
      touch: null,
      eligibleCount: 0,
      rejected,
      detail: `${rejected.length} touch(es) outside the window`,
    };
  }
  let touch = eligible[0] as Touch;
  for (const candidate of eligible) {
    if (isBetter(candidate, touch, policy.model)) {
      touch = candidate;
    }
  }
  return {
    status: "attributed",
    touch: touch ?? null,
    eligibleCount: eligible.length,
    rejected,
    detail: `${policy.model} picked ${touch?.touchId ?? "-"} among ${eligible.length} eligible`,
  };
}

export type EventName = "install" | "activate" | "purchase";

export interface PostbackEvent {
  readonly eventId: string;
  readonly installId: string;
  readonly eventName: EventName;
  readonly valueCents: number;
  readonly receivedAtMs: number;
}

export type DedupeCode = "accepted" | "duplicate" | "conflict";

export interface DedupeResult {
  readonly code: DedupeCode;
  readonly events: readonly PostbackEvent[];
  readonly detail: string;
}

export function dedupePostback(
  seen: readonly PostbackEvent[],
  incoming: PostbackEvent,
): DedupeResult {
  const key = `${incoming.installId}:${incoming.eventId}`;
  const existing = seen.find(
    (event) => `${event.installId}:${event.eventId}` === key,
  );
  if (existing === undefined) {
    return { code: "accepted", events: [...seen, incoming], detail: `${key} accepted` };
  }
  if (existing.eventName === incoming.eventName && existing.valueCents === incoming.valueCents) {
    return { code: "duplicate", events: seen, detail: `${key} already recorded` };
  }
  return {
    code: "conflict",
    events: seen,
    detail: `${key} arrived with a different event name or value`,
  };
}

export type ConsentState = "granted" | "denied" | "unknown";
export type IdState = "available" | "limited" | "unavailable";
export type IdentifierMode = "device-id" | "fingerprint" | "aggregated" | "none";

export interface PrivacyPolicy {
  readonly requireConsent: boolean;
  readonly allowFingerprint: boolean;
  readonly allowAggregated: boolean;
}

export interface IdentifierDecision {
  readonly mode: IdentifierMode;
  readonly allowed: boolean;
  readonly reason: string;
}

export function resolveIdentifier(
  consent: ConsentState,
  ids: IdState,
  policy: PrivacyPolicy,
): IdentifierDecision {
  if (consent === "denied") {
    return { mode: "none", allowed: false, reason: "consent denied" };
  }
  if (policy.requireConsent && consent === "unknown") {
    return policy.allowAggregated
      ? { mode: "aggregated", allowed: true, reason: "consent unknown, aggregated only" }
      : { mode: "none", allowed: false, reason: "consent unknown and aggregation disabled" };
  }
  if (ids === "available") {
    return { mode: "device-id", allowed: true, reason: "device identifier available" };
  }
  if (ids === "limited") {
    if (policy.allowFingerprint) {
      return { mode: "fingerprint", allowed: true, reason: "identifier limited, fingerprint fallback" };
    }
    return policy.allowAggregated
      ? { mode: "aggregated", allowed: true, reason: "identifier limited, aggregated only" }
      : { mode: "none", allowed: false, reason: "identifier limited and fallbacks disabled" };
  }
  return policy.allowAggregated
    ? { mode: "aggregated", allowed: true, reason: "identifier unavailable, aggregated only" }
    : { mode: "none", allowed: false, reason: "identifier unavailable and aggregation disabled" };
}

export function formatSelection(selection: Selection): string {
  const touch = selection.touch;
  if (touch === null) {
    return `unattributed rejected=${selection.rejected.length}`;
  }
  return `${selection.status}:${touch.channel}:${touch.kind} eligible=${selection.eligibleCount}`;
}

代码里的判定顺序

checkTouch 先判 ageMs < 0 得到 future,再比较窗口,边界用 > 而不是 >=。窗口长度由 kind 决定,调用方不能传一个统一值进来,避免把浏览当成点击。

selectTouch 先过滤出合格触点,再用 isBetter 逐个比较。最后触点比较时间更大,首次触点比较时间更小;时间相同时点击优先,类型相同按 touch_id 字典序。这个比较函数是纯的,因此同一批输入永远得到同一个结果。

dedupePostback 的键是 install_id:event_id。新键返回 accepted 并追加;同键同值返回 duplicate 且不改变集合;同键不同值返回 conflict 且保留原记录。三种结果都不抛异常,因为重复和冲突是回传链路的正常输入。

resolveIdentifier 是一条短路决策树。同意被拒绝时直接返回 none,不再看标识状态;需要同意但状态未知时,只在允许聚合的前提下返回 aggregated;标识可用才返回 device-id;受限时依次尝试指纹和聚合;都不可用则返回 none。每一步都带 reason,便于统计「为什么这条安装没有归因」。

formatSelection 只输出一行稳定文本,用于日志与排查。它不参与结算,也不应该被当成归因结果本身。

取舍:窗口越长,报表越好看,决策越慢

把点击窗口继续拉长,最直接的效果是归因安装变多、CPI 变低、D7 ROI 变高。这三个变化都会让投放看起来更健康,但它们来自口径变化,不是产品变好。更隐蔽的代价是反馈延迟:更晚的点击要等到安装后才会被匹配,优化系统拿到的信号永远是滞后的。

案例选择 7 天,是在覆盖率和时效之间取了一个中间值。它牺牲了一部分「长决策周期」的安装:玩家看了广告、两周后才下载,这条安装会被记成自然量。补偿方式是看平台侧聚合报表和周维度的自然量趋势,而不是把窗口继续拉长。

去重也有代价。严格按 install_id + event_id 拒绝冲突,会让一些真实但字段被改写的回传被挡住;如果业务上确实存在「同一事件金额合法变更」的场景,应该建模成新的事件 id,而不是放宽去重键。隐私决策树同样有代价:拒绝个体归因会损失一部分可归因收入,但换来的是合规确定性和可解释的审计记录。

小结

  • 窗口是资格线,不是效果放大器;案例统一为点击 7 天、浏览 1 天,边界取闭区间。
  • 判定顺序必须是未来时间、触点类型、边界、过期;未来时间不先判,会被误认为在窗口内。
  • 最后触点适合效果结算,首次触点适合观察拉新;并列时要有确定性 tie-break。
  • 去重分点击、安装、事件三层,事件层的键必须带 install_id,冲突要留证而不是覆盖。
  • 隐私限制下按同意状态与标识可得性逐级降级,指纹只进观察通道,聚合结果只用于校准。

阅读导航:上一节:7.1 归因链路:点击、安装、激活与事件回传 · 下一节:7.3 买量 ROI 与回收周期 。

常见错误与边界

  1. 点击和浏览共用一个窗口。浏览没有交互意图,用 7 天窗口会把大量曝光算成功劳;必须按触点类型分别取窗口。
  2. 窗口边界用大于等于。恰好落在 7 天整的样本会被判为过期,跨时区时尤其难复现;应固定为闭区间并写在一处。
  3. 不先判未来时间。负年龄会永远落在窗口内,伪造或时钟错误会被当成有效触点。
  4. 丢弃窗口外触点而不记录。看不到过期比例,就无法发现渠道延迟或配置被改。
  5. 多点触控只保留最终渠道。没有 eligibleCount 和 rejected,无法复核对账,也无法切换归因模型。
  6. tie-break 不确定。同一批数据两次运行得到不同归属,对账时无法解释;必须有稳定的排序规则。
  7. 只用 event_id 去重。不同安装可能撞出相同 id,必须把 install_id 拼进键。
  8. 冲突时用后到的值覆盖。金额被改写或订单状态不一致会被静默接受;应保留两条证据并告警。
  9. 用户拒绝后仍用指纹归因。技术可行不等于合规可行;拒绝应直接落到 none。
  10. 把聚合数据用于个体结算。聚合口径只能校准总量,不能回答单条安装归属;混用会制造无法复核的账。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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