《互联网产品的客户端架构·端形态与体验基础》6.3 深链与状态恢复

深链是外部世界进入客户端的唯一入口,也是最容易把用户带到空白页的地方。本节给出一个带版本、时间戳与状态白名单的 TypeScript 深链编解码器,按格式、时效、路由、权限的顺序恢复,并为过期、下线、坏状态与未登录分别设计兜底,最后复盘一次白屏事故。

一条深链要在三秒内恢复什么

用户从聊天窗口点开一条「回声」订单链接,应用可能已经被杀掉,可能停在后台,也可能刚完成一次版本升级。深链恢复要在这三种情况下都给出同一个结果:打开订单详情,保留必要的上下文,并且让返回键有去处。它不能假设进程还活着,也不能假设路由还存在。

深链恢复最容易犯的错误,是把“解析 URL”当成全部工作。真正需要恢复的是四样东西:目标路由与参数、必要的查询上下文、可以安全回填的轻量状态、以及一个可用的返回栈。前两样决定“打开哪一页”,后两样决定“打开后能不能继续用”。

先给结论:深链格式要带版本,恢复顺序要固定,失败要有兜底,敏感状态永远不编码。下面用一个不依赖 DOM 的编解码器把这四件事固定下来。

深链格式:版本、时间与状态白名单

「回声」的深链长这样:echo://open/v1/orders/42?ref=push&_ts=1760000000000&_s=...。v1 是协议版本,_ts 是生成时间,_s 是经过 URI 编码的 JSON 状态。路径部分复用路由模式,动态段写作 :id,这样编码与恢复可以共用同一套路由表。

// file: src/router/deep-link.ts
// tier: L1
// verify: node --test test/router/deep-link.test.ts

export interface RouteSpec {
  name: string;
  /** 以 / 开头的路径模式,动态段写作 :id */
  pattern: string;
  requiresAuth?: boolean;
  /** 已下线的路由,深链应落到兜底页 */
  retired?: boolean;
}

export interface EncodeInput {
  route: RouteSpec;
  params: Record<string, string>;
  query?: Record<string, string>;
  /** 只放轻量状态;敏感键会被丢弃 */
  state?: Record<string, string>;
  now: number;
}

export interface EncodedLink {
  link: string;
  /** 编码阶段被丢弃的状态键 */
  dropped: string[];
}

export type FallbackReason =
  | "bad-link" | "expired" | "future-timestamp"
  | "unknown-route" | "retired" | "bad-state";

export type RestoreOutcome =
  | { kind: "restore"; route: string; params: Record<string, string>; query: Record<string, string>; state: Record<string, string> }
  | { kind: "auth"; route: string; query: Record<string, string>; state: Record<string, string>; to: string }
  | { kind: "fallback"; reason: FallbackReason; detail: string; to: string };

export interface RestoreContext {
  catalog: RouteSpec[];
  now: number;
  ttlMs: number;
  signedIn: boolean;
  fallback: string;
  loginPath?: string;
  maxStateKeys?: number;
}

export interface SavedDraft {
  routeKey: string;
  values: Record<string, string>;
  savedAt: number;
}

export interface DraftRestore {
  applied: boolean;
  merged: Record<string, string>;
  dropped: string[];
  reason?: "not-restorable" | "route-mismatch" | "stale" | "empty";
}

const PREFIX = "echo://open/v1/";
const CLOCK_SKEW_MS = 5 * 60 * 1000;
const MAX_VALUE = 200;
const MAX_KEYS = 16;
const SENSITIVE = /(^|\.)(token|password|passwd|secret|session|otp|cookie)$/i;

const fallback = (reason: FallbackReason, detail: string, to: string): RestoreOutcome =>
  ({ kind: "fallback", reason, detail, to });

/** 把路由与参数还原成可分享深链;状态只保留非敏感、短小的键。 */
export function encodeDeepLink(input: EncodeInput): EncodedLink {
  const used = new Set<string>();
  const path = input.route.pattern.split("/").filter(Boolean).map((segment) => {
    if (!segment.startsWith(":")) return encodeURIComponent(segment);
    const name = segment.slice(1);
    used.add(name);
    if (!(name in input.params)) throw new Error(`深链缺少参数 ${name}`);
    return encodeURIComponent(input.params[name] as string);
  }).join("/");
  for (const name of Object.keys(input.params)) {
    if (!used.has(name)) throw new Error(`路由 ${input.route.name} 不认识参数 ${name}`);
  }
  for (const key of Object.keys(input.query ?? {})) {
    if (key === "_ts" || key === "_s") throw new Error(`查询键 ${key} 是深链保留字`);
  }

  const cleaned = sanitize(input.state ?? {});
  const parts = Object.keys(input.query ?? {}).sort()
    .filter((key) => (input.query?.[key] as string).length > 0)
    .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(input.query?.[key] as string)}`);
  parts.push(`_ts=${input.now}`);
  if (Object.keys(cleaned.value).length > 0) {
    parts.push(`_s=${encodeURIComponent(JSON.stringify(cleaned.value))}`);
  }
  return { link: `${PREFIX}${path}?${parts.join("&")}`, dropped: cleaned.dropped };
}

/**
 * 冷启动恢复:依次校验格式、时效、路由是否存在、是否需要登录。
 * 任何一步失败都返回明确原因与兜底地址。
 */
export function restoreDeepLink(link: string, ctx: RestoreContext): RestoreOutcome {
  const to = ctx.fallback;
  if (!link.startsWith(PREFIX)) return fallback("bad-link", "不是回声深链", to);
  const rest = link.slice(PREFIX.length);
  const qIndex = rest.indexOf("?");
  const rawPath = qIndex === -1 ? rest : rest.slice(0, qIndex);
  const rawQuery = qIndex === -1 ? "" : rest.slice(qIndex + 1);

  const segments: string[] = [];
  for (const raw of rawPath.split("/").filter(Boolean)) {
    const decoded = decode(raw);
    if (decoded === undefined) return fallback("bad-link", "路径编码损坏", to);
    segments.push(decoded);
  }

  const query = parseQuery(rawQuery);
  if (!query.ok) return fallback("bad-link", query.detail, to);
  const timestamp = query.value._ts ? Number(query.value._ts) : Number.NaN;
  if (!Number.isSafeInteger(timestamp)) return fallback("bad-link", "缺少有效时间戳", to);
  if (timestamp > ctx.now + CLOCK_SKEW_MS) return fallback("future-timestamp", "时间戳晚于当前时间", to);
  if (ctx.now - timestamp > ctx.ttlMs) return fallback("expired", `超过 ${ctx.ttlMs} 毫秒有效期`, to);

  const matched = matchRoute(segments, ctx.catalog);
  if (!matched) return fallback("unknown-route", "路由不存在", to);
  if (matched.spec.retired) return fallback("retired", `路由 ${matched.spec.name} 已下线`, to);

  let state: Record<string, string> = {};
  if (query.value._s !== undefined) {
    const decoded = decode(query.value._s);
    if (decoded === undefined) return fallback("bad-state", "状态编码损坏", to);
    let parsed: unknown;
    try {
      parsed = JSON.parse(decoded);
    } catch {
      return fallback("bad-state", "状态不是合法 JSON", to);
    }
    if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
      return fallback("bad-state", "状态必须是对象", to);
    }
    const entries = Object.entries(parsed);
    if (entries.some(([, value]) => typeof value !== "string")) {
      return fallback("bad-state", "状态值必须是字符串", to);
    }
    if (entries.length > (ctx.maxStateKeys ?? MAX_KEYS)) {
      return fallback("bad-state", "状态键超过上限", to);
    }
    state = sanitize(parsed as Record<string, string>).value;
  }

  const safeQuery: Record<string, string> = {};
  for (const [key, value] of Object.entries(query.value)) {
    if (key !== "_ts" && key !== "_s" && !SENSITIVE.test(key)) safeQuery[key] = value;
  }
  if (matched.spec.requiresAuth && !ctx.signedIn) {
    return {
      kind: "auth", route: matched.spec.name, query: safeQuery, state,
      to: `${ctx.loginPath ?? "/login"}?returnTo=${encodeURIComponent(link)}`,
    };
  }
  return { kind: "restore", route: matched.spec.name, params: matched.params, query: safeQuery, state };
}

/** 稳定的路由指纹,用于判断草稿是否属于同一页面。 */
export function routeKey(route: string, params: Record<string, string>): string {
  const suffix = Object.keys(params).sort().map((key) => `${key}=${params[key]}`).join("&");
  return `${route}?${suffix}`;
}

/** 合并草稿:只接受同路由、未过期的草稿,链接自带状态优先。 */
export function restoreDraft(
  outcome: RestoreOutcome,
  saved: SavedDraft | undefined,
  now: number,
  maxAgeMs: number,
): DraftRestore {
  const none = (reason: DraftRestore["reason"]): DraftRestore =>
    ({ applied: false, merged: {}, dropped: [], reason });
  if (outcome.kind !== "restore") return none("not-restorable");
  if (!saved) return none("empty");
  if (saved.routeKey !== routeKey(outcome.route, outcome.params)) return none("route-mismatch");
  if (now - saved.savedAt > maxAgeMs || saved.savedAt > now + CLOCK_SKEW_MS) return none("stale");

  const cleaned = sanitize(saved.values);
  const merged: Record<string, string> = {};
  for (const [key, value] of Object.entries(cleaned.value)) {
    if (!(key in outcome.state)) merged[key] = value;
  }
  Object.assign(merged, outcome.state);
  const applied = Object.keys(merged).length > 0;
  return { applied, merged, dropped: cleaned.dropped, ...(applied ? {} : { reason: "empty" }) };
}

function sanitize(input: Record<string, string>): { value: Record<string, string>; dropped: string[] } {
  const value: Record<string, string> = {};
  const dropped: string[] = [];
  for (const [key, raw] of Object.entries(input)) {
    if (Object.keys(value).length >= MAX_KEYS || SENSITIVE.test(key) || typeof raw !== "string") {
      dropped.push(key);
      continue;
    }
    const trimmed = raw.trim();
    if (trimmed.length === 0 || trimmed.length > MAX_VALUE) dropped.push(key);
    else value[key] = trimmed;
  }
  return { value, dropped };
}

function decode(raw: string): string | undefined {
  try {
    return decodeURIComponent(raw.replace(/\+/g, " "));
  } catch {
    return undefined;
  }
}

function parseQuery(raw: string): { ok: true; value: Record<string, string> } | { ok: false; detail: string } {
  const value: Record<string, string> = {};
  for (const chunk of raw.split("&")) {
    if (chunk.length === 0) continue;
    const eq = chunk.indexOf("=");
    const key = decode(eq === -1 ? chunk : chunk.slice(0, eq));
    const decoded = decode(eq === -1 ? "" : chunk.slice(eq + 1));
    if (key === undefined || decoded === undefined) return { ok: false, detail: "查询串编码损坏" };
    value[key] = decoded;
  }
  return { ok: true, value };
}

function matchRoute(
  segments: string[],
  catalog: RouteSpec[],
): { spec: RouteSpec; params: Record<string, string> } | undefined {
  const ordered = [...catalog].sort((a, b) => {
    const ap = a.pattern.split("/").filter(Boolean);
    const bp = b.pattern.split("/").filter(Boolean);
    const statics = (list: string[]) => list.filter((s) => !s.startsWith(":")).length;
    return statics(bp) - statics(ap) || bp.length - ap.length;
  });
  for (const spec of ordered) {
    const pattern = spec.pattern.split("/").filter(Boolean);
    if (pattern.length !== segments.length) continue;
    const params: Record<string, string> = {};
    let matched = true;
    for (let i = 0; i < pattern.length; i += 1) {
      const expected = pattern[i] as string;
      const actual = segments[i] as string;
      if (expected.startsWith(":")) params[expected.slice(1)] = actual;
      else if (expected !== actual) {
        matched = false;
        break;
      }
    }
    if (matched) return { spec, params };
  }
  return undefined;
}

格式设计有四个取舍。第一,版本放在路径而不是查询串,旧客户端遇到不认识的版本可以直接拒绝,而不是解析到一半才发现字段缺失。第二,时间戳由生成方写入,恢复方只做有效期判断;它不防篡改,防篡改需要签名,但能拦住“半年前转发到群里的链接”。第三,状态只允许字符串键值对,并且限制键数量与值长度;深链不是数据库,塞进整个页面对象只会让链接变脆。第四,敏感键在编码阶段就丢弃,返回 dropped 列表供埋点告警;等到解码阶段再过滤,令牌已经出现在聊天记录里了。

_ts 与 _s 是保留字,用户查询串不能占用。这个约束看起来霸道,但它避免了“业务字段与协议字段撞名”这一类最难排查的问题。协议字段统一用下划线前缀,业务字段保持原样,边界清楚。

冷启动恢复的检查顺序

恢复函数按固定顺序检查,任何一步失败都立即返回兜底地址,不继续往下走:

  1. 前缀与路径编码:不是 echo://open/v1/ 开头、百分号编码损坏,直接判 bad-link。
  2. 时间戳:缺失或不是安全整数判 bad-link;晚于当前时间超过五分钟判 future-timestamp;超过 TTL 判 expired。
  3. 路由:按静态段优先匹配;找不到判 unknown-route,命中已下线路由判 retired。
  4. 状态:不是合法 JSON、不是字符串键值对、键数超限,判 bad-state。
  5. 权限:路由要求登录且当前未登录,返回 auth 与带 returnTo 的登录地址。
  6. 全部通过才返回 restore,交给页面栈执行 push 或冷启动铺栈。

这个顺序不是随意的。格式错误必须在解析路由之前拦住,否则一个损坏的路径可能被当成合法参数;时效必须在校验路由之前判断,否则过期链接会先触发一次无意义的业务查询;权限必须在返回 restore 之前判断,否则受保护页面会短暂渲染出未授权内容,再被登录页覆盖。

权限与登录拦截

未登录时,恢复函数不返回目标页,而是返回 auth:目标路由、查询上下文、状态都保留,同时给出 to=/login?returnTo=<原链接>。登录完成后,客户端应该重新执行原链接的恢复流程,而不是直接 push 登录页或首页。这样权限判断只有一处,登录前后走同一条路径。

这里有一个容易忽略的细节:returnTo 必须是完整的原始深链,而不是解析后的目标路径。登录过程可能跨多个页面、可能触发一次应用重启,只有原始链接能保证恢复所需的时间戳与状态都还在。把 returnTo 简化成 /orders/42,登录后就会丢失 _s 里的上下文。

另一个细节是权限变化。用户可能在链接生成后失去某个角色的权限。深链恢复只做“是否登录”的粗判断,细粒度权限仍要在页面数据加载时校验;深链层不能替代服务端鉴权,它只负责把用户送到正确的入口。

失效链接与兜底

失败原因典型场景兜底去处
bad-link链接被截断、协议不对首页
expired半年前转发的活动链接首页或活动落地页
future-timestamp设备时钟异常、伪造链接首页
unknown-route旧版本客户端打开新路由应用商店升级页
retired路由已下线对应的新页面或首页
bad-state状态 JSON 被手工篡改目标页但不回填状态

兜底不是“随便找个页面”。unknown-route 应该引导升级,因为用户手里的客户端太旧;retired 应该尽量映射到新路由,而不是直接回首页;bad-state 可以打开目标页,只是不回填状态,因为路由本身仍然有效。把失败原因分得足够细,兜底才有产品意义。

一次过期链接把用户带到空白页的事故

某次大促,运营把带 _s 状态的会场链接投放到外部渠道,有效期设置为三十天。活动结束后路由下线,但链接还在群里传播。用户点开后,客户端先匹配到了旧路由,渲染了一个空壳页面,数据请求返回 404,页面既没有错误提示也没有返回按钮。客服收到的描述是“点链接白屏”。

复盘发现两个问题:路由下线时只删了页面组件,没有在深链路由表里标记 retired;恢复流程在路由匹配成功后就直接返回 restore,把“页面能否加载数据”留给了页面组件,而页面组件没有处理 404。修复分两步:给下线路由加 retired 标记并映射到新会场;把数据加载失败纳入页面状态,至少渲染“活动已结束”与返回首页的按钮。

这个事故的教训不是“加一个 try/catch”,而是深链恢复要区分“导航成功”与“内容可用”。前者由路由层保证,后者由数据层保证;两层都成功,用户才真正到达。把两者混在一个布尔值里,失败时就只剩空白页。

测试与真实输出

测试覆盖七组场景:编解码往返、敏感状态丢弃、过期与未来时间戳、坏链接、未知与下线路由、登录拦截、坏状态与草稿合并。执行:

node --test test/router/deep-link.test.ts

本机一次运行的真实输出如下,耗时随机器变化:

✔ 编码与恢复保留路径参数、查询串与状态,并丢弃敏感状态 (2.149833ms)
✔ 过期与未来时间戳分别落到兜底页 (0.191375ms)
✔ 非回声深链、损坏编码与未知路由都被拒绝 (0.103917ms)
✔ 已下线路由与坏状态分别返回 retired 与 bad-state (0.129458ms)
✔ 未登录访问受保护路由时带上 returnTo 去登录 (0.153792ms)
✔ 草稿只在同路由且未过期时合并,链接状态优先 (0.250792ms)
✔ routeKey 与参数顺序无关 (0.086ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 149.118042

小结

深链是外部世界进入客户端的唯一入口,它必须假设进程是冷的、路由会变、链接会过期、用户可能未登录。格式带版本、状态带白名单、恢复顺序固定、失败有明确兜底,这四件事让深链从“能打开”变成“能到达”。

下一章从导航转向数据:请求如何编排、缓存如何失效、离线队列如何与页面栈协作。页面栈只知道用户去了哪里,数据层才知道那一页有没有内容。

阅读导航:上一节:6.2 页面栈与返回语义 · 下一节:7.1 请求编排 。

常见错误与边界

  • 把整页状态塞进深链。链接会变长、变脆,还会把草稿与令牌带出去。
  • 只在解码时过滤敏感字段。编码时就已经泄露,过滤只是事后补救。
  • 不设有效期。半年前的活动链接会把用户带到已经不存在的页面。
  • 恢复顺序随意。先查业务数据再判权限,会短暂渲染未授权内容。
  • 把 returnTo 简化成目标路径。登录后丢失时间戳与状态,恢复不完整。
  • 用深链层替代服务端鉴权。它只负责导航,不负责数据权限。
  • 路由下线只删组件。旧链接会命中空壳页面,用户看到白屏。
  • 认为兜底页越少越好。不同失败原因需要不同去向,统一回首页会丢掉可恢复的上下文。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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