完成修改密码功能

This commit is contained in:
2026-03-12 20:20:01 +08:00
parent a768597ca2
commit 103c6fe0b6
3 changed files with 279 additions and 83 deletions

View File

@@ -0,0 +1,112 @@
多 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 退出时清空 localStorageB 在随后的验证或新请求中自然也就拿不到 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在底层框架机制不变的前提下类似数据倒流与覆盖的噩梦将再无将在工程中宣告绝迹。

View File

@@ -56,9 +56,11 @@
</template>
<script setup lang="uts">
import { reactive, computed, onMounted } from 'vue'
import { reactive, computed, onMounted, ref } from 'vue'
import { state, logout } from '@/utils/store.uts'
import supa from '@/components/supadb/aksupainstance.uts'
import { createTempClient } from '@/components/supadb/aksupa.uts'
import { SUPA_URL, SUPA_KEY } from '@/ak/config.uts'
const userAccount = computed((): string => state.userProfile.email || 'demo@example.com')
const avatarUrl = computed((): string => state.userProfile.avatar_url || '/static/logo.png')
@@ -70,119 +72,123 @@ const formData = reactive({
confirmPassword: ''
})
const isSubmitting = ref(false)
onMounted(() => {
formData.name = state.userProfile.username || ''
})
const onSubmit = async () => {
if (formData.name.trim() == '') {
if (isSubmitting.value) return
const nameTrimmed = formData.name.trim()
if (nameTrimmed == '') {
uni.showToast({ title: '姓名不能为空', icon: 'none' })
return
}
// 修改密码逻辑
if (formData.oldPassword != '' || formData.newPassword != '' || formData.confirmPassword != '') {
if (formData.oldPassword == '') {
uni.showToast({ title: '需要输入原始密码', icon: 'none' })
return
}
if (formData.newPassword == '') {
uni.showToast({ title: '新密码不能为空', icon: 'none' })
return
}
if (formData.newPassword !== formData.confirmPassword) {
uni.showToast({ title: '两次输入的新密码不一致', icon: 'none' })
return
}
if (formData.newPassword === formData.oldPassword) {
uni.showToast({ title: '新密码不能与原始密码相同', icon: 'none' })
return
isSubmitting.value = true
uni.showLoading({ title: '正在处理...', mask: true })
try {
// 1. 如果姓名有变化,更新姓名
if (nameTrimmed !== state.userProfile.username) {
const resName = await supa.from('ak_users').update({
username: nameTrimmed
}).eq('id', state.userProfile.id).execute()
if (resName.error != null) {
throw new Error('更新姓名失败: ' + (resName.error?.message ?? '网络异常'))
}
// 更新本地状态
state.userProfile.username = nameTrimmed
}
uni.showLoading({ title: '验证并提交中...' })
// 2. 处理密码修改
const wantsChangePassword = formData.oldPassword != '' || formData.newPassword != '' || formData.confirmPassword != ''
if (wantsChangePassword) {
if (formData.oldPassword == '') {
throw new Error('需要输入原始密码')
}
if (formData.newPassword == '') {
throw new Error('新密码不能为空')
}
// 关键修复:确保比较前去除可能的多余空格,或至少确保值确实一致
const newPwd = formData.newPassword
const confPwd = formData.confirmPassword
if (newPwd !== confPwd) {
throw new Error('两次输入的新密码不一致')
}
if (newPwd === formData.oldPassword) {
throw new Error('新密码不能与原始密码相同')
}
try {
const email = state.userProfile.email
if (email == '') {
uni.hideLoading()
uni.showToast({ title: '账号缺失,无法验证', icon: 'none' })
return
}
// 1. 验证原密码
const resSignIn = await supa.auth.signInWithPassword({
email: email,
password: formData.oldPassword
})
if (resSignIn.error != null) {
uni.hideLoading()
uni.showToast({ title: '原密码错误', icon: 'none' })
return
throw new Error('账号信息缺失,请重新登录后再试')
}
// 2. 更新新密码
// 使用临时客户端校验密码,不影响当前会话
const tempSupa = createTempClient(SUPA_URL, SUPA_KEY)
try {
await tempSupa.auth.signInWithPassword({
email: email,
password: formData.oldPassword,
options: { persistSession: false }
} as UTSJSONObject)
} catch (e : any) {
const errMsg = e.message || '原始密码校验失败'
if (errMsg.toLowerCase().includes('invalid login credentials')) {
throw new Error('原始密码错误')
}
throw new Error(errMsg)
}
// 密码更新(使用主客户端)
const resUpdate = await supa.auth.updateUser({
password: formData.newPassword
})
} as UTSJSONObject)
if (resUpdate.error != null) {
uni.hideLoading()
uni.showToast({ title: '密码更新失败', icon: 'none' })
return
}
// 3. 同时更新姓名
if (formData.name !== state.userProfile.username) {
await supa.from('ak_users').update({
username: formData.name
}).eq('id', state.userProfile.id)
throw new Error('密码修改失败: ' + (resUpdate.error?.message ?? '网络异常'))
}
uni.hideLoading()
uni.showToast({ title: '修改成功, 请重新登录', icon: 'success' })
// 退出登录
// 退出登录并强制跳转
setTimeout(() => {
logout()
uni.removeStorageSync('adminRole')
// 同时清理管理端特定的角色缓存和状态
try {
const { clearAdminRoleCache } = require('@/layouts/admin/utils/role.uts')
clearAdminRoleCache()
} catch (e) {}
uni.removeStorageSync('adminRole') // 确保清理旧的缓存标识
uni.removeStorageSync('token')
uni.reLaunch({
url: '/pages/user/login'
})
uni.reLaunch({ url: '/pages/user/login' })
}, 1500)
return
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '网络异常', icon: 'none' })
return
}
}
// 仅修改基本信息
if (formData.name !== state.userProfile.username) {
uni.showLoading({ title: '保存中...' })
try {
const res = await supa.from('ak_users').update({
username: formData.name
}).eq('id', state.userProfile.id)
// 如果只是更新了姓名
uni.hideLoading()
uni.showToast({ title: '基本信息已更新', icon: 'success' })
isSubmitting.value = false
if (res.error != null) {
uni.hideLoading()
uni.showToast({ title: '保存失败', icon: 'none' })
return
}
state.userProfile.username = formData.name
uni.hideLoading()
uni.showToast({ title: '保存成功', icon: 'success' })
} catch (e) {
uni.hideLoading()
uni.showToast({ title: '网络异常', icon: 'none' })
}
} else {
uni.showToast({ title: '无修改内容', icon: 'none' })
} catch (err : any) {
uni.hideLoading()
isSubmitting.value = false
const msg = err.message || '操作失败,请重试'
console.error('Submit user update failed:', err)
uni.showToast({
title: msg,
icon: 'none',
duration: 3000
})
}
}
</script>