From 1a82dfab35357ce7c876ae3b0f78daca8983b24e Mon Sep 17 00:00:00 2001 From: meowrain Date: Mon, 12 Jan 2026 02:06:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=AE=9E=E7=8E=B0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=99=BB=E5=87=BA=E5=8A=9F=E8=83=BD=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加用户登出方法到用户store,替换多处手动更新登录状态的代码 新增错误码枚举和映射,完善axios拦截器中的错误处理逻辑 重构token刷新逻辑为独立函数,支持401错误自动刷新token --- src/components/GlobalHeader.vue | 5 +- src/plugins/axios.ts | 181 +++++++++++++++++++---------- src/store/user.ts | 7 ++ src/types/errorCode.ts | 80 +++++++++++++ src/views/user/UserProfileView.vue | 2 +- 5 files changed, 211 insertions(+), 64 deletions(-) create mode 100644 src/types/errorCode.ts diff --git a/src/components/GlobalHeader.vue b/src/components/GlobalHeader.vue index 3a1f35a..768a2a6 100644 --- a/src/components/GlobalHeader.vue +++ b/src/components/GlobalHeader.vue @@ -131,10 +131,7 @@ const goToSettings = () => { // 退出登录 const handleLogout = () => { clearTokens(); - userStore.updateUserLoginStatus({ - userName: "未登录", - userRole: ACCESS_ENUM.NOT_LOGIN, - }); + userStore.logout(); Message.success("已退出登录"); router.push("/home"); }; diff --git a/src/plugins/axios.ts b/src/plugins/axios.ts index 91bf18c..71e5ad9 100644 --- a/src/plugins/axios.ts +++ b/src/plugins/axios.ts @@ -11,12 +11,97 @@ import { } from "@/utils/token"; import { Message } from "@arco-design/web-vue"; import { isApiSuccess } from "@/api/response"; +import { + ErrorCode, + ErrorMessages, + isNotLoginError, + isNoAuthError, + isParamsError, + isServerError, +} from "@/types/errorCode"; + +// 扩展 Axios 请求配置类型 +declare module "axios" { + interface InternalAxiosRequestConfig { + _retry?: boolean; + } +} // 是否正在刷新 token let isRefreshing = false; // 存储因为 token 过期而挂起的请求 let requests: ((token: string) => void)[] = []; +// 统一的刷新 token 并重试请求的函数 +const refreshTokenAndRetry = async (originalRequest: InternalAxiosRequestConfig) => { + // 如果正在刷新,将当前请求加入队列等待 + if (isRefreshing) { + return new Promise((resolve) => { + requests.push((token: string) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + resolve(request(originalRequest)); + }); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + throw new Error("No refresh token available"); + } + + // 使用原生 axios 发送刷新请求,避免拦截器循环 + const response = await axios.post( + `${ENV.API_BASE_URL}/v1/auth/refresh`, + null, + { + params: { refreshToken }, + } + ); + + if (isApiSuccess(response.data)) { + const { accessToken, refreshToken: newRefreshToken } = + response.data.data; + // 更新本地存储 + setTokens(accessToken, newRefreshToken); + + // 执行队列中的请求 + requests.forEach((cb) => cb(accessToken)); + requests = []; + + // 重试当前请求 + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + return request(originalRequest); + } else { + throw new Error("Refresh token failed"); + } + } catch (refreshError) { + console.error("Token 刷新失败:", refreshError); + // 清除过期 token + clearTokens(); + + // 动态导入 userStore,确保 Pinia 已初始化 + import("@/store/user").then(({ useUserStore }) => { + const userStore = useUserStore(); + userStore.logout(); + }); + + Message.error("登录已过期,请重新登录"); + + // 跳转到登录页 + setTimeout(() => { + window.location.href = "/user/login"; + }, 1000); + + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } +}; + const request: AxiosInstance = axios.create({ baseURL: ENV.API_BASE_URL, timeout: 10000, @@ -44,80 +129,58 @@ request.interceptors.request.use( // ======================= request.interceptors.response.use( (response) => { - // console.log("响应: ", response); const data = response.data; - /**TODO: 增加响应码处理 */ + const originalRequest = response.config; + const code = data?.code; + + // 未登录错误 (40100) + if (isNotLoginError(code)) { + clearTokens(); + import("@/store/user").then(({ useUserStore }) => { + const userStore = useUserStore(); + userStore.logout(); + }); + Message.error(data?.message || ErrorMessages[ErrorCode.NOT_LOGIN_ERROR]); + setTimeout(() => { + window.location.href = "/user/login"; + }, 1000); + return Promise.reject(new Error(data?.message || ErrorMessages[ErrorCode.NOT_LOGIN_ERROR])); + } + + // 无权限/token 过期错误 (40101) - 尝试刷新 token + if (isNoAuthError(code) && !originalRequest._retry) { + return refreshTokenAndRetry(originalRequest); + } + + // 参数错误 (40000) + if (isParamsError(code)) { + Message.warning(data?.message || ErrorMessages[ErrorCode.PARAMS_ERROR]); + return data; + } + + // 服务端错误 (5xxxx) + if (isServerError(code)) { + Message.error(data?.message || ErrorMessages[ErrorCode.SYSTEM_ERROR]); + return data; + } + return data; }, async (error) => { const originalRequest = error.config; - // 处理 401 未授权情况 (Token 过期) + // 处理 HTTP 401 未授权情况 (Token 过期) // 确保不是刷新 token 的请求本身 (避免死循环) if ( error.response?.status === 401 && !originalRequest._retry && !originalRequest.url.includes("/auth/refresh") ) { - // 如果正在刷新,将当前请求加入队列等待 - if (isRefreshing) { - return new Promise((resolve) => { - requests.push((token: string) => { - originalRequest.headers.Authorization = `Bearer ${token}`; - resolve(request(originalRequest)); - }); - }); - } - - originalRequest._retry = true; - isRefreshing = true; - - try { - const refreshToken = getRefreshToken(); - if (!refreshToken) { - throw new Error("No refresh token available"); - } - - // 使用原生 axios 发送刷新请求,避免拦截器循环 - const response = await axios.post( - `${ENV.API_BASE_URL}/v1/auth/refresh`, - null, - { - params: { refreshToken }, - } - ); - - if (isApiSuccess(response.data)) { - const { accessToken, refreshToken: newRefreshToken } = - response.data.data; - // 更新本地存储 - setTokens(accessToken, newRefreshToken); - - // 执行队列中的请求 - requests.forEach((cb) => cb(accessToken)); - requests = []; - - // 重试当前请求 - originalRequest.headers.Authorization = `Bearer ${accessToken}`; - return request(originalRequest); - } else { - throw new Error("Refresh token failed"); - } - } catch (refreshError) { - console.error("Token 刷新失败:", refreshError); - // 清除过期 token - clearTokens(); - Message.error("登录已过期,请重新登录"); - // 这里可以选择跳转到登录页,例如 window.location.href = '/user/login' - // 建议让路由守卫或者页面自行处理未登录状态 - return Promise.reject(refreshError); - } finally { - isRefreshing = false; - } + return refreshTokenAndRetry(originalRequest); } /** - * Promise.reject(error) 用来 返回一个状态为 rejected 的 Promise,相当于“主动抛出错误”, + * Promise.reject(error) 用来 返回一个状态为 rejected 的 Promise,相当于"主动抛出错误", * * 让调用者能够在 .catch() 或 try/catch 中捕获这个错误。 * 它在 异步流程、拦截器、错误处理 中非常常见。 diff --git a/src/store/user.ts b/src/store/user.ts index 2afa7d8..3b51092 100644 --- a/src/store/user.ts +++ b/src/store/user.ts @@ -39,6 +39,13 @@ export const useUserStore = defineStore("user", { // 手动更新用户状态 updateUserLoginStatus(user: LoginUesr) { this.loginUser = user; + }, + // 退出登录(清除登录状态) + logout() { + this.loginUser = { + userName: "未登录", + userRole: ACCESS_ENUM.NOT_LOGIN, + }; } }, }); diff --git a/src/types/errorCode.ts b/src/types/errorCode.ts new file mode 100644 index 0000000..e0c155f --- /dev/null +++ b/src/types/errorCode.ts @@ -0,0 +1,80 @@ +/** + * 错误码常量 + * 与后端保持一致 + */ +export const ErrorCode = { + // 成功 + SUCCESS: "0", + + // 客户端错误 4xxxx + PARAMS_ERROR: "40000", // 请求参数错误 + NOT_LOGIN_ERROR: "40100", // 未登录 + NO_AUTH_ERROR: "40101", // 无权限 + NOT_FOUND_ERROR: "40400", // 请求数据不存在 + FORBIDDEN_ERROR: "40300", // 禁止访问 + + // 服务端错误 5xxxx + SYSTEM_ERROR: "50000", // 系统内部异常 + OPERATION_ERROR: "50001", // 操作失败 + API_REQUEST_ERROR: "50010", // 接口调用失败 +} as const; + +/** + * 错误码类型 + */ +export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode]; + +/** + * 错误码对应的消息映射 + * 用于当后端未返回 message 时使用 + */ +export const ErrorMessages: Record = { + [ErrorCode.SUCCESS]: "操作成功", + [ErrorCode.PARAMS_ERROR]: "请求参数错误", + [ErrorCode.NOT_LOGIN_ERROR]: "未登录,请先登录", + [ErrorCode.NO_AUTH_ERROR]: "无权限访问", + [ErrorCode.NOT_FOUND_ERROR]: "请求的数据不存在", + [ErrorCode.FORBIDDEN_ERROR]: "禁止访问", + [ErrorCode.SYSTEM_ERROR]: "系统内部异常,请稍后重试", + [ErrorCode.OPERATION_ERROR]: "操作失败,请稍后重试", + [ErrorCode.API_REQUEST_ERROR]: "接口调用失败,请稍后重试", +}; + +/** + * 判断是否为成功错误码 + */ +export const isSuccessCode = (code: string | number | undefined): boolean => { + return code === ErrorCode.SUCCESS || code === 0; +}; + +/** + * 判断是否为未登录错误码 + */ +export const isNotLoginError = (code: string | number | undefined): boolean => { + return code === ErrorCode.NOT_LOGIN_ERROR; +}; + +/** + * 判断是否为无权限错误码 + */ +export const isNoAuthError = (code: string | number | undefined): boolean => { + return code === ErrorCode.NO_AUTH_ERROR; +}; + +/** + * 判断是否为参数错误 + */ +export const isParamsError = (code: string | number | undefined): boolean => { + return code === ErrorCode.PARAMS_ERROR; +}; + +/** + * 判断是否为服务端错误 (5xxxx) + */ +export const isServerError = (code: string | number | undefined): boolean => { + return ( + code === ErrorCode.SYSTEM_ERROR || + code === ErrorCode.OPERATION_ERROR || + code === ErrorCode.API_REQUEST_ERROR + ); +}; diff --git a/src/views/user/UserProfileView.vue b/src/views/user/UserProfileView.vue index d37e548..6df2571 100644 --- a/src/views/user/UserProfileView.vue +++ b/src/views/user/UserProfileView.vue @@ -54,7 +54,7 @@ import { updateUserAvatar } from '@/api/auth/user'; import { isApiSuccess } from '@/api/response'; import { Message } from '@arco-design/web-vue'; import { IconCamera } from '@arco-design/web-vue/es/icon'; -import SparkMD5 from 'spark-md5'; +import * as SparkMD5 from 'spark-md5'; import type { RequestOption } from '@arco-design/web-vue/es/upload/interfaces'; const userStore = useUserStore();