import { useAuthStore } from '@/stores/auth' const BASE = '/api/v1' export class ApiError extends Error { status: number /** 后端透传的 OCI 错误码(如 OrganizationExclusionError),供调用方做能力适配 */ ociCode?: string constructor(status: number, message: string, ociCode?: string) { super(message) this.status = status this.ociCode = ociCode } } interface RequestOptions { method?: string body?: unknown query?: Record /** 附加请求头 */ headers?: Record /** 成功响应会换发当前会话;并发 401 须等待其落入 Store 后再决定是否登出 */ refreshesSession?: boolean } interface RefreshWaiter { resolve: (waited: boolean) => void } interface RefreshState { count: number waiters: RefreshWaiter[] } interface ErrorDetails { message: string ociCode?: string rateLimited: boolean } const sessionRefreshes = new Map() function buildUrl(path: string, query?: RequestOptions['query']): string { if (!query) return BASE + path const qs = new URLSearchParams() for (const [k, v] of Object.entries(query)) { if (v !== undefined && v !== '') qs.set(k, String(v)) } const s = qs.toString() return s ? `${BASE}${path}?${s}` : BASE + path } async function parseError( resp: Response, sentToken: string, ): Promise { const details = await readErrorDetails(resp) if (resp.status === 401) await handleUnauthorized(sentToken) if (resp.status === 429 && details.rateLimited && location.pathname !== '/blocked') { location.assign('/blocked') } throw new ApiError(resp.status, details.message, details.ociCode) } async function readErrorDetails(resp: Response): Promise { const fallback = { message: `请求失败(${resp.status})`, rateLimited: false } try { const body = (await resp.json()) as { error?: string hint?: string errors?: unknown[] ociCode?: string code?: string } const list = (body.errors ?? []).filter((e): e is string => typeof e === 'string') const message = body.error ? body.hint ? `${body.hint}|${body.error}` : body.error : list.join('\n') || fallback.message return { message, ociCode: body.ociCode, rateLimited: body.code === 'RateLimited' } } catch { return fallback } } /** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新 * token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话; * 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */ async function handleUnauthorized(sentToken: string) { const waited = await waitForSessionRefresh(sentToken) if (waited) await new Promise((resolve) => setTimeout(resolve, 0)) const auth = useAuthStore() auth.syncFromStorage() if (sentToken !== auth.token) return auth.logout() if (location.pathname !== '/login') { const redirect = encodeURIComponent(location.pathname + location.search) location.assign(`/login?redirect=${redirect}`) } } /** 写请求(非 GET)在飞行中的去重表:key → 共享 Promise */ const inflightWrites = new Map>() function beginSessionRefresh(token: string) { const state = sessionRefreshes.get(token) if (state) { state.count++ return } sessionRefreshes.set(token, { count: 1, waiters: [] }) } function endSessionRefresh(token: string) { const state = sessionRefreshes.get(token) if (!state) return state.count-- state.waiters = state.waiters.filter((waiter) => { if (state.count > 0) return true waiter.resolve(true) return false }) if (state.count === 0) sessionRefreshes.delete(token) } function waitForSessionRefresh(token: string): Promise { const state = sessionRefreshes.get(token) if (!state || state.count === 0) return Promise.resolve(false) return new Promise((resolve) => state.waiters.push({ resolve })) } function trackSessionRefresh(token: string): () => void { beginSessionRefresh(token) let active = true return () => { if (!active) return active = false endSessionRefresh(token) } } /** 滑动续期:后端对临过期令牌在响应头中换发同会话新令牌;仅当发送时令牌仍是 * 当前会话令牌才应用(与 401 处理同一快照原则,防旧会话迟到响应覆盖新会话)。 */ function applyRenewedSession(resp: Response, sentToken: string) { // 测试环境的最小 Response stub 可能无 headers const token = resp.headers?.get('X-Renewed-Token') const expiresAt = resp.headers?.get('X-Renewed-Expires-At') if (!token || !expiresAt || !sentToken) return const auth = useAuthStore() auth.syncFromStorage() if (sentToken !== auth.token) return auth.setSession(token, expiresAt) } function applySessionRefresh(value: unknown): boolean { if (!value || typeof value !== 'object') return false const refresh = value as { token?: unknown; expiresAt?: unknown } if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return false useAuthStore().setSession(refresh.token, refresh.expiresAt) return true } /** 统一请求封装:注入 JWT、401 登出、错误转 ApiError; * 非 GET 请求按 token+method+url+body 做 in-flight 去重——前一发未返回时复用同一 Promise, * 防连点造成重复提交;完成(无论成败)即移除。GET 不去重(useAsync 已有后发优先语义)。 * 键含认证上下文:token 换发/切换账号后不得复用旧会话在飞行中的请求 */ export function request(path: string, opts: RequestOptions = {}): Promise { const method = (opts.method ?? 'GET').toUpperCase() if (method === 'GET') return doRequest(path, opts) const body = opts.body === undefined ? '' : JSON.stringify(opts.body) const sentToken = useAuthStore().token ?? '' const key = `${sentToken} ${method} ${buildUrl(path, opts.query)} ${body}` const existing = inflightWrites.get(key) if (existing) return existing as Promise const tracksRefresh = opts.refreshesSession === true && sentToken !== '' const finishRefresh = tracksRefresh ? trackSessionRefresh(sentToken) : () => {} const p = doRequest(path, opts, finishRefresh).finally(() => { inflightWrites.delete(key) finishRefresh() }) inflightWrites.set(key, p) return p } async function doRequest( path: string, opts: RequestOptions, finishRefresh: () => void = () => {}, ): Promise { const auth = useAuthStore() const headers: Record = { 'Content-Type': 'application/json', ...opts.headers } if (auth.token) headers.Authorization = `Bearer ${auth.token}` const sentToken = auth.token const resp = await fetch(buildUrl(path, opts.query), { method: opts.method ?? 'GET', headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body), }) if (!resp.ok) { if (resp.status === 401) finishRefresh() await parseError(resp, sentToken) } // 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input const text = await resp.text() const result = (text ? JSON.parse(text) : undefined) as T const bodyRefreshed = !!sentToken && !!opts.refreshesSession && applySessionRefresh(result) if (!bodyRefreshed) applyRenewedSession(resp, sentToken ?? '') return result } /** 原始请求:注入 JWT、错误同 request 转 ApiError,返回原始 Response(二进制内容读写用) */ export async function rawFetch( path: string, opts: { method?: string query?: RequestOptions['query'] headers?: Record body?: BodyInit } = {}, ): Promise { const auth = useAuthStore() const headers: Record = { ...opts.headers } if (auth.token) headers.Authorization = `Bearer ${auth.token}` const sentToken = auth.token const resp = await fetch(buildUrl(path, opts.query), { method: opts.method ?? 'GET', headers, body: opts.body, }) if (!resp.ok) await parseError(resp, sentToken) applyRenewedSession(resp, sentToken ?? '') return resp } export const mockOn = import.meta.env.VITE_MOCK === '1' /** mock 数据延时返回,模拟网络延迟 */ export function mocked(data: T, ms = 200): Promise { return new Promise((resolve) => setTimeout(() => resolve(structuredClone(data)), ms)) }