@@ -84,6 +84,7 @@ interface EventsResponse {
8484const apiBase = ( import . meta. env . VITE_API_BASE_URL || '' ) . replace ( / \/ $ / , '' ) ;
8585
8686const TOKEN_KEY = 'agentrazor_token' ;
87+ const REFRESH_TOKEN_KEY = 'agentrazor_refresh_token' ;
8788export function getToken ( ) {
8889 return localStorage . getItem ( TOKEN_KEY ) || '' ;
8990}
@@ -93,6 +94,15 @@ export function setToken(token: string) {
9394export function clearToken ( ) {
9495 localStorage . removeItem ( TOKEN_KEY ) ;
9596}
97+ export function getRefreshToken ( ) {
98+ return localStorage . getItem ( REFRESH_TOKEN_KEY ) || '' ;
99+ }
100+ export function setRefreshToken ( refreshToken : string ) {
101+ localStorage . setItem ( REFRESH_TOKEN_KEY , refreshToken ) ;
102+ }
103+ export function clearRefreshToken ( ) {
104+ localStorage . removeItem ( REFRESH_TOKEN_KEY ) ;
105+ }
96106
97107// authErrorHandler is invoked when a request returns 401, so the UI can reset
98108// to the logged-out state.
@@ -101,7 +111,49 @@ export function setAuthErrorHandler(fn: (() => void) | null) {
101111 authErrorHandler = fn ;
102112}
103113
104- async function request < T > ( path : string , init ?: RequestInit ) : Promise < T > {
114+ // 刷新 token 的并发单飞:多个请求同时 401 时只发起一次刷新,成功后一起重试
115+ let refreshTokenPromise : Promise < boolean > | null = null ;
116+
117+ async function refreshAccessToken ( ) : Promise < boolean > {
118+ if ( ! refreshTokenPromise ) {
119+ refreshTokenPromise = ( async ( ) => {
120+ const refreshToken = getRefreshToken ( ) ;
121+ if ( ! refreshToken ) return false ;
122+ try {
123+ const response = await fetch ( `${ apiBase } /api/v1/auth/refreshToken` , {
124+ method : 'POST' ,
125+ headers : { 'Content-Type' : 'application/json' } ,
126+ body : JSON . stringify ( { refreshToken } )
127+ } ) ;
128+ const body = ( await response . json ( ) . catch ( ( ) => null ) ) as Envelope < LoginResponse > | LoginResponse | null ;
129+ const envelope = body !== null && isEnvelope < LoginResponse > ( body ) ? body : null ;
130+ // 40102 = refresh token 过期,属于登录态失效,不做续期
131+ if ( envelope ?. code === 40102 ) return false ;
132+ const data = envelope ? envelope . data : ( body as LoginResponse | null ) ;
133+ if ( ! data || ! data . token || ! data . refreshToken ) return false ;
134+ setToken ( data . token ) ;
135+ setRefreshToken ( data . refreshToken ) ;
136+ return true ;
137+ } catch {
138+ return false ;
139+ }
140+ } ) ( ) ;
141+ refreshTokenPromise . finally ( ( ) => {
142+ setTimeout ( ( ) => {
143+ refreshTokenPromise = null ;
144+ } , 1000 ) ;
145+ } ) ;
146+ }
147+ return refreshTokenPromise ;
148+ }
149+
150+ function expireSession ( ) {
151+ clearToken ( ) ;
152+ clearRefreshToken ( ) ;
153+ authErrorHandler ?.( ) ;
154+ }
155+
156+ async function request < T > ( path : string , init ?: RequestInit , retried = false ) : Promise < T > {
105157 const token = getToken ( ) ;
106158 const response = await fetch ( `${ apiBase } ${ path } ` , {
107159 ...init ,
@@ -111,22 +163,30 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
111163 ...init ?. headers
112164 }
113165 } ) ;
114- if ( response . status === 401 ) {
115- clearToken ( ) ;
116- authErrorHandler ?.( ) ;
166+ const body = ( await response . json ( ) . catch ( ( ) => null ) ) as Envelope < T > | T | null ;
167+ const envelope = body !== null && isEnvelope < T > ( body ) ? body : null ;
168+ // 鉴权失败:HTTP 401(未来兼容)或业务码 40101(access token 过期,可刷新)
169+ const authFailed = response . status === 401 || envelope ?. code === 40101 ;
170+ if ( authFailed ) {
171+ // 先尝试用 refreshToken 续期,成功则带新 token 重试一次
172+ if ( ! retried && ( await refreshAccessToken ( ) ) ) {
173+ return request < T > ( path , init , true ) ;
174+ }
175+ expireSession ( ) ;
117176 throw new Error ( '登录已过期,请重新登录' ) ;
118177 }
119- const body = ( await response . json ( ) ) as Envelope < T > | T ;
120178 if ( ! response . ok ) {
121179 throw new Error ( `请求失败(${ response . status } )` ) ;
122180 }
123- if ( isEnvelope < T > ( body ) ) {
124- if ( body . code !== 200 ) {
125- throw new Error ( body . msg || '服务请求失败' ) ;
181+ if ( envelope ) {
182+ if ( envelope . code !== 200 ) {
183+ // 40102 = refresh token 过期,登录态失效,直接登出
184+ if ( envelope . code === 40102 ) expireSession ( ) ;
185+ throw new Error ( envelope . msg || '服务请求失败' ) ;
126186 }
127- return body . data ;
187+ return envelope . data ;
128188 }
129- return body ;
189+ return body as T ;
130190}
131191
132192function isEnvelope < T > ( body : Envelope < T > | T ) : body is Envelope < T > {
@@ -168,14 +228,31 @@ export const conversationApi = {
168228 } ) ;
169229 } ,
170230 subscribe ( id : string , onEvent : ( event : StreamEvent ) => void , onError : ( ) => void ) {
171- const token = getToken ( ) ;
172231 // EventSource 无法携带 Authorization 头,改用 fetch 流式读取 SSE,
173232 // 这样鉴权与其他接口保持一致(Bearer token 走 Authorization)。
174233 const controller = new AbortController ( ) ;
175234 const url = `${ apiBase } /api/v1/conversations/${ encodeURIComponent ( id ) } /events` ;
235+ // 断线重试标记:一次断开最多自动重连一次,避免死循环
236+ let retried = false ;
176237
177- void ( async ( ) => {
238+ // 断线后静默重连:先刷新 token(可能已过期),再用新 token 重新连接
239+ const reconnect = async ( ) : Promise < boolean > => {
240+ if ( controller . signal . aborted || retried ) return false ;
241+ retried = true ;
242+ await new Promise ( resolve => setTimeout ( resolve , 1000 ) ) ;
243+ if ( ! getRefreshToken ( ) ) return false ;
244+ if ( ! ( await refreshAccessToken ( ) ) ) {
245+ expireSession ( ) ;
246+ return false ;
247+ }
248+ await connect ( ) ;
249+ return true ;
250+ } ;
251+
252+ const connect = async ( ) : Promise < void > => {
253+ if ( controller . signal . aborted ) return ;
178254 try {
255+ const token = getToken ( ) ;
179256 const response = await fetch ( url , {
180257 headers : {
181258 Accept : 'text/event-stream' ,
@@ -184,10 +261,16 @@ export const conversationApi = {
184261 cache : 'no-store' ,
185262 signal : controller . signal
186263 } ) ;
187- if ( ! response . ok || ! response . body ) {
188- onError ( ) ;
264+ // 鉴权失败时服务端返回 HTTP 200 + JSON envelope(如 code 40101),
265+ // 用 Content-Type 区分:SSE 是 text/event-stream,失败响应是 JSON
266+ const contentType = response . headers . get ( 'content-type' ) || '' ;
267+ if ( ! response . ok || ! response . body || ! contentType . includes ( 'text/event-stream' ) ) {
268+ if ( await reconnect ( ) ) return ;
269+ if ( ! controller . signal . aborted ) onError ( ) ;
189270 return ;
190271 }
272+ // 连接成功,重置重试标记:后续再次断开仍可自动重连
273+ retried = false ;
191274 const reader = response . body . getReader ( ) ;
192275 const decoder = new TextDecoder ( ) ;
193276 let buffer = '' ;
@@ -213,11 +296,18 @@ export const conversationApi = {
213296 }
214297 }
215298 }
299+ // 流结束(服务端空闲回收/重启等):自动重连保持事件不中断
300+ if ( await reconnect ( ) ) return ;
301+ if ( ! controller . signal . aborted ) onError ( ) ;
216302 } catch ( error ) {
217- if ( ( error as Error ) ?. name !== 'AbortError' ) onError ( ) ;
303+ if ( ( error as Error ) ?. name !== 'AbortError' ) {
304+ if ( await reconnect ( ) ) return ;
305+ if ( ! controller . signal . aborted ) onError ( ) ;
306+ }
218307 }
219- } ) ( ) ;
308+ } ;
220309
310+ void connect ( ) ;
221311 return ( ) => controller . abort ( ) ;
222312 }
223313} ;
0 commit comments