登录系统架构与演进分析¶
类型: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。
这段代码两个版本逐字节相同。真正变的是它之上的三层:
- Cookie 自动登录(v0.10.2 新增
auto-login.ts)—— 嗅探抓不到时的兜底 - Popup 4 态状态机(v0.9.38 重做 + v0.10.16 定型)—— 原始 popup 根本没有登录 UI
- 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 → 当前的唯一差异:apiAccountCurrent 的 onError 回调。
// 原始 v0.8.37:onError 被注释掉 —— 网络错时不处理,TypeError 冒泡到 Chrome 错误追踪器
// onError: () => { ... }
// 当前:启用 onError —— 网络失败(VPN 切换 / 服务端瞬断)回退「未登录」态
onError: () => {
dispatch({ type: Types.INITIAL, payload: { user: null } });
},
这个改动小但重要:扩展长期挂在后台,网络抖动是常态。不处理
onError,Failed 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 极简:
当前版本在开窗前先试 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=true(0001-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的调用点setSession里axios.defaults全是注释、window.location.href注释、alert仍在
✅ 已处理(v0.10.124):三重确认零引用后整文件删除,见 0087-auth-context-utils-dead-jwt-code。与 0086-jsstore-insertone-empty-function-footgun 同类——迁移自母产品的未清理残留。
七、改这块时要 / 不要做什么¶
- ✅ 加新登录方式 → 在
auto-login.ts或handleLoginClick串行兜底(cookie → 新方式 → 开窗) - ✅ 改 cookie 候选名 → 直接加进
UID_CANDIDATES/TOKEN_CANDIDATES,模糊匹配自动覆盖 - ✅ 动嗅探 URL 范围 → 改
webRequest的urls白名单(注意 host_permissions 同步) - ❌ 不要动
headerListener的判定逻辑 —— 这是两版唯一不变的地基,全链路依赖它 - ❌ 不要把 open-login 改回 force=false —— 这是 ISSUE-0001 的修复重点
- ❌ 不要让 popup loading 态没有超时 / 没有手动登录入口 —— ISSUE-0002 死循环的根因
- ❌ 不要以为登录走 JWT —— 真实机制是 storage 轮询(原
utils.tsJWT 死代码已于 v0.10.124 删除)
八、跨职能连带¶
- 合规:cookie 自动登录读
laifaxin.com全域 cookie。当前只读自家域、不打 value,合规。但若未来TOKEN_CANDIDATES误匹配到第三方 cookie 名,有越权读取风险——加候选名时务必确认是自家 cookie。 - 商业化:popup 的「会员中/免费版 + 到期日」直接来自
user.mapVipValid/mapVipTime。登录态拉取失败(stale)时这块不显示,等于丢了一个续费提醒触点——stale 态的文案可考虑加轻量续费引导。 - 增长:登录"经常失败"是早期最大流失点之一(用户原话)。cookie 自动登录把"装了扩展但登不上"的漏斗堵住了,是留存的隐形功臣。