243 lines
8.3 KiB
TypeScript
243 lines
8.3 KiB
TypeScript
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<string, string | number | boolean | undefined>
|
||
/** 附加请求头 */
|
||
headers?: Record<string, string>
|
||
/** 成功响应会换发当前会话;并发 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<string, RefreshState>()
|
||
|
||
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<never> {
|
||
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<ErrorDetails> {
|
||
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<void>((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<string, Promise<unknown>>()
|
||
|
||
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<boolean> {
|
||
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<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||
const method = (opts.method ?? 'GET').toUpperCase()
|
||
if (method === 'GET') return doRequest<T>(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<T>
|
||
const tracksRefresh = opts.refreshesSession === true && sentToken !== ''
|
||
const finishRefresh = tracksRefresh ? trackSessionRefresh(sentToken) : () => {}
|
||
const p = doRequest<T>(path, opts, finishRefresh).finally(() => {
|
||
inflightWrites.delete(key)
|
||
finishRefresh()
|
||
})
|
||
inflightWrites.set(key, p)
|
||
return p
|
||
}
|
||
|
||
async function doRequest<T>(
|
||
path: string,
|
||
opts: RequestOptions,
|
||
finishRefresh: () => void = () => {},
|
||
): Promise<T> {
|
||
const auth = useAuthStore()
|
||
const headers: Record<string, string> = { '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<string, string>
|
||
body?: BodyInit
|
||
} = {},
|
||
): Promise<Response> {
|
||
const auth = useAuthStore()
|
||
const headers: Record<string, string> = { ...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<T>(data: T, ms = 200): Promise<T> {
|
||
return new Promise((resolve) => setTimeout(() => resolve(structuredClone(data)), ms))
|
||
}
|