+126
-24
@@ -20,8 +20,27 @@ interface RequestOptions {
|
||||
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()
|
||||
@@ -32,40 +51,46 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||
return s ? `${BASE}${path}?${s}` : BASE + path
|
||||
}
|
||||
|
||||
async function parseError(resp: Response, sentToken: string): Promise<never> {
|
||||
let message = `请求失败(${resp.status})`
|
||||
let ociCode: string | undefined
|
||||
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
|
||||
}
|
||||
ociCode = body.ociCode
|
||||
if (body.error) {
|
||||
message = body.hint ? `${body.hint}|${body.error}` : body.error
|
||||
} else {
|
||||
// 批量接口(如创建实例)全部失败时返回 errors 数组,逐行合并;
|
||||
// 调用方以「短标题 + detail」经 useToast 展示,多行在详情块中逐行可读
|
||||
const list = (body.errors ?? []).filter((e): e is string => typeof e === 'string')
|
||||
if (list.length) message = list.join('\n')
|
||||
}
|
||||
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 {
|
||||
/* 非 JSON 响应体,保留默认消息 */
|
||||
return fallback
|
||||
}
|
||||
if (resp.status === 401) handleUnauthorized(sentToken)
|
||||
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
|
||||
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
|
||||
location.assign('/blocked')
|
||||
}
|
||||
throw new ApiError(resp.status, message, ociCode)
|
||||
}
|
||||
|
||||
/** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新
|
||||
* token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话;
|
||||
* 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */
|
||||
function handleUnauthorized(sentToken: string) {
|
||||
async function handleUnauthorized(sentToken: string) {
|
||||
const waited = await waitForSessionRefresh(sentToken)
|
||||
if (waited) await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
const auth = useAuthStore()
|
||||
if (sentToken !== auth.token) return
|
||||
auth.logout()
|
||||
@@ -75,8 +100,80 @@ function handleUnauthorized(sentToken: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
|
||||
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
/** 写请求(非 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)
|
||||
}
|
||||
}
|
||||
|
||||
function applySessionRefresh(value: unknown) {
|
||||
if (!value || typeof value !== 'object') return
|
||||
const refresh = value as { token?: unknown; expiresAt?: unknown }
|
||||
if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return
|
||||
useAuthStore().setSession(refresh.token, refresh.expiresAt)
|
||||
}
|
||||
|
||||
/** 统一请求封装:注入 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}`
|
||||
@@ -86,10 +183,15 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
|
||||
headers,
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
})
|
||||
if (!resp.ok) await parseError(resp, sentToken)
|
||||
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()
|
||||
return (text ? JSON.parse(text) : undefined) as T
|
||||
const result = (text ? JSON.parse(text) : undefined) as T
|
||||
if (opts.refreshesSession && sentToken) applySessionRefresh(result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** 原始请求:注入 JWT、错误同 request 转 ApiError,返回原始 Response(二进制内容读写用) */
|
||||
|
||||
Reference in New Issue
Block a user