跳转至

登录系统架构与演进分析

类型:wiki(架构沉淀 + 版本对比) 目的:把扩展的登录逻辑一次讲透,并与最原始版本 v0.8.37 逐层对比,说清「哪里没变、哪里叠了什么、为什么叠」。 取证方式:全部源码用原始 cat/rg 读取交叉验证(本仓库 Read 工具有幻觉史,见开发日志 v0.10.124)。 对比基准backup/v0.8.37_20260521/(最老的源码备份,早于 git 首提 v0.10.26)vs 当前工作区(v0.10.124)。

一句话结论

登录的「地基」从 v0.8.37 到现在一字未变;所有演进都是在地基之上叠「兜底层」。

地基 = webRequest.onBeforeSendHeaders 嗅探请求头里的 uid/accesstoken → 写进扩展 storage。 这段代码两个版本逐字节相同。真正变的是它之上的三层:

  1. Cookie 自动登录(v0.10.2 新增 auto-login.ts)—— 嗅探抓不到时的兜底
  2. Popup 4 态状态机(v0.9.38 重做 + v0.10.16 定型)—— 原始 popup 根本没有登录 UI
  3. open-login force=true(v0.10.17)—— 点登录"没反应"的修复

一、登录的「地基」:请求头嗅探(两版相同)

扩展不自己做账号密码登录。它寄生在来发信 Web 站的登录态上:用户在 ggdt.laifaxin.com / m.laifaxin.com 登录后,凡是带 uid / accesstoken 请求头的 /api/account/* POST 请求,都被 background 的 headerListener 拦下,把这两个值抄进扩展 storage。

// src/entrypoints/background/index.ts(当前)
// backup/v0.8.37_20260521/.../background/index.ts(原始)
// —— 两版逐字节相同
function headerListener(details: any) {
  const { method, requestHeaders, url } = details ?? {};
  if (method === 'POST' && !url.includes('extPlug=1')) {
    if (url.includes('/api/account/logout')) {
      storage.setItem(StorageKey.uid, '');
      storage.setItem(StorageKey.accesstoken, '');
    } else {
      for (const header of requestHeaders) {
        const name = header.name.toLowerCase();
        if (name === 'uid')         storage.setItem(StorageKey.uid, header.value || '');
        if (name === 'accesstoken') storage.setItem(StorageKey.accesstoken, header.value || '');
      }
    }
  }
}
browser.webRequest.onBeforeSendHeaders.addListener(
  headerListener,
  { urls: ['https://ggdt.laifaxin.com/api/account/*', 'https://m.laifaxin.com/api/account/*'] },
  ['requestHeaders']
);

两个存储键src/types/data.ts,两版相同):

StorageKey 含义
local:uid 用户 ID 「是否登录」的唯一判据
local:accesstoken 用户 token 调云端 API 的凭证

⚠️ 嗅探的天然缺陷(这是后面所有兜底的起因):必须真的发出一个带头的 /api/account/* 请求才抓得到。用户登录后如果没触发这类请求、或窗口关太快、或 redirect 时序不对 → 头抓不到 → 扩展看不到 uid → 显示"未登录"。用户原话:"登录经常登录失败"。


二、登录态在前端怎么流动:AuthProvider(两版几乎相同)

src/auth/context/auth-provider.tsx 是登录态的中枢。它不直接读登录结果,而是轮询 storage:

flowchart TD
  A[AuthProvider 挂载] --> B[authRun 轮询 getAuth]
  B -->|每 2 秒| C{uid/token<br/>和上次不同?}
  C -->|否| B
  C -->|是| D[infoRun 调 apiAccountCurrent]
  D --> E{success?}
  E -->|有 data| F[dispatch LOGIN<br/>user 写入 + 提示登录成功]
  E -->|失败/网络错| G[dispatch INITIAL<br/>user=null 回退未登录]
  F --> H[每 300 秒轮询续期]

两条轮询: - authRun:每 2 秒 读 storage 的 uid/token,一旦和内存里的不同 → 触发拉取用户信息。这是「storage → 前端」的桥。 - infoRun:调 apiAccountCurrent 拿用户详情,成功后每 300 秒 续拉一次(保活 + 感知失效)。

v0.8.37 → 当前的唯一差异apiAccountCurrentonError 回调。

// 原始 v0.8.37:onError 被注释掉 —— 网络错时不处理,TypeError 冒泡到 Chrome 错误追踪器
//   onError: () => { ... }

// 当前:启用 onError —— 网络失败(VPN 切换 / 服务端瞬断)回退「未登录」态
onError: () => {
  dispatch({ type: Types.INITIAL, payload: { user: null } });
},

这个改动小但重要:扩展长期挂在后台,网络抖动是常态。不处理 onErrorFailed to fetch 会污染扩展的错误面板(关联 v0.10.0+ 的错误治理)。

login() / logout() 两版也相同: - login() = 给 background 发 open-login 消息(自己不开窗) - logout() = 调 apiAccountLogout → 清 storage → dispatch LOGOUT


三、三层演进(这才是真正的变化)

演进 1:Cookie 自动登录 —— auto-login.ts(v0.10.2 新增,原始版不存在)

原始 v0.8.37 没有 src/utils/auto-login.ts 这个文件。 这是嗅探缺陷的正面解药:不再被动等请求头,而是主动去读浏览器里 laifaxin.com 的 cookie,直接拿 uid/token。

flowchart TD
  A[autoLoginViaCookies force] --> B{force=false 且<br/>storage 已有 uid+token?}
  B -->|是| C[直接返回 already-logged-in]
  B -->|否| D[cookies.getAll laifaxin.com]
  D --> E{有 cookie?}
  E -->|无| F[返回 no-cookies]
  E -->|有| G[模糊匹配 uid/token 候选名]
  G --> H{都找到?}
  H -->|否| I[返回 no-uid/no-token-cookie]
  H -->|是| J[写 storage uid+token<br/>返回 ok]

三个设计要点(均有源码佐证): - 模糊匹配 cookie 名:后端命名约定不确定,所以 uid 试 uid/user_id/userId/lfx_uid/...,token 试 accesstoken/access_token/token/jwt/...,精确匹配优先、大小写不敏感兜底。 - force 参数false = storage 有值就跳过(启动静默用);true = 强制重抓(点登录按钮用)。这个参数是后面所有 bug 修复的关键开关。 - 不打印 cookie value:debug 只输出 cookie 名字,避免日志泄露 token。

两个调用场景(background):

autoLoginViaCookies(false).catch(() => {});   // line 99:SW 启动时静默恢复(重装/storage 被清)
const result = await autoLoginViaCookies(true); // open-login / try-cookie-login:用户主动触发强制重抓

演进 2:Popup 从「导航菜单」到「4 态登录状态机」

这是变化最大的部分。 原始 popup(sections/popup/index.tsx,83 行)完全没有登录概念——就是三个等权按钮:

原始 v0.8.37 popup 当前 popup(554 行)
打开谷歌地图 会员状态条 / 登录入口(4 态)
打开来发信 引擎运行状态 + 立即触发
进入搜索页面 累计商家/邮箱数据卡
—— 任务概况
—— 创建任务 / 打开主面板 CTA
—— 外链 + 设置/帮助/客服

登录部分演化成 4 态状态机(详见 popup-auth-state-machine,此处只讲与原始版的对比):

flowchart TD
  A[popup 打开] --> B{storage 有 uid?}
  B -->|无| C[logged-out<br/>未登录·点击登录]
  B -->|有| D[loading<br/>给 user 2 秒]
  D -->|uid 到达| E[logged-in<br/>会员状态条]
  D -->|2 秒超时| F[stale<br/>会话失效·重新登录]

原始版没有这套,是因为原始版 popup 压根不显示登录态——登录与否对那三个导航按钮无影响。当迷你仪表板要展示「我的会员还剩多久」时,才被迫处理「登录态未知」的中间态,于是演化出状态机。

踩坑里程碑(关联 issues): - v0.10.7:首次引入 3 态(loading/logged-in/logged-out)→ 埋下死循环:storage 有 uid 但 user 拉不到时永远卡 loading(0002-popup-loading-deadloop)。 - v0.10.16:加 2 秒超时 + 第 4 态 stale + loading 态也给「手动登录」按钮。 - v0.10.27:用 useRef 持有 timeoutId,uid 到达瞬间 clear,消除"闪现 stale 再变 logged-in"的竞态(0017-popup-loading-timeout-race)。

演进 3:open-login 改 force=true —— 修「点登录没反应」

原始 v0.8.37 的 open-login 极简:

// 原始:收到消息直接开窗
if (type === 'open-login') {
  openWindow('login');
  return { success: true };
}

当前版本在开窗前先试 cookie 自动登录,且 force=true

// 当前:先 cookie 兜底,失败才开窗
if (type === 'open-login') {
  const autoLoginResult = await autoLoginViaCookies(true);  // ← force=true 是关键
  if (autoLoginResult.ok) return { success: true, autoLogin: true };
  openWindow('login');
  return { success: true, autoLogin: false, reason: autoLoginResult.reason };
}

为什么必须 force=true0001-login-button-no-response):曾经用 force=false → storage 里有残留 uid+token 就直接返回 ok、不弹窗 → 用户看到「立即登录」按钮但点了毫无反应。force=true 绕过 storage 缓存强制重抓 cookie:有效就静默登录、无效才弹窗。


四、完整登录链路全景(当前版本)

flowchart TD
  U[用户在 web 站登录] -->|带 uid/token 请求头| L[background headerListener 嗅探]
  L --> S[(storage: uid + accesstoken)]
  P[popup 点登录] -->|try-cookie-login| AC[autoLoginViaCookies force=true]
  AC -->|读 cookie 命中| S
  AC -->|cookie 失败| W[openWindow login 弹登录窗]
  SW[SW 启动] -->|force=false 静默| AC
  S -->|每 2 秒轮询| AP[AuthProvider authRun]
  AP -->|变化| API[apiAccountCurrent]
  API --> US[user 写入 context]
  US --> UI[popup/main 显示会员态]

三个写入 storage 的入口(uid 的来源): 1. 请求头嗅探(地基,被动)—— 用户在 web 站有动作时 2. cookie 自动登录(兜底,主动)—— 启动静默 / 点登录强制 3. (登出时 headerListener 命中 /logout 清空)

一个读取 storage 的出口:AuthProvider 每 2 秒轮询 → 驱动整个前端登录态。


五、版本对比总表

维度 原始 v0.8.37 当前 v0.10.124 性质
请求头嗅探 headerListener ✅ 逐字节相同 未变(地基)
StorageKey uid/accesstoken ✅ 相同 未变
AuthProvider 双轮询(2s/300s) ✅ 相同 未变
apiAccountCurrent onError ❌ 注释掉 ✅ 回退未登录 健壮性增强
auto-login.ts cookie 登录 ❌ 不存在 ✅ 模糊匹配 + force 新增层
SW 启动静默自动登录 autoLoginViaCookies(false) 新增
open-login 行为 直接开窗 先 cookie(force=true) 再开窗 增强(修 0001)
try-cookie-login 消息 新增
Popup 登录 UI ❌ 仅 3 导航按钮 ✅ 4 态状态机 重做
Popup 整体 83 行导航 554 行迷你仪表板 重做

六、发现的技术债(顺手记录)

死代码:src/auth/context/utils.ts(已于 v0.10.124 删除 — ISSUE-0087)

该文件导出 isValidToken / tokenExpired / setSession,全部基于 sessionStorage + window.atob + alert('Token expired')JWT session 模式——这是母模板(minimals)的标准 auth 工具。但本扩展用的是「storage 轮询 uid+token」模式,根本不走 JWT session

  • rg 全仓未见 isValidToken/setSession/tokenExpired 的调用点
  • setSessionaxios.defaults 全是注释、window.location.href 注释、alert 仍在

✅ 已处理(v0.10.124):三重确认零引用后整文件删除,见 0087-auth-context-utils-dead-jwt-code。与 0086-jsstore-insertone-empty-function-footgun 同类——迁移自母产品的未清理残留。


七、改这块时要 / 不要做什么

  • 加新登录方式 → 在 auto-login.tshandleLoginClick 串行兜底(cookie → 新方式 → 开窗)
  • 改 cookie 候选名 → 直接加进 UID_CANDIDATES/TOKEN_CANDIDATES,模糊匹配自动覆盖
  • 动嗅探 URL 范围 → 改 webRequesturls 白名单(注意 host_permissions 同步)
  • 不要动 headerListener 的判定逻辑 —— 这是两版唯一不变的地基,全链路依赖它
  • 不要把 open-login 改回 force=false —— 这是 ISSUE-0001 的修复重点
  • 不要让 popup loading 态没有超时 / 没有手动登录入口 —— ISSUE-0002 死循环的根因
  • 不要以为登录走 JWT —— 真实机制是 storage 轮询(原 utils.ts JWT 死代码已于 v0.10.124 删除)

八、跨职能连带

  • 合规:cookie 自动登录读 laifaxin.com 全域 cookie。当前只读自家域、不打 value,合规。但若未来 TOKEN_CANDIDATES 误匹配到第三方 cookie 名,有越权读取风险——加候选名时务必确认是自家 cookie。
  • 商业化:popup 的「会员中/免费版 + 到期日」直接来自 user.mapVipValid/mapVipTime。登录态拉取失败(stale)时这块不显示,等于丢了一个续费提醒触点——stale 态的文案可考虑加轻量续费引导。
  • 增长:登录"经常失败"是早期最大流失点之一(用户原话)。cookie 自动登录把"装了扩展但登不上"的漏斗堵住了,是留存的隐形功臣。