《移动游戏产品的架构·商业化与发行工程》11.2 深链路由与冷启动恢复

深链的价值不在能不能打开,而在打开之后能否落到正确页面。本节以《潮汐消消》弱联网与《潮汐方舟》强联网为对照,拆解 scheme 白名单、活动与商品及内容三类目标、版本与登录的判定顺序,以及冷启动待处理链接的 TTL、重试、恢复边界与恢复失败时的丢弃口径。

深链要解决的是「打开之后去哪」

推送把用户带回应用,深链决定用户落在哪个页面。两者经常被当成一件事,其实边界很清楚:推送负责触达,深链负责路由。推送到达了但深链落错页,用户看到的是首页,运营以为活动没人点;深链正确但推送没到,页面再准也没有流量。

深链的完整链路是「消息或网页 → 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 参数则适合「清洗 + 截断」,而不是直接拒绝:运营偶尔会带空格、斜杠或中文,直接拒绝会让一条正常活动链接打不开;清洗成安全字符并限制长度,既保留归因能力,也不把脏数据带进分析表。

路由判定:版本、登录、目标存在性

解析成功不等于能落地。路由层至少要回答三个问题:

  1. 当前客户端版本认不认识这个目标? 老版本可能没有新活动页,也不知道新商品档位。
  2. 这个目标在当前版本里还存在吗? 活动下线、商品下架、内容删除都会让 id 失效。
  3. 用户是否具备访问条件? 活动页和商品页通常需要登录,内容页可以游客阅读。

这三件事有优先级。案例实现里的顺序是:

优先级判定结果理由
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 召回活动的频控与退订合规 。

常见错误与边界

  1. 把推送到达当成深链落地。消息到了设备不等于用户落到正确页面,两个指标要分开看。
  2. 解析层不做 scheme 和 host 白名单。任意链接都能唤起应用,钓鱼和参数注入的风险会直接暴露给用户。
  3. 自定义 scheme 和 https 各写一套路由。两套逻辑迟早会分叉,应该先归一成同一目标结构。
  4. 目标 id 不校验长度和字符。超长或带斜杠的 id 会破坏路径拼接,也可能被用于注入。
  5. campaign 参数直接拒绝特殊字符。运营的正常链接会因为一个空格整条失效,应该清洗后截断。
  6. 先判断登录、再判断版本。老客户端登录完仍然没有目标页面,用户会经历两次失败。
  7. 目标下线后仍然跳转。活动结束、商品下架都会让 id 失效,应该回首页并记录原因。
  8. 把待处理链接放在全局变量里。进程被杀或登录重置后链接就丢了,冷启动恢复率会很低。
  9. 恢复时不重新做路由判定。用户在等待期间可能已升级、已登录,或目标已下线,旧判定不能直接复用。
  10. 待处理链接没有 TTL。几天前的活动链接在启动时突然弹出,用户不知道发生了什么。
  11. 重试次数不设上限。每次启动都恢复同一个失败链接,会形成稳定的打扰和日志噪声。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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