From 543d1a59a9e8af01368f9be5133d2ef25264c945 Mon Sep 17 00:00:00 2001 From: hongawen <83944980@qq.com> Date: Fri, 15 May 2026 13:38:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E4=BF=AE=E5=A4=8D=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E8=BF=87=E6=9C=9F=E5=A4=84=E7=90=86=E5=92=8C=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=A4=B1=E6=95=88=E9=80=9A=E7=9F=A5=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 VITE_SERVICE_LOGOUT_CODES 中的 1002023000 状态码 - 将 VITE_SERVICE_EXPIRED_TOKEN_CODES 从 1002023001 改为 1002023000 - 修改 fetchRefreshToken 函数使用 params 传递 refreshToken 并设置 skipAuth - 添加 skipAuth 配置选项避免给公开接口带上过期 access 头 - 实现 notifySessionExpired 函数确保并发请求只弹一次会话失效提示 - 在登录成功后复位会话失效标志以支持下次正常提示 - 更新 handleExpiredRequest 使用 refreshTokenPromise 替代 refreshTokenFn --- .env | 4 ++-- src/service/api/auth.ts | 21 +++++++++++++++----- src/service/request/dedupe.ts | 7 +++++++ src/service/request/index.ts | 15 +++++++++----- src/service/request/shared.ts | 35 ++++++++++++++++++++++++++------- src/service/request/type.ts | 2 ++ src/store/modules/auth/index.ts | 4 ++++ 7 files changed, 69 insertions(+), 19 deletions(-) diff --git a/.env b/.env index 4f6bab1..df518b2 100644 --- a/.env +++ b/.env @@ -33,7 +33,7 @@ VITE_SERVICE_SUCCESS_CODE=0 # 后端登出状态码;当返回这些 code 时,前端会登出并跳回登录页 # 典型场景:token 无效、登录状态失效、账号被踢下线、后端要求强制重新登录 -VITE_SERVICE_LOGOUT_CODES=401,1002023000 +VITE_SERVICE_LOGOUT_CODES=401 # 后端弹窗登出状态码;当返回这些 code 时,前端会先弹窗再登出 # 典型场景:账号被禁用、密码已重置、登录安全策略触发、需要用户先确认后再重新登录 @@ -41,7 +41,7 @@ VITE_SERVICE_MODAL_LOGOUT_CODES=7777,7778 # token 过期状态码;当返回这些 code 时,前端会尝试刷新 token 并重发请求 # 典型场景:accessToken 过期但 refreshToken 仍有效、短期登录凭证失效但允许无感续期 -VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023001 +VITE_SERVICE_EXPIRED_TOKEN_CODES=1002023000 # 静态路由模式下定义的超级管理员角色 VITE_STATIC_SUPER_ROLE=R_SUPER diff --git a/src/service/api/auth.ts b/src/service/api/auth.ts index ccfe031..9d5d7d2 100644 --- a/src/service/api/auth.ts +++ b/src/service/api/auth.ts @@ -106,14 +106,25 @@ export async function fetchGetUserInfo(force = false): Promise({ +export async function fetchRefreshToken(refreshToken: string): Promise> { + // 后端要求 refreshToken 通过 query 参数传递,且 Content-Type 为 form-urlencoded + // skipAuth: 不注入过期 access 头,否则会被网关拦下死循环(网关一律校验 Authorization,不看 PermitAll) + const result = await request({ url: `${SYSTEM_SERVICE_PREFIX}/auth/refresh-token`, method: 'post', - data: { - refreshToken - } + params: { refreshToken }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + skipAuth: true }); + + if (result.error || !result.data) { + return result as ServiceRequestResult; + } + + return { + ...result, + data: mapLoginToken(result.data) + }; } /** diff --git a/src/service/request/dedupe.ts b/src/service/request/dedupe.ts index 2975098..6e04706 100644 --- a/src/service/request/dedupe.ts +++ b/src/service/request/dedupe.ts @@ -3,6 +3,13 @@ import type { InternalAxiosRequestConfig } from 'axios'; declare module 'axios' { interface AxiosRequestConfig { dedupe?: boolean; + /** + * 跳过 Authorization 注入。 + * + * 用于公开接口(refresh-token / login / register 等 PermitAll 路径), + * 避免给它们带上过期 access 头被网关拦截。 + */ + skipAuth?: boolean; } } diff --git a/src/service/request/index.ts b/src/service/request/index.ts index e4f9668..86c29b3 100644 --- a/src/service/request/index.ts +++ b/src/service/request/index.ts @@ -5,7 +5,7 @@ import { localStg } from '@/utils/storage'; import { getServiceBaseURL } from '@/utils/service'; import { $t } from '@/locales'; import { applyApiEncrypt } from './api-encrypt'; -import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared'; +import { getAuthorization, handleExpiredRequest, notifySessionExpired, showErrorMsg } from './shared'; import { withDedupe } from './dedupe'; import type { RequestInstanceState } from './type'; @@ -29,8 +29,12 @@ export const request = withDedupe( return response.data.data; }, async onRequest(config) { - const Authorization = getAuthorization(); - Object.assign(config.headers, { Authorization }); + // skipAuth 为 true 的请求不注入 Authorization——避免给公开接口(如 refresh-token) + // 带上过期 access 头被网关拦截(网关只看 Authorization,不区分路由是否 PermitAll) + if (!config.skipAuth) { + const Authorization = getAuthorization(); + Object.assign(config.headers, { Authorization }); + } applyApiEncrypt(config); return config; @@ -55,10 +59,11 @@ export const request = withDedupe( request.state.errMsgStack = request.state.errMsgStack.filter(msg => msg !== response.data.msg); } - // 当后端返回码命中 `logoutCodes` 时,表示用户需要退出登录并跳转到登录页 + // 当后端返回码命中 `logoutCodes` 时,表示登录态已失效,需要提示后退出登录 + // 走 notifySessionExpired 而不是裸 resetStore:保证并发请求只弹一次 toast、只清一次状态 const logoutCodes = import.meta.env.VITE_SERVICE_LOGOUT_CODES?.split(',') || []; if (logoutCodes.includes(responseCode)) { - handleLogout(); + notifySessionExpired(); return null; } diff --git a/src/service/request/shared.ts b/src/service/request/shared.ts index 12a757f..19dea30 100644 --- a/src/service/request/shared.ts +++ b/src/service/request/shared.ts @@ -12,8 +12,6 @@ export function getAuthorization() { /** 刷新 token */ async function handleRefreshToken() { - const { resetStore } = useAuthStore(); - const rToken = localStg.get('refreshToken') || ''; const { error, data } = await fetchRefreshToken(rToken); if (!error) { @@ -22,25 +20,48 @@ async function handleRefreshToken() { return true; } - resetStore(); + notifySessionExpired(); return false; } export async function handleExpiredRequest(state: RequestInstanceState) { - if (!state.refreshTokenFn) { - state.refreshTokenFn = handleRefreshToken(); + if (!state.refreshTokenPromise) { + state.refreshTokenPromise = handleRefreshToken(); } - const success = await state.refreshTokenFn; + const success = await state.refreshTokenPromise; setTimeout(() => { - state.refreshTokenFn = null; + state.refreshTokenPromise = null; }, 1000); return success; } +// 会话失效一次性锁:保证 N 个并发请求只弹一次 toast、只 resetStore 一次 +let sessionExpiredNotified = false; + +/** + * 通知用户会话已失效,弹一次 toast 后清状态、跳登录。 + * + * 多个并发请求触发时只会真正执行一次;登录成功后由 resetSessionExpiredFlag() 复位。 + */ +export function notifySessionExpired() { + if (sessionExpiredNotified) return; + sessionExpiredNotified = true; + + window.$message?.error('登录已过期,请重新登录'); + + const { resetStore } = useAuthStore(); + resetStore(); +} + +/** 登录成功后复位一次性锁,让下一次会话失效仍能正常提示 */ +export function resetSessionExpiredFlag() { + sessionExpiredNotified = false; +} + export function showErrorMsg(state: RequestInstanceState, message: string) { if (!state.errMsgStack?.length) { state.errMsgStack = []; diff --git a/src/service/request/type.ts b/src/service/request/type.ts index c63e722..cfab597 100644 --- a/src/service/request/type.ts +++ b/src/service/request/type.ts @@ -3,5 +3,7 @@ export interface RequestInstanceState { refreshTokenPromise: Promise | null; /** 请求错误信息栈 */ errMsgStack: string[]; + // 索引签名是 @sa/axios 的 defaultState 类型约束(要求 Record)的硬要求,不能删 + // 字段名对齐已通过把 shared.ts 里的 refreshTokenFn 全部改成 refreshTokenPromise 来消除隐患 [key: string]: unknown; } diff --git a/src/store/modules/auth/index.ts b/src/store/modules/auth/index.ts index 3e49439..94e5701 100644 --- a/src/store/modules/auth/index.ts +++ b/src/store/modules/auth/index.ts @@ -3,6 +3,7 @@ import { useRoute } from 'vue-router'; import { defineStore } from 'pinia'; import { useLoading } from '@sa/hooks'; import { clearAuthApiCache, fetchGetUserInfo, fetchLogin } from '@/service/api'; +import { resetSessionExpiredFlag } from '@/service/request/shared'; import { useRouterPush } from '@/hooks/common/router'; import { localStg } from '@/utils/storage'; import { SetupStoreId } from '@/enum'; @@ -149,6 +150,9 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => { token.value = loginToken.token; + // 复位会话失效一次性锁,让下一次会话失效仍能正常提示 + resetSessionExpiredFlag(); + return true; }