feat(auth): 实现用户登出功能并增强错误处理

添加用户登出方法到用户store,替换多处手动更新登录状态的代码
新增错误码枚举和映射,完善axios拦截器中的错误处理逻辑
重构token刷新逻辑为独立函数,支持401错误自动刷新token
This commit is contained in:
2026-01-12 02:06:13 +08:00
parent 3b6fb0cae1
commit 1a82dfab35
5 changed files with 211 additions and 64 deletions

View File

@@ -131,10 +131,7 @@ const goToSettings = () => {
// 退出登录 // 退出登录
const handleLogout = () => { const handleLogout = () => {
clearTokens(); clearTokens();
userStore.updateUserLoginStatus({ userStore.logout();
userName: "未登录",
userRole: ACCESS_ENUM.NOT_LOGIN,
});
Message.success("已退出登录"); Message.success("已退出登录");
router.push("/home"); router.push("/home");
}; };

View File

@@ -11,12 +11,97 @@ import {
} from "@/utils/token"; } from "@/utils/token";
import { Message } from "@arco-design/web-vue"; import { Message } from "@arco-design/web-vue";
import { isApiSuccess } from "@/api/response"; import { isApiSuccess } from "@/api/response";
import {
ErrorCode,
ErrorMessages,
isNotLoginError,
isNoAuthError,
isParamsError,
isServerError,
} from "@/types/errorCode";
// 扩展 Axios 请求配置类型
declare module "axios" {
interface InternalAxiosRequestConfig {
_retry?: boolean;
}
}
// 是否正在刷新 token // 是否正在刷新 token
let isRefreshing = false; let isRefreshing = false;
// 存储因为 token 过期而挂起的请求 // 存储因为 token 过期而挂起的请求
let requests: ((token: string) => void)[] = []; 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({ const request: AxiosInstance = axios.create({
baseURL: ENV.API_BASE_URL, baseURL: ENV.API_BASE_URL,
timeout: 10000, timeout: 10000,
@@ -44,80 +129,58 @@ request.interceptors.request.use(
// ======================= // =======================
request.interceptors.response.use( request.interceptors.response.use(
(response) => { (response) => {
// console.log("响应: ", response);
const data = response.data; 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; return data;
}, },
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config;
// 处理 401 未授权情况 (Token 过期) // 处理 HTTP 401 未授权情况 (Token 过期)
// 确保不是刷新 token 的请求本身 (避免死循环) // 确保不是刷新 token 的请求本身 (避免死循环)
if ( if (
error.response?.status === 401 && error.response?.status === 401 &&
!originalRequest._retry && !originalRequest._retry &&
!originalRequest.url.includes("/auth/refresh") !originalRequest.url.includes("/auth/refresh")
) { ) {
// 如果正在刷新,将当前请求加入队列等待 return refreshTokenAndRetry(originalRequest);
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;
}
} }
/** /**
* Promise.reject(error) 用来 返回一个状态为 rejected 的 Promise相当于主动抛出错误 * Promise.reject(error) 用来 返回一个状态为 rejected 的 Promise相当于"主动抛出错误"
* *
* 让调用者能够在 .catch() 或 try/catch 中捕获这个错误。 * 让调用者能够在 .catch() 或 try/catch 中捕获这个错误。
* 它在 异步流程、拦截器、错误处理 中非常常见。 * 它在 异步流程、拦截器、错误处理 中非常常见。

View File

@@ -39,6 +39,13 @@ export const useUserStore = defineStore("user", {
// 手动更新用户状态 // 手动更新用户状态
updateUserLoginStatus(user: LoginUesr) { updateUserLoginStatus(user: LoginUesr) {
this.loginUser = user; this.loginUser = user;
},
// 退出登录(清除登录状态)
logout() {
this.loginUser = {
userName: "未登录",
userRole: ACCESS_ENUM.NOT_LOGIN,
};
} }
}, },
}); });

80
src/types/errorCode.ts Normal file
View File

@@ -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<string, string> = {
[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
);
};

View File

@@ -54,7 +54,7 @@ import { updateUserAvatar } from '@/api/auth/user';
import { isApiSuccess } from '@/api/response'; import { isApiSuccess } from '@/api/response';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
import { IconCamera } from '@arco-design/web-vue/es/icon'; 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'; import type { RequestOption } from '@arco-design/web-vue/es/upload/interfaces';
const userStore = useUserStore(); const userStore = useUserStore();