- bzzoiro events/standings/stats 抓取失败写入 IngestFailure 死信(尽力而为, 写入失败不影响主流程);顺带修复 standings 失败路径 league_r 缺 errors 键 的 KeyError —— 该路径此前从未跑通,一旦失败会顶掉原始异常 - admin/api.ts 收敛为 lib/http.ts 薄门面,消除第二套 HTTP 实现; UNAUTHORIZED_EVENT 定义移至共享层,断开 lib→admin 反向依赖 - STATUS_META 死键 live 改为 in_play(对齐 normalize.py 口径), 补 paused/postponed/cancelled/suspended;移除无人消费的 DashboardStats.total_matches(items.length 近似,上限 100) - 新增 tests/test_ingest_deadletter.py(6 例,变异验证判别力)
104 lines
3.1 KiB
TypeScript
104 lines
3.1 KiB
TypeScript
/**
|
|
* 共享 HTTP 客户端(公开站 + Admin 统一)
|
|
*
|
|
* 特性:
|
|
* - 统一超时(默认 30s,可覆盖)
|
|
* - 统一错误处理(ApiError)
|
|
* - 401 自动广播(Admin 场景)
|
|
* - JSON/HTML 容错解析
|
|
* - 请求竞态防护(可选 signal)
|
|
*
|
|
* 注: Admin 侧的 admin/api.ts 是本模块的薄门面,不再有第二套实现。
|
|
*/
|
|
|
|
/** 会话失效事件名,AdminLayout 监听后弹出登录页 */
|
|
export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
|
|
|
|
const API_BASE = '/api/v1'
|
|
const DEFAULT_TIMEOUT = 30_000
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public status: number,
|
|
public data?: unknown,
|
|
) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
}
|
|
}
|
|
|
|
interface RequestOptions {
|
|
timeoutMs?: number
|
|
skipAuthHandling?: boolean
|
|
signal?: AbortSignal
|
|
method?: string
|
|
body?: string
|
|
}
|
|
|
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
|
const url = path.startsWith('http') ? path : path.startsWith('/') ? path : `${API_BASE}${path}`
|
|
const { timeoutMs = DEFAULT_TIMEOUT, skipAuthHandling, signal } = options
|
|
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
|
|
// 如果外部传了 signal,也关联到内部 controller
|
|
if (signal) {
|
|
signal.addEventListener('abort', () => controller.abort(), { once: true })
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
signal: controller.signal,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const rawText = await res.text()
|
|
let detail: unknown = rawText
|
|
try {
|
|
detail = JSON.parse(rawText)
|
|
} catch {
|
|
// 非 JSON(如 HTML 错误页),保留原始文本
|
|
}
|
|
let message =
|
|
detail && typeof detail === 'object' && detail !== null && 'detail' in detail
|
|
? String((detail as { detail: unknown }).detail)
|
|
: `HTTP ${res.status}: ${res.statusText}`
|
|
|
|
if (res.status === 401 && !skipAuthHandling) {
|
|
message += '\n登录已过期,请重新登录。'
|
|
window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT))
|
|
}
|
|
throw new ApiError(message, res.status, detail)
|
|
}
|
|
|
|
if (res.status === 204) {
|
|
return undefined as T
|
|
}
|
|
|
|
return res.json() as Promise<T>
|
|
} catch (err) {
|
|
if (err instanceof ApiError) throw err
|
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
throw new ApiError('请求超时,请稍后重试', 0)
|
|
}
|
|
throw new ApiError(
|
|
err instanceof Error ? err.message : '网络错误,请检查连接',
|
|
0,
|
|
)
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
export const http = {
|
|
get: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts }),
|
|
post: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
|
|
request<T>(path, { ...opts, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
|
|
put: <T>(path: string, body?: unknown, opts?: RequestOptions) =>
|
|
request<T>(path, { ...opts, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
|
|
delete: <T>(path: string, opts?: RequestOptions) => request<T>(path, { ...opts, method: 'DELETE' }),
|
|
}
|