import { isRawBody, isRecord, readString } from './internal' import { normalizeRulePack } from './rules' import { parseYaml } from './yaml' import type { AdjustmentInput, ApiErrorCode, Athlete, AthleteInput, AthleteStanding, AuditLog, BackupResult, CurrentUser, DisplayCommand, EntryHeatPayload, EntryHeatResult, EntryImportResult, EntryWithResult, Event, EventCategory, EventCategoryInput, EventInput, EventSettings, HealthInfo, ImportConflictDecision, LiveEvents, Page, PageQuery, PointAdjustment, ProjectActivateResult, ProjectInfo, PublishPayload, RecordBreak, RecordBreakInput, RecordEntry, Result, ResultQuery, RulePack, RuleSwitches, SaveDraftPayload, ScoreChannel, ScoreChannelInput, ScoringProfile, ScoringProfileInput, Sport, SportInput, StandingRow, Team, TeamBreakdown, TeamInput, VoidResultPayload, } from './types' export type QueryValue = string | number | boolean | null | undefined export type QueryParams = Record export interface ApiClientOptions { /** 基础路径,默认 `/api`;Electron 宿主或反向代理下可传绝对地址。 */ baseUrl?: string /** 会话是 HttpOnly Cookie,默认 `include` 才能带上。 */ credentials?: RequestCredentials /** CSRF Token 来源;默认读非 HttpOnly 的 csrf_token Cookie(登录时下发)。 */ csrfToken?: () => string | null /** 单次请求超时;现场局域网也可能出现半开连接,必须能主动放弃。 */ timeoutMs?: number /** 幂等键生成器;重试写操作时复用同一个 requestId,服务端据此返回首次结果。 */ requestId?: () => string onUnauthorized?: (error: ApiError) => void fetchImpl?: typeof fetch } interface RequestOptions { method?: HttpMethod /** 传接口对象即可;接口没有索引签名,所以这里用 object 接收后运行时收窄。 */ query?: object body?: unknown signal?: AbortSignal } type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' /** 统一响应约定见 05-接口与实时协议 第 6 节。 */ export class ApiError extends Error { readonly code: ApiErrorCode readonly status: number readonly detail: unknown constructor(code: ApiErrorCode, message: string, status: number, detail?: unknown) { super(message) this.name = 'ApiError' this.code = code this.status = status this.detail = detail } } const API_ERROR_CODES: readonly string[] = [ 'AUTH_REQUIRED', 'AUTH_FAILED', 'FORBIDDEN', 'VALIDATION_FAILED', 'CONFLICT', 'STATE_INVALID', 'RATE_LIMITED', 'INTERNAL', 'NETWORK', 'TIMEOUT', 'PARSE_FAILED', ] function isApiErrorCode(value: unknown): value is ApiErrorCode { return typeof value === 'string' && API_ERROR_CODES.includes(value) } /** 组别 / 通道随小项整体保存,避免只写了一半的配置被当成有效配置。 */ export interface CrudApi { list(): Promise get(id: number): Promise create(input: Input): Promise // 服务端 PUT 是整行覆盖(repo.go 的 UPDATE 写全部列),局部更新会把未传字段清空, // 所以这里要求完整 input,不能声明成 Partial。 update(id: number, input: Input): Promise remove(id: number): Promise } export interface AuthApi { login(username: string, password: string): Promise changePassword(currentPassword: string, newPassword: string): Promise<{ relogin: boolean }> logout(): Promise me(): Promise } export interface SettingsApi { get(): Promise update(patch: Partial): Promise } export interface EventsApi extends CrudApi { entries(eventId: number): Promise categories(eventId: number): Promise saveCategories(eventId: number, categories: EventCategoryInput[]): Promise /** * 就地更新单个组别(`PUT /api/events/{id}/categories/{categoryId}`)。 * * 与 saveCategories 的整表替换不同,服务端只发一条 UPDATE:组别 id 不变, * 挂在它下面的报名、成绩、积分流水与名次全部原地保留。有报名数据的小项靠它 * 改名 / 改年龄 / 改性别 / 改权重;增删组别仍只能走 saveCategories(有报名数据时会被拒)。 * * 注意:权重是算分输入——`multiplier` 真的变了时服务端会在同一事务里重算该组已发布成绩的 * 积分与名次(别人的分值/榜单会跟着变);name / gender / ageMin / ageMax / sortOrder 不重算。 * 返回该小项最新的组别列表。 */ updateCategory(eventId: number, categoryId: number, category: EventCategoryInput): Promise channels(eventId: number): Promise saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise } /** * 彻底删除的规模统计(服务端 `athletePurge`),同时进审计日志。 * * 名次与积分会随成绩消失而变化,所以 `recomputedEvents` 是给操作员看的重点: * 删掉一个人之后,哪些小项的榜单被重算了。 */ export interface AthletePurgeResult { deletedEntries: number deletedResults: number deletedLedger: number deletedBreaks: number /** 因成绩删除而重算了积分与名次的小项 ID。 */ recomputedEvents: number[] } export interface AthletesApi extends CrudApi { /** * 彻底删除(`DELETE /api/athletes/{id}?purge=1`)。 * * 与 `remove` 的软删除(停用,`active = 0`,数据都在)不同,这是物理删除: * 服务端在同一事务里删掉该人员的人员行、报名、成绩与相关积分流水, * 并按新排序重算受影响小项的积分与名次(别人的名次会变),随后推送大屏刷新。 * 人员已被别人删掉时回 404。 */ purge(id: number): Promise /** * 批量停用 / 批量彻底删除(`POST /api/athletes/batch`)。 * * 逐个调 DELETE 是「一次操作一条请求」,现场清理几十个人时会直接撞上写限流; * 收成一次请求既少一次配额,响应里还逐条回报成败(有人已被别人删掉、有人 id 不存在)。 * dryRun = true 时只回报「会删掉什么、重算哪些小项」,不改库,供危险确认框先展示。 */ batch(action: AthleteBatchAction, ids: number[], dryRun?: boolean): Promise } /** 批量动作的语义:与单条停用 / 单条彻底删除完全一致。 */ export type AthleteBatchAction = 'disable' | 'purge' /** 批量动作里逐条的结果:个别失败不能让整批失败,也不能静默跳过。 */ export interface AthleteBatchItem { id: number ok: boolean /** 失败原因的错误码(目前只有 NOT_FOUND:该 id 在库里不存在)。 */ code?: string message?: string } export interface AthleteBatchResult { action: AthleteBatchAction /** true 表示这是预览(dryRun),服务端一行都没动。 */ dryRun?: boolean /** 去重后的 id 数。 */ requested: number succeeded: number failed: number items: AthleteBatchItem[] /** 只有 action = 'purge' 时出现:合计删除规模与受影响小项。 */ purge?: AthletePurgeResult } export interface TeamsApi extends CrudApi { /** * 彻底删除(`DELETE /api/teams/{id}?purge=1`)。 * * 注意口径与 AthletesApi.purge 不同:这里是「假删除」——只让该单位从默认列表里消失, * 报名、成绩、积分流水与人工调整一行未动,积分榜不变,也不会有任何重算。 * 响应体是空响应(没有统计对象);单位已被别人删掉时回 404。 */ purge(id: number): Promise } export interface EntriesApi { /** 报名表按文件导入,后端解析 xlsx。 */ importFile(file: File): Promise /** 用原报名表 + 冲突决策重跑导入,服务端按决策保留一侧人员字段。 */ resolveImport(file: File, decisions: ImportConflictDecision[]): Promise /** * 批量设置 / 清除批次(heat)。 * * 幂等;要求 entryIds 非空、都属于同一个小项,heat 为 1-999 的整数或 null(清除)。 * 只改录入分组,不影响任何成绩与积分。 */ setHeat(payload: EntryHeatPayload): Promise } /** POST /api/uploads 的响应:path 是服务端存储后的路径,url 与其相同,可直接放进 。 */ export interface UploadResult { path: string url: string } export interface UploadsApi { /** 仅图片(png / jpeg / webp / gif),单文件上限由服务端限制。 */ upload(file: File): Promise } export interface ResultsApi { list(query: ResultQuery): Promise> saveDraft(payload: SaveDraftPayload): Promise publish(payload: PublishPayload): Promise unpublish(payload: PublishPayload): Promise void(id: number, payload: VoidResultPayload): Promise } export interface StandingsApi { teams(): Promise athletes(): Promise breakdown(teamId: number): Promise } export interface AdjustmentsApi { list(query?: PageQuery & { teamId?: number }): Promise> create(input: AdjustmentInput): Promise void(id: number, reason: string): Promise } export interface RecordsApi { list(query?: { eventId?: number }): Promise createBreak(input: RecordBreakInput): Promise approveBreak(id: number): Promise } /** * 在线大屏实例(`GET /api/display/instances`)。 * * 管理端的「在线大屏」列表与「本机显示器当前在哪一页」都靠它; * `remoteAddr` 是局域网浏览器唯一的身份线索,`screen` 是本机显示器与实例的对应关系。 */ export interface DisplayInstance { /** Console 大屏窗口带来的 id,或服务端生成的 display-xxxx。 */ id: string online: boolean /** 当前所在页;没上报过时服务端因 omitempty 根本不带这个字段,这里归一成空串。 */ page: string /** 最后心跳(ISO 时间串)。 */ lastHeartbeat: string connectedAt: string /** 来源 IP:端口;同样可能缺字段,归一成空串。 */ remoteAddr: string ackVersion: number /** 版本落后到阈值以上:大屏可能在卡 / 网络差。 */ slow: boolean /** 屏号;局域网浏览器直接开 /display 时是空串。 */ screen: string } export interface DisplayApi { command(command: DisplayCommand): Promise /** 当前注册表里的全部实例,含离线(服务端按 TTL 保留一段时间,便于排查)。 */ instances(): Promise /** 「实时赛况」当前项目与其已发布成绩(判定由服务端按设置与时序完成)。 */ live(): Promise } export interface ExportQuery { eventId?: number teamId?: number } export interface ExportApi { /** 返回可直接放进 的地址,省掉一次内存中的 Blob。 */ standingsUrl(query?: ExportQuery): string standings(query?: ExportQuery): Promise save(blob: Blob, filename: string): void } export interface AuditApi { list(query?: PageQuery & { entityType?: string; userId?: number }): Promise> } export interface RulesApi { /** GET /api/rules/export,返回当前规则包。 */ get(): Promise /** GET /api/rules/switches,recorder 可读的布尔开关(不含规则内容)。 */ switches(): Promise } export interface SystemApi { backup(): Promise } /** * 赛事项目切换。 * * 服务端一次只打开一个赛事项目(启动参数 -project → 记忆文件 → default), * 其余项目在数据目录里只是「躺着的数据」:导入赛事包并不会让它变成当前赛事,必须显式切换。 */ export interface ProjectsApi { /** GET /api/projects:列出数据目录下的赛事项目及当前打开的那个。 */ list(): Promise /** POST /api/projects/activate:切换当前赛事;服务端会退出并由 Console 自动拉起。 */ activate(name: string): Promise } /** GET /api/system/about 的响应(recorder 及以上可读)。注入字段缺失或未注入时为空串。 */ export interface SystemAbout { internalSignature?: string buildTime?: string } interface Http { request(path: string, options?: RequestOptions): Promise requestVoid(path: string, options?: RequestOptions): Promise requestJson(path: string, options?: RequestOptions): Promise requestText(path: string, options?: RequestOptions): Promise url(path: string, query?: object): string blob(path: string, options?: RequestOptions): Promise } function createCrudApi(http: Http, base: string): CrudApi { return { list: () => http.request(base), get: (id) => http.request(`${base}/${id}`), create: (input) => http.request(base, { method: 'POST', body: input }), update: (id, input) => http.request(`${base}/${id}`, { method: 'PUT', body: input }), remove: (id) => http.requestVoid(`${base}/${id}`, { method: 'DELETE' }), } } /** * 读删除统计。 * * 统计缺失时按 0 / 空数组处理:删除在服务端已经提交、不可回滚,这时提示里冒出 undefined * 或直接报「删除失败」都是在骗操作员。老服务端不认 ?purge=1、data 为 null 时也会走到这里。 */ function readPurgeResult(value: unknown): AthletePurgeResult { const source = isRecord(value) ? value : {} const count = (key: string): number => { const raw = source[key] return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0 } const events = source['recomputedEvents'] return { deletedEntries: count('deletedEntries'), deletedResults: count('deletedResults'), deletedLedger: count('deletedLedger'), deletedBreaks: count('deletedBreaks'), recomputedEvents: Array.isArray(events) ? events.filter((item): item is number => typeof item === 'number') : [], } } /** * 读批量动作的响应。 * * 逐条结果缺失时按「这一条失败」处理而不是成功:批量删除不可回滚,把没看到的 id 当成功 * 比多说一句「未能确认」危险得多。requested / succeeded / failed 也按 items 重算, * 老服务端字段缺失时不至于报出「0 人成功」这种自相矛盾的结论。 */ function readBatchResult(action: AthleteBatchAction, value: unknown): AthleteBatchResult { const source = isRecord(value) ? value : {} const items: AthleteBatchItem[] = Array.isArray(source['items']) ? source['items'].filter(isRecord).map((raw) => ({ id: typeof raw['id'] === 'number' ? raw['id'] : 0, ok: raw['ok'] === true, code: typeof raw['code'] === 'string' ? raw['code'] : undefined, message: typeof raw['message'] === 'string' ? raw['message'] : undefined, })) : [] const purge = source['purge'] return { action, dryRun: source['dryRun'] === true, requested: items.length, succeeded: items.filter((item) => item.ok).length, failed: items.filter((item) => !item.ok).length, items, purge: isRecord(purge) ? readPurgeResult(purge) : undefined, } } function createTeamsApi(http: Http): TeamsApi { return { ...createCrudApi(http, '/teams'), // 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;和运动员不同,这里是空响应。 purge: (id) => http.requestVoid(`/teams/${id}`, { method: 'DELETE', query: { purge: 1 } }), } } function createAthletesApi(http: Http): AthletesApi { return { ...createCrudApi(http, '/athletes'), // 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;响应体带删除统计。 purge: async (id) => readPurgeResult(await http.request(`/athletes/${id}`, { method: 'DELETE', query: { purge: 1 } })), batch: async (action, ids, dryRun = false) => // dryRun 缺省不传:老服务端不认这个字段时仍按真删处理,不能被预览悄悄降级。 readBatchResult( action, await http.request('/athletes/batch', { method: 'POST', body: dryRun ? { action, ids, dryRun: true } : { action, ids }, }), ), } } function createEventsApi(http: Http): EventsApi { return { ...createCrudApi(http, '/events'), entries: (eventId) => http.request(`/events/${eventId}/entries`), categories: (eventId) => http.request(`/events/${eventId}/categories`), saveCategories: (eventId, categories) => http.request(`/events/${eventId}/categories`, { method: 'PUT', body: { categories }, }), updateCategory: (eventId, categoryId, category) => http.request(`/events/${eventId}/categories/${categoryId}`, { method: 'PUT', body: category, }), channels: (eventId) => http.request(`/events/${eventId}/channels`), saveChannels: (eventId, channels) => http.request(`/events/${eventId}/channels`, { method: 'PUT', body: { channels }, }), } } function createResultsApi(http: Http): ResultsApi { return { list: (query) => http.request>('/results', { query }), saveDraft: (payload) => http.request('/results/draft', { method: 'PUT', body: payload }), publish: (payload) => http.request('/results/publish', { method: 'POST', body: payload }), unpublish: (payload) => http.request('/results/unpublish', { method: 'POST', body: payload }), void: (id, payload) => http.request(`/results/${id}/void`, { method: 'POST', body: payload }), } } function createExportApi(http: Http): ExportApi { // 服务端只注册了 /export/standings.xlsx 与 /export/all.xlsx 等; // 单位明细 / 分项成绩导出分别是 /export/teams/{id}、/export/events/{id},由各自页面拼地址。 const standingsPath = '/export/standings.xlsx' return { standingsUrl: (query) => http.url(standingsPath, query), standings: (query) => http.blob(standingsPath, { query }), save: (blob, filename) => { const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.rel = 'noopener' anchor.click() // 立即 revoke 会在部分浏览器里取消下载,等一帧再释放。 setTimeout(() => URL.revokeObjectURL(url), 0) }, } } function createRulesApi(http: Http): RulesApi { return { // 导出接口返回的是裸 YAML 文本(不是 {ok,data}),必须按文本取回再解析。 get: async () => { const text = await http.requestText('/rules/export') let parsed: unknown try { parsed = parseYaml(text) } catch (error) { throw new ApiError('PARSE_FAILED', '规则包 YAML 解析失败', 0, error) } const pack = normalizeRulePack(parsed) if (pack === null) { throw new ApiError('PARSE_FAILED', '规则包格式不符(需要 scoreboard-rules v1)', 0, parsed) } return pack }, switches: () => http.request('/rules/switches'), } } /** * 逐条归一化大屏实例。 * * 服务端 `page` / `remoteAddr` 带 omitempty,缺字段时这里补空串——否则界面会把 undefined * 直接渲染出来;id 都没有的行算脏数据,丢掉。 */ function readDisplayInstance(value: unknown): DisplayInstance | null { if (!isRecord(value)) return null const id = readString(value, 'id') if (id === null || id === '') return null const ackVersion = value['ackVersion'] return { id, online: value['online'] === true, page: readString(value, 'page') ?? '', lastHeartbeat: readString(value, 'lastHeartbeat') ?? '', connectedAt: readString(value, 'connectedAt') ?? '', remoteAddr: readString(value, 'remoteAddr') ?? '', ackVersion: typeof ackVersion === 'number' && Number.isFinite(ackVersion) ? ackVersion : 0, slow: value['slow'] === true, screen: readString(value, 'screen') ?? '', } } function readProjectInfo(value: unknown): ProjectInfo | null { if (!isRecord(value)) return null const name = readString(value, 'name') if (name === null) return null return { name, active: value.active === true, // 缺字段时按「有库」处理:真正缺库时激活接口会给出中文报错,不该在列表里被静默禁掉。 hasEvent: value.hasEvent !== false, sizeBytes: typeof value.sizeBytes === 'number' && Number.isFinite(value.sizeBytes) ? value.sizeBytes : 0, updatedAt: readString(value, 'updatedAt'), } } function createProjectsApi(http: Http): ProjectsApi { return { list: async () => { const body = await http.request('/projects') // 服务端回 { projects: [...] };裸数组也接受,省得为了外层字段名再改一次前端。 const raw = Array.isArray(body) ? body : isRecord(body) && Array.isArray(body.projects) ? body.projects : null if (raw === null) throw new ApiError('PARSE_FAILED', '赛事项目列表响应格式不符', 0, body) return raw.map(readProjectInfo).filter((row): row is ProjectInfo => row !== null) }, activate: async (name) => { const body = await http.request('/projects/activate', { method: 'POST', body: { name }, }) // 字段缺失时按「不需要重启」处理:宁可让页面直接刷新,也不要卡在一个不会来的重启上。 return { name: isRecord(body) ? (readString(body, 'name') ?? name) : name, restarting: isRecord(body) && body.restarting === true, } }, } } function readCsrfCookie(): string | null { if (typeof document === 'undefined') return null const match = /(?:^|;\s*)csrf_token=([^;]+)/.exec(document.cookie) const raw = match?.[1] return raw === undefined ? null : decodeURIComponent(raw) } function defaultRequestId(): string { const cryptoApi: Crypto | undefined = globalThis.crypto if (cryptoApi !== undefined && typeof cryptoApi.randomUUID === 'function') { return cryptoApi.randomUUID() } return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` } export class ApiClient { readonly auth: AuthApi readonly settings: SettingsApi readonly teams: TeamsApi readonly sports: CrudApi readonly events: EventsApi readonly scoringProfiles: CrudApi readonly athletes: AthletesApi readonly entries: EntriesApi readonly results: ResultsApi readonly standings: StandingsApi readonly adjustments: AdjustmentsApi readonly records: RecordsApi readonly display: DisplayApi readonly exports: ExportApi readonly auditLogs: AuditApi readonly rules: RulesApi readonly system: SystemApi readonly projects: ProjectsApi readonly uploads: UploadsApi private readonly baseUrl: string private readonly credentials: RequestCredentials private readonly timeoutMs: number private readonly csrfToken: () => string | null private readonly newRequestId: () => string private readonly onUnauthorized: ((error: ApiError) => void) | undefined private readonly fetchImpl: typeof fetch constructor(options: ApiClientOptions = {}) { this.baseUrl = (options.baseUrl ?? '/api').replace(/\/+$/, '') this.credentials = options.credentials ?? 'include' this.timeoutMs = options.timeoutMs ?? 15_000 this.csrfToken = options.csrfToken ?? readCsrfCookie this.newRequestId = options.requestId ?? defaultRequestId this.onUnauthorized = options.onUnauthorized this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis) const http: Http = { request: (path, requestOptions) => this.request(path, requestOptions), requestVoid: (path, requestOptions) => this.requestVoid(path, requestOptions), requestJson: (path, requestOptions) => this.requestJson(path, requestOptions), requestText: (path, requestOptions) => this.requestText(path, requestOptions), url: (path, query) => this.buildUrl(path, query), blob: (path, requestOptions) => this.fetchBlob(path, requestOptions), } this.auth = { login: (username, password) => http.request('/auth/login', { method: 'POST', body: { username, password }, }), changePassword: (currentPassword, newPassword) => http.request<{ relogin: boolean }>('/auth/password', { method: 'POST', body: { currentPassword, newPassword }, }), logout: () => http.requestVoid('/auth/logout', { method: 'POST' }), // 未登录时服务端回 200 + guest DTO,调用方用 isGuestUser(user) 判定,不能只看 null。 me: () => http.request('/auth/me'), } this.settings = { get: () => http.request('/settings'), update: (patch) => http.request('/settings', { method: 'PUT', body: patch }), } this.teams = createTeamsApi(http) this.sports = createCrudApi(http, '/sports') this.events = createEventsApi(http) this.scoringProfiles = createCrudApi(http, '/scoring-profiles') this.athletes = createAthletesApi(http) this.entries = { importFile: (file) => { const form = new FormData() form.append('file', file) return http.request('/entries/import', { method: 'POST', body: form }) }, resolveImport: (file, decisions) => { const form = new FormData() form.append('file', file) form.append('decisions', JSON.stringify(decisions)) return http.request('/entries/import/resolve', { method: 'POST', body: form }) }, setHeat: (payload) => http.request('/entries/heat', { method: 'PUT', body: payload }), } this.results = createResultsApi(http) this.standings = { teams: () => http.request('/standings'), athletes: () => http.request('/standings/athletes'), breakdown: (teamId) => http.request(`/standings/teams/${teamId}/breakdown`), } this.adjustments = { list: (query) => http.request>('/adjustments', { query }), create: (input) => http.request('/adjustments', { method: 'POST', body: input }), void: (id, reason) => http.request(`/adjustments/${id}/void`, { method: 'POST', body: { reason }, }), } this.records = { list: (query) => http.request('/records', { query }), createBreak: (input) => http.request('/record-breaks', { method: 'POST', body: input }), approveBreak: (id) => http.request(`/record-breaks/${id}/approve`, { method: 'POST' }), } this.display = { command: (command) => http.requestVoid('/display/command', { method: 'POST', body: command }), instances: async () => { const body = await http.request('/display/instances') if (!Array.isArray(body)) { throw new ApiError('PARSE_FAILED', '大屏实例列表响应格式不符', 0, body) } return body.map(readDisplayInstance).filter((item): item is DisplayInstance => item !== null) }, live: () => http.request('/display/live'), } this.exports = createExportApi(http) this.auditLogs = { list: (query) => http.request>('/audit-logs', { query }), } this.rules = createRulesApi(http) this.system = { backup: () => http.request('/backup', { method: 'POST' }), } this.projects = createProjectsApi(http) this.uploads = { upload: (file) => { const form = new FormData() form.append('file', file) return http.request('/uploads', { method: 'POST', body: form }) }, } } /** 健康检查不走 {ok,data} 包装,直接返回 `{ok,version}`(05 第 7 节)。 */ async health(): Promise { const body = await this.requestJson('/health') if (isRecord(body) && body.ok === false) throw this.toApiError(body, 0) const version = readString(body, 'version') if (version === null) { throw new ApiError('PARSE_FAILED', '健康检查响应缺少 version 字段', 0, body) } return { version } } private async request(path: string, options: RequestOptions = {}): Promise { const body = await this.requestJson(path, options) return this.unwrap(body) } private async requestVoid(path: string, options: RequestOptions = {}): Promise { const body = await this.requestJson(path, options) // 204 或空响应视为成功;有响应体时仍校验 {ok:true}。 if (body === null || body === undefined) return this.unwrap(body) } /** * 401 也用于「原密码不正确」等业务失败(服务端回 AUTH_FAILED)。 * 只有会话缺失 / 过期(AUTH_REQUIRED)才需要通知调用方清空登录态。 */ private notifyUnauthorized(error: ApiError): void { if (error.code === 'AUTH_REQUIRED') this.onUnauthorized?.(error) } private async requestJson(path: string, options: RequestOptions = {}): Promise { const response = await this.send(path, options) const body = await readBody(response) if (response.status === 401) { const error = this.toApiError(body, response.status) this.notifyUnauthorized(error) throw error } if (!response.ok) throw this.toApiError(body, response.status) return body } /** 规则包导出等接口返回裸文本(非 JSON),错误体仍按 {ok,error} 解析。 */ private async requestText(path: string, options: RequestOptions = {}): Promise { const response = await this.send(path, options) if (!response.ok) { const body = await readBody(response) if (response.status === 401) { const error = this.toApiError(body, response.status) this.notifyUnauthorized(error) throw error } throw this.toApiError(body, response.status) } return response.text() } private async fetchBlob(path: string, options: RequestOptions = {}): Promise { const response = await this.send(path, options) if (!response.ok) { const body = await readBody(response) if (response.status === 401) { const error = this.toApiError(body, response.status) this.notifyUnauthorized(error) throw error } throw this.toApiError(body, response.status) } return response.blob() } private async send(path: string, options: RequestOptions): Promise { const method: HttpMethod = options.method ?? 'GET' const payload = this.prepareBody(method, options.body) // 断言:二进制 / 表单载荷只能原样透传,一旦被归一化展开就会静默丢内容(报名表导入踩过)。 if (isRawBody(options.body) && payload !== options.body) { throw new ApiError('INTERNAL', '请求体归一化破坏了 FormData/Blob 载荷', 0, options.body) } const headers: Record = { Accept: 'application/json' } if (payload instanceof FormData) { // 交给浏览器带 multipart boundary,手动设置 Content-Type 会丢 boundary。 } else if (isRawBody(payload)) { // Blob / URLSearchParams 自带类型语义,Content-Type 交给 fetch 决定。 } else if (payload !== undefined) { headers['Content-Type'] = 'application/json; charset=utf-8' } if (method !== 'GET') { const token = this.csrfToken() if (token !== null && token !== '') headers['X-CSRF-Token'] = token headers['X-Request-Id'] = this.newRequestId() } const controller = new AbortController() const timer = setTimeout(() => controller.abort(), this.timeoutMs) const external = options.signal let abortedByCaller = false if (external !== undefined) { const abortFromCaller = (): void => { abortedByCaller = true controller.abort() } if (external.aborted) abortFromCaller() else external.addEventListener('abort', abortFromCaller, { once: true }) } try { return await this.fetchImpl(this.buildUrl(path, options.query), { method, headers, credentials: this.credentials, body: payload === undefined ? undefined : isRawBody(payload) ? payload : JSON.stringify(payload), signal: controller.signal, }) } catch (error) { // 外部取消与服务端超时共用同一个 signal,必须分开归类,调用方才能决定要不要提示。 if (abortedByCaller) throw new ApiError('ABORTED', '请求已取消', 0, error) if (controller.signal.aborted) { throw new ApiError('TIMEOUT', `请求超时(${this.timeoutMs}ms)`, 0, error) } throw new ApiError('NETWORK', '无法连接服务端', 0, error) } finally { clearTimeout(timer) } } /** 写操作注入 requestId:网络抖动重发时服务端能返回首次结果而不是重复执行。 */ private prepareBody(method: HttpMethod, body: unknown): unknown { if (method === 'GET' || body === undefined) return undefined // FormData/Blob/URLSearchParams 不能展开,否则文件/二进制内容会被丢掉。 if (isRawBody(body)) return body if (isRecord(body) && !('requestId' in body)) { return { ...body, requestId: this.newRequestId() } } return body } private unwrap(body: unknown): T { if (!isRecord(body) || typeof body.ok !== 'boolean') { throw new ApiError('PARSE_FAILED', '服务端响应不是约定的 {ok,data} 结构', 0, body) } if (body.ok) return body.data as T throw this.toApiError(body, 0) } private toApiError(body: unknown, status: number): ApiError { if (isRecord(body) && isRecord(body.error)) { const code = body.error.code const message = readString(body.error, 'message') return new ApiError( isApiErrorCode(code) ? code : 'INTERNAL', message ?? `请求失败(HTTP ${status})`, status, body.error.detail, ) } return new ApiError('INTERNAL', `请求失败(HTTP ${status})`, status, body) } private buildUrl(path: string, query?: object): string { const base = path.startsWith('/') ? `${this.baseUrl}${path}` : `${this.baseUrl}/${path}` if (query === undefined || query === null || !isRecord(query)) return base const params = new URLSearchParams() for (const [key, value] of Object.entries(query)) { if (Array.isArray(value)) { for (const item of value) { if (isScalar(item)) params.append(key, String(item)) } continue } if (isScalar(value)) params.append(key, String(value)) } const search = params.toString() return search === '' ? base : `${base}?${search}` } } function isScalar(value: unknown): value is string | number | boolean { return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' } async function readBody(response: Response): Promise { if (response.status === 204) return null const text = await response.text() if (text.trim() === '') return null try { return JSON.parse(text) } catch { // 非 JSON 也原样返回,报错里带上原文才定位得到问题。 return text } } export function createApiClient(options: ApiClientOptions = {}): ApiClient { return new ApiClient(options) }