44 * - 未设置:开发环境默认连仓库里的远程后端;生产环境默认同域(与上面空字符串一致)。
55 * 注意:不要用 `||`,否则空字符串会被当成假值而误用默认 IP。
66 */
7- import { clearAuth , getStoredAccessToken } from '../auth/session'
7+ import {
8+ clearAuth ,
9+ getStoredAccessToken ,
10+ getStoredRefreshToken ,
11+ hasUsableRefreshToken ,
12+ isJwtExpired ,
13+ isLikelyJwt ,
14+ parseJwtPayload
15+ } from '../auth/session'
816
917function resolveApiBase ( ) {
1018 const v = import . meta. env . VITE_API_BASE
@@ -16,21 +24,107 @@ function resolveApiBase() {
1624
1725export const API_BASE = resolveApiBase ( )
1826
19- const baseUrl = API_BASE
20-
2127function buildUrl ( path ) {
2228 // 后端统一是 /api/v1 前缀
2329 if ( path . startsWith ( 'http://' ) || path . startsWith ( 'https://' ) ) return path
24- return `${ baseUrl } ${ path } `
30+ return `${ API_BASE } ${ path } `
31+ }
32+
33+ /** 并发刷新合并为同一 Promise */
34+ let refreshInflight = null
35+
36+ /**
37+ * 使用 refreshToken 换发双 token(不经过 request,避免环)。
38+ * 失败时会 clearAuth。
39+ */
40+ export function refreshSessionTokens ( ) {
41+ if ( refreshInflight ) return refreshInflight
42+
43+ refreshInflight = ( async ( ) => {
44+ const rt = getStoredRefreshToken ( )
45+ if ( ! rt || ! isLikelyJwt ( rt ) || isJwtExpired ( rt ) ) {
46+ clearAuth ( )
47+ const err = new Error ( '登录已失效' )
48+ err . httpStatus = 401
49+ throw err
50+ }
51+
52+ const res = await fetch ( buildUrl ( '/api/v1/auth/refresh' ) , {
53+ method : 'POST' ,
54+ headers : { 'Content-Type' : 'application/json' } ,
55+ body : JSON . stringify ( { refreshToken : rt } )
56+ } )
57+
58+ const text = await res . text ( )
59+ let json = null
60+ try {
61+ json = text ? JSON . parse ( text ) : null
62+ } catch {
63+ // ignore
64+ }
65+
66+ if ( ! res . ok || ( json && typeof json . code === 'number' && json . code !== 0 ) ) {
67+ clearAuth ( )
68+ const err = new Error ( json ?. message || '登录已失效' )
69+ err . httpStatus = res . status || 401
70+ err . code = json ?. code
71+ throw err
72+ }
73+
74+ const data = json ?. data
75+ if ( ! data ?. accessToken || ! data ?. refreshToken ) {
76+ clearAuth ( )
77+ const err = new Error ( '刷新返回异常' )
78+ err . httpStatus = 401
79+ throw err
80+ }
81+
82+ localStorage . setItem ( 'accessToken' , data . accessToken )
83+ localStorage . setItem ( 'refreshToken' , data . refreshToken )
84+ return data
85+ } ) ( )
86+
87+ return refreshInflight . finally ( ( ) => {
88+ refreshInflight = null
89+ } )
90+ }
91+
92+ function accessExpiresWithinSeconds ( seconds ) {
93+ const t = getStoredAccessToken ( )
94+ if ( ! t || ! isLikelyJwt ( t ) ) return true
95+ const p = parseJwtPayload ( t )
96+ if ( typeof p . exp !== 'number' ) return false
97+ return p . exp * 1000 - Date . now ( ) < seconds * 1000
98+ }
99+
100+ function redirectConsoleLoginIfNeeded ( ) {
101+ if ( typeof window !== 'undefined' && window . location . pathname . startsWith ( '/console' ) ) {
102+ const redirect = window . location . pathname + window . location . search
103+ window . location . assign ( '/console/login?redirect=' + encodeURIComponent ( redirect ) )
104+ }
25105}
26106
27107export async function request ( path , options = { } ) {
108+ const isRetry = Boolean ( options . _refreshRetried )
109+ const extraHeaders = options . headers && typeof options . headers === 'object' ? options . headers : { }
28110 const headers = {
29111 'Content-Type' : 'application/json' ,
30- ...( options . headers || { } )
112+ ...extraHeaders
31113 }
32114
33115 if ( options . withAuth ) {
116+ if (
117+ ! isRetry &&
118+ hasUsableRefreshToken ( ) &&
119+ ( ! getStoredAccessToken ( ) || accessExpiresWithinSeconds ( 90 ) )
120+ ) {
121+ try {
122+ await refreshSessionTokens ( )
123+ } catch {
124+ // 无 access 时交给后续分支;将过期 access 时可能仍失败,再由 401 重试
125+ }
126+ }
127+
34128 const token = getStoredAccessToken ( )
35129 if ( ! token ) {
36130 const err = new Error ( '未登录' )
@@ -40,31 +134,46 @@ export async function request(path, options = {}) {
40134 headers . Authorization = `Bearer ${ token } `
41135 }
42136
137+ const fetchOpts = { ...options }
138+ delete fetchOpts . withAuth
139+ delete fetchOpts . _refreshRetried
140+ delete fetchOpts . headers
141+
43142 const res = await fetch ( buildUrl ( path ) , {
44- headers ,
45- ... options
143+ ... fetchOpts ,
144+ headers
46145 } )
47146
48147 const text = await res . text ( )
49148 let json = null
50- try {
51- json = text ? JSON . parse ( text ) : null
52- } catch {
53- // ignore
149+ if ( ! _skipJson ) {
150+ try {
151+ json = text ? JSON . parse ( text ) : null
152+ } catch {
153+ // ignore
154+ }
54155 }
55156
56- if ( res . status === 401 && options . withAuth ) {
57- clearAuth ( )
58- if ( typeof window !== 'undefined' && window . location . pathname . startsWith ( '/console' ) ) {
59- const redirect = window . location . pathname + window . location . search
60- window . location . assign ( '/console/login?redirect=' + encodeURIComponent ( redirect ) )
157+ if ( res . status === 401 && options . withAuth && ! isRetry ) {
158+ try {
159+ await refreshSessionTokens ( )
160+ return request ( path , { ...options , _refreshRetried : true } )
161+ } catch {
162+ redirectConsoleLoginIfNeeded ( )
163+ const err = new Error ( '登录已失效' )
164+ err . httpStatus = 401
165+ throw err
61166 }
167+ }
168+
169+ if ( res . status === 401 && options . withAuth && isRetry ) {
170+ clearAuth ( )
171+ redirectConsoleLoginIfNeeded ( )
62172 const err = new Error ( '登录已失效' )
63173 err . httpStatus = 401
64174 throw err
65175 }
66176
67- // API 统一返回 ApiResponse,HTTP 层可能仍为 200
68177 if ( json && typeof json . code === 'number' && json . code !== 0 ) {
69178 const msg = json . message || '请求失败'
70179 const err = new Error ( msg )
@@ -83,4 +192,3 @@ export async function request(path, options = {}) {
83192
84193 return json ?. data ?? json
85194}
86-
0 commit comments