From b3f82b35b7fea2d27334342bef074aff0caba454 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:08:13 +0800 Subject: [PATCH 1/8] fix(http-factory): harden auth retry path and request dedupe Prevent double onError on refresh failure, keep headersProvider after explicit Authorization, prefer 401 refresh over generic retry, and make dedupe in-flight with configurable methods and stable keys. --- .../__tests__/http-client.test.ts | 382 +++++++++++++++++- .../api/http-factory/dedupe-manager.ts | 91 +++-- .../src/services/api/http-factory/index.ts | 128 +++--- .../api/http-factory/reference/README.md | 12 +- .../api/http-factory/reference/http-demo.ts | 3 +- .../http-factory/types/http-client-options.ts | 13 +- 6 files changed, 510 insertions(+), 119 deletions(-) diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts index fb7f539..7b6b020 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts @@ -493,6 +493,86 @@ describe("createHttpClient", () => { "x-trace-id": undefined, }); }); + + it("请求已预置 Authorization 时仍会执行 headersProvider", async () => { + const tokenStore = createTokenStore("default-token", "test-refresh"); + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: tokenStore.getAccessToken, + headersProvider: () => ({ + "x-trace-id": "trace-from-provider", + }), + }); + + let receivedHeaders: Record = {}; + + queueCustomHandler(async (config) => { + receivedHeaders = { + Authorization: config.headers?.Authorization, + "x-trace-id": config.headers?.["x-trace-id"], + }; + return { status: 200, data: { ok: true }, config }; + }); + + await http.get("/profile", { + headers: { + Authorization: "Bearer explicit-token", + }, + }); + + expect(receivedHeaders).toEqual({ + Authorization: "Bearer explicit-token", + "x-trace-id": "trace-from-provider", + }); + expect(tokenStore.getAccessToken).not.toHaveBeenCalled(); + }); + + it("刷新后重试仍会执行 headersProvider", async () => { + const tokenStore = createTokenStore("old-access", "old-refresh"); + const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); + let providerCallCount = 0; + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: tokenStore.getAccessToken, + refreshAccessToken, + headersProvider: () => { + providerCallCount += 1; + return { + "x-trace-id": `trace-${providerCallCount}`, + }; + }, + }); + + queueAxiosError({ status: 401, data: { message: "unauthorized" } }); + queueResponse({ + status: 200, + data: { + accessToken: "new-access", + refreshToken: "new-refresh", + }, + }); + + let retriedHeaders: Record = {}; + queueCustomHandler(async (config) => { + retriedHeaders = { + Authorization: config.headers?.Authorization, + "x-trace-id": config.headers?.["x-trace-id"], + }; + return { status: 200, data: { ok: true }, config }; + }); + + const response = await http.get("/profile"); + + expect(response.data).toEqual({ ok: true }); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(providerCallCount).toBe(2); + expect(retriedHeaders).toEqual({ + Authorization: "Bearer new-access", + "x-trace-id": "trace-2", + }); + }); }); describe("retryPolicy", () => { @@ -646,8 +726,133 @@ describe("createHttpClient", () => { }); }); + describe("onError", () => { + it("刷新鉴权失败时 onError 只触发一次", async () => { + const tokenStore = createTokenStore("old-access", "old-refresh"); + const onError = vi.fn(); + const onAuthFailure = vi.fn(); + const refreshAccessToken = vi.fn(async () => { + throw new Error("refresh token expired"); + }); + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: tokenStore.getAccessToken, + refreshAccessToken, + onError, + onAuthFailure, + }); + + queueAxiosError({ status: 401, data: { message: "unauthorized" } }); + + await expect(http.get("/profile")).rejects.toMatchObject({ + message: "refreshToken 已失效,登录过期", + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "refreshToken 已失效,登录过期" }), + { type: "refresh" }, + ); + expect(onAuthFailure).toHaveBeenCalledTimes(1); + }); + + it("刷新网络失败时 onError 只触发一次", async () => { + const tokenStore = createTokenStore("old-access", "old-refresh"); + const onError = vi.fn(); + const onAuthFailure = vi.fn(); + const refreshAccessToken = vi.fn(async () => { + const error = new axios.AxiosError("Network error"); + error.isAxiosError = true; + throw error; + }); + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: tokenStore.getAccessToken, + refreshAccessToken, + onError, + onAuthFailure, + }); + + queueAxiosError({ status: 401, data: { message: "unauthorized" } }); + + await expect(http.get("/profile")).rejects.toMatchObject({ + message: "Network error", + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "Network error" }), + { type: "refresh" }, + ); + expect(onAuthFailure).not.toHaveBeenCalled(); + }); + }); + + describe("401 与通用重试顺序", () => { + it("自定义 shouldRetry 返回 true 时,401 仍优先走 refresh 而不是通用重试", async () => { + const tokenStore = createTokenStore("old-access", "old-refresh"); + const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); + const shouldRetry = vi.fn(() => true); + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: tokenStore.getAccessToken, + refreshAccessToken, + retryPolicy: { + maxRetries: 2, + shouldRetry, + retryDelay: () => 0, + }, + }); + + queueAxiosError({ status: 401, data: { message: "unauthorized" } }); + queueResponse({ + status: 200, + data: { + accessToken: "new-access", + refreshToken: "new-refresh", + }, + }); + queueCustomHandler(async (config) => ({ + status: 200, + data: { ok: true }, + config, + })); + + const response = await http.get("/profile"); + + expect(response.data).toEqual({ ok: true }); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(shouldRetry).not.toHaveBeenCalled(); + }); + + it("5xx 仍按 retryPolicy 重试", async () => { + const shouldRetry = vi.fn(() => true); + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: async () => "", + retryPolicy: { + maxRetries: 1, + shouldRetry, + retryDelay: () => 0, + }, + }); + + queueAxiosError({ status: 500, data: { message: "server error" } }); + queueResponse({ status: 200, data: { ok: true } }); + + const response = await http.get("/profile"); + + expect(response.data).toEqual({ ok: true }); + expect(shouldRetry).toHaveBeenCalledTimes(1); + }); + }); + describe("dedupePolicy", () => { - it("相同 GET 请求在时间窗口内会复用同一个 Promise", async () => { + it("相同 in-flight GET 请求会复用同一个 Promise", async () => { let requestCount = 0; const http = createHttpClient({ @@ -676,6 +881,71 @@ describe("createHttpClient", () => { expect(requestCount).toBe(1); }); + it("请求仍在进行中时,即使超过 windowMs 也会继续合并", async () => { + let requestCount = 0; + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: async () => "", + dedupePolicy: { + enabled: true, + windowMs: 20, + }, + }); + + queueCustomHandler(async (config) => { + requestCount++; + await gate; + return { status: 200, data: { count: requestCount }, config }; + }); + + const firstPromise = http.get("/profile"); + + await new Promise((resolve) => setTimeout(resolve, 40)); + + const secondPromise = http.get("/profile"); + release?.(); + + const [first, second] = await Promise.all([firstPromise, secondPromise]); + + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 1 }); + expect(requestCount).toBe(1); + }); + + it("请求结束后,相同 GET 会重新发起", async () => { + let requestCount = 0; + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: async () => "", + dedupePolicy: { + enabled: true, + windowMs: 1000, + }, + }); + + queueCustomHandler(async (config) => { + requestCount++; + return { status: 200, data: { count: requestCount }, config }; + }); + queueCustomHandler(async (config) => { + requestCount++; + return { status: 200, data: { count: requestCount }, config }; + }); + + const first = await http.get("/profile"); + const second = await http.get("/profile"); + + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 2 }); + expect(requestCount).toBe(2); + }); + it("不同 URL 的请求不会合并", async () => { let requestCount = 0; @@ -851,6 +1121,116 @@ describe("createHttpClient", () => { expect(second.data).toEqual({ count: 1 }); expect(requestCount).toBe(1); }); + + it("可通过 methods 配置允许合并 HEAD 请求", async () => { + let requestCount = 0; + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: async () => "", + dedupePolicy: { + enabled: true, + methods: ["head"], + }, + }); + + queueCustomHandler(async (config) => { + requestCount++; + return { status: 200, data: { count: requestCount }, config }; + }); + + const [first, second] = await Promise.all([ + http.request({ method: "head", url: "/profile" }), + http.request({ method: "HEAD", url: "/profile" }), + ]); + + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 1 }); + expect(requestCount).toBe(1); + }); + + it("methods 未包含的 method 不会被合并", async () => { + let requestCount = 0; + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: async () => "", + dedupePolicy: { + enabled: true, + methods: ["get"], + }, + }); + + queueCustomHandler(async (config) => { + requestCount++; + return { status: 200, data: { count: requestCount }, config }; + }); + queueCustomHandler(async (config) => { + requestCount++; + return { status: 200, data: { count: requestCount }, config }; + }); + + const [first, second] = await Promise.all([ + http.request({ method: "head", url: "/profile" }), + http.request({ method: "head", url: "/profile" }), + ]); + + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 2 }); + expect(requestCount).toBe(2); + }); + + it("默认 key 会归一化 method 大小写并纳入 baseURL", async () => { + let requestCount = 0; + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: async () => "", + dedupePolicy: { + enabled: true, + }, + }); + + queueCustomHandler(async (config) => { + requestCount++; + return { status: 200, data: { count: requestCount }, config }; + }); + + const [first, second] = await Promise.all([ + http.request({ method: "GET", url: "/profile", baseURL: "/api" }), + http.request({ method: "get", url: "/profile", baseURL: "/api" }), + ]); + + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 1 }); + expect(requestCount).toBe(1); + }); + + it("params 顺序不同但内容相同时会合并", async () => { + let requestCount = 0; + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: async () => "", + dedupePolicy: { + enabled: true, + }, + }); + + queueCustomHandler(async (config) => { + requestCount++; + return { status: 200, data: { count: requestCount }, config }; + }); + + const [first, second] = await Promise.all([ + http.get("/profile", { params: { b: 2, a: 1 } }), + http.get("/profile", { params: { a: 1, b: 2 } }), + ]); + + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 1 }); + expect(requestCount).toBe(1); + }); }); describe("onBusinessResponse", () => { diff --git a/apps/web/src/services/api/http-factory/dedupe-manager.ts b/apps/web/src/services/api/http-factory/dedupe-manager.ts index ddc7dcb..a6826b0 100644 --- a/apps/web/src/services/api/http-factory/dedupe-manager.ts +++ b/apps/web/src/services/api/http-factory/dedupe-manager.ts @@ -1,39 +1,65 @@ import type { AxiosRequestConfig } from "axios"; -interface IPendingRequest { - promise: Promise; - timestamp: number; -} - /** * 请求合并管理器。 - * 在时间窗口内的相同请求会复用同一个 Promise。 + * 仅合并仍在进行中的相同请求(in-flight coalesce)。 */ export class DedupeManager { - private pending = new Map(); - private windowMs: number; + private pending = new Map>(); private generateKey: (config: AxiosRequestConfig) => string; - constructor( - windowMs: number = 100, - generateKey?: (config: AxiosRequestConfig) => string, - ) { - this.windowMs = windowMs; + constructor(generateKey?: (config: AxiosRequestConfig) => string) { this.generateKey = generateKey ?? this.defaultGenerateKey; } /** * 默认的 key 生成器。 - * 由 method + url + sorted params 组成。 + * 由 lower-case method + baseURL + url + 稳定序列化 params 组成。 */ private defaultGenerateKey(config: AxiosRequestConfig): string { - const { method, url, params } = config; - const sortedParams = params - ? JSON.stringify( - Object.entries(params).sort(([a], [b]) => a.localeCompare(b)), - ) - : ""; - return `${method}:${url}:${sortedParams}`; + const method = (config.method ?? "get").toLowerCase(); + const baseURL = config.baseURL ?? ""; + const url = config.url ?? ""; + const sortedParams = this.stableSerialize(config.params); + return `${method}:${baseURL}:${url}:${sortedParams}`; + } + + private stableSerialize(value: unknown): string { + if (value == null) { + return ""; + } + + if (value instanceof URLSearchParams) { + return JSON.stringify( + Array.from(value.entries()).sort(([a], [b]) => a.localeCompare(b)), + ); + } + + if (Array.isArray(value)) { + return JSON.stringify(value.map((item) => this.normalizeForSerialize(item))); + } + + if (typeof value === "object") { + return JSON.stringify(this.normalizeForSerialize(value)); + } + + return JSON.stringify(value); + } + + private normalizeForSerialize(value: unknown): unknown { + if (value == null || typeof value !== "object") { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => this.normalizeForSerialize(item)); + } + + return Object.fromEntries( + Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, nested]) => [key, this.normalizeForSerialize(nested)]), + ); } /** @@ -48,34 +74,19 @@ export class DedupeManager { * 有则返回该 Promise,无则返回 null。 */ getPending(key: string): Promise | null { - const entry = this.pending.get(key); - - if (!entry) { - return null; - } - - // 检查是否过期 - if (Date.now() - entry.timestamp > this.windowMs) { - this.pending.delete(key); - return null; - } - - return entry.promise as Promise; + const promise = this.pending.get(key); + return (promise as Promise | undefined) ?? null; } /** * 注册新的请求。 */ setPending(key: string, promise: Promise): void { - this.pending.set(key, { - promise, - timestamp: Date.now(), - }); + this.pending.set(key, promise); // 请求完成后自动清理(使用 then 替代 finally,避免派生 Promise 未消费导致 unhandledrejection) const cleanup = () => { - const current = this.pending.get(key); - if (current && current.promise === promise) { + if (this.pending.get(key) === promise) { this.pending.delete(key); } }; diff --git a/apps/web/src/services/api/http-factory/index.ts b/apps/web/src/services/api/http-factory/index.ts index 7d59adc..d7d693b 100644 --- a/apps/web/src/services/api/http-factory/index.ts +++ b/apps/web/src/services/api/http-factory/index.ts @@ -49,10 +49,10 @@ export const createHttpClient = < const refreshEnabled = options.refreshAccessToken !== undefined; const resolvedRetryPolicy = resolveRetryPolicy(options.retryPolicy); - // 请求合并 + // 请求合并(in-flight coalesce) const dedupePolicy = options.dedupePolicy; const dedupeManager = dedupePolicy?.enabled - ? new DedupeManager(dedupePolicy.windowMs, dedupePolicy.generateKey) + ? new DedupeManager(dedupePolicy.generateKey) : null; const instance = axios.create({ @@ -63,10 +63,13 @@ export const createHttpClient = < // 包装 request 方法,实现请求合并 if (dedupeManager) { const originalRequest = instance.request.bind(instance); + const dedupeMethods = new Set( + (dedupePolicy?.methods ?? ["get"]).map((method) => method.toLowerCase()), + ); + instance.request = ((config: InternalAxiosRequestConfig) => { - // 只对 GET 请求进行合并 const method = (config.method ?? "get").toLowerCase(); - if (method !== "get") { + if (!dedupeMethods.has(method)) { return originalRequest(config); } @@ -152,30 +155,22 @@ export const createHttpClient = < config._retry = true; - try { - const refreshResult = await refreshAccessToken(); + const refreshResult = await refreshAccessToken(); - // 冷却期内跳过刷新时重新获取 token,否则使用刷新结果 - const tokenSource = - refreshResult === REFRESH_SKIPPED - ? await resolvedOptions.getAccessToken() - : refreshResult; - const { token } = normalizeTokenResult(tokenSource); + // 冷却期内跳过刷新时重新获取 token,否则使用刷新结果 + const tokenSource = + refreshResult === REFRESH_SKIPPED + ? await resolvedOptions.getAccessToken() + : refreshResult; + const { token } = normalizeTokenResult(tokenSource); - config.headers = config.headers ?? {}; - config.headers[resolvedOptions.accessTokenHeaderName] = formatAccessToken( - resolvedOptions.accessTokenPrefix, - token, - ); + config.headers = config.headers ?? {}; + config.headers[resolvedOptions.accessTokenHeaderName] = formatAccessToken( + resolvedOptions.accessTokenPrefix, + token, + ); - return await instance.request(config); - } catch (error) { - throw await invokeOnError( - normalizeError(error), - { type: "refresh" }, - resolvedOptions.onError, - ); - } + return instance.request(config); }; instance.interceptors.request.use( @@ -183,36 +178,34 @@ export const createHttpClient = < const currentAuthorization = config.headers?.[resolvedOptions.accessTokenHeaderName]; - if (currentAuthorization) { - return config; - } - - const tokenResult = await resolvedOptions.getAccessToken(); - const { token, expiresAt } = normalizeTokenResult(tokenResult); - - if (!token) return config; - - // 主动刷新:token 即将过期时异步触发刷新,不阻塞当前请求 - if ( - refreshEnabled && - expiresAt && - !shouldSkipRefresh(resolvedOptions.skipRefreshUrls, config) - ) { - const bufferMs = resolvedOptions.refreshBufferMs; - - if (bufferMs > 0 && isTokenExpiringSoon(expiresAt, bufferMs)) { - refreshAccessToken().catch(() => {}); + // 仅在没有显式 Authorization 时注入 token,并判断是否主动刷新 + if (!currentAuthorization) { + const tokenResult = await resolvedOptions.getAccessToken(); + const { token, expiresAt } = normalizeTokenResult(tokenResult); + + if (token) { + // 主动刷新:token 即将过期时异步触发刷新,不阻塞当前请求 + if ( + refreshEnabled && + expiresAt && + !shouldSkipRefresh(resolvedOptions.skipRefreshUrls, config) + ) { + const bufferMs = resolvedOptions.refreshBufferMs; + + if (bufferMs > 0 && isTokenExpiringSoon(expiresAt, bufferMs)) { + refreshAccessToken().catch(() => {}); + } + } + + config.headers = config.headers ?? {}; + config.headers[resolvedOptions.accessTokenHeaderName] = + formatAccessToken(resolvedOptions.accessTokenPrefix, token); } } - config.headers = config.headers ?? {}; - config.headers[resolvedOptions.accessTokenHeaderName] = formatAccessToken( - resolvedOptions.accessTokenPrefix, - token, - ); - - // 合并运行时动态 headers + // 合并运行时动态 headers(即使已有 Authorization 也要执行) if (resolvedOptions.headersProvider) { + config.headers = config.headers ?? {}; const runtimeHeaders = await resolvedOptions.headersProvider(); Object.assign(config.headers, runtimeHeaders); } @@ -272,24 +265,9 @@ export const createHttpClient = < return Promise.reject(finalError); } - // 通用重试逻辑(在 token 刷新之前判断) - if (resolvedRetryPolicy) { - const retryCount = config.__retryCount ?? 0; - - if ( - retryCount < resolvedRetryPolicy.maxRetries && - resolvedRetryPolicy.shouldRetry(error, retryCount) - ) { - const delay = resolvedRetryPolicy.retryDelay(retryCount); - config.__retryCount = retryCount + 1; - - await new Promise((resolve) => setTimeout(resolve, delay)); - return instance.request(config); - } - } - const status = error.response?.status; + // 鉴权失败优先于通用重试,避免 401 被 shouldRetry 空耗重试次数 if (status === resolvedOptions.unauthorizedStatusCode) { if ( !refreshEnabled || @@ -311,6 +289,22 @@ export const createHttpClient = < } } + // 通用重试逻辑(仅处理非鉴权失败) + if (resolvedRetryPolicy) { + const retryCount = config.__retryCount ?? 0; + + if ( + retryCount < resolvedRetryPolicy.maxRetries && + resolvedRetryPolicy.shouldRetry(error, retryCount) + ) { + const delay = resolvedRetryPolicy.retryDelay(retryCount); + config.__retryCount = retryCount + 1; + + await new Promise((resolve) => setTimeout(resolve, delay)); + return instance.request(config); + } + } + const finalError = await invokeOnError( normalizedError, { type: "request" }, diff --git a/apps/web/src/services/api/http-factory/reference/README.md b/apps/web/src/services/api/http-factory/reference/README.md index 3e29c6d..fca9373 100644 --- a/apps/web/src/services/api/http-factory/reference/README.md +++ b/apps/web/src/services/api/http-factory/reference/README.md @@ -14,7 +14,7 @@ - 刷新失败或重试失败后统一执行 `onAuthFailure` - 错误直接透传 axios 原始错误,不额外包装 - 支持通用重试策略(`retryPolicy`):5xx 或网络错误时自动重试,指数退避 -- 支持请求合并(`dedupePolicy`):相同 GET 请求在时间窗口内复用同一个 Promise +- 支持请求合并(`dedupePolicy`):相同 in-flight 请求复用同一个 Promise(默认仅 GET) - 支持运行时动态 headers(`headersProvider`):每次请求时注入自定义 headers ## 目录说明 @@ -195,10 +195,13 @@ const http = createHttpClient({ - **`dedupePolicy`** — 请求合并策略,默认关闭 - `enabled` — 是否启用 - - `windowMs` — 合并时间窗口(毫秒),默认 `100ms` - - `generateKey` — 自定义合并 key 生成器,默认 `method:url:sortedParams` + - `methods` — 允许合并的 method 列表,默认 `["get"]` + - `generateKey` — 自定义合并 key 生成器,默认 `method:baseURL:url:stableParams` + - `windowMs` — 已废弃,保留仅为兼容旧配置,会被忽略 -只对 GET 请求生效。请求级 `dedupePolicy.enabled = false` 可覆盖客户端级配置,禁止单个请求的合并。 +默认只对 GET 请求生效;可通过 `methods` 扩展。请求级 `dedupePolicy.enabled = false` 可覆盖客户端级配置,禁止单个请求的合并。 + +鉴权失败(命中 `unauthorizedStatusCode`)会优先于通用 `retryPolicy` 处理,避免 401 被自定义 `shouldRetry` 空耗重试次数。 ```ts const http = createHttpClient({ @@ -206,7 +209,6 @@ const http = createHttpClient({ getAccessToken: async () => "", dedupePolicy: { enabled: true, - windowMs: 100, }, }); diff --git a/apps/web/src/services/api/http-factory/reference/http-demo.ts b/apps/web/src/services/api/http-factory/reference/http-demo.ts index 77c6431..9ec88b5 100644 --- a/apps/web/src/services/api/http-factory/reference/http-demo.ts +++ b/apps/web/src/services/api/http-factory/reference/http-demo.ts @@ -132,10 +132,9 @@ export const http = createHttpClient({ // ---- Dedupe ---- - // 请求合并:相同 GET 请求在时间窗口内复用同一个 Promise + // 请求合并:相同 in-flight GET 请求复用同一个 Promise dedupePolicy: { enabled: true, - windowMs: 100, }, // ---- Runtime headers ---- diff --git a/apps/web/src/services/api/http-factory/types/http-client-options.ts b/apps/web/src/services/api/http-factory/types/http-client-options.ts index 884c042..42024a9 100644 --- a/apps/web/src/services/api/http-factory/types/http-client-options.ts +++ b/apps/web/src/services/api/http-factory/types/http-client-options.ts @@ -15,15 +15,20 @@ export interface DedupePolicy { enabled?: boolean; /** - * 合并时间窗口(毫秒)。 - * 在此时间窗口内的相同请求会复用同一个 Promise。 - * 默认 100ms。 + * 允许合并的 HTTP method 列表。 + * 默认 `["get"]`,比较时忽略大小写。 + */ + methods?: string[]; + + /** + * @deprecated 已不再使用。当前仅合并仍在进行中的相同请求。 + * 保留字段仅为兼容旧配置,传入后会被忽略。 */ windowMs?: number; /** * 自定义合并 key 生成器。 - * 默认:`method:url:sortedParams` + * 默认:`method:baseURL:url:stableParams` */ generateKey?: (config: AxiosRequestConfig) => string; } From e3eacd6585c2b70047a581bd88ef2361f6dc8a30 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:29:58 +0800 Subject: [PATCH 2/8] refactor(http-factory): reduce factory-level business assumptions Use neutral English default messages, rename authFailureCodes to refreshFailureCodes, and match skipRefreshUrls by path segments instead of substring includes. --- .../__tests__/http-client.edge-cases.test.ts | 12 ++-- .../__tests__/http-client.refresh.test.ts | 25 ++++++-- .../__tests__/http-client.test.ts | 8 +-- .../services/api/http-factory/constants.ts | 12 ++-- .../src/services/api/http-factory/index.ts | 4 +- .../api/http-factory/reference/README.md | 16 +++-- .../api/http-factory/reference/http-demo.ts | 2 +- .../http-factory/types/http-client-options.ts | 20 +++++-- .../api/http-factory/utils/refresh.ts | 60 ++++++++++++++++++- 9 files changed, 122 insertions(+), 37 deletions(-) diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.edge-cases.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.edge-cases.test.ts index ce2ccaf..6ddbec8 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.edge-cases.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.edge-cases.test.ts @@ -156,7 +156,7 @@ describe("createHttpClient edge cases", () => { }); await expect(http.get("/profile")).rejects.toMatchObject({ - message: "refreshToken 已失效,登录过期", + message: "Refresh token is invalid or expired", }); expect(refreshAccessToken).toHaveBeenCalledTimes(1); @@ -253,7 +253,7 @@ describe("createHttpClient edge cases", () => { await expect(http.get("/profile")).rejects.toMatchObject({ name: "Error", - message: "登录已失效,请重新登录", + message: "Login session has expired", }); expect(refreshAccessToken).toHaveBeenCalledTimes(1); @@ -380,7 +380,7 @@ describe("createHttpClient edge cases", () => { ]); }); - it("refresh 错误命中 authFailureCodes 时会视为登录过期", async () => { + it("refresh 错误命中 refreshFailureCodes 时会视为登录过期", async () => { const tokenStore = createTokenStore("old-access", "old-refresh"); const onAuthFailure = vi.fn(async () => {}); @@ -388,7 +388,7 @@ describe("createHttpClient edge cases", () => { axiosConfig: { baseURL: "/api", }, - authFailureCodes: [1001002], + refreshFailureCodes: [1001002], getAccessToken: tokenStore.getAccessToken, onAuthFailure, refreshAccessToken: createRefreshAccessToken(tokenStore), @@ -403,7 +403,7 @@ describe("createHttpClient edge cases", () => { await expect(http.get("/profile")).rejects.toMatchObject({ name: "Error", - message: "refreshToken 已失效,登录过期", + message: "Refresh token is invalid or expired", }); expect(onAuthFailure).toHaveBeenCalledTimes(1); @@ -426,7 +426,7 @@ describe("createHttpClient edge cases", () => { queueAxiosError({ status: 401, data: { message: "unauthorized" } }); await expect(http.get("/profile")).rejects.toMatchObject({ - message: "refreshToken 已失效,登录过期", + message: "Refresh token is invalid or expired", }); expect(onAuthFailure).toHaveBeenCalledTimes(1); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts index 2a065ac..86fa4bc 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts @@ -39,12 +39,27 @@ describe("shouldSkipRefresh", () => { expect(shouldSkipRefresh(["/auth"], {} as any)).toBe(false); }); - it("URL 包含 skipRefreshUrl → true", () => { + it("URL 以 path 段形式命中 skipRefreshUrl → true", () => { expect( shouldSkipRefresh(["/auth/login"], { url: "/api/auth/login" } as any), ).toBe(true); }); + it("URL 以 prefix path 命中 skipRefreshUrl → true", () => { + expect( + shouldSkipRefresh(["/public"], { url: "/public/data" } as any), + ).toBe(true); + }); + + it("子串误匹配不会跳过刷新", () => { + expect( + shouldSkipRefresh(["/auth"], { url: "/user/auth-history" } as any), + ).toBe(false); + expect( + shouldSkipRefresh(["/auth"], { url: "/authorization" } as any), + ).toBe(false); + }); + it("URL 不包含任何 skipRefreshUrl → false", () => { expect( shouldSkipRefresh(["/auth/login"], { url: "/api/profile" } as any), @@ -59,7 +74,7 @@ describe("shouldSkipRefresh", () => { }); describe("defaultIsRefreshFailure", () => { - const baseOptions = { unauthorizedStatusCode: 401, authFailureCodes: [1001002] }; + const baseOptions = { unauthorizedStatusCode: 401, refreshFailureCodes: [1001002] }; it("非 AxiosError → true(业务错误视为鉴权失败)", () => { expect(defaultIsRefreshFailure(new Error("something"), baseOptions)).toBe(true); @@ -81,13 +96,13 @@ describe("defaultIsRefreshFailure", () => { expect(defaultIsRefreshFailure(makeAxiosError({ status: 401 }), baseOptions)).toBe(true); }); - it("data.code 在 authFailureCodes 中 → true", () => { + it("data.code 在 refreshFailureCodes 中 → true", () => { expect( defaultIsRefreshFailure(makeAxiosError({ status: 403, code: 1001002 }), baseOptions), ).toBe(true); }); - it("data.code 不在 authFailureCodes 中 → false", () => { + it("data.code 不在 refreshFailureCodes 中 → false", () => { expect( defaultIsRefreshFailure(makeAxiosError({ status: 403, code: 999999 }), baseOptions), ).toBe(false); @@ -100,7 +115,7 @@ describe("defaultIsRefreshFailure", () => { }); it("自定义 unauthorizedStatusCode 生效", () => { - const options = { unauthorizedStatusCode: 498, authFailureCodes: [] }; + const options = { unauthorizedStatusCode: 498, refreshFailureCodes: [] }; expect(defaultIsRefreshFailure(makeAxiosError({ status: 498 }), options)).toBe(true); expect(defaultIsRefreshFailure(makeAxiosError({ status: 401 }), options)).toBe(false); }); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts index 7b6b020..cd00dd3 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts @@ -162,7 +162,7 @@ describe("createHttpClient", () => { await expect(http.get("/profile")).rejects.toMatchObject({ name: "Error", - message: "refreshToken 已失效,登录过期", + message: "Refresh token is invalid or expired", }); expect(refreshAccessToken).toHaveBeenCalledTimes(1); @@ -319,7 +319,7 @@ describe("createHttpClient", () => { await expect(http.get("/profile")).rejects.toMatchObject({ name: "Error", - message: "登录已失效,请重新登录", + message: "Login session has expired", }); expect(refreshAccessToken).toHaveBeenCalledTimes(1); @@ -746,12 +746,12 @@ describe("createHttpClient", () => { queueAxiosError({ status: 401, data: { message: "unauthorized" } }); await expect(http.get("/profile")).rejects.toMatchObject({ - message: "refreshToken 已失效,登录过期", + message: "Refresh token is invalid or expired", }); expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith( - expect.objectContaining({ message: "refreshToken 已失效,登录过期" }), + expect.objectContaining({ message: "Refresh token is invalid or expired" }), { type: "refresh" }, ); expect(onAuthFailure).toHaveBeenCalledTimes(1); diff --git a/apps/web/src/services/api/http-factory/constants.ts b/apps/web/src/services/api/http-factory/constants.ts index ffc205c..3bff997 100644 --- a/apps/web/src/services/api/http-factory/constants.ts +++ b/apps/web/src/services/api/http-factory/constants.ts @@ -1,12 +1,12 @@ -/** 默认错误消息 */ +/** Default error messages */ export const DEFAULT_MESSAGES = { - refreshTokenExpired: "refreshToken 已失效,登录过期", - loginExpired: "登录已失效,请重新登录", - refreshDisabled: "未启用 refresh token 逻辑", + refreshTokenExpired: "Refresh token is invalid or expired", + loginExpired: "Login session has expired", + refreshDisabled: "Refresh token flow is not enabled", } as const; -/** 默认 token 刷新缓冲时间(毫秒) */ +/** Default proactive refresh buffer (ms) */ export const DEFAULT_REFRESH_BUFFER_MS = 0; -/** 默认重试延迟上限(毫秒) */ +/** Default retry delay cap (ms) */ export const DEFAULT_RETRY_DELAY_CAP = 30000; diff --git a/apps/web/src/services/api/http-factory/index.ts b/apps/web/src/services/api/http-factory/index.ts index d7d693b..51b7feb 100644 --- a/apps/web/src/services/api/http-factory/index.ts +++ b/apps/web/src/services/api/http-factory/index.ts @@ -29,13 +29,13 @@ export const createHttpClient = < const resolvedOptions: ResolvedHttpClientOptions = { accessTokenHeaderName: "Authorization", accessTokenPrefix: "Bearer", - authFailureCodes: [], + refreshFailureCodes: [], unauthorizedStatusCode: 401, errorMessages: {}, isRefreshFailure: (error: unknown) => defaultIsRefreshFailure(error, { unauthorizedStatusCode: resolvedOptions.unauthorizedStatusCode, - authFailureCodes: resolvedOptions.authFailureCodes, + refreshFailureCodes: resolvedOptions.refreshFailureCodes, }), skipRefreshUrls: [], refreshBufferMs: DEFAULT_REFRESH_BUFFER_MS, diff --git a/apps/web/src/services/api/http-factory/reference/README.md b/apps/web/src/services/api/http-factory/reference/README.md index fca9373..0f9bc34 100644 --- a/apps/web/src/services/api/http-factory/reference/README.md +++ b/apps/web/src/services/api/http-factory/reference/README.md @@ -114,6 +114,7 @@ const http = createHttpClient({ - **`onBusinessResponse`** — 业务响应拦截器,返回 `void` 表示继续,返回 `Error` 表示业务失败,返回 `AxiosResponse` 会替换原响应 - **`onError`** — 全局错误钩子,在请求失败、刷新失败时触发,可修改错误或执行副作用 - **`errorMessages`** — 覆盖内部默认错误消息(`refreshTokenExpired`、`loginExpired`) + - 默认文案为中性英文;业务侧如需中文提示,请通过该配置覆盖 ```ts const http = createHttpClient({ @@ -142,7 +143,10 @@ const http = createHttpClient({ - **`refreshBufferMs`** — 提前刷新的毫秒数,默认 `0`(不提前刷新),设置后会在 token 即将过期前异步触发刷新 - **`refreshCooldownMs`** — 刷新后的冷却期,默认 `15000ms`,在冷却期内收到的 401 请求会跳过刷新,直接用新 token 重试 - **`refreshManager`** — 外部传入 `TokenRefreshManager` 实例,用于多个客户端共享同一套 token 刷新逻辑 -- **`skipRefreshUrls`** — 不触发 refresh token 流程的请求 URL 列表 +- **`skipRefreshUrls`** — 不触发 refresh token 流程的请求路径列表 + - 使用路径边界 / 完整路径段匹配,不是任意子串 includes + - 例如 `/auth` 匹配 `/auth`、`/auth/login`、`/api/auth/login` + - 不会匹配 `/user/auth-history` 或 `/authorization` ```ts import { TokenRefreshManager } from "@/services/api/http-factory/token-refresh-manager"; @@ -164,12 +168,14 @@ const http2 = createHttpClient({ ### 鉴权失败判定 -- **`authFailureCodes`** — 业务状态码中用于识别鉴权失败的 code 列表 +- **`refreshFailureCodes`** — 仅服务 refresh 失败判定的业务 code 列表(不是通用业务鉴权能力) + - 只在 `defaultIsRefreshFailure` 中读取响应体 `data.code` + - 普通请求鉴权失败请用 `shouldRefreshByResponseData` / `onBusinessResponse` / 自定义 `isRefreshFailure` - **`unauthorizedStatusCode`** — 触发刷新流程的 HTTP 状态码,默认 `401` - **`isRefreshFailure`** — 判断刷新请求是否已经失败到需要退出登录,默认行为: - 非 AxiosError(如业务代码抛出的 Error)→ 视为鉴权失败 - AxiosError 无 response(网络错误)或 status >= 500 → 不视为鉴权失败(token 可能仍有效) - - 命中 `unauthorizedStatusCode` 或 `authFailureCodes` → 视为鉴权失败 + - 命中 `unauthorizedStatusCode` 或 `refreshFailureCodes` → 视为鉴权失败 ### 重试策略 @@ -294,7 +300,7 @@ const [a, b] = await Promise.all([ 5. 并发 401 请求在 refresh 返回 500 时不会触发 onAuthFailure(服务端错误不代表 token 失效) 6. 重试后的业务响应再次命中刷新条件时会停止重试并退出登录 7. refresh 进行中时新来的 401 请求会复用同一次 refresh -8. refresh 错误命中 authFailureCodes 时会视为登录过期 +8. refresh 错误命中 refreshFailureCodes 时会视为登录过期 9. refresh 抛出非 Error 异常时会触发鉴权失败(归一化后作为 refreshToken 失效处理) 10. onAuthFailure 抛错时会透出回调错误而不是原始鉴权错误 11. onBusinessResponse 抛出的特定消息能在 onError 中正常接收 @@ -315,6 +321,6 @@ expiresAt 边界:0、空字符串、null、undefined、无效字符串、有 ### 工具函数单元测试 - `http-client.error.test.ts` — normalizeError(Error/对象/可序列化/循环引用)、invokeOnError(void/替换/抛错/异步) -- `http-client.refresh.test.ts` — shouldSkipRefresh(undefined config/url)、defaultIsRefreshFailure(非 AxiosError/无 response/5xx/authFailureCodes) +- `http-client.refresh.test.ts` — shouldSkipRefresh(undefined config/url)、defaultIsRefreshFailure(非 AxiosError/无 response/5xx/refreshFailureCodes) - `http-client.retry-policy.test.ts` — defaultShouldRetry(非 AxiosError/网络错误/5xx/4xx)、defaultRetryDelay(指数退避/上限)、resolveRetryPolicy(undefined/0/负数/自定义) - `http-client.token.test.ts` — formatAccessToken(空/空格前缀)、normalizeTokenResult(null/undefined/0/空串/无效日期/Date/ISO) diff --git a/apps/web/src/services/api/http-factory/reference/http-demo.ts b/apps/web/src/services/api/http-factory/reference/http-demo.ts index 9ec88b5..332fcc4 100644 --- a/apps/web/src/services/api/http-factory/reference/http-demo.ts +++ b/apps/web/src/services/api/http-factory/reference/http-demo.ts @@ -70,7 +70,7 @@ export const http = createHttpClient({ // ---- Auth failure ---- // 业务状态码中用于识别鉴权失败的 code 列表 - authFailureCodes: [40103, 1001002], + refreshFailureCodes: [40103, 1001002], // ---- Refresh ---- diff --git a/apps/web/src/services/api/http-factory/types/http-client-options.ts b/apps/web/src/services/api/http-factory/types/http-client-options.ts index 42024a9..4496da0 100644 --- a/apps/web/src/services/api/http-factory/types/http-client-options.ts +++ b/apps/web/src/services/api/http-factory/types/http-client-options.ts @@ -100,9 +100,15 @@ export interface HttpClientOptions< // ---- Auth failure ---- /** - * 业务状态码中,用于识别鉴权失败的 code 列表。 + * 刷新失败判定的业务 code 列表。 + * + * 仅用于 `defaultIsRefreshFailure`:当 refresh 请求响应体存在 `data.code` + * 且命中该列表时,视为刷新鉴权失败。 + * + * 不会影响普通业务请求的鉴权识别;更通用的入口是 `isRefreshFailure`。 + * 默认假设响应体形状为 `{ code?: number }`。 */ - authFailureCodes?: number[]; + refreshFailureCodes?: number[]; /** * 通用重试策略。 @@ -150,7 +156,7 @@ export interface HttpClientOptions< * - 非 AxiosError(如 refreshAccessToken 函数内部抛出的业务错误)视为刷新失败 * - AxiosError 无 response(网络错误)或状态码 >= 500 时不视为刷新失败 * - 状态码 === unauthorizedStatusCode 时视为刷新失败 - * - 响应 data.code 在 authFailureCodes 列表中时视为刷新失败 + * - 响应 data.code 在 refreshFailureCodes 列表中时视为刷新鉴权失败 */ isRefreshFailure?: (error: unknown) => boolean; @@ -163,7 +169,11 @@ export interface HttpClientOptions< refreshAccessToken?: () => T | Promise; /** - * 不触发 refresh token 流程的请求 URL 列表。 + * 不触发 refresh token 流程的请求路径列表。 + * + * 使用路径边界匹配(exact / prefix),不是任意子串 includes。 + * 例如配置 `/auth` 会匹配 `/auth`、`/auth/login`, + * 但不会匹配 `/user/auth-history` 或 `/authorization`。 */ skipRefreshUrls?: string[]; @@ -234,7 +244,7 @@ export interface HttpClientOptions< type ResolvedHttpClientOptionKeys = | "accessTokenHeaderName" | "accessTokenPrefix" - | "authFailureCodes" + | "refreshFailureCodes" | "unauthorizedStatusCode" | "errorMessages" | "isRefreshFailure" diff --git a/apps/web/src/services/api/http-factory/utils/refresh.ts b/apps/web/src/services/api/http-factory/utils/refresh.ts index 5ad2bc3..04e52aa 100644 --- a/apps/web/src/services/api/http-factory/utils/refresh.ts +++ b/apps/web/src/services/api/http-factory/utils/refresh.ts @@ -57,6 +57,11 @@ export const resolveRetryPolicy = ( /** * 判断是否跳过刷新流程。 + * + * 匹配规则: + * - 使用路径边界匹配,而不是任意子串 includes + * - 支持 exact / prefix 路径(例如 `/auth/login`、`/public`) + * - `/auth` 不会误伤 `/user/auth-history` 或 `/authorization` */ export const shouldSkipRefresh = ( skipRefreshUrls: string[], @@ -68,7 +73,56 @@ export const shouldSkipRefresh = ( return false; } - return skipRefreshUrls.some((url) => requestUrl.includes(url)); + const normalizedRequestPath = normalizeRequestPath(requestUrl); + + return skipRefreshUrls.some((skipUrl) => + matchesSkipPath(normalizedRequestPath, skipUrl), + ); +}; + +const normalizeRequestPath = (requestUrl: string): string => { + // 兼容相对路径、绝对 URL、带 query/hash 的地址 + try { + const parsed = new URL(requestUrl, "http://localhost"); + return parsed.pathname || "/"; + } catch { + const withoutQuery = requestUrl.split("?")[0]?.split("#")[0] ?? requestUrl; + return withoutQuery.startsWith("/") ? withoutQuery : `/${withoutQuery}`; + } +}; + +const matchesSkipPath = (requestPath: string, skipUrl: string): boolean => { + if (!skipUrl) { + return false; + } + + const normalizedSkip = skipUrl.startsWith("/") ? skipUrl : `/${skipUrl}`; + const skipPath = normalizedSkip.replace(/\/+$/, "") || "/"; + const path = requestPath.replace(/\/+$/, "") || "/"; + + if (path === skipPath || path.startsWith(`${skipPath}/`)) { + return true; + } + + // 允许匹配路径中的完整段序列(如 /api/auth/login 命中 /auth/login) + // 但不会把 /auth 误匹配到 /user/auth-history 或 /authorization + const pathSegments = path.split("/").filter(Boolean); + const skipSegments = skipPath.split("/").filter(Boolean); + + if (skipSegments.length === 0 || skipSegments.length > pathSegments.length) { + return false; + } + + for (let i = 0; i <= pathSegments.length - skipSegments.length; i++) { + const matched = skipSegments.every( + (segment, index) => pathSegments[i + index] === segment, + ); + if (matched) { + return true; + } + } + + return false; }; /** @@ -76,7 +130,7 @@ export const shouldSkipRefresh = ( */ export const defaultIsRefreshFailure = ( error: unknown, - options: { unauthorizedStatusCode: number; authFailureCodes: number[] }, + options: { unauthorizedStatusCode: number; refreshFailureCodes: number[] }, ): boolean => { // 非 AxiosError(如 refreshAccessToken 函数内部抛出的业务错误) // 说明刷新逻辑本身失败,应视为鉴权失败 @@ -94,6 +148,6 @@ export const defaultIsRefreshFailure = ( return ( status === options.unauthorizedStatusCode || - (data?.code !== undefined && options.authFailureCodes.includes(data.code)) + (data?.code !== undefined && options.refreshFailureCodes.includes(data.code)) ); }; From df795d8ef3ad5347687c1bebd99908b6a0d3caf1 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:49:12 +0800 Subject: [PATCH 3/8] test(http-factory): cover proactive refresh error hooks Document and verify that proactive refresh failures still notify onError/onAuthFailure without blocking the current request. --- .../http-client.token-normalization.test.ts | 89 +++++++++++++++++++ .../src/services/api/http-factory/index.ts | 4 +- .../api/http-factory/reference/README.md | 1 + 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts index ff41c3a..662ebdd 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts @@ -1,4 +1,5 @@ import "./test-utils/mock-axios"; +import axios from "axios"; import { describe, expect, it, vi } from "vitest"; import type { AccessTokenResult } from "../types/token"; @@ -175,6 +176,94 @@ describe("Token 规范化边界情况", () => { expect(refreshAccessToken).toHaveBeenCalledTimes(1); }); + it("主动刷新网络失败时仍会触发 onError,且不阻塞当前请求", async () => { + const futureTime = Date.now() + 30000; + const onError = vi.fn(); + const onAuthFailure = vi.fn(); + + const getAccessToken = vi.fn(async () => ({ + token: "expiring-token", + expiresAt: futureTime, + })); + + const refreshAccessToken = vi.fn(async () => { + const error = new axios.AxiosError("Network error"); + error.isAxiosError = true; + throw error; + }); + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken, + refreshAccessToken, + refreshBufferMs: 60000, + onError, + onAuthFailure, + }); + + queueCustomHandler(async (config) => { + expect(config.headers?.Authorization).toBe("Bearer expiring-token"); + return { status: 200, data: { ok: true } }; + }); + + const response = await http.get("/test"); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(response.data).toEqual({ ok: true }); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "Network error" }), + { type: "refresh" }, + ); + expect(onAuthFailure).not.toHaveBeenCalled(); + }); + + it("主动刷新鉴权失败时会触发 onError 与 onAuthFailure,且不阻塞当前请求", async () => { + const futureTime = Date.now() + 30000; + const onError = vi.fn(); + const onAuthFailure = vi.fn(); + + const getAccessToken = vi.fn(async () => ({ + token: "expiring-token", + expiresAt: futureTime, + })); + + const refreshAccessToken = vi.fn(async () => { + throw new Error("refresh rejected"); + }); + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken, + refreshAccessToken, + refreshBufferMs: 60000, + onError, + onAuthFailure, + }); + + queueCustomHandler(async (config) => { + expect(config.headers?.Authorization).toBe("Bearer expiring-token"); + return { status: 200, data: { ok: true } }; + }); + + const response = await http.get("/test"); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(response.data).toEqual({ ok: true }); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Refresh token is invalid or expired", + }), + { type: "refresh" }, + ); + expect(onAuthFailure).toHaveBeenCalledTimes(1); + }); + it("expiresAt 为有效时间但未过期时不应该触发主动刷新", async () => { const futureTime = Date.now() + 120000; // 120 秒后过期 diff --git a/apps/web/src/services/api/http-factory/index.ts b/apps/web/src/services/api/http-factory/index.ts index 51b7feb..fca28d8 100644 --- a/apps/web/src/services/api/http-factory/index.ts +++ b/apps/web/src/services/api/http-factory/index.ts @@ -193,7 +193,9 @@ export const createHttpClient = < const bufferMs = resolvedOptions.refreshBufferMs; if (bufferMs > 0 && isTokenExpiringSoon(expiresAt, bufferMs)) { - refreshAccessToken().catch(() => {}); + // fire-and-forget:refreshAccessToken 内部已调用 onError/onAuthFailure + // 这里仅吞掉 rejection,避免 unhandledrejection,且不阻塞当前请求 + void refreshAccessToken().catch(() => {}); } } diff --git a/apps/web/src/services/api/http-factory/reference/README.md b/apps/web/src/services/api/http-factory/reference/README.md index 0f9bc34..30be210 100644 --- a/apps/web/src/services/api/http-factory/reference/README.md +++ b/apps/web/src/services/api/http-factory/reference/README.md @@ -141,6 +141,7 @@ const http = createHttpClient({ - **`refreshAccessToken`** — 自定义刷新逻辑,返回类型与 `getAccessToken` 一致 - **`shouldRefreshByResponseData`** — 通过业务响应内容判断是否需要刷新 token - **`refreshBufferMs`** — 提前刷新的毫秒数,默认 `0`(不提前刷新),设置后会在 token 即将过期前异步触发刷新 + - 主动刷新失败不会阻塞当前请求;鉴权失败走 `onAuthFailure`,网络等错误仍会触发 `onError({ type: "refresh" })` - **`refreshCooldownMs`** — 刷新后的冷却期,默认 `15000ms`,在冷却期内收到的 401 请求会跳过刷新,直接用新 token 重试 - **`refreshManager`** — 外部传入 `TokenRefreshManager` 实例,用于多个客户端共享同一套 token 刷新逻辑 - **`skipRefreshUrls`** — 不触发 refresh token 流程的请求路径列表 From 8a12bccaeb4893a0865515af27ec2e06da6ad21a Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:59:58 +0800 Subject: [PATCH 4/8] refactor(http-factory): Simplify dedupe policy and enhance request handling --- .../__tests__/http-client.test.ts | 94 +++++++++++++------ .../src/services/api/http-factory/index.ts | 23 +++-- .../api/http-factory/reference/README.md | 13 ++- .../services/api/http-factory/types/common.ts | 2 + .../http-factory/types/http-client-options.ts | 14 +-- 5 files changed, 88 insertions(+), 58 deletions(-) diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts index cd00dd3..3c66b92 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts @@ -860,7 +860,6 @@ describe("createHttpClient", () => { getAccessToken: async () => "", dedupePolicy: { enabled: true, - windowMs: 100, }, }); @@ -881,7 +880,7 @@ describe("createHttpClient", () => { expect(requestCount).toBe(1); }); - it("请求仍在进行中时,即使超过 windowMs 也会继续合并", async () => { + it("请求仍在进行中时,后续相同 GET 会继续合并", async () => { let requestCount = 0; let release: (() => void) | undefined; const gate = new Promise((resolve) => { @@ -893,7 +892,6 @@ describe("createHttpClient", () => { getAccessToken: async () => "", dedupePolicy: { enabled: true, - windowMs: 20, }, }); @@ -925,7 +923,6 @@ describe("createHttpClient", () => { getAccessToken: async () => "", dedupePolicy: { enabled: true, - windowMs: 1000, }, }); @@ -954,7 +951,6 @@ describe("createHttpClient", () => { getAccessToken: async () => "", dedupePolicy: { enabled: true, - windowMs: 100, }, }); @@ -985,7 +981,6 @@ describe("createHttpClient", () => { getAccessToken: async () => "", dedupePolicy: { enabled: true, - windowMs: 100, }, }); @@ -1041,7 +1036,6 @@ describe("createHttpClient", () => { getAccessToken: async () => "", dedupePolicy: { enabled: true, - windowMs: 100, }, }); @@ -1071,7 +1065,6 @@ describe("createHttpClient", () => { getAccessToken: async () => "", dedupePolicy: { enabled: true, - windowMs: 100, }, }); @@ -1102,7 +1095,6 @@ describe("createHttpClient", () => { getAccessToken: async () => "", dedupePolicy: { enabled: true, - windowMs: 100, generateKey: () => "fixed-key", }, }); @@ -1122,7 +1114,7 @@ describe("createHttpClient", () => { expect(requestCount).toBe(1); }); - it("可通过 methods 配置允许合并 HEAD 请求", async () => { + it("HEAD 请求不会被合并", async () => { let requestCount = 0; const http = createHttpClient({ @@ -1130,10 +1122,13 @@ describe("createHttpClient", () => { getAccessToken: async () => "", dedupePolicy: { enabled: true, - methods: ["head"], }, }); + queueCustomHandler(async (config) => { + requestCount++; + return { status: 200, data: { count: requestCount }, config }; + }); queueCustomHandler(async (config) => { requestCount++; return { status: 200, data: { count: requestCount }, config }; @@ -1145,11 +1140,51 @@ describe("createHttpClient", () => { ]); expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 1 }); - expect(requestCount).toBe(1); + expect(second.data).toEqual({ count: 2 }); + expect(requestCount).toBe(2); }); - it("methods 未包含的 method 不会被合并", async () => { + it("启用 dedupe 后,401 刷新重试不会因 pending 复用而挂起", async () => { + const tokenStore = createTokenStore("old-access", "old-refresh"); + const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: tokenStore.getAccessToken, + refreshAccessToken, + dedupePolicy: { + enabled: true, + }, + }); + + queueAxiosError({ status: 401, data: { message: "unauthorized" } }); + queueResponse({ + status: 200, + data: { + accessToken: "new-access", + refreshToken: "new-refresh", + }, + }); + queueCustomHandler(async (config) => ({ + status: 200, + data: { ok: true, auth: config.headers?.Authorization }, + config, + })); + + await expect( + Promise.race([ + http.get("/profile"), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("dedupe refresh retry hung")), 200); + }), + ]), + ).resolves.toMatchObject({ + data: { ok: true, auth: "Bearer new-access" }, + }); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + }); + + it("启用 dedupe 后,5xx 通用重试不会因 pending 复用而挂起", async () => { let requestCount = 0; const http = createHttpClient({ @@ -1157,27 +1192,30 @@ describe("createHttpClient", () => { getAccessToken: async () => "", dedupePolicy: { enabled: true, - methods: ["get"], + }, + retryPolicy: { + maxRetries: 1, + retryDelay: () => 0, }, }); + queueAxiosError({ status: 500, data: { message: "server error" } }); queueCustomHandler(async (config) => { requestCount++; - return { status: 200, data: { count: requestCount }, config }; - }); - queueCustomHandler(async (config) => { - requestCount++; - return { status: 200, data: { count: requestCount }, config }; + return { status: 200, data: { ok: true, attempt: requestCount }, config }; }); - const [first, second] = await Promise.all([ - http.request({ method: "head", url: "/profile" }), - http.request({ method: "head", url: "/profile" }), - ]); - - expect(first.data).toEqual({ count: 1 }); - expect(second.data).toEqual({ count: 2 }); - expect(requestCount).toBe(2); + await expect( + Promise.race([ + http.get("/profile"), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("dedupe retry hung")), 200); + }), + ]), + ).resolves.toMatchObject({ + data: { ok: true, attempt: 1 }, + }); + expect(requestCount).toBe(1); }); it("默认 key 会归一化 method 大小写并纳入 baseURL", async () => { diff --git a/apps/web/src/services/api/http-factory/index.ts b/apps/web/src/services/api/http-factory/index.ts index fca28d8..376a202 100644 --- a/apps/web/src/services/api/http-factory/index.ts +++ b/apps/web/src/services/api/http-factory/index.ts @@ -60,16 +60,15 @@ export const createHttpClient = < ...resolvedOptions.axiosConfig, }); - // 包装 request 方法,实现请求合并 + // 包装 request 方法,实现请求合并(仅 GET) + // 内部 refresh / retry 通过 originalRequest 旁路 dedupe,避免 pending 自引用死锁 + const originalRequest = instance.request.bind(instance); if (dedupeManager) { - const originalRequest = instance.request.bind(instance); - const dedupeMethods = new Set( - (dedupePolicy?.methods ?? ["get"]).map((method) => method.toLowerCase()), - ); - - instance.request = ((config: InternalAxiosRequestConfig) => { + instance.request = (( + config: InternalAxiosRequestConfig & RequestRetryState, + ) => { const method = (config.method ?? "get").toLowerCase(); - if (!dedupeMethods.has(method)) { + if (method !== "get" || config.__skipDedupe) { return originalRequest(config); } @@ -170,7 +169,9 @@ export const createHttpClient = < token, ); - return instance.request(config); + // 内部重试旁路 dedupe:当前请求 Promise 仍在 pending map 中 + config.__skipDedupe = true; + return originalRequest(config); }; instance.interceptors.request.use( @@ -303,7 +304,9 @@ export const createHttpClient = < config.__retryCount = retryCount + 1; await new Promise((resolve) => setTimeout(resolve, delay)); - return instance.request(config); + // 内部重试旁路 dedupe:当前请求 Promise 仍在 pending map 中 + config.__skipDedupe = true; + return originalRequest(config); } } diff --git a/apps/web/src/services/api/http-factory/reference/README.md b/apps/web/src/services/api/http-factory/reference/README.md index 30be210..6ccb06f 100644 --- a/apps/web/src/services/api/http-factory/reference/README.md +++ b/apps/web/src/services/api/http-factory/reference/README.md @@ -14,7 +14,7 @@ - 刷新失败或重试失败后统一执行 `onAuthFailure` - 错误直接透传 axios 原始错误,不额外包装 - 支持通用重试策略(`retryPolicy`):5xx 或网络错误时自动重试,指数退避 -- 支持请求合并(`dedupePolicy`):相同 in-flight 请求复用同一个 Promise(默认仅 GET) +- 支持请求合并(`dedupePolicy`):相同 in-flight GET 请求复用同一个 Promise - 支持运行时动态 headers(`headersProvider`):每次请求时注入自定义 headers ## 目录说明 @@ -202,11 +202,10 @@ const http = createHttpClient({ - **`dedupePolicy`** — 请求合并策略,默认关闭 - `enabled` — 是否启用 - - `methods` — 允许合并的 method 列表,默认 `["get"]` - `generateKey` — 自定义合并 key 生成器,默认 `method:baseURL:url:stableParams` - - `windowMs` — 已废弃,保留仅为兼容旧配置,会被忽略 -默认只对 GET 请求生效;可通过 `methods` 扩展。请求级 `dedupePolicy.enabled = false` 可覆盖客户端级配置,禁止单个请求的合并。 +仅对 GET 请求生效,不支持配置其他 method。请求级 `dedupePolicy.enabled = false` 可覆盖客户端级配置,禁止单个请求的合并。 +内部 refresh / retry 会自动旁路 dedupe,避免 pending 自引用导致请求挂起。 鉴权失败(命中 `unauthorizedStatusCode`)会优先于通用 `retryPolicy` 处理,避免 401 被自定义 `shouldRetry` 空耗重试次数。 @@ -245,9 +244,9 @@ const [a, b] = await Promise.all([ - 业务响应拦截(`onBusinessResponse`) - 成功时返回原始 `AxiosResponse` 5. 响应失败时: - - 先执行通用重试(`retryPolicy`,5xx / 网络错误) - - 若为 `401` 且启用了刷新,进入刷新流程 + - 若为 `401` 且启用了刷新,优先进入刷新流程 - 若为 `401` 且未启用刷新,直接触发 `onAuthFailure` + - 非鉴权失败时再执行通用重试(`retryPolicy`,5xx / 网络错误) 6. 刷新流程: - 通过 `TokenRefreshManager` 合并并发刷新 - 调用业务侧 `refreshAccessToken` @@ -289,7 +288,7 @@ const [a, b] = await Promise.all([ 11. 多个客户端共享 TokenRefreshManager 时只刷新一次 12. headersProvider:同步/异步注入、覆盖 Authorization、未配置时不变 13. retryPolicy:5xx 重试、503 重试、超过上限、4xx 不重试、未配置不重试、自定义 shouldRetry、网络错误重试 -14. dedupePolicy:相同 GET 合并、不同 URL 不合并、请求级禁用、未配置不合并、POST 不合并、自定义 generateKey +14. dedupePolicy:相同 GET 合并、不同 URL 不合并、请求级禁用、未配置不合并、POST/HEAD 不合并、自定义 generateKey、与 401/5xx 重试交互 15. onBusinessResponse 返回 AxiosResponse 对象时会替换原响应 ### edge cases(`http-client.edge-cases.test.ts`) diff --git a/apps/web/src/services/api/http-factory/types/common.ts b/apps/web/src/services/api/http-factory/types/common.ts index 7586217..dc3210e 100644 --- a/apps/web/src/services/api/http-factory/types/common.ts +++ b/apps/web/src/services/api/http-factory/types/common.ts @@ -8,6 +8,8 @@ export interface RequestRetryState { _retry?: boolean; /** 通用重试计数 */ __retryCount?: number; + /** 内部重试旁路 dedupe,避免 pending 自引用死锁 */ + __skipDedupe?: boolean; } /** diff --git a/apps/web/src/services/api/http-factory/types/http-client-options.ts b/apps/web/src/services/api/http-factory/types/http-client-options.ts index 4496da0..cfe2b95 100644 --- a/apps/web/src/services/api/http-factory/types/http-client-options.ts +++ b/apps/web/src/services/api/http-factory/types/http-client-options.ts @@ -11,21 +11,9 @@ import type { AccessTokenResult } from "./token"; * 请求合并配置。 */ export interface DedupePolicy { - /** 是否启用请求合并。默认 false。 */ + /** 是否启用请求合并。默认 false。仅合并 GET 请求。 */ enabled?: boolean; - /** - * 允许合并的 HTTP method 列表。 - * 默认 `["get"]`,比较时忽略大小写。 - */ - methods?: string[]; - - /** - * @deprecated 已不再使用。当前仅合并仍在进行中的相同请求。 - * 保留字段仅为兼容旧配置,传入后会被忽略。 - */ - windowMs?: number; - /** * 自定义合并 key 生成器。 * 默认:`method:baseURL:url:stableParams` From adbb6ce90ee3761c356399e7a66d8dce464683f8 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:48:19 +0800 Subject: [PATCH 5/8] refactor(http-factory): Update refresh token handling and error management --- .../__tests__/http-client.edge-cases.test.ts | 124 +++++++++++++++++- .../__tests__/http-client.refresh.test.ts | 6 +- .../__tests__/http-client.test.ts | 23 +++- .../http-client.token-normalization.test.ts | 6 +- .../src/services/api/http-factory/index.ts | 60 +++++++-- .../api/http-factory/reference/README.md | 4 +- .../services/api/http-factory/types/common.ts | 2 +- .../http-factory/types/http-client-options.ts | 6 +- .../api/http-factory/utils/refresh.ts | 6 +- 9 files changed, 206 insertions(+), 31 deletions(-) diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.edge-cases.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.edge-cases.test.ts index 6ddbec8..27d2ebb 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.edge-cases.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.edge-cases.test.ts @@ -133,9 +133,10 @@ describe("createHttpClient edge cases", () => { expect(onAuthFailure).not.toHaveBeenCalled(); }); - it("refresh 成功响应缺少 accessToken 时会触发鉴权失败且不重试原请求", async () => { + it("refresh 成功响应缺少 accessToken 时默认不鉴权失败(业务 Error 需自定义 isRefreshFailure)", async () => { const tokenStore = createTokenStore("old-access", "old-refresh"); const onAuthFailure = vi.fn(async () => {}); + const onError = vi.fn(); const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); const http = createHttpClient({ @@ -144,6 +145,7 @@ describe("createHttpClient edge cases", () => { }, getAccessToken: tokenStore.getAccessToken, onAuthFailure, + onError, refreshAccessToken, }); @@ -156,12 +158,13 @@ describe("createHttpClient edge cases", () => { }); await expect(http.get("/profile")).rejects.toMatchObject({ - message: "Refresh token is invalid or expired", + message: "missing accessToken", }); expect(refreshAccessToken).toHaveBeenCalledTimes(1); expect(tokenStore.getRefreshToken).toHaveBeenCalledTimes(1); - expect(onAuthFailure).toHaveBeenCalledTimes(1); + expect(onAuthFailure).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); expect(tokenStore.setAccessToken).not.toHaveBeenCalled(); expect(tokenStore.setRefreshToken).not.toHaveBeenCalled(); }); @@ -409,7 +412,37 @@ describe("createHttpClient edge cases", () => { expect(onAuthFailure).toHaveBeenCalledTimes(1); }); - it("refresh 抛出非 Error 异常时会触发鉴权失败(归一化后作为 refreshToken 失效处理)", async () => { + it("refresh 抛出非 AxiosError 时默认不触发 onAuthFailure", async () => { + const onAuthFailure = vi.fn(async () => {}); + const onError = vi.fn(); + + const http = createHttpClient({ + axiosConfig: { + baseURL: "/api", + }, + getAccessToken: async () => "old-access", + onAuthFailure, + onError, + refreshAccessToken: async () => { + throw new TypeError("refresh broken"); + }, + }); + + queueAxiosError({ status: 401, data: { message: "unauthorized" } }); + + await expect(http.get("/profile")).rejects.toMatchObject({ + message: "refresh broken", + }); + + expect(onAuthFailure).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "refresh broken" }), + { type: "refresh" }, + ); + }); + + it("自定义 isRefreshFailure 仍可把非 AxiosError 判为鉴权失败", async () => { const onAuthFailure = vi.fn(async () => {}); const http = createHttpClient({ @@ -418,8 +451,9 @@ describe("createHttpClient edge cases", () => { }, getAccessToken: async () => "old-access", onAuthFailure, + isRefreshFailure: () => true, refreshAccessToken: async () => { - throw { reason: "boom" }; + throw new Error("business refresh failed"); }, }); @@ -432,6 +466,86 @@ describe("createHttpClient edge cases", () => { expect(onAuthFailure).toHaveBeenCalledTimes(1); }); + it("refresh 返回空 token 时触发鉴权失败且不进入冷却", async () => { + const onAuthFailure = vi.fn(async () => {}); + const refreshAccessToken = vi + .fn() + .mockResolvedValueOnce("") + .mockResolvedValueOnce("new-access"); + + const http = createHttpClient({ + axiosConfig: { + baseURL: "/api", + }, + getAccessToken: async () => "old-access", + onAuthFailure, + refreshAccessToken, + refreshCooldownMs: 15000, + }); + + queueAxiosError({ status: 401, data: { message: "unauthorized" } }); + + await expect(http.get("/profile")).rejects.toMatchObject({ + message: "Refresh token is invalid or expired", + }); + expect(onAuthFailure).toHaveBeenCalledTimes(1); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + + // 空 token 刷新失败不应进入冷却,下一次 401 仍可再次 refresh + queueAxiosError({ status: 401, data: { message: "unauthorized" } }); + queueCustomHandler(async (config) => ({ + status: 200, + data: { ok: true, auth: config.headers?.Authorization }, + config, + })); + + const response = await http.get("/profile"); + expect(response.data).toEqual({ ok: true, auth: "Bearer new-access" }); + expect(refreshAccessToken).toHaveBeenCalledTimes(2); + }); + + it("冷却期内 getAccessToken 为空时不重试原请求,直接鉴权失败", async () => { + const tokenStore = createTokenStore("old-access", "old-refresh"); + const onAuthFailure = vi.fn(async () => {}); + const refreshAccessToken = vi.fn(async () => { + tokenStore.setAccessToken("new-access"); + tokenStore.setRefreshToken("new-refresh"); + return "new-access"; + }); + + const http = createHttpClient({ + axiosConfig: { + baseURL: "/api", + }, + getAccessToken: tokenStore.getAccessToken, + onAuthFailure, + refreshAccessToken, + refreshCooldownMs: 15000, + }); + + queueAxiosError({ status: 401, data: { message: "unauthorized" } }); + queueCustomHandler(async (config) => ({ + status: 200, + data: { ok: true }, + config, + })); + + await http.get("/profile"); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + + // 模拟 logout:清空 token,但仍处于冷却期 + tokenStore.clearAuth(); + + queueAxiosError({ status: 401, data: { message: "unauthorized" } }); + + await expect(http.get("/me")).rejects.toMatchObject({ + message: "Login session has expired", + }); + + expect(refreshAccessToken).toHaveBeenCalledTimes(1); // 冷却内未再次 refresh + expect(onAuthFailure).toHaveBeenCalledTimes(1); + }); + it("onAuthFailure 抛错时会透出回调错误而不是原始鉴权错误", async () => { const onAuthFailure = vi.fn(async () => { throw new Error("cleanup failed"); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts index 86fa4bc..6cb56f1 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts @@ -76,8 +76,10 @@ describe("shouldSkipRefresh", () => { describe("defaultIsRefreshFailure", () => { const baseOptions = { unauthorizedStatusCode: 401, refreshFailureCodes: [1001002] }; - it("非 AxiosError → true(业务错误视为鉴权失败)", () => { - expect(defaultIsRefreshFailure(new Error("something"), baseOptions)).toBe(true); + it("非 AxiosError → false(编程/业务 Error 默认不视为鉴权失败)", () => { + expect(defaultIsRefreshFailure(new Error("something"), baseOptions)).toBe(false); + expect(defaultIsRefreshFailure(new TypeError("boom"), baseOptions)).toBe(false); + expect(defaultIsRefreshFailure({ reason: "boom" }, baseOptions)).toBe(false); }); it("AxiosError 无 response → false", () => { diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts index 3c66b92..3f5ad75 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts @@ -732,7 +732,8 @@ describe("createHttpClient", () => { const onError = vi.fn(); const onAuthFailure = vi.fn(); const refreshAccessToken = vi.fn(async () => { - throw new Error("refresh token expired"); + // 空 token 由工厂按鉴权失败处理 + return ""; }); const http = createHttpClient({ @@ -1272,7 +1273,7 @@ describe("createHttpClient", () => { }); describe("onBusinessResponse", () => { - it("返回 AxiosResponse 对象时会替换原响应", async () => { + it("返回完整 AxiosResponse 形态对象时会替换原响应", async () => { const http = createHttpClient({ axiosConfig: { baseURL: "/api" }, getAccessToken: async () => "", @@ -1291,5 +1292,23 @@ describe("createHttpClient", () => { expect(response.data).toEqual({ replaced: true }); }); + + it("仅含 status/data 的业务对象不会被当成响应替换", async () => { + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: async () => "", + onBusinessResponse: () => + ({ + status: 0, + data: { business: true }, + }) as any, + }); + + queueResponse({ status: 200, data: { original: true } }); + + const response = await http.get("/profile"); + + expect(response.data).toEqual({ original: true }); + }); }); }); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts index 662ebdd..656dd90 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts @@ -231,7 +231,11 @@ describe("Token 规范化边界情况", () => { })); const refreshAccessToken = vi.fn(async () => { - throw new Error("refresh rejected"); + // 空 token 由工厂按鉴权失败处理;返回形态需与 getAccessToken 的 T 一致 + return { + token: "", + expiresAt: futureTime, + }; }); const http = createHttpClient({ diff --git a/apps/web/src/services/api/http-factory/index.ts b/apps/web/src/services/api/http-factory/index.ts index 376a202..3464d5c 100644 --- a/apps/web/src/services/api/http-factory/index.ts +++ b/apps/web/src/services/api/http-factory/index.ts @@ -106,33 +106,48 @@ export const createHttpClient = < throw new Error(DEFAULT_MESSAGES.refreshDisabled); } + const rejectRefreshAuthFailure = async (): Promise => { + const authError = new Error( + resolvedOptions.errorMessages?.refreshTokenExpired ?? + DEFAULT_MESSAGES.refreshTokenExpired, + ); + await handleAuthFailure(authError); + throw await invokeOnError( + authError, + { type: "refresh" }, + resolvedOptions.onError, + ); + }; + return refreshManager.runRefresh(async () => { + let result: AccessTokenResult; + try { - return await requestRefreshAccessToken(); + result = await requestRefreshAccessToken(); } catch (error) { const normalizedError = normalizeError(error); const isAuthFailure = resolvedOptions.isRefreshFailure(error); if (isAuthFailure) { - const authError = new Error( - resolvedOptions.errorMessages?.refreshTokenExpired ?? - DEFAULT_MESSAGES.refreshTokenExpired, - ); - await handleAuthFailure(authError); - throw await invokeOnError( - authError, - { type: "refresh" }, - resolvedOptions.onError, - ); + await rejectRefreshAuthFailure(); } - // 非鉴权失败(如网络错误),不调用 handleAuthFailure + // 非鉴权失败(如网络错误 / 编程错误),不调用 handleAuthFailure throw await invokeOnError( normalizedError, { type: "refresh" }, resolvedOptions.onError, ); } + + const { token } = normalizeTokenResult(result); + + // 空 token 视为刷新鉴权失败:不写冷却,不重试业务请求 + if (!token) { + await rejectRefreshAuthFailure(); + } + + return result; }); }; @@ -163,6 +178,20 @@ export const createHttpClient = < : refreshResult; const { token } = normalizeTokenResult(tokenSource); + // 冷却跳过或刷新结果为空 token 时,不再重试原请求 + if (!token) { + const authError = new Error( + resolvedOptions.errorMessages?.loginExpired ?? + DEFAULT_MESSAGES.loginExpired, + ); + await handleAuthFailure(authError); + throw await invokeOnError( + authError, + { type: "refresh" }, + resolvedOptions.onError, + ); + } + config.headers = config.headers ?? {}; config.headers[resolvedOptions.accessTokenHeaderName] = formatAccessToken( resolvedOptions.accessTokenPrefix, @@ -239,12 +268,15 @@ export const createHttpClient = < throw businessResult; } - // 返回了新响应 + // 仅接受完整 AxiosResponse 形态,避免 {status,data} 业务对象被误替换 if ( businessResult && typeof businessResult === "object" && "status" in businessResult && - "data" in businessResult + "data" in businessResult && + "config" in businessResult && + "headers" in businessResult && + "statusText" in businessResult ) { return businessResult; } diff --git a/apps/web/src/services/api/http-factory/reference/README.md b/apps/web/src/services/api/http-factory/reference/README.md index 6ccb06f..206a8ec 100644 --- a/apps/web/src/services/api/http-factory/reference/README.md +++ b/apps/web/src/services/api/http-factory/reference/README.md @@ -174,9 +174,11 @@ const http2 = createHttpClient({ - 普通请求鉴权失败请用 `shouldRefreshByResponseData` / `onBusinessResponse` / 自定义 `isRefreshFailure` - **`unauthorizedStatusCode`** — 触发刷新流程的 HTTP 状态码,默认 `401` - **`isRefreshFailure`** — 判断刷新请求是否已经失败到需要退出登录,默认行为: - - 非 AxiosError(如业务代码抛出的 Error)→ 视为鉴权失败 + - 非 AxiosError(编程错误 / 业务自定义 Error)→ 不视为鉴权失败 - AxiosError 无 response(网络错误)或 status >= 500 → 不视为鉴权失败(token 可能仍有效) - 命中 `unauthorizedStatusCode` 或 `refreshFailureCodes` → 视为鉴权失败 + - 若业务需要“refresh 抛 Error 即登出”,请自定义该函数 + - `refreshAccessToken` 返回空 token 时,工厂会按鉴权失败处理(不进入冷却、不重试原请求) ### 重试策略 diff --git a/apps/web/src/services/api/http-factory/types/common.ts b/apps/web/src/services/api/http-factory/types/common.ts index dc3210e..fbd34f4 100644 --- a/apps/web/src/services/api/http-factory/types/common.ts +++ b/apps/web/src/services/api/http-factory/types/common.ts @@ -16,7 +16,7 @@ export interface RequestRetryState { * onBusinessResponse 的返回值类型。 * - void:继续正常流程(表示成功) * - Error:抛出错误(表示业务失败) - * - AxiosResponse:用新响应替换原响应,不会二次触发 onBusinessResponse + * - AxiosResponse:用完整响应形态(status/data/headers/config/statusText)替换原响应,不会二次触发 onBusinessResponse */ export type BusinessResponseResult = void | Error | AxiosResponse; diff --git a/apps/web/src/services/api/http-factory/types/http-client-options.ts b/apps/web/src/services/api/http-factory/types/http-client-options.ts index cfe2b95..84f2f66 100644 --- a/apps/web/src/services/api/http-factory/types/http-client-options.ts +++ b/apps/web/src/services/api/http-factory/types/http-client-options.ts @@ -141,10 +141,12 @@ export interface HttpClientOptions< * 判断刷新 token 请求本身是否已经失败到需要退出登录。 * * 默认行为: - * - 非 AxiosError(如 refreshAccessToken 函数内部抛出的业务错误)视为刷新失败 + * - 非 AxiosError(编程错误 / 业务自定义 Error)不视为刷新鉴权失败 * - AxiosError 无 response(网络错误)或状态码 >= 500 时不视为刷新失败 * - 状态码 === unauthorizedStatusCode 时视为刷新失败 * - 响应 data.code 在 refreshFailureCodes 列表中时视为刷新鉴权失败 + * + * 若业务需要“refresh 抛 Error 即登出”,请自定义该函数。 */ isRefreshFailure?: (error: unknown) => boolean; @@ -202,7 +204,7 @@ export interface HttpClientOptions< * 业务响应拦截器。 * - 返回 void:继续正常流程(表示成功) * - 返回 Error:抛出错误(表示业务失败) - * - 返回 AxiosResponse:用新响应替换原响应,不会二次触发 onBusinessResponse + * - 返回完整 AxiosResponse 形态:用新响应替换原响应,不会二次触发 onBusinessResponse * - 可以是 async */ onBusinessResponse?: ( diff --git a/apps/web/src/services/api/http-factory/utils/refresh.ts b/apps/web/src/services/api/http-factory/utils/refresh.ts index 04e52aa..406e862 100644 --- a/apps/web/src/services/api/http-factory/utils/refresh.ts +++ b/apps/web/src/services/api/http-factory/utils/refresh.ts @@ -132,10 +132,10 @@ export const defaultIsRefreshFailure = ( error: unknown, options: { unauthorizedStatusCode: number; refreshFailureCodes: number[] }, ): boolean => { - // 非 AxiosError(如 refreshAccessToken 函数内部抛出的业务错误) - // 说明刷新逻辑本身失败,应视为鉴权失败 + // 非 AxiosError(编程错误 / 业务自定义 Error)默认不视为鉴权失败。 + // 若业务需要“抛 Error 即登出”,请自定义 isRefreshFailure。 if (!axios.isAxiosError(error)) { - return true; + return false; } // 无 response(网络错误)或 5xx 服务端错误:不代表 token 失效 From 98fd96f7b8f4943af79b23799a867b639a51030e Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:06:46 +0800 Subject: [PATCH 6/8] refactor(http-factory): Improve refresh token handling and skip refresh URL matching --- .../__tests__/http-client.refresh.test.ts | 17 ++++-- .../__tests__/http-client.test.ts | 59 +++++++++++++++++++ .../http-client.token-normalization.test.ts | 2 +- .../src/services/api/http-factory/index.ts | 26 ++++---- .../api/http-factory/reference/README.md | 12 ++-- .../services/api/http-factory/types/common.ts | 4 +- .../http-factory/types/http-client-options.ts | 9 +-- .../api/http-factory/utils/refresh.ts | 31 ++-------- 8 files changed, 107 insertions(+), 53 deletions(-) diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts index 6cb56f1..fb6b149 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts @@ -39,9 +39,9 @@ describe("shouldSkipRefresh", () => { expect(shouldSkipRefresh(["/auth"], {} as any)).toBe(false); }); - it("URL 以 path 段形式命中 skipRefreshUrl → true", () => { + it("URL exact 命中 skipRefreshUrl → true", () => { expect( - shouldSkipRefresh(["/auth/login"], { url: "/api/auth/login" } as any), + shouldSkipRefresh(["/auth/login"], { url: "/auth/login" } as any), ).toBe(true); }); @@ -49,18 +49,27 @@ describe("shouldSkipRefresh", () => { expect( shouldSkipRefresh(["/public"], { url: "/public/data" } as any), ).toBe(true); + expect( + shouldSkipRefresh(["/auth"], { url: "/auth/login" } as any), + ).toBe(true); }); - it("子串误匹配不会跳过刷新", () => { + it("中间段/子串路径不会跳过刷新", () => { expect( shouldSkipRefresh(["/auth"], { url: "/user/auth-history" } as any), ).toBe(false); expect( shouldSkipRefresh(["/auth"], { url: "/authorization" } as any), ).toBe(false); + expect( + shouldSkipRefresh(["/auth"], { url: "/gateway/user/auth/session" } as any), + ).toBe(false); + expect( + shouldSkipRefresh(["/auth/login"], { url: "/api/auth/login" } as any), + ).toBe(false); }); - it("URL 不包含任何 skipRefreshUrl → false", () => { + it("URL 不匹配任何 skipRefreshUrl → false", () => { expect( shouldSkipRefresh(["/auth/login"], { url: "/api/profile" } as any), ).toBe(false); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts index 3f5ad75..8ccbefd 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts @@ -117,6 +117,65 @@ describe("createHttpClient", () => { expect(tokenStore.getAccessToken).not.toHaveBeenCalled(); }); + it("显式传入空 Authorization 不会被 token 注入覆盖", async () => { + const tokenStore = createTokenStore("old-access", "old-refresh"); + + const http = createHttpClient({ + axiosConfig: { + baseURL: "/api", + }, + getAccessToken: tokenStore.getAccessToken, + }); + + let receivedAuthorization: unknown = "unset"; + + queueCustomHandler(async (config) => { + receivedAuthorization = config.headers?.Authorization; + return { status: 200, data: { ok: true }, config }; + }); + + await http.get("/profile", { + headers: { + Authorization: "", + }, + }); + + expect(receivedAuthorization).toBe(""); + expect(tokenStore.getAccessToken).not.toHaveBeenCalled(); + }); + + it("内部 refresh 重试不会污染 config 上的 __skipDedupe", async () => { + const tokenStore = createTokenStore("old-access", "old-refresh"); + const refreshAccessToken = vi.fn(createRefreshAccessToken(tokenStore)); + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: tokenStore.getAccessToken, + refreshAccessToken, + dedupePolicy: { enabled: true }, + }); + + queueAxiosError({ status: 401, data: { message: "unauthorized" } }); + queueResponse({ + status: 200, + data: { + accessToken: "new-access", + refreshToken: "new-refresh", + }, + }); + + let retriedConfig: any; + queueCustomHandler(async (config) => { + retriedConfig = config; + return { status: 200, data: { ok: true }, config }; + }); + + await http.get("/profile"); + + expect(retriedConfig.__skipDedupe).toBeUndefined(); + expect(refreshAccessToken).toHaveBeenCalledTimes(1); + }); + it("getAccessToken 为空时不会注入鉴权 header", async () => { const getAccessToken = vi.fn(async () => ""); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts index 656dd90..748ee06 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts @@ -32,7 +32,7 @@ describe("Token 规范化边界情况", () => { await http.get("/test"); - // 不应该触发主动刷新 + // 0 视为无效过期时间,不触发主动刷新 expect(refreshAccessToken).not.toHaveBeenCalled(); }); diff --git a/apps/web/src/services/api/http-factory/index.ts b/apps/web/src/services/api/http-factory/index.ts index 3464d5c..5b77471 100644 --- a/apps/web/src/services/api/http-factory/index.ts +++ b/apps/web/src/services/api/http-factory/index.ts @@ -61,14 +61,19 @@ export const createHttpClient = < }); // 包装 request 方法,实现请求合并(仅 GET) - // 内部 refresh / retry 通过 originalRequest 旁路 dedupe,避免 pending 自引用死锁 + // 内部 refresh / retry 通过 replayRequest(=originalRequest) 旁路 dedupe, + // 避免 pending 自引用死锁,也不污染业务 config const originalRequest = instance.request.bind(instance); + const replayRequest = ( + config: InternalAxiosRequestConfig & RequestRetryState, + ) => originalRequest(config); + if (dedupeManager) { instance.request = (( config: InternalAxiosRequestConfig & RequestRetryState, ) => { const method = (config.method ?? "get").toLowerCase(); - if (method !== "get" || config.__skipDedupe) { + if (method !== "get") { return originalRequest(config); } @@ -198,9 +203,8 @@ export const createHttpClient = < token, ); - // 内部重试旁路 dedupe:当前请求 Promise 仍在 pending map 中 - config.__skipDedupe = true; - return originalRequest(config); + // 内部重试旁路 dedupe:不经过 instance.request 包装 + return replayRequest(config); }; instance.interceptors.request.use( @@ -208,12 +212,13 @@ export const createHttpClient = < const currentAuthorization = config.headers?.[resolvedOptions.accessTokenHeaderName]; - // 仅在没有显式 Authorization 时注入 token,并判断是否主动刷新 - if (!currentAuthorization) { + // 仅在调用方未显式提供 header(undefined/null)时注入 token + // 空字符串也视为显式控制,不覆盖 + if (currentAuthorization == null) { const tokenResult = await resolvedOptions.getAccessToken(); const { token, expiresAt } = normalizeTokenResult(tokenResult); - if (token) { + if (token !== "") { // 主动刷新:token 即将过期时异步触发刷新,不阻塞当前请求 if ( refreshEnabled && @@ -336,9 +341,8 @@ export const createHttpClient = < config.__retryCount = retryCount + 1; await new Promise((resolve) => setTimeout(resolve, delay)); - // 内部重试旁路 dedupe:当前请求 Promise 仍在 pending map 中 - config.__skipDedupe = true; - return originalRequest(config); + // 内部重试旁路 dedupe:不经过 instance.request 包装 + return replayRequest(config); } } diff --git a/apps/web/src/services/api/http-factory/reference/README.md b/apps/web/src/services/api/http-factory/reference/README.md index 206a8ec..dc5b109 100644 --- a/apps/web/src/services/api/http-factory/reference/README.md +++ b/apps/web/src/services/api/http-factory/reference/README.md @@ -12,7 +12,7 @@ - 成功时返回原始 `AxiosResponse` - 支持自定义业务响应拦截(`onBusinessResponse`)、全局错误钩子(`onError`)、刷新失败判定 - 刷新失败或重试失败后统一执行 `onAuthFailure` -- 错误直接透传 axios 原始错误,不额外包装 +- 请求失败默认透传 axios 原始 `AxiosError`;refresh 鉴权失败 / 登录过期时会构造普通 `Error`,均可经 `onError` 替换 - 支持通用重试策略(`retryPolicy`):5xx 或网络错误时自动重试,指数退避 - 支持请求合并(`dedupePolicy`):相同 in-flight GET 请求复用同一个 Promise - 支持运行时动态 headers(`headersProvider`):每次请求时注入自定义 headers @@ -91,7 +91,10 @@ const profileData = profile.data; ### Token 注入 - **`getAccessToken`** — 获取当前 access token。返回 `string` 时仅注入 token;返回 `AccessTokenDetail` 时会在 token 即将过期前主动触发刷新 + - 返回空值 / 空字符串时不注入鉴权 header - **`accessTokenHeaderName`** — 注入到请求头的字段名,默认 `Authorization` + - 调用方未传该 header(`undefined/null`)时工厂注入 + - 调用方显式传入任意值(含空字符串)时不覆盖 - **`accessTokenPrefix`** — token 前缀,默认 `Bearer` - **`headersProvider`** — 运行时动态 headers 提供者,每次请求时调用,返回的 headers 会合并到请求中(支持同步/异步) @@ -145,9 +148,9 @@ const http = createHttpClient({ - **`refreshCooldownMs`** — 刷新后的冷却期,默认 `15000ms`,在冷却期内收到的 401 请求会跳过刷新,直接用新 token 重试 - **`refreshManager`** — 外部传入 `TokenRefreshManager` 实例,用于多个客户端共享同一套 token 刷新逻辑 - **`skipRefreshUrls`** — 不触发 refresh token 流程的请求路径列表 - - 使用路径边界 / 完整路径段匹配,不是任意子串 includes - - 例如 `/auth` 匹配 `/auth`、`/auth/login`、`/api/auth/login` - - 不会匹配 `/user/auth-history` 或 `/authorization` + - 仅 exact / prefix 匹配,不是任意子串 includes,也不做中间段滑动匹配 + - 例如 `/auth` 匹配 `/auth`、`/auth/login` + - 不会匹配 `/user/auth-history`、`/authorization`、`/api/auth/login` ```ts import { TokenRefreshManager } from "@/services/api/http-factory/token-refresh-manager"; @@ -208,6 +211,7 @@ const http = createHttpClient({ 仅对 GET 请求生效,不支持配置其他 method。请求级 `dedupePolicy.enabled = false` 可覆盖客户端级配置,禁止单个请求的合并。 内部 refresh / retry 会自动旁路 dedupe,避免 pending 自引用导致请求挂起。 +dedupe 合并的是用户可见 Promise(含 interceptor 后的 refresh/retry 结果),不是“仅首个网络 attempt”。 鉴权失败(命中 `unauthorizedStatusCode`)会优先于通用 `retryPolicy` 处理,避免 401 被自定义 `shouldRetry` 空耗重试次数。 diff --git a/apps/web/src/services/api/http-factory/types/common.ts b/apps/web/src/services/api/http-factory/types/common.ts index fbd34f4..6bdf9df 100644 --- a/apps/web/src/services/api/http-factory/types/common.ts +++ b/apps/web/src/services/api/http-factory/types/common.ts @@ -1,15 +1,13 @@ import type { AxiosResponse } from "axios"; /** - * 请求内部状态。 + * 请求内部状态(仅工厂内部使用,业务侧请勿写入)。 */ export interface RequestRetryState { /** token 刷新重试标记 */ _retry?: boolean; /** 通用重试计数 */ __retryCount?: number; - /** 内部重试旁路 dedupe,避免 pending 自引用死锁 */ - __skipDedupe?: boolean; } /** diff --git a/apps/web/src/services/api/http-factory/types/http-client-options.ts b/apps/web/src/services/api/http-factory/types/http-client-options.ts index 84f2f66..19cce1a 100644 --- a/apps/web/src/services/api/http-factory/types/http-client-options.ts +++ b/apps/web/src/services/api/http-factory/types/http-client-options.ts @@ -21,10 +21,11 @@ export interface DedupePolicy { generateKey?: (config: AxiosRequestConfig) => string; } -// 扩展 AxiosRequestConfig,支持请求级 dedupePolicy +// 应用内 axios 类型扩展:仅 createHttpClient 实例消费 dedupePolicy。 +// 其他 axios 实例即使出现该字段也无运行时效果。 declare module "axios" { interface AxiosRequestConfig { - /** 请求合并策略。覆盖客户端级配置。 */ + /** 请求合并策略。覆盖客户端级配置。仅 http-factory 创建的实例生效。 */ dedupePolicy?: DedupePolicy; } } @@ -161,9 +162,9 @@ export interface HttpClientOptions< /** * 不触发 refresh token 流程的请求路径列表。 * - * 使用路径边界匹配(exact / prefix),不是任意子串 includes。 + * 仅 exact / prefix 匹配,不是任意子串 includes,也不做中间段滑动匹配。 * 例如配置 `/auth` 会匹配 `/auth`、`/auth/login`, - * 但不会匹配 `/user/auth-history` 或 `/authorization`。 + * 但不会匹配 `/user/auth-history`、`/authorization`、`/api/auth/login`。 */ skipRefreshUrls?: string[]; diff --git a/apps/web/src/services/api/http-factory/utils/refresh.ts b/apps/web/src/services/api/http-factory/utils/refresh.ts index 406e862..7935d87 100644 --- a/apps/web/src/services/api/http-factory/utils/refresh.ts +++ b/apps/web/src/services/api/http-factory/utils/refresh.ts @@ -59,9 +59,10 @@ export const resolveRetryPolicy = ( * 判断是否跳过刷新流程。 * * 匹配规则: - * - 使用路径边界匹配,而不是任意子串 includes - * - 支持 exact / prefix 路径(例如 `/auth/login`、`/public`) - * - `/auth` 不会误伤 `/user/auth-history` 或 `/authorization` + * - 仅 exact / prefix 路径匹配 + * - 不是任意子串 includes,也不做中间段滑动匹配 + * - 例如 `/auth` 匹配 `/auth`、`/auth/login` + * - 不匹配 `/user/auth-history`、`/authorization`、`/api/auth/login` */ export const shouldSkipRefresh = ( skipRefreshUrls: string[], @@ -100,29 +101,7 @@ const matchesSkipPath = (requestPath: string, skipUrl: string): boolean => { const skipPath = normalizedSkip.replace(/\/+$/, "") || "/"; const path = requestPath.replace(/\/+$/, "") || "/"; - if (path === skipPath || path.startsWith(`${skipPath}/`)) { - return true; - } - - // 允许匹配路径中的完整段序列(如 /api/auth/login 命中 /auth/login) - // 但不会把 /auth 误匹配到 /user/auth-history 或 /authorization - const pathSegments = path.split("/").filter(Boolean); - const skipSegments = skipPath.split("/").filter(Boolean); - - if (skipSegments.length === 0 || skipSegments.length > pathSegments.length) { - return false; - } - - for (let i = 0; i <= pathSegments.length - skipSegments.length; i++) { - const matched = skipSegments.every( - (segment, index) => pathSegments[i + index] === segment, - ); - if (matched) { - return true; - } - } - - return false; + return path === skipPath || path.startsWith(`${skipPath}/`); }; /** From 9a6f04aedf0cce0476ebcdf9fdf5f5dfa8c27936 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:15:57 +0800 Subject: [PATCH 7/8] refactor(http-factory): Enhance error handling and improve token management --- .../src/services/api/http-factory/index.ts | 60 +++++++++++-------- .../api/http-factory/reference/README.md | 18 ++++-- apps/web/src/services/api/new-http.ts | 4 ++ 3 files changed, 53 insertions(+), 29 deletions(-) diff --git a/apps/web/src/services/api/http-factory/index.ts b/apps/web/src/services/api/http-factory/index.ts index 5b77471..9724c63 100644 --- a/apps/web/src/services/api/http-factory/index.ts +++ b/apps/web/src/services/api/http-factory/index.ts @@ -3,7 +3,7 @@ import axios, { AxiosError } from "axios"; import { DedupeManager } from "./dedupe-manager"; import { REFRESH_SKIPPED, TokenRefreshManager } from "./token-refresh-manager"; import type { AccessTokenResult } from "./types/token"; -import type { RequestRetryState } from "./types/common"; +import type { ErrorContext, RequestRetryState } from "./types/common"; import type { HttpClientOptions, ResolvedHttpClientOptions, @@ -102,6 +102,19 @@ export const createHttpClient = < await resolvedOptions.onAuthFailure?.(error); }; + const rejectWithError = async ( + error: unknown, + type: ErrorContext["type"], + ) => { + const normalized = normalizeError(error); + const finalError = await invokeOnError( + normalized, + { type }, + resolvedOptions.onError, + ); + return Promise.reject(finalError); + }; + const refreshAccessToken = async (): Promise< AccessTokenResult | typeof REFRESH_SKIPPED > => { @@ -214,9 +227,23 @@ export const createHttpClient = < // 仅在调用方未显式提供 header(undefined/null)时注入 token // 空字符串也视为显式控制,不覆盖 - if (currentAuthorization == null) { - const tokenResult = await resolvedOptions.getAccessToken(); - const { token, expiresAt } = normalizeTokenResult(tokenResult); + const needToken = currentAuthorization == null; + const headersProvider = resolvedOptions.headersProvider; + + // token 与 runtime headers 无依赖,可并行;headersProvider 仍后合并以允许覆盖 Authorization + const [tokenResult, runtimeHeaders] = await Promise.all([ + needToken + ? Promise.resolve(resolvedOptions.getAccessToken()) + : Promise.resolve(undefined), + headersProvider + ? Promise.resolve(headersProvider()) + : Promise.resolve(undefined), + ]); + + if (needToken) { + const { token, expiresAt } = normalizeTokenResult( + tokenResult as AccessTokenResult, + ); if (token !== "") { // 主动刷新:token 即将过期时异步触发刷新,不阻塞当前请求 @@ -240,10 +267,8 @@ export const createHttpClient = < } } - // 合并运行时动态 headers(即使已有 Authorization 也要执行) - if (resolvedOptions.headersProvider) { + if (runtimeHeaders) { config.headers = config.headers ?? {}; - const runtimeHeaders = await resolvedOptions.headersProvider(); Object.assign(config.headers, runtimeHeaders); } @@ -297,12 +322,7 @@ export const createHttpClient = < const normalizedError = normalizeError(error); if (!config) { - const finalError = await invokeOnError( - normalizedError, - { type: "request" }, - resolvedOptions.onError, - ); - return Promise.reject(finalError); + return rejectWithError(normalizedError, "request"); } const status = error.response?.status; @@ -314,12 +334,7 @@ export const createHttpClient = < shouldSkipRefresh(resolvedOptions.skipRefreshUrls, config) ) { await handleAuthFailure(normalizedError); - const finalError = await invokeOnError( - normalizedError, - { type: "request" }, - resolvedOptions.onError, - ); - return Promise.reject(finalError); + return rejectWithError(normalizedError, "request"); } try { @@ -346,12 +361,7 @@ export const createHttpClient = < } } - const finalError = await invokeOnError( - normalizedError, - { type: "request" }, - resolvedOptions.onError, - ); - return Promise.reject(finalError); + return rejectWithError(normalizedError, "request"); }, ); diff --git a/apps/web/src/services/api/http-factory/reference/README.md b/apps/web/src/services/api/http-factory/reference/README.md index dc5b109..b075f4a 100644 --- a/apps/web/src/services/api/http-factory/reference/README.md +++ b/apps/web/src/services/api/http-factory/reference/README.md @@ -88,6 +88,19 @@ const profileData = profile.data; ## 配置项 +### 最小可用 + +- `axiosConfig` +- `getAccessToken` +- 可选:`refreshAccessToken` + `onAuthFailure` + `skipRefreshUrls` + +### 高级能力 + +- Token:`accessTokenHeaderName` / `accessTokenPrefix` / `headersProvider` / `refreshBufferMs` +- 刷新:`refreshCooldownMs` / `refreshManager` / `shouldRefreshByResponseData` / `isRefreshFailure` / `refreshFailureCodes` +- 稳定性:`retryPolicy` / `dedupePolicy` +- 业务钩子:`onBusinessResponse` / `onError` / `errorMessages` + ### Token 注入 - **`getAccessToken`** — 获取当前 access token。返回 `string` 时仅注入 token;返回 `AccessTokenDetail` 时会在 token 即将过期前主动触发刷新 @@ -225,10 +238,7 @@ const http = createHttpClient({ }); // 两个并发请求只会发出一次 HTTP 请求 -const [a, b] = await Promise.all([ - http.get("/config"), - http.get("/config"), -]); +const [a, b] = await Promise.all([http.get("/config"), http.get("/config")]); ``` ## 请求约定 diff --git a/apps/web/src/services/api/new-http.ts b/apps/web/src/services/api/new-http.ts index 09cad74..e67bac2 100644 --- a/apps/web/src/services/api/new-http.ts +++ b/apps/web/src/services/api/new-http.ts @@ -34,6 +34,10 @@ const newHttp = createHttpClient({ useUserStore.getState().logout(); }, skipRefreshUrls: NO_AUTO_REFRESH_API_LIST, + errorMessages: { + refreshTokenExpired: "登录已过期,请重新登录", + loginExpired: "登录已失效,请重新登录", + }, }); export default newHttp; From 891d72acfb8abdcfb58902c3e36bc4bd538412b3 Mon Sep 17 00:00:00 2001 From: "Nacho.L" <112751823+fengzai6@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:38:48 +0800 Subject: [PATCH 8/8] fix(http-factory): harden headers merge and request-level dedupe --- .../__tests__/http-client.refresh.test.ts | 41 ++-- .../__tests__/http-client.test.ts | 82 ++++++- .../http-client.token-normalization.test.ts | 28 ++- .../src/services/api/http-factory/index.ts | 200 +++++++++--------- .../api/http-factory/reference/README.md | 8 +- .../api/http-factory/reference/http-demo.ts | 2 +- .../services/api/http-factory/types/common.ts | 2 + .../http-factory/types/http-client-options.ts | 17 +- 8 files changed, 254 insertions(+), 126 deletions(-) diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts index fb6b149..8ce54fe 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.refresh.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { AxiosError } from "axios"; +import type { InternalAxiosRequestConfig } from "axios"; +import { AxiosError, AxiosHeaders } from "axios"; import { shouldSkipRefresh, defaultIsRefreshFailure } from "../utils/refresh"; +import type { RequestRetryState } from "../types/common"; vi.mock("axios", async () => { const actual = await vi.importActual("axios"); @@ -14,6 +16,15 @@ vi.mock("axios", async () => { }; }); +const createRequestConfig = ( + config: Partial = {}, +): InternalAxiosRequestConfig & RequestRetryState => { + return { + headers: new AxiosHeaders(), + ...config, + } as InternalAxiosRequestConfig & RequestRetryState; +}; + const makeAxiosError = ( options: { status?: number; code?: number; response?: boolean } = {}, ): AxiosError => { @@ -24,7 +35,7 @@ const makeAxiosError = ( data: options.code !== undefined ? { code: options.code } : {}, headers: {}, statusText: "Error", - config: {} as any, + config: createRequestConfig(), }; } return error; @@ -36,48 +47,54 @@ describe("shouldSkipRefresh", () => { }); it("config.url 为 undefined → false", () => { - expect(shouldSkipRefresh(["/auth"], {} as any)).toBe(false); + expect(shouldSkipRefresh(["/auth"], createRequestConfig())).toBe(false); }); it("URL exact 命中 skipRefreshUrl → true", () => { expect( - shouldSkipRefresh(["/auth/login"], { url: "/auth/login" } as any), + shouldSkipRefresh(["/auth/login"], createRequestConfig({ url: "/auth/login" })), ).toBe(true); }); it("URL 以 prefix path 命中 skipRefreshUrl → true", () => { expect( - shouldSkipRefresh(["/public"], { url: "/public/data" } as any), + shouldSkipRefresh(["/public"], createRequestConfig({ url: "/public/data" })), ).toBe(true); expect( - shouldSkipRefresh(["/auth"], { url: "/auth/login" } as any), + shouldSkipRefresh(["/auth"], createRequestConfig({ url: "/auth/login" })), ).toBe(true); }); it("中间段/子串路径不会跳过刷新", () => { expect( - shouldSkipRefresh(["/auth"], { url: "/user/auth-history" } as any), + shouldSkipRefresh(["/auth"], createRequestConfig({ url: "/user/auth-history" })), ).toBe(false); expect( - shouldSkipRefresh(["/auth"], { url: "/authorization" } as any), + shouldSkipRefresh(["/auth"], createRequestConfig({ url: "/authorization" })), ).toBe(false); expect( - shouldSkipRefresh(["/auth"], { url: "/gateway/user/auth/session" } as any), + shouldSkipRefresh( + ["/auth"], + createRequestConfig({ url: "/gateway/user/auth/session" }), + ), ).toBe(false); expect( - shouldSkipRefresh(["/auth/login"], { url: "/api/auth/login" } as any), + shouldSkipRefresh( + ["/auth/login"], + createRequestConfig({ url: "/api/auth/login" }), + ), ).toBe(false); }); it("URL 不匹配任何 skipRefreshUrl → false", () => { expect( - shouldSkipRefresh(["/auth/login"], { url: "/api/profile" } as any), + shouldSkipRefresh(["/auth/login"], createRequestConfig({ url: "/api/profile" })), ).toBe(false); }); it("skipRefreshUrls 为空数组 → 始终 false", () => { expect( - shouldSkipRefresh([], { url: "/auth/login" } as any), + shouldSkipRefresh([], createRequestConfig({ url: "/auth/login" })), ).toBe(false); }); }); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts index 8ccbefd..ddb2d42 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.test.ts @@ -164,15 +164,15 @@ describe("createHttpClient", () => { }, }); - let retriedConfig: any; + let retriedConfig: { __skipDedupe?: unknown } | undefined; queueCustomHandler(async (config) => { - retriedConfig = config; + retriedConfig = config as { __skipDedupe?: unknown }; return { status: 200, data: { ok: true }, config }; }); await http.get("/profile"); - expect(retriedConfig.__skipDedupe).toBeUndefined(); + expect(retriedConfig?.__skipDedupe).toBeUndefined(); expect(refreshAccessToken).toHaveBeenCalledTimes(1); }); @@ -523,8 +523,32 @@ describe("createHttpClient", () => { await http.get("/profile"); expect(receivedAuthorization).toBe("Bearer custom-token"); - // headersProvider 覆盖后,token 注入仍会执行,但最终结果以 headersProvider 为准 - expect(tokenStore.getAccessToken).toHaveBeenCalledTimes(1); + expect(tokenStore.getAccessToken).not.toHaveBeenCalled(); + }); + + it("headersProvider 返回小写 authorization 时不会再读取本地 token", async () => { + const tokenStore = createTokenStore("default-token", "test-refresh"); + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: tokenStore.getAccessToken, + headersProvider: () => ({ + authorization: "Bearer custom-token", + }), + }); + + let receivedAuthorization: unknown; + + queueCustomHandler(async (config) => { + receivedAuthorization = + config.headers?.Authorization ?? config.headers?.authorization; + return { status: 200, data: { ok: true }, config }; + }); + + await http.get("/profile"); + + expect(receivedAuthorization).toBe("Bearer custom-token"); + expect(tokenStore.getAccessToken).not.toHaveBeenCalled(); }); it("未配置 headersProvider 时行为不变", async () => { @@ -1063,6 +1087,34 @@ describe("createHttpClient", () => { expect(requestCount).toBe(2); }); + it("客户端未启用时,请求级 dedupePolicy.enabled=true 可临时启用合并", async () => { + let requestCount = 0; + + const http = createHttpClient({ + axiosConfig: { baseURL: "/api" }, + getAccessToken: async () => "", + }); + + queueCustomHandler(async (config) => { + requestCount++; + return { status: 200, data: { count: requestCount }, config }; + }); + + const [first, second] = await Promise.all([ + http.get("/profile", { dedupePolicy: { enabled: true } }), + http.get("/profile", { dedupePolicy: { enabled: true } }), + ]); + + expect(first.data).toEqual({ count: 1 }); + expect(second.data).toEqual({ count: 1 }); + expect(requestCount).toBe(1); + }); + + + + + + it("未配置 dedupePolicy 时不会合并请求", async () => { let requestCount = 0; @@ -1302,6 +1354,24 @@ describe("createHttpClient", () => { expect(first.data).toEqual({ count: 1 }); expect(second.data).toEqual({ count: 1 }); expect(requestCount).toBe(1); + + queueCustomHandler(async (config) => { + requestCount++; + return { status: 200, data: { count: requestCount }, config }; + }); + queueCustomHandler(async (config) => { + requestCount++; + return { status: 200, data: { count: requestCount }, config }; + }); + + const [third, fourth] = await Promise.all([ + http.request({ method: "get", url: "/profile", baseURL: "/api" }), + http.request({ method: "get", url: "/profile", baseURL: "/api-v2" }), + ]); + + expect(third.data).toEqual({ count: 2 }); + expect(fourth.data).toEqual({ count: 3 }); + expect(requestCount).toBe(3); }); it("params 顺序不同但内容相同时会合并", async () => { @@ -1360,7 +1430,7 @@ describe("createHttpClient", () => { ({ status: 0, data: { business: true }, - }) as any, + }) as unknown as import("axios").AxiosResponse, }); queueResponse({ status: 200, data: { original: true } }); diff --git a/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts b/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts index 748ee06..1417ee1 100644 --- a/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts +++ b/apps/web/src/services/api/http-factory/__tests__/http-client.token-normalization.test.ts @@ -138,6 +138,10 @@ describe("Token 规范化边界情况", () => { it("expiresAt 为有效时间且即将过期时应该触发主动刷新", async () => { const futureTime = Date.now() + 30000; // 30 秒后过期 + let resolveRefresh!: () => void; + const refreshCalled = new Promise((resolve) => { + resolveRefresh = resolve; + }); const getAccessToken = vi.fn(async () => ({ token: "expiring-token", @@ -152,7 +156,7 @@ describe("Token 规范化边界情况", () => { refreshToken: "new-refresh", }, }); - + resolveRefresh(); return "refreshed-token" as AccessTokenResult; }); @@ -171,14 +175,20 @@ describe("Token 规范化边界情况", () => { await http.get("/test"); // 应该异步触发主动刷新(不阻塞当前请求) - await new Promise((resolve) => setTimeout(resolve, 10)); + await refreshCalled; expect(refreshAccessToken).toHaveBeenCalledTimes(1); }); it("主动刷新网络失败时仍会触发 onError,且不阻塞当前请求", async () => { const futureTime = Date.now() + 30000; - const onError = vi.fn(); + let resolveOnError!: () => void; + const onErrorCalled = new Promise((resolve) => { + resolveOnError = resolve; + }); + const onError = vi.fn(() => { + resolveOnError(); + }); const onAuthFailure = vi.fn(); const getAccessToken = vi.fn(async () => ({ @@ -208,7 +218,7 @@ describe("Token 规范化边界情况", () => { const response = await http.get("/test"); - await new Promise((resolve) => setTimeout(resolve, 10)); + await onErrorCalled; expect(response.data).toEqual({ ok: true }); expect(refreshAccessToken).toHaveBeenCalledTimes(1); @@ -222,7 +232,13 @@ describe("Token 规范化边界情况", () => { it("主动刷新鉴权失败时会触发 onError 与 onAuthFailure,且不阻塞当前请求", async () => { const futureTime = Date.now() + 30000; - const onError = vi.fn(); + let resolveOnError!: () => void; + const onErrorCalled = new Promise((resolve) => { + resolveOnError = resolve; + }); + const onError = vi.fn(() => { + resolveOnError(); + }); const onAuthFailure = vi.fn(); const getAccessToken = vi.fn(async () => ({ @@ -254,7 +270,7 @@ describe("Token 规范化边界情况", () => { const response = await http.get("/test"); - await new Promise((resolve) => setTimeout(resolve, 10)); + await onErrorCalled; expect(response.data).toEqual({ ok: true }); expect(refreshAccessToken).toHaveBeenCalledTimes(1); diff --git a/apps/web/src/services/api/http-factory/index.ts b/apps/web/src/services/api/http-factory/index.ts index 9724c63..5625329 100644 --- a/apps/web/src/services/api/http-factory/index.ts +++ b/apps/web/src/services/api/http-factory/index.ts @@ -1,25 +1,25 @@ import type { AxiosInstance, InternalAxiosRequestConfig } from "axios"; -import axios, { AxiosError } from "axios"; +import axios, { AxiosError, AxiosHeaders } from "axios"; +import { DEFAULT_MESSAGES, DEFAULT_REFRESH_BUFFER_MS } from "./constants"; import { DedupeManager } from "./dedupe-manager"; import { REFRESH_SKIPPED, TokenRefreshManager } from "./token-refresh-manager"; -import type { AccessTokenResult } from "./types/token"; import type { ErrorContext, RequestRetryState } from "./types/common"; import type { HttpClientOptions, ResolvedHttpClientOptions, } from "./types/http-client-options"; -import { DEFAULT_MESSAGES, DEFAULT_REFRESH_BUFFER_MS } from "./constants"; -import { normalizeError, invokeOnError } from "./utils/error"; -import { - formatAccessToken, - normalizeTokenResult, - isTokenExpiringSoon, -} from "./utils/token"; +import type { AccessTokenResult } from "./types/token"; +import { invokeOnError, normalizeError } from "./utils/error"; import { - shouldSkipRefresh, defaultIsRefreshFailure, resolveRetryPolicy, + shouldSkipRefresh, } from "./utils/refresh"; +import { + formatAccessToken, + isTokenExpiringSoon, + normalizeTokenResult, +} from "./utils/token"; export const createHttpClient = < T extends AccessTokenResult = AccessTokenResult, @@ -50,10 +50,11 @@ export const createHttpClient = < const resolvedRetryPolicy = resolveRetryPolicy(options.retryPolicy); // 请求合并(in-flight coalesce) - const dedupePolicy = options.dedupePolicy; - const dedupeManager = dedupePolicy?.enabled - ? new DedupeManager(dedupePolicy.generateKey) - : null; + // 始终准备 manager:客户端默认关闭时,仍允许请求级 { enabled: true } 临时启用 + // 请求级仅可覆盖 enabled,generateKey 只读取客户端级配置 + const clientDedupePolicy = options.dedupePolicy; + const clientDedupeEnabled = clientDedupePolicy?.enabled === true; + const dedupeManager = new DedupeManager(clientDedupePolicy?.generateKey); const instance = axios.create({ timeout: 15 * 1000, @@ -68,36 +69,6 @@ export const createHttpClient = < config: InternalAxiosRequestConfig & RequestRetryState, ) => originalRequest(config); - if (dedupeManager) { - instance.request = (( - config: InternalAxiosRequestConfig & RequestRetryState, - ) => { - const method = (config.method ?? "get").toLowerCase(); - if (method !== "get") { - return originalRequest(config); - } - - // 请求级配置覆盖客户端级配置 - const requestDedupePolicy = config.dedupePolicy; - const shouldDedupe = requestDedupePolicy?.enabled ?? true; - - if (!shouldDedupe) { - return originalRequest(config); - } - - const key = dedupeManager.getKey(config); - const pendingPromise = dedupeManager.getPending(key); - - if (pendingPromise) { - return pendingPromise as ReturnType; - } - - const promise = originalRequest(config); - dedupeManager.setPending(key, promise); - return promise; - }) as typeof instance.request; - } - const handleAuthFailure = async (error?: unknown) => { await resolvedOptions.onAuthFailure?.(error); }; @@ -210,69 +181,108 @@ export const createHttpClient = < ); } - config.headers = config.headers ?? {}; - config.headers[resolvedOptions.accessTokenHeaderName] = formatAccessToken( - resolvedOptions.accessTokenPrefix, - token, + const headers = AxiosHeaders.from(config.headers ?? {}); + headers.set( + resolvedOptions.accessTokenHeaderName, + formatAccessToken(resolvedOptions.accessTokenPrefix, token), ); + config.headers = headers; + // 重试需要重新跑 headersProvider / 请求准备逻辑 + config.__headersPrepared = false; // 内部重试旁路 dedupe:不经过 instance.request 包装 return replayRequest(config); }; - instance.interceptors.request.use( - async (config: InternalAxiosRequestConfig & RequestRetryState) => { - const currentAuthorization = - config.headers?.[resolvedOptions.accessTokenHeaderName]; - - // 仅在调用方未显式提供 header(undefined/null)时注入 token - // 空字符串也视为显式控制,不覆盖 - const needToken = currentAuthorization == null; - const headersProvider = resolvedOptions.headersProvider; - - // token 与 runtime headers 无依赖,可并行;headersProvider 仍后合并以允许覆盖 Authorization - const [tokenResult, runtimeHeaders] = await Promise.all([ - needToken - ? Promise.resolve(resolvedOptions.getAccessToken()) - : Promise.resolve(undefined), - headersProvider - ? Promise.resolve(headersProvider()) - : Promise.resolve(undefined), - ]); - - if (needToken) { - const { token, expiresAt } = normalizeTokenResult( - tokenResult as AccessTokenResult, - ); + const prepareRequestHeaders = async ( + config: InternalAxiosRequestConfig & RequestRetryState, + ) => { + if (config.__headersPrepared) { + return config; + } - if (token !== "") { - // 主动刷新:token 即将过期时异步触发刷新,不阻塞当前请求 - if ( - refreshEnabled && - expiresAt && - !shouldSkipRefresh(resolvedOptions.skipRefreshUrls, config) - ) { - const bufferMs = resolvedOptions.refreshBufferMs; - - if (bufferMs > 0 && isTokenExpiringSoon(expiresAt, bufferMs)) { - // fire-and-forget:refreshAccessToken 内部已调用 onError/onAuthFailure - // 这里仅吞掉 rejection,避免 unhandledrejection,且不阻塞当前请求 - void refreshAccessToken().catch(() => {}); - } - } + const headersProvider = resolvedOptions.headersProvider; + const runtimeHeaders = headersProvider + ? await headersProvider() + : undefined; + + // 先合并调用方 / headersProvider 的 headers,再做大小写无关的 token 判定 + const headers = AxiosHeaders.from(config.headers ?? {}); + if (runtimeHeaders) { + headers.set(runtimeHeaders); + } + config.headers = headers; + + const currentAuthorization = headers.get( + resolvedOptions.accessTokenHeaderName, + ); + + // 仅在调用方未显式提供 header(undefined/null)时注入 token + // 空字符串也视为显式控制,不覆盖 + const needToken = currentAuthorization == null; + + if (needToken) { + const tokenResult = await resolvedOptions.getAccessToken(); + const { token, expiresAt } = normalizeTokenResult(tokenResult); - config.headers = config.headers ?? {}; - config.headers[resolvedOptions.accessTokenHeaderName] = - formatAccessToken(resolvedOptions.accessTokenPrefix, token); + if (token !== "") { + // 主动刷新:token 即将过期时异步触发刷新,不阻塞当前请求 + if ( + refreshEnabled && + expiresAt && + !shouldSkipRefresh(resolvedOptions.skipRefreshUrls, config) + ) { + const bufferMs = resolvedOptions.refreshBufferMs; + + if (bufferMs > 0 && isTokenExpiringSoon(expiresAt, bufferMs)) { + // fire-and-forget:refreshAccessToken 内部已调用 onError/onAuthFailure + // 这里仅吞掉 rejection,避免 unhandledrejection,且不阻塞当前请求 + void refreshAccessToken().catch(() => {}); + } } - } - if (runtimeHeaders) { - config.headers = config.headers ?? {}; - Object.assign(config.headers, runtimeHeaders); + headers.set( + resolvedOptions.accessTokenHeaderName, + formatAccessToken(resolvedOptions.accessTokenPrefix, token), + ); } + } - return config; + config.__headersPrepared = true; + return config; + }; + + // 请求级仅可覆盖 enabled;generateKey 固定使用客户端级配置 + instance.request = (( + config: InternalAxiosRequestConfig & RequestRetryState, + ) => { + const method = (config.method ?? "get").toLowerCase(); + if (method !== "get") { + return originalRequest(config); + } + + const requestDedupePolicy = config.dedupePolicy; + const shouldDedupe = requestDedupePolicy?.enabled ?? clientDedupeEnabled; + + if (!shouldDedupe) { + return originalRequest(config); + } + + const key = dedupeManager.getKey(config); + const pendingPromise = dedupeManager.getPending(key); + + if (pendingPromise) { + return pendingPromise as ReturnType; + } + + const promise = originalRequest(config); + dedupeManager.setPending(key, promise); + return promise; + }) as typeof instance.request; + + instance.interceptors.request.use( + async (config: InternalAxiosRequestConfig & RequestRetryState) => { + return prepareRequestHeaders(config); }, (error) => Promise.reject(error), ); diff --git a/apps/web/src/services/api/http-factory/reference/README.md b/apps/web/src/services/api/http-factory/reference/README.md index b075f4a..37c04ae 100644 --- a/apps/web/src/services/api/http-factory/reference/README.md +++ b/apps/web/src/services/api/http-factory/reference/README.md @@ -11,7 +11,7 @@ - 通过 `TokenRefreshManager` 合并并发刷新,避免同一时刻重复刷新 - 成功时返回原始 `AxiosResponse` - 支持自定义业务响应拦截(`onBusinessResponse`)、全局错误钩子(`onError`)、刷新失败判定 -- 刷新失败或重试失败后统一执行 `onAuthFailure` +- 刷新鉴权失败、空 token 刷新结果、或重试后仍登录过期时执行 `onAuthFailure`;网络错误 / 5xx / 非 AxiosError 默认不触发 - 请求失败默认透传 axios 原始 `AxiosError`;refresh 鉴权失败 / 登录过期时会构造普通 `Error`,均可经 `onError` 替换 - 支持通用重试策略(`retryPolicy`):5xx 或网络错误时自动重试,指数退避 - 支持请求合并(`dedupePolicy`):相同 in-flight GET 请求复用同一个 Promise @@ -295,7 +295,7 @@ const [a, b] = await Promise.all([http.get("/config"), http.get("/config")]); 2. 并发 401 请求只刷新一次,并在重试时使用新 token 3. 显式传入的鉴权 header 不会被 token 注入覆盖 4. getAccessToken 为空时不会注入鉴权 header -5. refresh 失败时会触发 onAuthFailure 并返回登录过期错误 +5. refresh 鉴权失败(如 401 / refreshFailureCodes / 空 token)时会触发 onAuthFailure 并返回登录过期错误 6. 业务响应命中 accessToken 失效 code 时会刷新并重试 7. refresh 返回空字符串 refreshToken 时会交给使用者自行处理 8. 未启用刷新时,401 会触发 onAuthFailure 且不会读取 refreshToken @@ -312,12 +312,12 @@ const [a, b] = await Promise.all([http.get("/config"), http.get("/config")]); 1. 命中 skipRefreshUrls 时,401 / 业务响应触发刷新都不会执行 refresh 2. 自定义 unauthorizedStatusCode 命中时会刷新并重试 3. 业务响应要求刷新但未启用刷新时会原样返回响应 -4. refresh 成功响应缺少 accessToken 时会触发鉴权失败(refreshToken 已失效) +4. refresh 成功响应缺少 accessToken / 返回空 token 时会触发鉴权失败(refreshToken 已失效) 5. 并发 401 请求在 refresh 返回 500 时不会触发 onAuthFailure(服务端错误不代表 token 失效) 6. 重试后的业务响应再次命中刷新条件时会停止重试并退出登录 7. refresh 进行中时新来的 401 请求会复用同一次 refresh 8. refresh 错误命中 refreshFailureCodes 时会视为登录过期 -9. refresh 抛出非 Error 异常时会触发鉴权失败(归一化后作为 refreshToken 失效处理) +9. refresh 抛出非 AxiosError / 非 Error 异常时默认不触发鉴权失败,仅透传错误 10. onAuthFailure 抛错时会透出回调错误而不是原始鉴权错误 11. onBusinessResponse 抛出的特定消息能在 onError 中正常接收 diff --git a/apps/web/src/services/api/http-factory/reference/http-demo.ts b/apps/web/src/services/api/http-factory/reference/http-demo.ts index 332fcc4..454032b 100644 --- a/apps/web/src/services/api/http-factory/reference/http-demo.ts +++ b/apps/web/src/services/api/http-factory/reference/http-demo.ts @@ -69,7 +69,7 @@ export const http = createHttpClient({ // ---- Auth failure ---- - // 业务状态码中用于识别鉴权失败的 code 列表 + // refresh 请求失败时,用于识别 refresh token 失效的业务 code 列表 refreshFailureCodes: [40103, 1001002], // ---- Refresh ---- diff --git a/apps/web/src/services/api/http-factory/types/common.ts b/apps/web/src/services/api/http-factory/types/common.ts index 6bdf9df..ff70fd2 100644 --- a/apps/web/src/services/api/http-factory/types/common.ts +++ b/apps/web/src/services/api/http-factory/types/common.ts @@ -8,6 +8,8 @@ export interface RequestRetryState { _retry?: boolean; /** 通用重试计数 */ __retryCount?: number; + /** 内部标记:请求 headers 已完成注入,避免 dedupe / interceptor 重复处理 */ + __headersPrepared?: boolean; } /** diff --git a/apps/web/src/services/api/http-factory/types/http-client-options.ts b/apps/web/src/services/api/http-factory/types/http-client-options.ts index 19cce1a..e610d05 100644 --- a/apps/web/src/services/api/http-factory/types/http-client-options.ts +++ b/apps/web/src/services/api/http-factory/types/http-client-options.ts @@ -21,12 +21,25 @@ export interface DedupePolicy { generateKey?: (config: AxiosRequestConfig) => string; } +/** + * 请求级合并配置。 + * 仅允许覆盖 enabled;generateKey 只能在客户端级配置。 + */ +export interface RequestDedupePolicy { + /** 是否启用请求合并。覆盖客户端级 enabled。 */ + enabled?: boolean; +} + // 应用内 axios 类型扩展:仅 createHttpClient 实例消费 dedupePolicy。 // 其他 axios 实例即使出现该字段也无运行时效果。 declare module "axios" { interface AxiosRequestConfig { - /** 请求合并策略。覆盖客户端级配置。仅 http-factory 创建的实例生效。 */ - dedupePolicy?: DedupePolicy; + /** + * 请求级合并策略。 + * 仅可覆盖 enabled,不能修改 generateKey。 + * 仅 http-factory 创建的实例生效。 + */ + dedupePolicy?: RequestDedupePolicy; } }