From 51987882ebf141a67dedd687a3a14ab1fa6c6325 Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 2 Jul 2026 12:08:56 +0800 Subject: [PATCH] fix(api): replay requests after token refresh --- .../interceptor/auth_refresh_interceptor.ts | 21 +++-- src/core/net/interceptor/token_interceptor.ts | 1 + .../api/__tests__/http_client.test.ts | 89 +++++++++++++++++++ src/data/services/api/http_client.ts | 16 +++- 4 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 src/data/services/api/__tests__/http_client.test.ts diff --git a/src/core/net/interceptor/auth_refresh_interceptor.ts b/src/core/net/interceptor/auth_refresh_interceptor.ts index 10c7dbac..57e752ce 100644 --- a/src/core/net/interceptor/auth_refresh_interceptor.ts +++ b/src/core/net/interceptor/auth_refresh_interceptor.ts @@ -1,7 +1,7 @@ /** * 认证刷新拦截器 * - * 处理 401 错误,自动刷新 token 并重试原请求。 + * 处理 401 错误,自动刷新 token;原请求重放由 ofetch retry 机制完成。 * */ import type { FetchHook } from "ofetch"; @@ -132,7 +132,7 @@ function withTestAccountFlag>( } /** - * 401 → 刷新 token 并重试 + * 401 → 刷新 token,并允许 ofetch retry 重放原请求 */ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => { const status = ctx.response?.status; @@ -144,10 +144,15 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => { const requestUrl = ctx.request instanceof Request ? ctx.request.url : ctx.request; const path = new URL(requestUrl).pathname; - if (NO_AUTH_REFRESH_PATHS.some((p) => path.includes(p))) return; + if (NO_AUTH_REFRESH_PATHS.some((p) => path.includes(p))) { + ctx.options.retry = false; + return; + } // 已登录则刷新登录 token,否则刷新游客 token - const isGuestMode = !AuthStorage.getInstance().hasLoginToken(); + const hasLoginTokenResult = await AuthStorage.getInstance().hasLoginToken(); + const isGuestMode = + !Result.isOk(hasLoginTokenResult) || !hasLoginTokenResult.data; // 并发刷新:等待或启动 if (isRefreshing && refreshPromise) { @@ -177,10 +182,10 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => { ); } - // 刷新成功:更新请求头的 token - if (ctx.request instanceof Request) { - ctx.request.headers.set("Authorization", `Bearer ${newToken}`); - } + // 刷新成功:更新当前 options;retry 时 tokenInterceptor 也会重新读取最新 token。 + const headers = new Headers(ctx.options.headers); + headers.set("Authorization", `Bearer ${newToken}`); + ctx.options.headers = headers; }; /** diff --git a/src/core/net/interceptor/token_interceptor.ts b/src/core/net/interceptor/token_interceptor.ts index 58865347..b414f44a 100644 --- a/src/core/net/interceptor/token_interceptor.ts +++ b/src/core/net/interceptor/token_interceptor.ts @@ -20,6 +20,7 @@ const NO_TOKEN_PATHS: readonly string[] = [ ApiPath.emailLogin, ApiPath.register, ApiPath.guestLogin, + ApiPath.refresh, ]; /** diff --git a/src/data/services/api/__tests__/http_client.test.ts b/src/data/services/api/__tests__/http_client.test.ts new file mode 100644 index 00000000..4c415e9e --- /dev/null +++ b/src/data/services/api/__tests__/http_client.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createStorage } from "unstorage"; +import memoryDriver from "unstorage/drivers/memory"; + +import { AuthStorage } from "@/data/storage/auth"; +import { SpAsyncUtil } from "@/utils"; + +import { createHttpClient } from "../http_client"; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + ...init, + }); +} + +function callUrl(call: Parameters): URL { + const request = call[0]; + return new URL(request instanceof Request ? request.url : String(request)); +} + +describe("createHttpClient", () => { + beforeEach(() => { + process.env.NEXT_PUBLIC_API_BASE_URL = "https://api.example.test"; + SpAsyncUtil.setStorage(createStorage({ driver: memoryDriver() })); + AuthStorage._resetForTests(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("refreshes a business token and replays the original request once after 401", async () => { + const authStorage = AuthStorage.getInstance(); + await authStorage.setLoginToken("old-login-token"); + await authStorage.setRefreshToken("refresh-token"); + + const requestAuthHeaders: Array = []; + const fetchMock = vi.fn(async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const headers = + input instanceof Request ? input.headers : new Headers(init?.headers); + requestAuthHeaders.push(headers.get("Authorization")); + + if (url.pathname === "/api/auth/refresh") { + return jsonResponse({ + success: true, + data: { + token: "new-login-token", + refreshToken: "new-refresh-token", + }, + }); + } + + if ( + url.pathname === "/api/chat/history" && + fetchMock.mock.calls.filter( + (call) => callUrl(call).pathname === "/api/chat/history", + ).length === 1 + ) { + return jsonResponse( + { message: "expired" }, + { status: 401, statusText: "Unauthorized" }, + ); + } + + return jsonResponse({ success: true, data: { ok: true } }); + }); + vi.stubGlobal("fetch", fetchMock); + + const http = createHttpClient(); + await expect(http("/api/chat/history")).resolves.toEqual({ + success: true, + data: { ok: true }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls.map((call) => callUrl(call).pathname)).toEqual([ + "/api/chat/history", + "/api/auth/refresh", + "/api/chat/history", + ]); + expect(requestAuthHeaders).toEqual([ + "Bearer old-login-token", + null, + "Bearer new-login-token", + ]); + }); +}); diff --git a/src/data/services/api/http_client.ts b/src/data/services/api/http_client.ts index be1fff5f..2035ea3c 100644 --- a/src/data/services/api/http_client.ts +++ b/src/data/services/api/http_client.ts @@ -22,6 +22,17 @@ function chain(...fns: (FetchHook | undefined)[]): FetchHook { }; } +function shouldLetAuthRetryRun(ctx: { + response?: { status?: number }; + options?: { retry?: number | false }; +}): boolean { + return ( + ctx.response?.status === ErrorCode.httpUnauthorized && + typeof ctx.options?.retry === "number" && + ctx.options.retry > 0 + ); +} + /** * 创建 ofetch 实例 */ @@ -31,7 +42,8 @@ export function createHttpClient(): $Fetch { return ofetch.create({ baseURL: config.baseUrl, timeout: config.receiveTimeout, - retry: 0, // 401 刷新由 auth_refresh_interceptor 自定义处理 + retry: 1, // 401 刷新后由 ofetch 重放一次原请求 + retryStatusCodes: [ErrorCode.httpUnauthorized], headers: { "Content-Type": "application/json", }, @@ -45,9 +57,11 @@ export function createHttpClient(): $Fetch { authRefreshInterceptor.onResponseError, ((ctx: unknown) => { const c = ctx as { + options?: { retry?: number | false }; response?: { status?: number; _data?: unknown }; error?: Error; }; + if (shouldLetAuthRetryRun(c)) return; const status = c.response?.status; const code = status === ErrorCode.httpUnauthorized