分享链接是一份会过期的承诺
用户在分享面板里看到一张卡片:标题、图片、一句「我在参加秋季活动」。点开后,他期望看到同一件商品、同一个活动、同一个价格。增长团队则期望这次点击能被记到正确的渠道、活动和邀请人名下。
这两个期望由同一串参数承载,却经常被两拨人分别修改:客户端为了卡片好看,运营为了活动能追踪,渠道为了统计自行拼接 utm_*。结果是一串谁都能改、谁都不完全负责的查询参数。分享链路的失败不会像支付那样立刻造成资金损失,它的代价是归因失真:拉新成本记到了自然流量,邀请人拿不到奖励,活动复盘得出错误结论。更糟的是,失真通常不可逆——用户已经安装、注册、下单,链路无法重放。
所以这一节不讨论「分享面板怎么设计」,只回答四个工程问题:
- 哪些参数必须带,谁生成,端上能校验到什么程度。
- 参数在渠道、短链、安装包之间丢失后,回落到哪里。
- 分享卡片与落地页不一致时,以谁为准。
- 点击、激活、首单三个时间点,归因窗口分别怎么算。
五个参数与一条校验链
先固定参数表。参数越少越好,但每一个都要有明确的归属和丢失后的行为。
| 参数 | 含义 | 生成方 | 端上校验 | 丢失后的行为 |
|---|---|---|---|---|
ch | 渠道码 | 分享面板白名单 | 格式 + 白名单 | 归为 direct,页面照常打开 |
cid | 活动 id | 活动配置 | 在开放列表内 | 丢活动,保留渠道与邀请人 |
inv | 邀请人 id | 当前登录态 | 格式校验 | 丢邀请关系,不猜邀请人 |
sku | 商品 id | 商详页 | 可空 | 落到活动默认商品 |
ts | 生成时间 | 端上 | 有限数字 | 无法判断链接年龄,按弱归因处理 |
sig | 校验和 | 端上示意 / 服务端正式 | 重算比对 | 整条归因作废 |
第一,端上签名只能发现「参数被改坏」,不能防伪造。 本节示例用 FNV-1a 计算校验和,目的是让「渠道把 inv 改了一位」这类问题在解析时暴露出来。任何人读过代码都能重算这个值,所以生产环境必须由服务端下发签名,或者直接使用服务端短链。把端上校验和当成安全机制,是这类模块最常见的误用。
第二,ts 不是用来排序的,是用来判断链接年龄的。 分享卡片可能被保存、转发、截图识别,几天后才被打开。没有生成时间,端上无法区分「刚分享的新链接」和「上一次活动的旧链接」,只能全部接受。
参数会在三个地方丢
分享参数不是从分享面板直接走到落地页的,中间至少经过三个可能改写它的地方:
- 渠道改写:社交平台会把链接包进自己的跳转页,部分平台只保留白名单参数,
inv、cid可能被剥掉。 - 短链与二维码:短链服务通常只做 302,不保证查询串完整;二维码容量有限,长参数会被截断。
- 安装与重启:未安装用户先落到下载页,安装后首次打开,启动参数可能已经丢失,只剩剪贴板或 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,继续展示活动页。
结果出现了三个问题:
- 旧活动已经结束,价格与库存按新活动计算,页面文案却还是旧的。
- 归因记录了旧活动,活动复盘把新活动的订单算到了旧活动头上。
- 部分用户点进去时旧活动页已经被下线,直接看到 404。
复盘发现根因不是缓存,而是端上把「卡片」当成了事实来源。卡片是用户看到的承诺,落地页才是实际打开的事实;两者冲突时,只能以落地页为准,并且把冲突记录下来。修复做了三件事:分享面板不再缓存活动参数,每次打开重新读取;落地页遇到未知活动时丢弃 cid、保留 ch 与 inv,跳到常规商品页;一致性检查把「活动冲突」与「活动丢失」分开处理,前者放弃归因,后者只丢活动。回归后,同类问题不再产生错误归因。
这个复盘留下的规则是:分享模块不修复参数,只报告参数。 任何「帮用户把参数改回去」的兜底,都会把可观测的降级变成不可解释的错账。
小结
分享链路的工程核心不是生成一个好看的链接,而是让五个归因参数在渠道、短链、安装包之间丢失时仍能安全降级。参数按白名单生成并签名,解析时区分「页面降级」与「归因作废」;卡片与落地页冲突时以落地页为准;归因按邀请人、激活、点击的优先级和各自窗口计算,过期与倒挂都要留下原因。
下一节讨论分享之后的事:邀请关系在什么时候绑定,绑定能不能撤销,以及如何防止自己邀请自己。
阅读导航:上一节:8.3 权益降级的边界 · 下一节:9.2 邀请关系与绑定时机 。
常见错误与边界
- 把端上校验和当成防伪签名,任何人改完参数重算一次就能通过。
- 签名覆盖全部查询参数,渠道附加一个
utm_*就导致整条归因失效。 - 参数缺失时直接报错或跳 404,用户为渠道的能力买单。
- 落地页发现活动不存在时,用卡片里的旧活动 id「修复」参数。
- 把
campaign_lost与campaign冲突当成同一种情况处理。 - 归因只取最后一次点击,忽略激活与邀请人的更高优先级。
- 不记录过期触点的年龄,复盘时无法解释「为什么没算给分享人」。
- 接受订单时间早于触点的数据,产生负的归因年龄。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。