一条深链要在三秒内恢复什么
用户从聊天窗口点开一条「回声」订单链接,应用可能已经被杀掉,可能停在后台,也可能刚完成一次版本升级。深链恢复要在这三种情况下都给出同一个结果:打开订单详情,保留必要的上下文,并且让返回键有去处。它不能假设进程还活着,也不能假设路由还存在。
深链恢复最容易犯的错误,是把“解析 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 是保留字,用户查询串不能占用。这个约束看起来霸道,但它避免了“业务字段与协议字段撞名”这一类最难排查的问题。协议字段统一用下划线前缀,业务字段保持原样,边界清楚。
冷启动恢复的检查顺序
恢复函数按固定顺序检查,任何一步失败都立即返回兜底地址,不继续往下走:
- 前缀与路径编码:不是
echo://open/v1/开头、百分号编码损坏,直接判bad-link。 - 时间戳:缺失或不是安全整数判
bad-link;晚于当前时间超过五分钟判future-timestamp;超过 TTL 判expired。 - 路由:按静态段优先匹配;找不到判
unknown-route,命中已下线路由判retired。 - 状态:不是合法 JSON、不是字符串键值对、键数超限,判
bad-state。 - 权限:路由要求登录且当前未登录,返回
auth与带returnTo的登录地址。 - 全部通过才返回
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简化成目标路径。登录后丢失时间戳与状态,恢复不完整。 - 用深链层替代服务端鉴权。它只负责导航,不负责数据权限。
- 路由下线只删组件。旧链接会命中空壳页面,用户看到白屏。
- 认为兜底页越少越好。不同失败原因需要不同去向,统一回首页会丢掉可恢复的上下文。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。