深链要解决的是「打开之后去哪」
推送把用户带回应用,深链决定用户落在哪个页面。两者经常被当成一件事,其实边界很清楚:推送负责触达,深链负责路由。推送到达了但深链落错页,用户看到的是首页,运营以为活动没人点;深链正确但推送没到,页面再准也没有流量。
深链的完整链路是「消息或网页 → scheme / universal link → 应用启动 → 解析链接 → 路由判定 → 登录 / 升级 → 落地页」,其中任何一步失败,用户都会回到首页。首页不是错误页,但它会吞掉所有上下文:用户不知道自己要参加哪个活动、买哪个商品、看哪篇内容,运营也拿不到有效的落地数据。
本节只讨论「解析 + 路由 + 冷启动恢复」。推送通道与到达率在 11.1,召回频控与退订在 11.3,不在这里重复。
一条链接的三种来源
同样一条深链,来源不同,可信度不同:
| 来源 | 典型形态 | 可信度 | 主要风险 |
|---|---|---|---|
| 推送 | 服务端生成,带 campaign 参数 | 较高 | 令牌或消息被转发后仍可能被复用 |
| 网页 / 二维码 | https 链接、扫码 | 中 | 参数可被手工改写,活动 id 可被枚举 |
| 应用内 | 站内 banner、活动页跳转 | 较高 | 版本内路由表不一致 |
推送链接由服务端生成,campaign 参数通常可以直接用于归因;网页和二维码面向公网,任何人都能拼接参数。因此解析层不能假设「链接来自自家 App」就跳过校验,scheme、host、目标 id、campaign 长度都要检查。
解析:白名单与最小参数
解析层的第一原则是白名单,而不是黑名单。只允许已知的 scheme 和 host,未知的一律拒绝;不要试图枚举「哪些 scheme 危险」,因为攻击面永远比清单长。
自定义 scheme 的形态通常是:
tide://activity/summer?campaign=recall_w1
https 链接的形态通常是:
https://link.tide.example/activity/summer?campaign=recall_w1
两种形态在解析后应该归一成同一个目标结构,否则路由层要写两套判断。归一时要注意:自定义 scheme 的 host 位置放的是路由类型,https 的 host 是域名,路由类型在第一个路径段。这个差异必须在解析层消化掉。
目标 id 需要约束长度、空白和斜杠。过长的 id 可能是注入尝试,带空白或斜杠的 id 会破坏后续的路径拼接。campaign 参数则适合「清洗 + 截断」,而不是直接拒绝:运营偶尔会带空格、斜杠或中文,直接拒绝会让一条正常活动链接打不开;清洗成安全字符并限制长度,既保留归因能力,也不把脏数据带进分析表。
路由判定:版本、登录、目标存在性
解析成功不等于能落地。路由层至少要回答三个问题:
- 当前客户端版本认不认识这个目标? 老版本可能没有新活动页,也不知道新商品档位。
- 这个目标在当前版本里还存在吗? 活动下线、商品下架、内容删除都会让 id 失效。
- 用户是否具备访问条件? 活动页和商品页通常需要登录,内容页可以游客阅读。
这三件事有优先级。案例实现里的顺序是:
| 优先级 | 判定 | 结果 | 理由 |
|---|---|---|---|
| 1 | 版本低于最低版本 | upgrade-required | 老客户端可能根本不认识目标,先升级 |
| 2 | 目标不在当前清单 | fallback 到首页 | 目标已下线,继续跳转只会 404 |
| 3 | 需要登录但未登录 | login-required | 保留目标,登录后继续 |
| 4 | 以上都通过 | ready | 直接进入落地页 |
版本判定放在登录之前,是因为登录之后仍然可能进不去:老版本没有对应的页面组件,用户登录完还是回到首页,体验更差。先提示升级,至少用户知道发生了什么。
登录判定放在最后,因为它不是失败,而是「暂停」。login-required 的路由应该保留完整目标信息,登录成功后继续,而不是把用户丢到首页再让他重新点一次。
冷启动:把待处理链接活到登录之后
冷启动是深链最容易丢上下文的场景:用户点了推送,应用被杀掉,启动后先闪屏、再登录、再加载配置,等真正能路由时,原始链接已经不知道去哪了。
常见的错误做法是把链接塞进一个全局变量。进程被杀,变量就没了;即使进程还活着,登录流程也可能重置应用状态。正确做法是把待处理链接持久化,带上接收时间、重试次数和来源,等应用具备路由条件后再恢复。
待处理链接需要两个约束:
- TTL:链接只在有限时间内有意义。一个三天前的活动链接,即使活动还在,用户也已经忘了自己为什么点它;过期后恢复只会造成困惑。
- 重试次数:登录失败、网络失败、版本升级都可能让恢复中断。重试次数用完就丢弃,避免每次启动都弹同一个链接。
恢复时还要重新做一次路由判定。用户在待处理期间可能已经升级、已经登录,也可能活动刚好下线;不能拿接收时的判定结果直接执行。
代码:解析、路由与恢复
下面的实现把三件事拆开:parseDeepLink 只负责把字符串变成结构化目标,resolveRoute 只负责判定,recoverPendingLink 只负责冷启动恢复。三者都不读写存储,也不调用登录或升级接口。
// file: src/biz/deeplink.ts
// tier: L1
// verify: node --test test/biz/deeplink.test.ts
export type RouteKind = "activity" | "store" | "post" | "home";
export type LinkSource = "push" | "web" | "qr" | "inapp";
export type RouteStatus = "ready" | "login-required" | "upgrade-required" | "fallback";
export type RouteTarget =
| { readonly kind: "activity"; readonly id: string }
| { readonly kind: "store"; readonly sku: string }
| { readonly kind: "post"; readonly id: string }
| { readonly kind: "home" };
export interface LinkPolicy {
readonly schemes: readonly string[];
readonly hosts: readonly string[];
readonly maxLength: number;
readonly campaignMaxLength: number;
}
export interface DeepLink {
readonly source: LinkSource;
readonly campaign: string;
readonly target: RouteTarget;
readonly raw: string;
}
export interface AppContext {
readonly loggedIn: boolean;
readonly appVersion: number;
readonly minVersion: number;
readonly activityIds: readonly string[];
readonly skuIds: readonly string[];
readonly postIds: readonly string[];
}
export interface RouteDecision {
readonly kind: RouteKind;
readonly id: string | null;
readonly campaign: string;
readonly status: RouteStatus;
readonly requiresLogin: boolean;
readonly requiresUpgrade: boolean;
readonly reason: string;
}
export interface PendingLink {
readonly link: DeepLink;
readonly receivedAtMs: number;
readonly attempts: number;
}
export interface RecoveryPolicy {
readonly ttlMs: number;
readonly maxAttempts: number;
}
export interface RecoveryResult {
readonly action: "resume" | "discard";
readonly reason: string;
readonly link: DeepLink | null;
readonly attempts: number;
}
const MAX_TARGET_ID_LENGTH = 128;
function decodeTargetId(segment: string | null): string {
if (segment === null) throw new RangeError("deep link target is missing an id");
let id: string;
try {
id = decodeURIComponent(segment);
} catch {
throw new RangeError(`deep link target id is not valid percent-encoding: ${segment}`);
}
const trimmed = id.trim();
if (trimmed === "") throw new RangeError("deep link target id must not be empty");
if (trimmed.length > MAX_TARGET_ID_LENGTH) {
throw new RangeError(`deep link target id is too long: ${trimmed.length}`);
}
if (/[\s/]/.test(trimmed)) {
throw new RangeError(`deep link target id contains whitespace or a slash: ${trimmed}`);
}
return trimmed;
}
function sanitizeCampaign(value: string | null, maxLength: number): string {
if (value === null) return "organic";
const cleaned = value.trim().replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, maxLength);
return cleaned === "" ? "organic" : cleaned;
}
function buildTarget(kindSegment: string, idSegment: string | null): RouteTarget {
switch (kindSegment) {
case "activity":
return { kind: "activity", id: decodeTargetId(idSegment) };
case "store":
return { kind: "store", sku: decodeTargetId(idSegment) };
case "post":
return { kind: "post", id: decodeTargetId(idSegment) };
case "home":
return { kind: "home" };
default:
throw new RangeError(`unknown deep link target kind: ${kindSegment}`);
}
}
export function parseDeepLink(
raw: string,
policy: LinkPolicy,
source: LinkSource = "push",
): DeepLink {
if (typeof raw !== "string" || raw.trim() === "") {
throw new RangeError("deep link must not be empty");
}
const trimmed = raw.trim();
if (trimmed.length > policy.maxLength) {
throw new RangeError(`deep link exceeds ${policy.maxLength} characters`);
}
if (!Number.isInteger(policy.campaignMaxLength) || policy.campaignMaxLength < 1) {
throw new RangeError("campaignMaxLength must be a positive integer");
}
let url: URL;
try {
url = new URL(trimmed);
} catch {
throw new RangeError(`deep link is not a valid URL: ${trimmed}`);
}
const scheme = url.protocol.replace(/:$/, "").toLowerCase();
let kindSegment: string;
let idSegment: string | null;
if (scheme === "http" || scheme === "https") {
const host = url.host.toLowerCase();
if (!policy.hosts.includes(host)) {
throw new RangeError(`deep link host is not allowed: ${host}`);
}
const parts = url.pathname.split("/").filter((part) => part !== "");
kindSegment = parts[0] ?? "";
idSegment = parts[1] ?? null;
} else {
if (!policy.schemes.includes(scheme)) {
throw new RangeError(`deep link scheme is not allowed: ${scheme}`);
}
kindSegment = url.host.toLowerCase();
idSegment = url.pathname.replace(/^\/+/, "") || null;
}
return {
source,
campaign: sanitizeCampaign(url.searchParams.get("campaign"), policy.campaignMaxLength),
target: buildTarget(kindSegment, idSegment),
raw: trimmed,
};
}
function targetId(target: RouteTarget): string | null {
return target.kind === "home" ? null : target.kind === "store" ? target.sku : target.id;
}
function needsLogin(kind: RouteKind): boolean {
return kind === "activity" || kind === "store";
}
function targetAvailable(target: RouteTarget, context: AppContext): boolean {
switch (target.kind) {
case "home":
return true;
case "activity":
return context.activityIds.includes(target.id);
case "store":
return context.skuIds.includes(target.sku);
case "post":
return context.postIds.includes(target.id);
}
}
export function resolveRoute(link: DeepLink, context: AppContext): RouteDecision {
if (!Number.isFinite(context.appVersion) || !Number.isFinite(context.minVersion)) {
throw new RangeError("appVersion and minVersion must be finite numbers");
}
const target = link.target;
const requiresLogin = needsLogin(target.kind);
const requiresUpgrade = context.appVersion < context.minVersion;
const base = {
kind: target.kind,
id: targetId(target),
campaign: link.campaign,
requiresLogin,
requiresUpgrade,
};
if (requiresUpgrade) {
return { ...base, status: "upgrade-required", reason: "app-version-below-minimum" };
}
if (!targetAvailable(target, context)) {
return { ...base, kind: "home", id: null, status: "fallback", reason: "target-unavailable" };
}
if (requiresLogin && !context.loggedIn) {
return { ...base, status: "login-required", reason: "login-required" };
}
return { ...base, status: "ready", reason: "resolved" };
}
export function recoverPendingLink(
pending: PendingLink,
context: AppContext,
policy: RecoveryPolicy,
nowMs: number,
): RecoveryResult {
if (!Number.isFinite(pending.receivedAtMs) || !Number.isFinite(nowMs)) {
throw new RangeError("receivedAtMs and nowMs must be finite numbers");
}
if (nowMs < pending.receivedAtMs) {
throw new RangeError("nowMs cannot be earlier than receivedAtMs");
}
if (!Number.isInteger(pending.attempts) || pending.attempts < 0) {
throw new RangeError("attempts must be a non-negative integer");
}
if (!Number.isFinite(policy.ttlMs) || policy.ttlMs <= 0) {
throw new RangeError("ttlMs must be a positive finite number");
}
if (!Number.isInteger(policy.maxAttempts) || policy.maxAttempts < 1) {
throw new RangeError("maxAttempts must be a positive integer");
}
if (nowMs - pending.receivedAtMs > policy.ttlMs) {
return { action: "discard", reason: "expired", link: null, attempts: pending.attempts };
}
if (pending.attempts >= policy.maxAttempts) {
return { action: "discard", reason: "attempts-exhausted", link: null, attempts: pending.attempts };
}
const decision = resolveRoute(pending.link, context);
if (decision.status === "upgrade-required") {
return { action: "discard", reason: "upgrade-required", link: null, attempts: pending.attempts };
}
if (decision.status === "fallback") {
return { action: "discard", reason: "target-unavailable", link: null, attempts: pending.attempts };
}
return { action: "resume", reason: decision.status, link: pending.link, attempts: pending.attempts + 1 };
}
export function formatRouteDecision(decision: RouteDecision): string {
const target = decision.id === null ? decision.kind : `${decision.kind}:${decision.id}`;
return `${target} status=${decision.status} campaign=${decision.campaign} login=${decision.requiresLogin} upgrade=${decision.requiresUpgrade} reason=${decision.reason}`;
}
代码里有几个刻意的边界。
第一,解析层先校验长度,再交给 new URL。超长字符串在部分运行时里会拖慢解析,甚至触发异常;先做长度上限,成本更低,也避免把超长输入带进日志。
第二,自定义 scheme 和 https 走不同的路径提取规则,但最后都收敛到 RouteTarget。路由层不需要知道链接来自哪种形态,只关心目标类型和 id。
第三,campaign 清洗用替换而不是拒绝。空格、斜杠、中文都会变成下划线,再按上限截断;空串回落到 organic。这样运营侧的正常链接不会因为一个特殊字符整条失效。
第四,路由判定是纯函数,输入是链接和上下文,输出是判定结果。它不执行跳转,也不修改上下文;客户端拿到 login-required 后自己决定是弹登录还是先缓存。
第五,冷启动恢复只恢复 ready 和 login-required。upgrade-required 直接丢弃,因为升级不是一次恢复能完成的事;fallback 也丢弃,因为目标已经不存在,恢复只会把用户带回首页,没有增量价值。
第六,TTL 和重试次数都在策略里,不写死在函数里。消消和方舟的召回节奏不同,TTL 可以不同;把策略外置,测试和线上可以共用同一套逻辑。
双案例对照
消消是弱联网、首包 120MB,冷启动相对快,但国内安卓 55% 的用户分布在多家 OEM 通道上,深链来源更杂,scheme 白名单和二维码参数校验要更严。它的召回 7 日回流率是 9.5%,深链落错页会直接吃掉这部分回流。
方舟是强联网、首包 350MB,iOS 占 45%,冷启动更慢、登录态更重,待处理链接的 TTL 和重试次数要留得更宽。它的召回 7 日回流率是 6.8%,单次回流的价值更高,落错页的损失也更大。
取舍:白名单越严,运营越慢
白名单能挡住伪造链接,但代价是每次新增活动域名、scheme 或路由类型,都要走一次客户端发版或配置下发。如果运营每周有两个活动(消消的案例设定是每周 2 个),而白名单只能随版本更新,活动上线节奏就会被客户端卡住。
常见的折中是「域名白名单 + 路径白名单分级」:域名必须严格白名单,路径类型可以走服务端配置,但配置项本身要有审核和回滚。这样新增活动不需要发版,新增域名仍然需要发版。代价是配置系统多了一层权限和审计,出问题时定位链路也更长。
另一个取舍是冷启动持久化。保存待处理链接意味着本地多一份用户行为数据,隐私披露和清理策略都要跟上;不保存则冷启动恢复率会明显下降。案例里的选择是保存,但只保存目标 id、来源、campaign 和时间戳,不保存完整 URL,也不保存用户标识。
小结
- 推送负责触达,深链负责路由;到达率和落地率要分开观测。
- 解析层用 scheme 与 host 白名单,自定义 scheme 和 https 归一成同一目标结构。
- 路由判定按版本、目标存在性、登录条件的顺序执行,避免登录后才发现进不去。
- 冷启动要把待处理链接持久化,并用 TTL 与重试次数限制恢复范围。
- 白名单和持久化都提高可靠性,也分别带来运营发版成本和隐私治理成本。
阅读导航:上一节:11.1 推送通道与到达率 · 下一节:11.3 召回活动的频控与退订合规 。
常见错误与边界
- 把推送到达当成深链落地。消息到了设备不等于用户落到正确页面,两个指标要分开看。
- 解析层不做 scheme 和 host 白名单。任意链接都能唤起应用,钓鱼和参数注入的风险会直接暴露给用户。
- 自定义 scheme 和 https 各写一套路由。两套逻辑迟早会分叉,应该先归一成同一目标结构。
- 目标 id 不校验长度和字符。超长或带斜杠的 id 会破坏路径拼接,也可能被用于注入。
- campaign 参数直接拒绝特殊字符。运营的正常链接会因为一个空格整条失效,应该清洗后截断。
- 先判断登录、再判断版本。老客户端登录完仍然没有目标页面,用户会经历两次失败。
- 目标下线后仍然跳转。活动结束、商品下架都会让 id 失效,应该回首页并记录原因。
- 把待处理链接放在全局变量里。进程被杀或登录重置后链接就丢了,冷启动恢复率会很低。
- 恢复时不重新做路由判定。用户在等待期间可能已升级、已登录,或目标已下线,旧判定不能直接复用。
- 待处理链接没有 TTL。几天前的活动链接在启动时突然弹出,用户不知道发生了什么。
- 重试次数不设上限。每次启动都恢复同一个失败链接,会形成稳定的打扰和日志噪声。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。