import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { AuthService, setAuthToken, clearAuthToken, OmsLoginType, type LoginResultDto, type UserInfoDto, type MenuTreeDto, } from '@/api/rbac' import { handleApiError } from '@/utils/rbac-error-handler' import { getLoginRouteNameByType } from '@/utils/login-portal' import { getLoginTypeFromAccessToken } from '@/utils/jwt' import { markSessionEndedByUser, resetSessionExpiredGuard, } from '@/utils/auth-session-guard' // Token 存储 key const TOKEN_KEY = 'rbac_access_token' const REFRESH_TOKEN_KEY = 'rbac_refresh_token' const TOKEN_EXPIRES_KEY = 'rbac_token_expires_at' const USER_INFO_KEY = 'rbac_userInfo' const LAST_ACTIVITY_KEY = 'rbac_last_activity' const MENUS_SESSION_KEY = 'rbac_user_menus' const MENUS_USER_KEY = 'rbac_user_menus_uid' // 兼容旧代码的 key const OLD_TOKEN_KEY = 'rbac_token' const LOGIN_TYPE_KEY = 'rbac_login_type' const SSO_SESSION_FLAG = 'rbac_sso_session' function isSsoSessionActive(): boolean { return sessionStorage.getItem(SSO_SESSION_FLAG) === '1' } function getAuthStorage(): Storage { return isSsoSessionActive() ? sessionStorage : localStorage } function readAuthItem(key: string): string { if (isSsoSessionActive()) { return sessionStorage.getItem(key) || '' } return localStorage.getItem(key) || '' } // 1小时无操作则认为会话应过期(前端主导的“空闲超时”,用于控制是否允许续期) const KEEP_ALIVE_IDLE_MS = 60 * 60 * 1000 // 注意:tokenExpiresAt 在 saveTokens 里会提前 5 分钟到期刷新(当前实现是 -300s) // 因此“真实 accessToken 过期时间”= tokenExpiresAt + 5min const ACCESS_TOKEN_REAL_EXPIRES_AT_OFFSET_MS = 5 * 60 * 1000 export const useRbacUserStore = defineStore('rbacUser', () => { const persistInSession = ref(isSsoSessionActive()) const accessToken = ref(readAuthItem(TOKEN_KEY)) const refreshToken = ref(readAuthItem(REFRESH_TOKEN_KEY)) const tokenExpiresAt = ref(Number(readAuthItem(TOKEN_EXPIRES_KEY)) || 0) // 仅在用户发生交互后才会开启;开启后允许续期,并在倒计时到达时登出 const keepAliveUntil = ref(0) const savedUserInfo = getAuthStorage().getItem(USER_INFO_KEY) const userInfo = ref(savedUserInfo ? JSON.parse(savedUserInfo) : null) const loginPortalType = ref(readStoredLoginType()) /** get-current-user 单飞,避免 F5 多处并发重复请求 */ let currentUserInflight: Promise | null = null const permissions = computed(() => userInfo.value?.permissions || []) const menus = computed(() => userInfo.value?.menus || []) const isLoggedIn = computed(() => !!accessToken.value) // 兼容旧代码 const token = computed(() => accessToken.value) /** 持久化瘦身:menus 仅留内存 / session,避免 localStorage 写入过大 JSON */ function persistUserInfo(info: UserInfoDto | null) { if (!info) { getAuthStorage().removeItem(USER_INFO_KEY) return } const slim: UserInfoDto = { ...info } delete slim.menus getAuthStorage().setItem(USER_INFO_KEY, JSON.stringify(slim)) } function persistMenusToSession(info: UserInfoDto) { if (!info.id || !info.menus?.length) return try { sessionStorage.setItem(MENUS_USER_KEY, String(info.id)) sessionStorage.setItem(MENUS_SESSION_KEY, JSON.stringify(info.menus)) } catch { // quota / private mode:忽略,不影响主流程 } } function clearMenusSession() { sessionStorage.removeItem(MENUS_SESSION_KEY) sessionStorage.removeItem(MENUS_USER_KEY) } /** 同标签页 F5:从 sessionStorage 恢复 menus,可跳过 get-current-user */ function restoreMenusFromSession() { if (!userInfo.value?.id) return const uid = sessionStorage.getItem(MENUS_USER_KEY) if (uid !== String(userInfo.value.id)) return const raw = sessionStorage.getItem(MENUS_SESSION_KEY) if (!raw) return try { const cached = JSON.parse(raw) as MenuTreeDto[] if (Array.isArray(cached) && cached.length > 0) { userInfo.value = { ...userInfo.value, menus: cached } } } catch { clearMenusSession() } } function setUserInfo(info: UserInfoDto) { userInfo.value = info persistUserInfo(info) persistMenusToSession(info) } function readStoredLoginTypeRaw(): OmsLoginType | null { const raw = getAuthStorage().getItem(LOGIN_TYPE_KEY) ?? localStorage.getItem(LOGIN_TYPE_KEY) if (raw === String(OmsLoginType.Customer)) return OmsLoginType.Customer if (raw === String(OmsLoginType.User)) return OmsLoginType.User return null } function readStoredLoginType(): OmsLoginType { return readStoredLoginTypeRaw() ?? OmsLoginType.User } function setLoginPortalType(type: OmsLoginType, options?: { sessionOnly?: boolean }) { loginPortalType.value = type if (options?.sessionOnly) { persistInSession.value = true sessionStorage.setItem(SSO_SESSION_FLAG, '1') sessionStorage.setItem(LOGIN_TYPE_KEY, String(type)) return } persistInSession.value = false sessionStorage.removeItem(SSO_SESSION_FLAG) localStorage.setItem(LOGIN_TYPE_KEY, String(type)) } /** 从 JWT 校正门户类型(兼容旧会话无 rbac_login_type) */ function syncLoginPortalTypeFromToken(token?: string | null) { const fromJwt = getLoginTypeFromAccessToken(token || accessToken.value) if (fromJwt == null) return if (loginPortalType.value === fromJwt && readStoredLoginTypeRaw() === fromJwt) return setLoginPortalType(fromJwt, { sessionOnly: persistInSession.value }) } /** * 登出/过期回跳用的主体类型。 * 优先 JWT claim → 显式存储 → 内存门户类型(登录时写入,须可区分两端)。 */ function resolveLoginTypeForRedirect(token?: string | null): OmsLoginType { const fromJwt = getLoginTypeFromAccessToken(token || accessToken.value) if (fromJwt != null) return fromJwt const stored = readStoredLoginTypeRaw() if (stored != null) return stored return loginPortalType.value } function getLoginRouteName(): string { return getLoginRouteNameByType(loginPortalType.value) } function isCustomerPortal(): boolean { return loginPortalType.value === OmsLoginType.Customer } // 保存 Token 信息 function saveTokens(result: LoginResultDto, options?: { sessionOnly?: boolean }) { if (!result.accessToken) { return } resetSessionExpiredGuard() accessToken.value = result.accessToken refreshToken.value = result.refreshToken || '' const expiresInSeconds = typeof result.expiresIn === 'number' && result.expiresIn > 0 ? result.expiresIn : 3600 tokenExpiresAt.value = Date.now() + (expiresInSeconds - 300) * 1000 const storage = options?.sessionOnly ? sessionStorage : localStorage if (options?.sessionOnly) { persistInSession.value = true sessionStorage.setItem(SSO_SESSION_FLAG, '1') } else { persistInSession.value = false sessionStorage.removeItem(SSO_SESSION_FLAG) } storage.setItem(TOKEN_KEY, accessToken.value) if (refreshToken.value) { storage.setItem(REFRESH_TOKEN_KEY, refreshToken.value) } storage.setItem(TOKEN_EXPIRES_KEY, String(tokenExpiresAt.value)) setAuthToken(accessToken.value) if (!options?.sessionOnly) { // 兼容旧代码及 auth utils 的 isLogin 检查 localStorage.setItem(OLD_TOKEN_KEY, accessToken.value) localStorage.setItem('token', accessToken.value) } } // 真实 accessToken 过期时间(用于“无操作也要按时间跳登录”的兜底) function getAccessTokenRealExpiresAt(): number { if (!tokenExpiresAt.value) return 0 return tokenExpiresAt.value + ACCESS_TOKEN_REAL_EXPIRES_AT_OFFSET_MS } function isKeepAliveActive(): boolean { return keepAliveUntil.value > 0 && Date.now() < keepAliveUntil.value } function persistLastActivity(at = Date.now()) { getAuthStorage().setItem(LAST_ACTIVITY_KEY, String(at)) } function enableKeepAlive() { const now = Date.now() keepAliveUntil.value = now + KEEP_ALIVE_IDLE_MS persistLastActivity(now) } function disableKeepAlive() { keepAliveUntil.value = 0 } function getKeepAliveUntil(): number { return keepAliveUntil.value } /** F5 后根据上次交互时间恢复保活窗,允许冷启动续 token */ function restoreKeepAliveFromStorage() { const raw = Number(readAuthItem(LAST_ACTIVITY_KEY) || 0) if (!raw || Number.isNaN(raw)) return const until = raw + KEEP_ALIVE_IDLE_MS if (Date.now() < until) { keepAliveUntil.value = until } } // 检查 Token 是否需要刷新 function isTokenExpiringSoon(): boolean { return tokenExpiresAt.value > 0 && Date.now() >= tokenExpiresAt.value } function normalizeRefreshToken(raw: string): string | null { let rt = raw if (rt.startsWith('Bearer ')) { rt = rt.slice('Bearer '.length) } const parts = rt.split('.') if (parts.length !== 3 || parts.some((p) => !p || p.length < 8)) { return null } if (rt.includes('[') || rt.includes(']') || rt.includes('{') || rt.includes('}')) { return null } return rt } /** * 刷新 Token。 * @param options.force 冷启动/首请求:即使 keepAlive 未恢复也允许用 refreshToken 换新 access */ async function refreshAccessToken(options?: { force?: boolean }): Promise { if (!options?.force && !isKeepAliveActive()) { return false } if (!refreshToken.value) { return false } const rt = normalizeRefreshToken(refreshToken.value) if (!rt) { console.warn('[rbac] refreshToken format invalid, skip refresh') logout() return false } try { const result = await AuthService.postRbacAuthRefreshToken({ refreshToken: rt }) if (result?.accessToken) { saveTokens(result, { sessionOnly: persistInSession.value }) return true } return false } catch { logout() return false } } // 登录(OMS 管理端默认 type=User) // 失败不弹全局 Message,由登录页内联 errorBox 展示 async function login(account: string, password: string, loginType: OmsLoginType = OmsLoginType.User) { try { const result = await AuthService.postRbacAuthLogin({ account, password, type: loginType, }) saveTokens(result) setLoginPortalType(loginType) enableKeepAlive() await hydrateUserAfterLogin(result) return true } catch { return false } } /** 微信扫码登录成功后写入 Token 并拉取用户信息 */ async function loginByWxResult(result: LoginResultDto): Promise { try { if (!result?.accessToken) { return false } saveTokens(result) setLoginPortalType(OmsLoginType.Customer) enableKeepAlive() await hydrateUserAfterLogin(result) return true } catch (error) { handleApiError(error) return false } } /** 管理员代登客户门户:Token 仅存 sessionStorage,避免覆盖管理端登录态 */ async function loginBySsoResult(result: LoginResultDto): Promise { try { if (!result?.accessToken) { return false } saveTokens(result, { sessionOnly: true }) setLoginPortalType(OmsLoginType.Customer, { sessionOnly: true }) enableKeepAlive() await hydrateUserAfterLogin(result) return true } catch (error) { handleApiError(error) return false } } /** 登录响应若已带 userInfo 则直接用,否则再打 get-current-user */ async function hydrateUserAfterLogin(result: LoginResultDto) { const embedded = result.userInfo if (embedded?.id) { setUserInfo(embedded) return embedded } return getCurrentUser() } // 获取当前用户信息(基于 Token,无需 userId 参数) async function getCurrentUser() { try { if (isTokenExpiringSoon()) { // 冷启动可能尚未 restore keepAlive,强制允许一次续期 const ok = await refreshAccessToken({ force: true }) if (!ok && isTokenExpiringSoon()) { logout() throw new Error('登录已过期') } } const data = await AuthService.getRbacAuthGetCurrentUser() setUserInfo(data) return data } catch (error) { logout() throw error } } /** * 确保有完整用户信息(含 menus)。 * - 内存已有 menus 且非 force:直接返回 * - 多处并发共用同一 Promise */ async function ensureCurrentUser(options?: { force?: boolean }): Promise { if (!accessToken.value) { return null } if ( !options?.force && userInfo.value?.id && (userInfo.value.menus?.length ?? 0) > 0 ) { return userInfo.value } if (!currentUserInflight) { currentUserInflight = getCurrentUser().finally(() => { currentUserInflight = null }) } return currentUserInflight } /** bootstrap 提前发起,与 i18n 并行 */ function prefetchCurrentUser() { if (!accessToken.value) return if ((userInfo.value?.menus?.length ?? 0) > 0) return void ensureCurrentUser() } // 刷新用户信息(强制从服务器获取最新数据) async function refreshUserInfo() { try { const data = await AuthService.getRbacAuthGetCurrentUser() setUserInfo(data) return data } catch (error) { handleApiError(error) throw error } } // 登出:保留 loginPortalType / LOGIN_TYPE,确保过期回跳仍进对应门户登录页 function logout() { // 先标记会话结束,避免页面卸载前 in-flight 请求 401 连弹「登录已过期」 markSessionEndedByUser() accessToken.value = '' refreshToken.value = '' tokenExpiresAt.value = 0 keepAliveUntil.value = 0 userInfo.value = null currentUserInflight = null // SSO 会话结束前,把客户门户类型落到 localStorage,避免仅存在 session 时丢失 if (persistInSession.value && loginPortalType.value === OmsLoginType.Customer) { localStorage.setItem(LOGIN_TYPE_KEY, String(OmsLoginType.Customer)) } persistInSession.value = false localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(REFRESH_TOKEN_KEY) localStorage.removeItem(TOKEN_EXPIRES_KEY) localStorage.removeItem(USER_INFO_KEY) localStorage.removeItem(LAST_ACTIVITY_KEY) // 兼容旧代码 localStorage.removeItem(OLD_TOKEN_KEY) localStorage.removeItem('token') localStorage.removeItem('userInfo') sessionStorage.removeItem(SSO_SESSION_FLAG) sessionStorage.removeItem(TOKEN_KEY) sessionStorage.removeItem(REFRESH_TOKEN_KEY) sessionStorage.removeItem(TOKEN_EXPIRES_KEY) sessionStorage.removeItem(USER_INFO_KEY) sessionStorage.removeItem(LAST_ACTIVITY_KEY) clearMenusSession() clearAuthToken() } // 初始化:恢复登录状态、保活窗、同页菜单缓存 function initAuth() { if (accessToken.value) { setAuthToken(accessToken.value) syncLoginPortalTypeFromToken(accessToken.value) } restoreKeepAliveFromStorage() restoreMenusFromSession() } initAuth() // 检查权限(与 @/utils/rbac-permission 规则一致:资源码 ↔ Query 互通) function hasPermission(permission: string): boolean { if (!permission?.trim()) return true // Admin 角色默认全权限 if (hasRole('Admin')) return true const code = permission.trim() const granted = permissions.value if (granted.includes(code)) return true if (/\.Query$/i.test(code)) { const resource = code.replace(/\.Query$/i, '') if (resource && granted.includes(resource)) return true } else if (granted.includes(`${code}.Query`)) { return true } return false } // 检查角色 function hasRole(roleCode: string): boolean { return userInfo.value?.roles?.some((r) => r.code === roleCode) || false } return { token, accessToken, refreshToken, userInfo, permissions, menus, isLoggedIn, loginPortalType, getLoginRouteName, isCustomerPortal, resolveLoginTypeForRedirect, syncLoginPortalTypeFromToken, getAccessTokenRealExpiresAt, isKeepAliveActive, enableKeepAlive, disableKeepAlive, getKeepAliveUntil, login, loginByWxResult, loginBySsoResult, getCurrentUser, ensureCurrentUser, prefetchCurrentUser, refreshUserInfo, logout, hasPermission, hasRole, refreshAccessToken, isTokenExpiringSoon, } })