多 Tab 环境下身份隔离方案与架构重构事故复盘
文档状态： 最终闭环归档
涉及模块： 认证持久化基座、响应式全局状态树、UI 挂载生命周期、路由权限与菜单控制
系统特性： uni-app x / Vue 3 / Supabase

1. 问题背景与最初验证方式
   本项目作为一个多角色的电商/管理后台（包含超级管理员 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 生命周期控制的一连串系统性设计缺陷。

2. 原始实现逻辑是什么
   在重修之前，我们需要剖析清楚旧系统中的“虚假身份链路树”，才能理解为什么这棵树结出了毒果：

登录写缓存（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 的不良习惯。一旦不铲除，在发业务请求时权限依然会被串号所污染。

4. 每一轮修复分别做了什么
   为了解决这四个层面的交织问题，我们实施了由表及里、从 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 身份冲突缺陷已经从根源和机制两端收口。

8. 最终总结
   回顾本次排查修复之旅，它极具警示意义：前端多重状态混乱与生命周期视觉闪烁，往往不是在组件内调调判断条件或给几个 timeout 能绕过去的，而是“设计架构中的状态流转通道出现了裂隙”。

问题本质：是系统缺少对环境隔离上下文（Web Context Scope）与 Vue 内存生命周期一致性的尊重。任由各个模块向大池子（localStorage）不负责任地伸手索取。
机制收益：通过一套稳固的：“物理层面按 Tab 剥离” + “生命周期按重获取阻断” + “业务使用按单一中枢获取” + “清理法外旁路缓存” 组合拳，使得前端对登录信息的掌控进入了真正的自治安全期。
长效架构思考：这套解法相比只在界面写个逻辑来挡一挡或者仅仅修复由于 undefined 导致的文案显示，它的层级更加高维。未来我们无论新增多少种中等角色抑或新增几十个需要拉取 user_id 的特殊业务，只要严格依从这条收拢唯一化的高速链路去调用接口去获取 Token/ID，在底层框架机制不变的前提下，类似数据倒流与覆盖的噩梦将再无将在工程中宣告绝迹。
