12 KiB
多 Tab 环境下身份隔离方案与架构重构事故复盘 文档状态: 最终闭环归档 涉及模块: 认证持久化基座、响应式全局状态树、UI 挂载生命周期、路由权限与菜单控制 系统特性: uni-app x / Vue 3 / Supabase
- 问题背景与最初验证方式 本项目作为一个多角色的电商/管理后台(包含超级管理员 admin、商户 merchant),在日常开发和商务运营中,用户往往期望在同一浏览器内开启多个标签页(Tab)来验证不同角色的权限和数据。由于最近收到大量关于“账号串号、菜单错乱、UI 闪烁”的反馈,我们进行了系统的场景验证:
1.1 原始验证场景与表现 多重登录覆盖: 打开 Tab A 登录 admin,操作正常。 打开 Tab B 登录 merchant,操作正常。 异常: 切回 Tab A 并刷新页面,Tab A 的身份立刻倒戈,变成了 Tab B 的 merchant 账号。 退出连坐效应: 在 Tab A 点击退出登录,结果 Tab B 的请求也会随即报 Token 失败,双双被踢下线。 视觉逻辑撕裂(缝合怪现象): 某次同时开两个账号测试中发现,左侧菜单渲染的是 merchant 特有的那五个选项,但顶部 Header 上显示的仍是原来 admin 的用户名,右上角个人中心的下拉框又是第三个状态;页面呈现出极度的割裂感。 刷新时的残影与突变: 任何角色在按 F5 刷新时,页面总是先短暂显示为“未知用户”或硬编码的“admin”,左侧菜单只显示一个“Dashboard”,经过零点几秒的闪烁后,才猛然渲染出正确的菜单和用户名。 以上异常现象之间具有高度联动性,这指向的并不是一个单纯的 UI 的 Bug,而是从 Token 凭证基座、缓存读写策略、到全局状态流转以及 UI 生命周期控制的一连串系统性设计缺陷。
- 原始实现逻辑是什么 在重修之前,我们需要剖析清楚旧系统中的“虚假身份链路树”,才能理解为什么这棵树结出了毒果:
登录写缓存(Token & 身份旁路): 用户登录后,底层 HTTP 服务 (uni_modules/ak-req/ak-req.uts) 会调用 uni.setStorageSync 将 access_token 和 refresh_token 等写入缓存。 业务层 login.uvue 怕以后拿不到数据,自作主张又通过 uni.setStorageSync('user_id', uid) 偷偷写了一份 ID;同时部分代码还保留了 uni.setStorageSync('merchant_id', 'admin') 的远古 mock。 角色获取的投机取巧: role.uts 提供菜单与权限计算。当内存状态 (state.userProfile) 没有到位时,它立刻回退去读 uni.getStorageSync('adminRole') 或者旧版本的 admin_role。 UI 数据的分裂抓取: 左侧菜单:调用 getCurrentAdminRole()(受上面那条缓存影响)。 顶栏 Header (AdminHeader.uvue):部分信息读取了 state.userProfile.username,但在为空时,代码里直接硬塞了一个 'admin' 字符串兜底。 业务接口:比如商品管理 edit.uvue,发请求前不在意当前的系统 Session,而是直接通过 uni.getStorageSync('merchant_id') 去拼数据库条件。 挂载生命周期: AdminLayout.uvue 无视了 aksupainstance 从远程获取并补齐 Session 的百毫秒级网络/IO时延。框架 onMounted 后 Vue 立刻画出 DOM,拿着空的数据源或错误的高优缓存“抢跑”渲染了页面。3. 原来为什么会出错:根因拆解 通过追踪以上数据流向,我将系统的错误拆分为四个不同层次的叠加灾难。
3.1 共享存储导致的跨 Tab 串号 (Persistence Layer) H5 环境下,uni-app 的 uni.setStorageSync 会被编译为调用原生 Web 的 window.localStorage。这是一个同源域(跨 Tab)全面共享的持久化存储。
覆盖: 无论是 Tab A 还是 Tab B 操作,只要发网络请求产生的新 Token、新 role,全都会写入同一个全局大池子中。“后发者通吃”,全站缓存均反映最后一个登录的人。 连坐: A 退出时清空 localStorage,B 在随后的验证或新请求中自然也就拿不到 Token 从而崩溃。 3.2 Header、菜单、个人中心不是同一数据源 (State Layer) 数据未能做到一元化集散管理。Vue 的响应式并没有覆盖整条身份获取通道。
菜单侧信赖了 localStorage 的快捷读取(读到了 B 的角色)。 Header 侧信赖了尚未挂载完成的 state,由于请求未回,显示“未知用户”。 结果导致同一时刻,一个页面上有三个不同渠道供出的不同身份残影。 3.3 异步时序错位导致的抢跑渲染 (Runtime Layer) 页面组件在试图绘制时,单例 aksupainstance 触发的 hydrateSessionFromStorage() 可能还没走完,state.userProfile 尚为空。 Vue 在这短暂的“数据真空期”画完了错误的页面,等网络回调到来,再用 state 的更新硬性把画面抖回去,视觉上就是难看的闪烁。
3.4 共享 identity 旁路残留问题 (Business Context) 就算底层修好了,页面业务里(比如商户端拉取商品列表的 index.uvue 或 supabaseService.uts 后备逻辑)依然散落着向 localStorage 直接索要 user_id 和 merchant_id 的不良习惯。一旦不铲除,在发业务请求时权限依然会被串号所污染。
- 每一轮修复分别做了什么 为了解决这四个层面的交织问题,我们实施了由表及里、从 UI 深入底层的五轮演进修复:
第一阶段:修显示层异常 动作:尝试优化 AdminHeader.uvue 使得其响应更加自然。移除了硬编码的 'admin'。 局限:只是治标不治本,缓解了文案假死的情况,但跨 Tab 强刷还是会变成别人的账号。这告诉我们,问题的根本源头在更下游。 第二阶段:修角色缓存与 Header 错误兜底 动作:我们在 store.uts 中扩建了响应式存储,不仅维护 userProfile,并列新增了底层的授权原身 authUser(直接映射 session 的基础 user 数据)。我们改造 AdminHeader.uvue 的 computed 属性,使其有了严密的瀑布降级:userProfile.username -> authUser.email -> authUser.phone -> truncate(id)。 效果:UI 的渲染此时有了唯一根据。但一旦按下 F5 刷新依然会闪烁错位,并且跨 Tab 的数据霸权问题未解。 第三阶段:修页面刷新时的阻断与身份恢复时序 动作:为彻底消灭渲染抢跑和左侧菜单的“一帧残影”,我们在 AdminLayout.uvue 强力引入了阻断变量 isAuthReady = ref(false)。 实现:单例 aksupainstance.uts 导出 export const supaReady = supaInstance.hydrateSessionFromStorage() 这个关键的顶层 await。只有当它从磁盘恢复甚至向后端校验完凭证后,isAuthReady 才会变成 True 放行子组件(包含菜单组件)挂载。 效果:页面变得顺滑不再闪动,但核心发现爆出——A 的数据被验证仍然读取到了缓存池中 B 的身份。 第四阶段:修多 Tab 会话隔离 (本役关键转折点) 动作:剖开底层基石,拦截 ak-req.uts 所有对 Token 的操作,使用条件编译为 H5 环境单独开辟特区。 实现: 效果:Token 不再流入跨 Tab 共享的 localStorage,而是被封存入完全按浏览器标签页物理隔离的 sessionStorage!至此,“连坐”与“身份覆盖”奇迹般停止了,基础认证的主链路完成了隔离闭环。 第五阶段:清理共享 identity 旁路 动作:利用正则在全局排查出所有 getStorageSync('user_id')、merchant_id 的老旧黑魔法并肃清。 执行细节: 删除 login.uvue 里私自存放 user_id 的操作。 修复 edit.uvue 中原有的越权隐患代码: const mId = uni.getStorageSync('merchant_id') --> 坚决替换为:const mId = supa.getSession().user?.id 删除 aksupa.uts 中退出登录时清理全局变量的指令,保护了其他 Tab 的安危。 效果:彻底拔除了所有潜伏在业务深处的侧漏点。user_id/merchant_id 现在只通过当前 Tab 的运行时计算得来。5. 最终闭环逻辑是什么 此时整套前端系统的状态机和请求流向,已凝结为绝对干净的单向闭环。
【最终身份闭环链路图】
因果解答:
为什么始终是同一个账号? 因为 View 层的渲染全盘接驳到了 store.uts 这个单例枢纽。绝不会有组件私自“偷吃”外部缓存。 为什么刷新不串号? Tab 刷新加载的第一秒,框架在 sessionStorage 摸到的全是属于自己这个窗口原教旨级的鉴权 Token,从而去 Supabase 换来了自身的资料。它哪怕想认识别的 Tab 也跨越不来。 为什么 A 退出不影响 B? 移除底部的黑魔法连坐缓存后,A 退出仅仅清空其拥有的 sessionStorage 和当前内存;B 安然无恙。6. 为什么这种方式能够实现真正闭环 从宏观建构看,这次并不是用“if/else”补坑或者临时写一套脚本洗数据造成的“表面修复”。它的彻底生效依赖四大架构调整:
共享状态源的强制切断机制(持久化隔离):这不单是将 localStorage 改为 sessionStorage,而是从最底盘网络客户端驱动发起底层劫持(条件编译拦截),确保了跨平台包容性不折损的同时完成了 H5 下环境级别的强制网闸。 重塑出唯一真实数据源(SSOT - Single Source Of Truth):剥夺了之前 role.uts 中那些诸如 admin_role 等眼花缭乱却错误百出的缓存优先读取权利,改由 user_metadata 与远程 ak_users 库做绝对断言。从根本收缩了多数据源带来的状态裂变。 引入 UI 的防抢跑闸门(同步阻断设计):通过 isAuthReady 这一全局屏障的引入,我们将应用初始化时异步加载造成的并发渲染冲突消灭。这类似于后端的资源自检网关(Health Check)。 抹除缓存旁路的零容忍合规化(业务重耦合):清查了业务内残存直接对接 Storage 获取凭据的调用(如商品 edit.uvue)。强制业务层取身份只能向上游状态池发出“请示”,而非自己瞎去找“私房钱”;这是在业务流闭环。7. 最终验证结果与结论 严格重走原始所有异常验证用例:
并行共存: Tab A 在 admin 权限全开操作,Tab B 在 merchant 权限受限下操作。各自跑通。 单独或交叉强刷测试: 狂按 F5。A 的屏幕显示阻断遮罩 -> 解锁 -> 依然是完美的 Admin 环境;B 屏幕显示遮罩 -> 解锁 -> 依然是严谨的 Merchant 环境。无“串号重置”现象,无“未知用户”闪烁,无菜单“退化”现象。 退登独立测试: 在 A 主动注销,系统清理当前 Tab 无任何抛错跳转回页;切回 B,点任何需要验证的请求,依然成功拉取属于 B 的数据。无“拉断电闸式连坐”。 唯余一项边界规范注意: 现代浏览器存在“复制当前标签页(Duplicate Tab)”功能。使用该原生的操作会附带将原封不动的 sessionStorage 克隆给新的克隆 Tab。这是浏览器的底层特性,也是合乎常理的双开机制(相当于镜像);普通“新打开一个空白标签访问系统”,将仍是未经登录的纯净环境,不会继承。
结论: 跨 Tab 身份冲突缺陷已经从根源和机制两端收口。
- 最终总结 回顾本次排查修复之旅,它极具警示意义:前端多重状态混乱与生命周期视觉闪烁,往往不是在组件内调调判断条件或给几个 timeout 能绕过去的,而是“设计架构中的状态流转通道出现了裂隙”。
问题本质:是系统缺少对环境隔离上下文(Web Context Scope)与 Vue 内存生命周期一致性的尊重。任由各个模块向大池子(localStorage)不负责任地伸手索取。 机制收益:通过一套稳固的:“物理层面按 Tab 剥离” + “生命周期按重获取阻断” + “业务使用按单一中枢获取” + “清理法外旁路缓存” 组合拳,使得前端对登录信息的掌控进入了真正的自治安全期。 长效架构思考:这套解法相比只在界面写个逻辑来挡一挡或者仅仅修复由于 undefined 导致的文案显示,它的层级更加高维。未来我们无论新增多少种中等角色抑或新增几十个需要拉取 user_id 的特殊业务,只要严格依从这条收拢唯一化的高速链路去调用接口去获取 Token/ID,在底层框架机制不变的前提下,类似数据倒流与覆盖的噩梦将再无将在工程中宣告绝迹。