@@ -7,57 +7,72 @@ const BASE_URL = import.meta.env.VITE_APP_BASE_API;
77const axiosInstance = axios . create ( {
88 baseURL : BASE_URL ,
99 withCredentials : true , // 携带 HttpOnly refresh cookie
10- // timeout: 8000,
1110} ) ;
1211
1312const TOKEN_KEY = "token" ;
1413const TOKEN_EXPIRES_AT_KEY = "tokenExpiresAt" ;
1514const REFRESH_TOKEN_EXPIRES_AT_KEY = "refreshTokenExpiresAt" ;
15+ const USER_INFO_KEY = "userInfo" ;
1616
17- const FORCE_LOGOUT_CODES = new Set ( [
18- "ZC_ERROR_INVALID_REFRESH_TOKEN" ,
19- "ZC_ERROR_REFRESH_TOKEN_EXPIRED" ,
20- ] ) ;
17+ // 后端统一错误码
18+ const ZC_ERROR = {
19+ NEED_LOGIN : "ZC_ERROR_NEED_LOGIN" , // 未携带令牌
20+ NEED_LOGOUT : "ZC_ERROR_NEED_LOGOUT" , // 令牌无效/过期/已吊销/刷新失败
21+ FORBIDDEN : "ZC_ERROR_FORBIDDEN" , // 已登录但无权限
22+ } ;
2123
2224export const TOKEN_REFRESHED_EVENT_NAME = "auth:token-refreshed" ;
2325
2426// 用独立 refresh client,避免走 axiosInstance 的响应拦截器(防递归)
2527const refreshClient = axios . create ( {
2628 baseURL : BASE_URL ,
2729 withCredentials : true ,
28- // timeout: 8000,
2930} ) ;
3031
3132let refreshPromise = null ;
3233
33- const normalizeUrlPath = ( url ) => {
34- if ( ! url ) return "" ;
35- try {
36- // 处理绝对 URL
37- if ( url . startsWith ( "http://" ) || url . startsWith ( "https://" ) ) {
38- return new URL ( url ) . pathname ;
39- }
40- // 处理相对 URL:确保能被 URL 解析
41- const base = BASE_URL ?. startsWith ( "http" ) ? BASE_URL : "http://localhost" ;
42- return new URL ( url , base ) . pathname ;
43- } catch {
44- return String ( url ) ;
45- }
46- } ;
34+ // 防止并发请求触发重复跳转
35+ let isRedirecting = false ;
4736
48- const isRefreshEndpoint = ( url ) => {
49- const path = normalizeUrlPath ( url ) ;
50- return path === "/account/refresh-token" ;
51- } ;
37+ const getErrorCode = ( error ) => error ?. response ?. data ?. code || error ?. code ;
5238
53- const shouldForceLogoutByCode = ( code ) => FORCE_LOGOUT_CODES . has ( code ) ;
39+ /**
40+ * 清除本地所有认证相关状态
41+ */
42+ const clearLocalAuth = ( ) => {
43+ localStorage . removeItem ( TOKEN_KEY ) ;
44+ localStorage . removeItem ( TOKEN_EXPIRES_AT_KEY ) ;
45+ localStorage . removeItem ( REFRESH_TOKEN_EXPIRES_AT_KEY ) ;
46+ localStorage . removeItem ( USER_INFO_KEY ) ;
47+ localStorage . removeItem ( "sudo_token" ) ;
48+ localStorage . removeItem ( "sudo_token_expires_at" ) ;
49+ localStorage . removeItem ( "sudo_token_duration" ) ;
50+ } ;
5451
55- const getErrorCode = ( error ) => error ?. response ?. data ?. code || error ?. code ;
52+ /**
53+ * 处理 ZC_ERROR_NEED_LOGOUT:令牌已失效(过期/吊销/刷新失败等)
54+ * 清除本地令牌 → 通知 store → 跳转登录页
55+ */
56+ export const handleNeedLogout = ( ) => {
57+ if ( isRedirecting ) return ;
58+ isRedirecting = true ;
59+
60+ clearLocalAuth ( ) ;
61+ window . dispatchEvent ( new CustomEvent ( "forceLogout" ) ) ;
62+ window . location . href = "/?reason=session_expired" ;
63+ } ;
5664
57- const triggerForceLogout = ( ) => {
58- if ( typeof window !== "undefined" ) {
59- window . dispatchEvent ( new CustomEvent ( "forceLogout" ) ) ;
60- }
65+ /**
66+ * 处理 ZC_ERROR_NEED_LOGIN:刷新令牌也失败后
67+ * 清除本地登录态 → 通知 store → 跳转登录页
68+ */
69+ export const handleNeedLogin = ( ) => {
70+ if ( isRedirecting ) return ;
71+ isRedirecting = true ;
72+
73+ clearLocalAuth ( ) ;
74+ window . dispatchEvent ( new CustomEvent ( "forceLogout" ) ) ;
75+ window . location . href = "/" ;
6176} ;
6277
6378const emitTokenRefreshed = ( refreshData ) => {
@@ -77,12 +92,8 @@ const emitTokenRefreshed = (refreshData) => {
7792const saveRefreshedToken = ( refreshData ) => {
7893 localStorage . setItem ( TOKEN_KEY , refreshData . token ) ;
7994
80- // 这里保留后端给的 expires_at(若你 store 用 JWT exp 为准,也没问题)
8195 if ( refreshData . expires_at ) {
8296 localStorage . setItem ( TOKEN_EXPIRES_AT_KEY , refreshData . expires_at ) ;
83- } else {
84- // 如果后端不返回 expires_at,你也可以选择清掉,让 store 走 JWT exp
85- // localStorage.removeItem(TOKEN_EXPIRES_AT_KEY);
8697 }
8798
8899 if ( refreshData . refresh_expires_at ) {
@@ -99,7 +110,7 @@ const performRefreshRequest = async () => {
99110 if ( data . status !== "success" || ! data . token ) {
100111 const err = new Error ( data . message || "Refresh token failed" ) ;
101112 err . code = data . code || "REFRESH_FAILED" ;
102- err . response = resp ; // 保留一些上下文(可选)
113+ err . response = resp ;
103114 throw err ;
104115 }
105116
@@ -123,13 +134,11 @@ axiosInstance.interceptors.request.use(
123134 ( config ) => {
124135 const t = localStorage . getItem ( TOKEN_KEY ) ;
125136
126- // AxiosHeaders / plain object 都兼容
127137 config . headers = config . headers || { } ;
128138
129139 if ( t ) {
130140 config . headers . Authorization = `Bearer ${ t } ` ;
131141 } else {
132- // 避免发送 Bearer null
133142 try {
134143 delete config . headers . Authorization ;
135144 } catch { }
@@ -140,56 +149,51 @@ axiosInstance.interceptors.request.use(
140149 ( error ) => Promise . reject ( error )
141150) ;
142151
143- // 响应拦截器:401 → 刷新 → 重放
152+ // 响应拦截器:根据后端统一错误码处理认证问题
144153axiosInstance . interceptors . response . use (
145154 ( response ) => response ,
146155 async ( error ) => {
147156 const originalRequest = error ?. config ;
148157 const response = error ?. response ;
149158
150159 // 没有响应(网络断开/超时)直接抛出
151- if ( ! originalRequest || ! response ) {
160+ if ( ! response || ! originalRequest ) {
152161 return Promise . reject ( error ) ;
153162 }
154163
155- // 刷新接口本身不参与重试
156- if ( isRefreshEndpoint ( originalRequest . url ) ) {
164+ const errorCode = getErrorCode ( error ) ;
165+
166+ // ZC_ERROR_NEED_LOGOUT: 令牌已失效(过期/吊销/刷新失败等)
167+ // 必须清除本地令牌,跳转登录页
168+ if ( errorCode === ZC_ERROR . NEED_LOGOUT ) {
169+ handleNeedLogout ( ) ;
157170 return Promise . reject ( error ) ;
158171 }
159172
160- // 只处理 401,且同一个请求只重试一次
161- if ( response . status === 401 && ! originalRequest . _retry ) {
162- originalRequest . _retry = true ;
173+ // ZC_ERROR_NEED_LOGIN: 未携带令牌
174+ // 先尝试刷新令牌,刷新成功则重放请求;失败则清除登录态并跳转
175+ if ( errorCode === ZC_ERROR . NEED_LOGIN && ! originalRequest . _retryAfterRefresh ) {
176+ originalRequest . _retryAfterRefresh = true ;
163177
164178 try {
165179 const newToken = await requestTokenRefresh ( ) ;
166-
167- // 兼容 headers 类型:合并而不是替换
168180 originalRequest . headers = originalRequest . headers || { } ;
169181 originalRequest . headers . Authorization = `Bearer ${ newToken } ` ;
170-
171182 return axiosInstance ( originalRequest ) ;
172- } catch ( refreshError ) {
173- const code = getErrorCode ( refreshError ) ;
174-
175- // 1) 明确的 refresh token 失效 → 强制登出
176- if ( shouldForceLogoutByCode ( code ) ) {
177- triggerForceLogout ( ) ;
178- return Promise . reject ( refreshError ) ;
179- }
180-
181- // 2) refresh 接口返回 401/403(有些后端不带业务 code)→ 也登出更合理
182- const refreshStatus = refreshError ?. response ?. status ;
183- if ( refreshStatus === 401 || refreshStatus === 403 ) {
184- triggerForceLogout ( ) ;
185- return Promise . reject ( refreshError ) ;
186- }
187-
188- // 3) 网络错误/500:不强制登出,让上层决定(可弹提示)
189- return Promise . reject ( refreshError ) ;
183+ } catch {
184+ // 刷新失败,清除登录态并跳转
185+ handleNeedLogin ( ) ;
186+ return Promise . reject ( error ) ;
190187 }
191188 }
192189
190+ // 重试过仍然 NEED_LOGIN,直接跳转
191+ if ( errorCode === ZC_ERROR . NEED_LOGIN ) {
192+ handleNeedLogin ( ) ;
193+ return Promise . reject ( error ) ;
194+ }
195+
196+ // 其他错误(包括 ZC_ERROR_FORBIDDEN)由调用方自行处理
193197 return Promise . reject ( error ) ;
194198 }
195199) ;
0 commit comments