《互联网产品的客户端架构·交易、增长与平台化》9.1 分享卡片的参数与归因

分享链接不是拼几个查询参数,而是一份要穿过渠道、安装包和归因窗口的承诺。本节拆开渠道码、活动 id、邀请人、商品与时间戳的生成与校验,说明分享卡片和落地页不一致时以谁为准、参数丢失后如何兜底,并给出一个可运行、可测试的 L1 分享参数与归因模型。

分享链接是一份会过期的承诺

用户在分享面板里看到一张卡片:标题、图片、一句「我在参加秋季活动」。点开后,他期望看到同一件商品、同一个活动、同一个价格。增长团队则期望这次点击能被记到正确的渠道、活动和邀请人名下。

这两个期望由同一串参数承载,却经常被两拨人分别修改:客户端为了卡片好看,运营为了活动能追踪,渠道为了统计自行拼接 utm_*。结果是一串谁都能改、谁都不完全负责的查询参数。分享链路的失败不会像支付那样立刻造成资金损失,它的代价是归因失真:拉新成本记到了自然流量,邀请人拿不到奖励,活动复盘得出错误结论。更糟的是,失真通常不可逆——用户已经安装、注册、下单,链路无法重放。

所以这一节不讨论「分享面板怎么设计」,只回答四个工程问题:

  • 哪些参数必须带,谁生成,端上能校验到什么程度。
  • 参数在渠道、短链、安装包之间丢失后,回落到哪里。
  • 分享卡片与落地页不一致时,以谁为准。
  • 点击、激活、首单三个时间点,归因窗口分别怎么算。

五个参数与一条校验链

先固定参数表。参数越少越好,但每一个都要有明确的归属和丢失后的行为。

参数含义生成方端上校验丢失后的行为
ch渠道码分享面板白名单格式 + 白名单归为 direct,页面照常打开
cid活动 id活动配置在开放列表内丢活动,保留渠道与邀请人
inv邀请人 id当前登录态格式校验丢邀请关系,不猜邀请人
sku商品 id商详页可空落到活动默认商品
ts生成时间端上有限数字无法判断链接年龄,按弱归因处理
sig校验和端上示意 / 服务端正式重算比对整条归因作废

第一,端上签名只能发现「参数被改坏」,不能防伪造。 本节示例用 FNV-1a 计算校验和,目的是让「渠道把 inv 改了一位」这类问题在解析时暴露出来。任何人读过代码都能重算这个值,所以生产环境必须由服务端下发签名,或者直接使用服务端短链。把端上校验和当成安全机制,是这类模块最常见的误用。

第二,ts 不是用来排序的,是用来判断链接年龄的。 分享卡片可能被保存、转发、截图识别,几天后才被打开。没有生成时间,端上无法区分「刚分享的新链接」和「上一次活动的旧链接」,只能全部接受。

参数会在三个地方丢

分享参数不是从分享面板直接走到落地页的,中间至少经过三个可能改写它的地方:

  1. 渠道改写:社交平台会把链接包进自己的跳转页,部分平台只保留白名单参数,inv、cid 可能被剥掉。
  2. 短链与二维码:短链服务通常只做 302,不保证查询串完整;二维码容量有限,长参数会被截断。
  3. 安装与重启:未安装用户先落到下载页,安装后首次打开,启动参数可能已经丢失,只剩剪贴板或 deferred deep link。

对应的兜底顺序是:

  • 有 ch 就记渠道,没有就记 direct,但页面必须能正常打开。
  • 有 cid 且活动仍在开放列表就记活动;活动已下线则丢活动,不要把用户送进 404。
  • 有 inv 且格式合法就保留;邀请关系是否成立由 9.2 的绑定规则决定,分享模块不替它做判断。
  • 完全没有参数时,按自然流量处理,并记录「无参数」这一事实,方便后续排查渠道质量。

关键原则只有一句:降级的是归因,不是页面。 参数缺失可以让这次分享不计入增长,但不能让用户看到一个错误页。

生成:先校验,再拼参数,最后签名

下面的模块属于 L1:只有类型、常量与纯函数。它做三件事:生成分享链接、解析落地参数、按窗口归因。

// file: src/growth/share.ts
// tier: L1
// verify: node --test test/growth/share.test.ts

export type ShareIssueCode =
  | "channel_missing"
  | "channel_unknown"
  | "campaign_unknown"
  | "inviter_invalid"
  | "path_invalid"
  | "time_invalid"
  | "signature_missing"
  | "signature_mismatch";

export interface ShareIssue { code: ShareIssueCode; field: string; message: string }

export interface SharePolicy {
  channels: string[];
  campaignIds: string[];
  clickWindowMs: number;
  activateWindowMs: number;
}

export interface ShareCard {
  channel: string; campaignId: string | null; inviterId: string | null;
  path: string; skuId: string | null; issuedAt: number;
}

export type ShareBuild =
  | { ok: true; path: string; params: Record<string, string> }
  | { ok: false; issues: ShareIssue[] };

const CHANNEL_RE = /^[a-z][a-z0-9_]{2,15}$/;
const CAMPAIGN_RE = /^c_[a-z0-9]{4,12}$/;
const INVITER_RE = /^u_[0-9]{6,12}$/;
const SIGNED_KEYS = ["ch", "cid", "inv", "sku", "ts"];

// 端上校验只能挡住手误与脏参数,挡不住伪造;签名真伪最终必须由服务端裁决。
export function fnv1a(input: string): string {
  let hash = 0x811c9dc5;
  for (let i = 0; i < input.length; i += 1) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash.toString(16).padStart(8, "0");
}

// 只对归因参数签名;渠道方附加的 utm_* 等参数不参与,避免它们把签名改坏。
export function signParams(path: string, params: Record<string, string>): string {
  const canonical = SIGNED_KEYS.filter((key) => params[key] !== undefined)
    .map((key) => `${key}=${params[key]}`)
    .join("&");
  return fnv1a(`${path}?${canonical}`);
}

function pathProblem(path: string): boolean {
  return !path.startsWith("/") || path.startsWith("//") || path.length > 200 || /[?#\s]/.test(path);
}

export function buildShareLink(card: ShareCard, policy: SharePolicy): ShareBuild {
  const issues: ShareIssue[] = [];
  const add = (code: ShareIssueCode, field: string, message: string) =>
    issues.push({ code, field, message });
  if (!Number.isFinite(card.issuedAt)) add("time_invalid", "ts", "分享时间非法");
  if (!CHANNEL_RE.test(card.channel) || !policy.channels.includes(card.channel)) {
    add("channel_unknown", "ch", `渠道码不在白名单:${card.channel}`);
  }
  if (
    card.campaignId !== null &&
    (!CAMPAIGN_RE.test(card.campaignId) || !policy.campaignIds.includes(card.campaignId))
  ) {
    add("campaign_unknown", "cid", `活动不存在或未开放:${card.campaignId}`);
  }
  if (card.inviterId !== null && !INVITER_RE.test(card.inviterId)) {
    add("inviter_invalid", "inv", "邀请人编号格式非法");
  }
  if (pathProblem(card.path)) {
    add("path_invalid", "path", "落地路径必须是站内绝对路径且不带查询串");
  }
  if (issues.length > 0) return { ok: false, issues };

  const params: Record<string, string> = { ch: card.channel, ts: String(card.issuedAt) };
  if (card.campaignId !== null) params.cid = card.campaignId;
  if (card.inviterId !== null) params.inv = card.inviterId;
  if (card.skuId !== null) params.sku = card.skuId;
  params.sig = signParams(card.path, params);
  return { ok: true, path: card.path, params };
}

export type SignatureState = "valid" | "missing" | "invalid";

export interface Landing {
  channel: string; campaignId: string | null; inviterId: string | null;
  skuId: string | null; signature: SignatureState;
  attributable: boolean; fallback: boolean; issues: ShareIssue[];
}

export function parseLanding(
  raw: Record<string, string | undefined>,
  policy: SharePolicy,
): Landing {
  const issues: ShareIssue[] = [];
  const add = (code: ShareIssueCode, field: string, message: string) =>
    issues.push({ code, field, message });
  if (raw.path !== undefined && pathProblem(raw.path)) {
    add("path_invalid", "path", "落地路径非法");
  }

  let signature: SignatureState = "missing";
  if (raw.sig !== undefined) {
    const signed = signParams(raw.path ?? "", raw as Record<string, string>);
    signature = signed === raw.sig ? "valid" : "invalid";
  } else {
    add("signature_missing", "sig", "缺少签名,只能按弱归因处理");
  }
  if (signature === "invalid") add("signature_mismatch", "sig", "签名不匹配,参数可能被改写");

  let channel = raw.ch ?? "";
  let fallback = false;
  if (!CHANNEL_RE.test(channel) || !policy.channels.includes(channel)) {
    add(channel === "" ? "channel_missing" : "channel_unknown", "ch", `渠道码不可用:${channel || "空"}`);
    channel = "direct";
    fallback = true;
  }

  let campaignId = raw.cid ?? null;
  if (campaignId !== null && !policy.campaignIds.includes(campaignId)) {
    add("campaign_unknown", "cid", `活动不在开放列表:${campaignId}`);
    campaignId = null;
  }

  let inviterId = raw.inv ?? null;
  if (inviterId !== null && !INVITER_RE.test(inviterId)) {
    add("inviter_invalid", "inv", "邀请人编号格式非法");
    inviterId = null;
  }

  if (signature === "invalid") {
    campaignId = null;
    inviterId = null;
    fallback = true;
  }
  return {
    channel, campaignId, inviterId, skuId: raw.sku ?? null, signature,
    attributable: signature !== "invalid" && !fallback, fallback, issues,
  };
}

export type MismatchCode = "channel" | "campaign" | "campaign_lost" | "inviter" | "sku";

export interface Consistency {
  consistent: boolean; mismatches: MismatchCode[]; action: "accept" | "fallback";
}

// 分享卡片是用户看到的承诺,落地页是实际打开的事实;两者不一致时以落地页为准,
// 但邀请人与活动冲突必须放弃归因,不能在两个候选人里猜一个。
export function compareCard(card: ShareCard, landing: Landing): Consistency {
  const mismatches: MismatchCode[] = [];
  if (card.channel !== landing.channel) mismatches.push("channel");
  if (card.campaignId !== landing.campaignId) {
    mismatches.push(landing.campaignId === null ? "campaign_lost" : "campaign");
  }
  if (card.inviterId !== null && landing.inviterId !== null && card.inviterId !== landing.inviterId) {
    mismatches.push("inviter");
  }
  if (card.skuId !== null && landing.skuId !== null && card.skuId !== landing.skuId) {
    mismatches.push("sku");
  }
  const blocking =
    mismatches.includes("channel") ||
    mismatches.includes("campaign") ||
    mismatches.includes("inviter");
  return {
    consistent: mismatches.length === 0,
    mismatches,
    action: blocking ? "fallback" : "accept",
  };
}

export interface Touch {
  kind: "click" | "activate"; at: number; channel: string;
  campaignId: string | null; inviterId: string | null; shareId: string;
}

export type AttributionSource = "invite" | "activate" | "click" | "organic";
export type AttributionReason = "within_window" | "expired" | "no_touch" | "future_touch";

export interface Attribution {
  source: AttributionSource; channel: string; campaignId: string | null;
  inviterId: string | null; shareId: string | null;
  reason: AttributionReason; ageMs: number | null;
}

function tierOf(touch: Touch): number {
  if (touch.inviterId !== null) return 3;
  return touch.kind === "activate" ? 2 : 1;
}

// 归因窗口从触点算到支付成功,不看安装包内的本地时间;
// 同一优先级取最后一次触点,邀请人优先于激活、激活优先于点击。
export function attributeOrder(
  touches: Touch[],
  orderAt: number,
  policy: SharePolicy,
): Attribution {
  if (!Number.isFinite(orderAt)) throw new Error("下单时间非法");
  const past = touches.filter((touch) => Number.isFinite(touch.at) && touch.at <= orderAt);
  const fresh = past.filter((touch) => {
    const limit = touch.kind === "activate" ? policy.activateWindowMs : policy.clickWindowMs;
    return orderAt - touch.at <= limit;
  });
  fresh.sort((a, b) => tierOf(a) - tierOf(b) || a.at - b.at);
  const winner = fresh.length > 0 ? fresh[fresh.length - 1] : null;
  if (winner !== null) {
    return {
      source: winner.inviterId !== null ? "invite" : winner.kind,
      channel: winner.channel,
      campaignId: winner.campaignId,
      inviterId: winner.inviterId,
      shareId: winner.shareId,
      reason: "within_window",
      ageMs: orderAt - winner.at,
    };
  }
  const last = past.length > 0 ? past[past.length - 1] : null;
  return {
    source: "organic",
    channel: "direct",
    campaignId: null,
    inviterId: null,
    shareId: null,
    reason: last !== null ? "expired" : touches.length > 0 ? "future_touch" : "no_touch",
    ageMs: last === null ? null : orderAt - last.at,
  };
}

签名覆盖的字段是白名单。 SIGNED_KEYS 只包含归因参数,渠道自行附加的 utm_source、from 不参与计算。否则渠道每加一个统计参数,签名就会失效,落地页会把一次正常分享判成篡改。

解析结果区分「不可归因」与「页面降级」。 attributable 表示这次访问能不能记到渠道或邀请人;fallback 表示页面是否要退回常规页。签名不匹配时两者都为真:页面可以打开,但归因整条作废。渠道未知时也一样,用户不该为参数错误买单。

卡片与落地页的一致性检查不替用户做选择。 compareCard 只报告差异,并给出 accept 或 fallback。渠道不一致、活动不一致、邀请人不一致都会触发 fallback;活动只是丢失(campaign_lost)则仍然接受页面。这条区分很重要:丢失是渠道能力问题,冲突才可能是参数被改写。

归因窗口:三个时间点,三条规则

归因不是「最后一次点击」,而是一组带优先级和有效期的规则:

触点窗口说明
邀请绑定以绑定时间为准由 9.2 的绑定模块裁决,优先于所有分享触点
激活24 小时安装后首次启动,视为本次安装的来源
点击7 天未安装用户的点击,允许在窗口内完成安装与首单

同一优先级取最后一次触点;不同优先级按「邀请人 > 激活 > 点击」排序。超过窗口的触点不参与归因,但会被记录下来:返回 organic 的同时给出 reason: "expired" 和 ageMs,复盘时能回答「这次为什么没算给分享人」。另一个边界是订单时间早于触点时间:系统时间回拨、服务端补单、离线队列重放都会造成倒挂,attributeOrder 直接忽略未来触点,返回 future_touch,而不是产生负的 ageMs。

测试与真实输出

本机运行结果如下:

$ node --test test/growth/share.test.ts
✔ 合法卡片生成全部归因参数与签名 (1.050542ms)
✔ 没有邀请人与商品时只生成渠道参数 (0.145292ms)
✔ 渠道不在白名单时拒绝生成 (0.087333ms)
✔ 活动不在开放列表时拒绝生成 (0.091167ms)
✔ 落地路径带查询串或协议头时拒绝生成 (0.081333ms)
✔ 签名只覆盖归因参数,附加 utm 参数不影响校验 (0.136667ms)
✔ 签名被改写时丢弃邀请人与活动 (0.088875ms)
✔ 参数被渠道剥掉时回落到 direct 并记录原因 (0.066708ms)
✔ 未知活动只丢活动,不影响渠道与邀请人 (0.063334ms)
✔ 分享卡与落地页完全一致时接受 (0.142583ms)
✔ 落地页丢了活动时记为 campaign_lost 但仍接受页面 (0.072333ms)
✔ 邀请人被替换时放弃归因而不是二选一 (0.052834ms)
✔ 点击窗口内按最后一次点击归因 (0.13725ms)
✔ 超出点击窗口的触点不参与归因 (0.055584ms)
✔ 激活归因优先于点击归因 (0.0735ms)
✔ 带邀请人的触点优先级最高 (0.068084ms)
✔ 订单时间早于全部触点时不产生倒挂归因 (0.044875ms)
✔ 没有触点时返回自然流量 (0.062375ms)
✔ 非法下单时间被拒绝 (0.202459ms)
✔ 同一输入生成稳定签名 (0.048958ms)
ℹ tests 20
ℹ suites 0
ℹ pass 20
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 177.244458

二十条用例覆盖了生成、解析、一致性与归因四组行为。其中三条最值得盯住:签名只覆盖归因参数(渠道加 utm_* 不影响校验)、签名被改写时丢弃邀请人与活动、超出窗口的触点不参与归因但保留年龄。

一次「卡片说 A、落地页算 B」的复盘

某交易客户端的分享卡片由商详页生成,参数里带 cid。卡片生成后在本地缓存 30 分钟,避免用户每次打开分享面板都请求一次。一次活动切换期间,运营在 10:00 下线了旧活动、上线了新活动。10:10,一个用户打开了 9:50 生成的分享面板,卡片仍然指向旧活动。落地页按 cid 找不到活动,端上为了「不让用户困惑」,做了一个看起来很体贴的兜底:把落地页的 cid 改回卡片里的旧活动 id,继续展示活动页。

结果出现了三个问题:

  1. 旧活动已经结束,价格与库存按新活动计算,页面文案却还是旧的。
  2. 归因记录了旧活动,活动复盘把新活动的订单算到了旧活动头上。
  3. 部分用户点进去时旧活动页已经被下线,直接看到 404。

复盘发现根因不是缓存,而是端上把「卡片」当成了事实来源。卡片是用户看到的承诺,落地页才是实际打开的事实;两者冲突时,只能以落地页为准,并且把冲突记录下来。修复做了三件事:分享面板不再缓存活动参数,每次打开重新读取;落地页遇到未知活动时丢弃 cid、保留 ch 与 inv,跳到常规商品页;一致性检查把「活动冲突」与「活动丢失」分开处理,前者放弃归因,后者只丢活动。回归后,同类问题不再产生错误归因。

这个复盘留下的规则是:分享模块不修复参数,只报告参数。 任何「帮用户把参数改回去」的兜底,都会把可观测的降级变成不可解释的错账。

小结

分享链路的工程核心不是生成一个好看的链接,而是让五个归因参数在渠道、短链、安装包之间丢失时仍能安全降级。参数按白名单生成并签名,解析时区分「页面降级」与「归因作废」;卡片与落地页冲突时以落地页为准;归因按邀请人、激活、点击的优先级和各自窗口计算,过期与倒挂都要留下原因。

下一节讨论分享之后的事:邀请关系在什么时候绑定,绑定能不能撤销,以及如何防止自己邀请自己。

阅读导航:上一节:8.3 权益降级的边界 · 下一节:9.2 邀请关系与绑定时机 。

常见错误与边界

  • 把端上校验和当成防伪签名,任何人改完参数重算一次就能通过。
  • 签名覆盖全部查询参数,渠道附加一个 utm_* 就导致整条归因失效。
  • 参数缺失时直接报错或跳 404,用户为渠道的能力买单。
  • 落地页发现活动不存在时,用卡片里的旧活动 id「修复」参数。
  • 把 campaign_lost 与 campaign 冲突当成同一种情况处理。
  • 归因只取最后一次点击,忽略激活与邀请人的更高优先级。
  • 不记录过期触点的年龄,复盘时无法解释「为什么没算给分享人」。
  • 接受订单时间早于触点的数据,产生负的归因年龄。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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