《移动游戏产品的架构·商业化与发行工程》7.1 归因链路:点击、安装、激活与事件回传

买量报表上的一个安装要穿过点击落地、安装、激活和事件回传四个系统。本节以《潮汐消消》月新增 42 万、买量占比 45% 为背景,拆开身份拼接、顺序校验与回传字段,说明哪些断点会让归因静默丢失、自然量与重装为什么必须单独记账,以及每一步为何都要留下可复核的证据。

报表上的安装,和链路上的安装不是一回事

买量后台说今天带来一批安装,游戏后台说今天新增另一批,归因平台给出第三个数。三个数字都「没错」,因为它们统计的是同一条链路的不同截面:广告平台知道自己被点了多少次,商店知道自己交付了多少次安装,游戏知道自己启动了多少个新账号,而只有把这四件事拼到同一个人身上,才能回答「这笔买量花得值不值」。

拼接失败时,链路不会报错。点击照常计费,安装照常发生,玩家照常进游戏,只是这条安装被记成了自然量。买量团队看到 CPI 变高,自然量团队看到占比变好,两边都基于错误的数字做决策。归因工程的第一目标不是「算得准」,而是「丢失可见」:每一条没有被归因的安装,都要能说清楚它丢在哪一段。

本节只处理链路的骨架:点击怎么落地、安装怎么拿到身份、激活怎么等待归因结果、事件怎么回传。窗口长度、多触点归属和隐私限制留到 7.2,回收与放量留到 7.3。

链路四段:点击、安装、激活、回传

把一次买量安装拆开,四个阶段各有自己的责任方和失败方式:

阶段发生地关键字段典型失败
点击广告平台与落地页click_id、channel、campaign_id、creative_id、ts、device_key落地页丢参、click_id 为空
安装应用商店与安装器install_id、platform、installed_at、device_key、referrerreferrer 为空、重装被当成新增
激活客户端首次启动install_id、first_open_at、账号 id激活早于归因结果、重复上报
回传游戏服务端到广告平台event_name、value、currency、dedupe_key字段缺失、金额单位错、重复回传

四个阶段的顺序在日志里看起来很自然,在系统里却不是。点击发生在广告平台,安装发生在商店,激活发生在客户端,回传发生在服务端;没有任何一个系统同时看见四件事。归因要做的,是用有限的标识把四段日志连成一条记录。

这里有一个容易被忽略的时序问题:激活通常早于归因结果。玩家下载完立刻打开游戏,此时服务端可能还没收到点击记录,更没算完匹配。如果客户端在激活时直接向广告平台回传,它会因为拿不到 click_id 而放弃,或者更糟,回传一条没有归因键的事件。正确做法是客户端只把激活写入自己的事件队列,服务端等归因结果落定后再决定回传还是记为自然量。

身份拼接:click_id 优先,设备键兜底

拼接两个事件,靠的是它们共享的键。按可靠性从高到低:

方式来源可靠性主要问题
click_id 透传Android install referrer、iOS 剪贴板或 Universal Link高用户关闭追踪、落地页丢参时拿不到
设备键精确相等IDFA/GAID 哈希或应用内生成的稳定 id中隐私限制下可得率下降,跨设备无法覆盖
指纹近似IP、UA、机型、系统版本组合低误配与合规风险,只适合兜底

工程上建议按这个顺序逐级降级,并且每一级都要留下使用了哪一级的证据。只在结果里写 matched: true,事后无法判断这条归因是 click_id 命中的,还是指纹猜出来的;前者可以直接进结算,后者只能进观察报表。代码里的 Attribution 结构没有单独的「置信度」字段,而是用 status 区分 matched、organic、out-of-order、device-mismatch 和 reinstall,让每条记录都能回答「为什么是这个结果」。

精确相等是一个刻意保守的选择。click.deviceKey !== install.deviceKey 直接拒绝,不做编辑距离、不做模糊匹配。代价是覆盖率下降:同一个用户在点击后被系统重置了广告标识,或者点击发生在平板、安装发生在手机,这条安装就会掉到自然量。收益是结算口径干净:进入买量成本的每一条归因,都有可复核的强身份。模糊匹配应该作为独立通道运行,单独统计、单独定价,而不是悄悄混进主链路。

四类断点,四种账

归因丢失不是一种故障,而是四种不同的账:

  1. 点击没落地。落地页重定向丢了 query,或者渠道把 click_id 放在 URL fragment 里而页面没有解析。表现是点击有计费、安装无来源。处理方式是落地页只做参数透传,不依赖前端脚本拼接。
  2. referrer 为空。Android 安装器可能不返回 referrer,iOS 在用户拒绝追踪后也拿不到设备标识。这类安装应进入「待定」而不是直接记自然量,等 7.2 的聚合口径回补。
  3. 激活早到。客户端先上报激活,归因结果后到。若服务端把「先到」当成最终状态,这条安装永远无法回传。正确做法是激活事件带 install_id 入库,归因结果到达时再触发回传。
  4. 重装被当成新增。玩家卸载后重新安装,商店会再给一个 install_id。若不做重装识别,同一用户会被重复计入新增、重复回传安装事件,买量侧就会为同一个人付两次费。

四类断点里,只有第一类能在客户端修;后三类都必须由服务端的状态机兜住。这也是为什么归因不能只做成一个 SDK 埋点,它必须有一条服务端记录,能等待、能回补、能去重。

回传什么:事件、金额、币种、去重键

回传给广告平台的事件不需要很多,但每个字段都要有明确口径:

字段口径常见错误
event_nameinstall、activate、purchase 三级别混用把激活当安装回传,导致平台优化目标漂移
value以分为单位的整数,退款记负数元与分混用,金额放大 100 倍
currency显式写 CNY 或 USD依赖平台默认币种,跨区结算对不上
occurred_at事件发生时间,不是回传时间用回传时间排序,迟到事件被排到未来
dedupe_keyinstall_id 与 event_id 的组合只用 event_id,两个安装的同名事件互相覆盖

回传是「至少一次」的投递。网络超时、平台限流、服务重启都会让同一条事件发两次。归因服务端不能假设平台一定去重,自己要先有一层幂等:同一个 install_id + event_id 只接受一次,值相同记为重复,值不同记为冲突并留证。7.2 会把这条规则扩展成完整的去重策略。

金额字段还有一个跨系统的坑:商店回调的金额、游戏内商品定价、广告平台回传的 value,三者可能使用不同币种和不同单位。归因层必须统一到「分」,并在记录里保留原始币种与汇率版本,否则月底对账时无法解释差异。

双案例:弱联网与强联网的链路差异

《潮汐消消》月新增 42 万,买量占 45%,约 18.9 万条安装需要点击归因;首包 120MB、弱联网,下载与首次启动之间可能隔几个小时,激活早到是常态,服务端必须把激活挂起等归因。

《潮汐方舟》月新增 8 万,买量占 65%,约 5.2 万条;首包 350MB、强联网,点击到安装的间隔被下载时长拉长,iOS 占 45%,设备标识可得率更低,拼接更依赖 7.2 的聚合口径。

把链路写成可运行的纯函数

两款产品的瓶颈并不在同一个位置:消消卡在「激活等归因」,方舟卡在「安装等下载」。前者要在服务端做事件挂起,后者要在点击记录里保留更长的有效期,两者都不能靠客户端本地缓存解决。这些判断需要一段可以逐分支断言的代码来固定。

下面的实现把链路拆成三个纯函数:解析点击、拼接安装、构造回传。它们不访问网络、不读写数据库,输入输出都是普通对象,因此每个分支都能被直接断言。

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

export type ChannelId = "adnet-alpha" | "adnet-beta" | "adnet-gamma" | "organic";
export type Platform = "ios" | "android";
export type Currency = "CNY" | "USD";

export interface ClickRecord {
  readonly clickId: string;
  readonly channel: ChannelId;
  readonly campaignId: string;
  readonly creativeId: string;
  readonly clickedAtMs: number;
  readonly deviceKey: string;
  readonly landing: string;
}

export interface InstallRecord {
  readonly installId: string;
  readonly platform: Platform;
  readonly installedAtMs: number;
  readonly deviceKey: string;
  readonly referrer: string;
  readonly reinstall: boolean;
}

export type ParseCode =
  | "missing-click-id"
  | "unknown-channel"
  | "missing-campaign"
  | "missing-device-key"
  | "bad-timestamp";

export type ParseResult =
  | { readonly ok: true; readonly click: ClickRecord }
  | { readonly ok: false; readonly code: ParseCode; readonly detail: string };

const CHANNELS: readonly ChannelId[] = ["adnet-alpha", "adnet-beta", "adnet-gamma", "organic"];

function isChannel(value: string): value is ChannelId {
  return (CHANNELS as readonly string[]).includes(value);
}

export function parseClick(raw: Record<string, string | undefined>): ParseResult {
  const clickId = (raw.click_id ?? "").trim();
  if (clickId === "") {
    return { ok: false, code: "missing-click-id", detail: "click_id is required" };
  }

  const channelText = (raw.channel ?? "").trim().toLowerCase();
  if (!isChannel(channelText)) {
    return {
      ok: false,
      code: "unknown-channel",
      detail: `channel "${channelText}" is not in the allowed list`,
    };
  }

  const campaignId = (raw.campaign_id ?? "").trim();
  if (campaignId === "") {
    return { ok: false, code: "missing-campaign", detail: "campaign_id is required" };
  }

  const deviceKey = (raw.device_key ?? "").trim();
  if (deviceKey === "") {
    return { ok: false, code: "missing-device-key", detail: "device_key is required" };
  }

  const timestampText = (raw.ts ?? "").trim();
  const clickedAtMs = Number(timestampText);
  if (timestampText === "" || !Number.isInteger(clickedAtMs) || clickedAtMs <= 0) {
    return {
      ok: false,
      code: "bad-timestamp",
      detail: `ts "${timestampText}" must be a positive integer in milliseconds`,
    };
  }

  return {
    ok: true,
    click: {
      clickId,
      channel: channelText,
      campaignId,
      creativeId: (raw.creative_id ?? "").trim(),
      clickedAtMs,
      deviceKey,
      landing: (raw.landing ?? "").trim(),
    },
  };
}

export type AttributionStatus =
  | "matched"
  | "organic"
  | "reinstall"
  | "out-of-order"
  | "device-mismatch";

export interface Attribution {
  readonly status: AttributionStatus;
  readonly channel: ChannelId;
  readonly clickId: string | null;
  readonly installId: string;
  readonly detail: string;
}

export function attributeInstall(click: ClickRecord | null, install: InstallRecord): Attribution {
  if (install.reinstall) {
    return {
      status: "reinstall",
      channel: click?.channel ?? "organic",
      clickId: click?.clickId ?? null,
      installId: install.installId,
      detail: "reinstall does not create a new install attribution",
    };
  }
  if (click === null) {
    return {
      status: "organic",
      channel: "organic",
      clickId: null,
      installId: install.installId,
      detail: "no click record for this install",
    };
  }
  if (click.clickedAtMs > install.installedAtMs) {
    return {
      status: "out-of-order",
      channel: click.channel,
      clickId: click.clickId,
      installId: install.installId,
      detail: "click happened after the install",
    };
  }
  if (click.deviceKey !== install.deviceKey) {
    return {
      status: "device-mismatch",
      channel: click.channel,
      clickId: click.clickId,
      installId: install.installId,
      detail: "click and install device keys differ",
    };
  }
  return {
    status: "matched",
    channel: click.channel,
    clickId: click.clickId,
    installId: install.installId,
    detail: `matched via ${click.channel}/${click.campaignId}`,
  };
}

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

export interface EventRecord {
  readonly eventId: string;
  readonly eventName: EventName;
  readonly valueCents: number;
  readonly currency: Currency;
  readonly occurredAtMs: number;
}

export interface Postback {
  readonly channel: ChannelId;
  readonly clickId: string;
  readonly installId: string;
  readonly eventName: EventName;
  readonly valueCents: number;
  readonly currency: Currency;
  readonly occurredAtMs: number;
  readonly dedupeKey: string;
}

export type PostbackCode = "not-attributed" | "missing-event-id" | "invalid-value";

export type PostbackResult =
  | { readonly ok: true; readonly postback: Postback }
  | { readonly ok: false; readonly code: PostbackCode; readonly detail: string };

export function buildPostback(attribution: Attribution, event: EventRecord): PostbackResult {
  if (attribution.status !== "matched" || attribution.clickId === null) {
    return {
      ok: false,
      code: "not-attributed",
      detail: `${attribution.status} installs have no partner click to post back to`,
    };
  }
  if (event.eventId.trim() === "") {
    return { ok: false, code: "missing-event-id", detail: "event_id is required for dedupe" };
  }
  if (event.valueCents < 0 || (event.eventName === "purchase" && event.valueCents === 0)) {
    return {
      ok: false,
      code: "invalid-value",
      detail: `value ${event.valueCents} is not valid for ${event.eventName}`,
    };
  }
  return {
    ok: true,
    postback: {
      channel: attribution.channel,
      clickId: attribution.clickId,
      installId: attribution.installId,
      eventName: event.eventName,
      valueCents: event.valueCents,
      currency: event.currency,
      occurredAtMs: event.occurredAtMs,
      dedupeKey: `${attribution.installId}:${event.eventId}`,
    },
  };
}

export function formatAttribution(attribution: Attribution): string {
  const click = attribution.clickId ?? "-";
  return `${attribution.status}:${attribution.channel} click=${click} install=${attribution.installId}`;
}

代码里的四种结局

parseClick 只做参数归一化与必填校验,返回 ok 或带 code 的拒绝。五个 code 分别对应五类脏数据:missing-click-id、unknown-channel、missing-campaign、missing-device-key、bad-timestamp。拒绝不抛异常,因为点击参数来自外部,脏数据是常态而不是异常。

attributeInstall 返回五种状态。matched 是唯一可以进入结算的状态;organic 表示没有点击记录;reinstall 表示这不是一次新安装;out-of-order 表示点击晚于安装;device-mismatch 表示两个设备键对不上。后四种都不是「错误」,而是链路必须如实记录的结局。

buildPostback 只接受 matched 且带 click_id 的归因。自然量没有可回传的广告平台,重装和顺序异常也不应该产生新的安装事件。它还会拒绝空 event_id 和不合法的金额:购买事件的 value 必须大于零,安装与激活可以为零。

formatAttribution 把结果压成一行稳定文本,用于日志和排查。它不用于玩家展示,也不用于结算;结算读的是结构化字段,不是这行字符串。

取舍:强身份换来的覆盖率损失

这套实现最重要的取舍,是宁可不归因,也不做模糊匹配。它牺牲了三类安装:设备标识被重置的、跨设备点击的、referrer 丢失但指纹可能对上的。按案例的量级,这些损失不会让报表归零,但足以让买量 ROI 被系统性低估。

补偿方式不是放宽主链路,而是增加旁路:

  • 广告平台侧的聚合报表独立留存,用于校准总量,不参与单条归因。
  • 指纹匹配只做观察通道,命中结果标记为低置信度,不进入买量成本。
  • 对账任务按渠道比较「平台安装数」与「归因安装数」,差异超过阈值时告警,而不是自动补记。

代价是系统多了一套旁路和对账,收益是每一条进入结算的记录都能被复核。对按月投放百万级的团队,这个交换通常值得;对刚起步、量级很小的产品,旁路可以后置,但主链路的「拒绝要留痕」不能省。

小结

  • 一次买量安装要穿过点击、安装、激活、回传四段,没有任何一个系统同时看见全部四段。
  • 身份拼接按 click_id、设备键、指纹逐级降级,每一级都要留下证据,模糊匹配不能混进结算。
  • 激活通常早于归因结果,服务端必须挂起激活事件,等归因落定后再决定回传或记自然量。
  • 回传字段的口径要固定:事件名、以分为单位的金额、显式币种、install_id 与 event_id 组成的去重键。
  • 强身份匹配会损失覆盖率,补偿应放在旁路报表和对账,而不是放宽主链路。

阅读导航:上一节:6.3 广告收入核算与 eCPM 口径 · 下一节:7.2 归因窗口、去重与隐私限制 。

常见错误与边界

  1. 把广告平台报表当成归因结果。平台只知道自己被点击和被回传了多少,不知道游戏内新增;直接用平台安装数结算会重复计算自然量。
  2. 落地页用前端脚本拼参数。脚本失败、重定向或用户禁用 JS 时 click_id 直接丢失;参数透传应由服务端或静态跳转完成。
  3. 激活时立即回传。此时归因结果通常还没到,回传会缺少 click_id;应先入库挂起,等归因落定再发。
  4. 把重装当新增。同一用户卸载重装会再次产生 install_id,重复计入新增并重复计费;必须识别重装并单独记账。
  5. 只用一个 event_id 去重。不同安装可能产生相同 event_id,键里必须带 install_id。
  6. 金额用元而不是分。浮点与单位混用会让回传金额放大或缩小 100 倍,跨币种时更难发现。
  7. 用回传时间代替事件时间。迟到事件会被排到错误的时间窗口,回收曲线随之失真。
  8. 模糊匹配直接进结算。指纹误配会把自然量算成买量,也会把买量算成自然量;只能作为观察通道。
  9. 归因失败不留原因。只记「未归因」无法区分丢参、重装和设备不匹配;必须保留 status 与拒绝 code。
  10. 认为归因只属于客户端。客户端只能采集,等待、回补、去重和结算都必须由服务端完成。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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