Преглед изворни кода

fix:解决token过期无法跳转登录问题

Jerry Liang пре 2 месеци
родитељ
комит
44c41b5540
3 измењених фајлова са 101 додато и 10 уклоњено
  1. 43 0
      src/service/auth/index.ts
  2. 39 6
      src/service/fetchClient.ts
  3. 19 4
      src/service/serverReq.ts

+ 43 - 0
src/service/auth/index.ts

@@ -38,6 +38,49 @@ export const refreshToken = async (): Promise<string> => {
   return res
 }
 
+// POST 刷新Token(绕过业务响应拦截器)
+//
+// 说明:refreshToken() 走的是共享的 http 实例,会经过 serverReq.ts 的响应拦截器。
+// 当刷新失败(401/403)时,拦截器会静默处理并返回 undefined 而不抛错,导致上层
+// 无法感知刷新失败、无法干净地跳转登录页。这里用原生 fetch 直接请求刷新接口,
+// 不经过任何拦截器:成功返回新的 AccessToken,失败则抛出带状态码的错误。
+//
+// 刷新接口依赖 HttpOnly 的 sid cookie(credentials: 'include' 自动携带),
+// 因此无需手动附加 Authorization 头。
+export const refreshTokenRaw = async (): Promise<string> => {
+  const baseUrl = import.meta.env.BASE_URL
+  const url = `${baseUrl}api${API_List.refreshToken}`
+
+  const res = await fetch(url, {
+    method: 'POST',
+    credentials: 'include',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+  })
+
+  const contentType = res.headers.get('Content-Type') || ''
+  const body = contentType.includes('application/json') ? await res.json() : await res.text()
+
+  if (!res.ok) {
+    const message =
+      typeof body === 'object' && body !== null && 'message' in body
+        ? String((body as { message?: unknown }).message ?? '')
+        : String(body)
+    const err = new Error(message || `Refresh token failed with status ${res.status}`) as Error & {
+      status?: number
+    }
+    err.status = res.status
+    throw err
+  }
+
+  // 后端成功响应结构:{ code, message, data, traceId, timestamp },token 在 data 字段
+  if (typeof body === 'object' && body !== null && 'data' in body) {
+    return String((body as { data?: unknown }).data ?? '')
+  }
+  return String(body)
+}
+
 // POST 请求登出
 // return boolean
 export const logout = async (): Promise<boolean> => {

+ 39 - 6
src/service/fetchClient.ts

@@ -79,6 +79,43 @@ export class FetchClient {
     return 'download'
   }
 
+  /**
+   * 处理下载类请求的非 2xx 响应。
+   *
+   * download/downloadToBlob 不像 request() 那样自动经过响应拦截器,
+   * 导致 401/403 时不会触发刷新 Token / 跳转登录。这里补齐这一行为:
+   * 把非 ok 响应构造成 FetchResponse 交给响应拦截器处理(触发刷新/跳转),
+   * 无论拦截器是否抛错,最终都以下载失败错误结束(下载无法用重放结果续流)。
+   */
+  private async handleDownloadNotOk(res: globalThis.Response): Promise<never> {
+    const contentType = res.headers.get('Content-Type') || ''
+    const data = contentType.includes('application/json') ? await res.json() : await res.text()
+
+    const result: FetchResponse<any> = {
+      status: res.status,
+      ok: false,
+      data: typeof data === 'string' ? data : JSON.stringify(data),
+      // 下载请求无法安全重放为流,这里提供一个占位 retry,
+      // 让拦截器识别为「可刷新」的请求并触发刷新/跳转逻辑。
+      retry: async () => ({
+        status: res.status,
+        ok: false,
+        data: typeof data === 'string' ? data : JSON.stringify(data),
+      }),
+    }
+
+    if (this.responseInterceptor) {
+      try {
+        await this.responseInterceptor(result)
+      } catch (err) {
+        // 拦截器已处理(如刷新失败并跳转登录),继续抛出下载失败错误
+        console.warn('Download interceptor handled error:', err)
+      }
+    }
+
+    throw new Error(`Download failed with status ${res.status}`)
+  }
+
   async request<D = any>(
     url: string,
     method: HttpMethod = 'GET',
@@ -254,9 +291,7 @@ export class FetchClient {
 
     // 发起请求
     const res = await fetch(fullURL, finalConfig)
-    if (!res.ok) {
-      throw new Error(`Download failed with status ${res.status}`)
-    }
+    if (!res.ok) { await this.handleDownloadNotOk(res) }
 
     // 尝试从后端 header 获取文件名
     let downloadName = filename
@@ -337,9 +372,7 @@ export class FetchClient {
 
     // 发起请求
     const res = await fetch(fullURL, finalConfig)
-    if (!res.ok) {
-      throw new Error(`Download failed with status ${res.status}`)
-    }
+    if (!res.ok) { await this.handleDownloadNotOk(res) }
 
     if (!res.body) throw new Error('No response body')
 

+ 19 - 4
src/service/serverReq.ts

@@ -2,7 +2,7 @@ import { v4 as uuid_v4 } from 'uuid'
 import { FetchClient, type FetchResponse } from './fetchClient'
 import { useUserStore } from '@/stores/user'
 import { jumpToSignin } from '@/utils/casdoor/casdoor'
-import { refreshToken } from './auth'
+import { refreshTokenRaw } from './auth'
 
 const baseUrl = import.meta.env.BASE_URL
 
@@ -18,6 +18,8 @@ const http = new FetchClient(baseUrl + 'api', 60 * 1000) // 超时时间设置
 
 let isRefreshing = false
 let pendingQueue: (() => void)[] = []
+// 是否已进入登录跳转流程,避免刷新失败时重复跳转
+let isRedirecting = false
 
 async function handleTokenExpired(
   response: FetchResponse<Response<any>>,
@@ -42,19 +44,26 @@ async function handleTokenExpired(
   isRefreshing = true
 
   try {
-    // 调用刷新接口
-    const res = await refreshToken()
+    // 调用刷新接口(绕过业务拦截器,失败会抛出带 status 的错误)
+    const res = await refreshTokenRaw()
+
+    if (!res) {
+      // 刷新接口返回空 token,视为刷新失败
+      throw new Error('Empty token from refresh')
+    }
 
     const userStore = useUserStore()
     userStore.setToken(res)
 
     // 刷新完成,重放队列请求
-    await Promise.all(pendingQueue.map((fn) => fn()))
+    const queue = pendingQueue
     pendingQueue = []
+    await Promise.all(queue.map((fn) => fn()))
 
     // 重新执行当前请求
     return originalRequest()
   } catch (err) {
+    // 刷新失败:清理并跳转登录,抛出错误让上层感知(不再用已清空的 token 重放请求)
     handleRefreshFailed()
     throw err
   } finally {
@@ -68,6 +77,12 @@ function handleRefreshFailed() {
   pendingQueue = []
   isRefreshing = false
 
+  // 保证只跳转一次,避免多个并发请求同时触发跳转
+  if (isRedirecting) {
+    return
+  }
+  isRedirecting = true
+
   console.log('jumpToSignin')
 
   // 跳转登录页