Files
oci-portal-dash/src/api/request.ts
T

243 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}