Files
Scoreboard/web/src/shared/api.ts
T
AskaEth 1b8a37ff8c feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘
三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开):

一、分工作表报名模板(新增能力)
- 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板,
  一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。
- 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。
- 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。
- 单表 CSV/XLSX 导入路径不回归。

二、号码布判重(修误判)
- 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。
- 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行;
  号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。
- 团体行的号码布一并纳入查重。

三、自动备份的关停与落盘(修数据安全隐患)
- export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限,
  超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。
- vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件;
  Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。
- 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件,
  避免半截 .db 被列进备份列表、进而被「一键恢复」选中。

文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
2026-09-14 01:10:16 +08:00

1234 lines
50 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 { firstNumberOrNull, isRawBody, isRecord, readString } from './internal'
import { normalizeRulePack } from './rules'
import { readTeamRanksView, readTeamRanksViews } from './teamRanks'
import { parseYaml } from './yaml'
import type {
AdjustmentInput,
ApiErrorCode,
Athlete,
AthleteInput,
AthleteStanding,
AuditLog,
AuditLogQuery,
BackupRestoreInput,
BackupRestoreResult,
CurrentUser,
DisplayCommand,
EntryHeatPayload,
EntryHeatResult,
EntryImportResult,
EntryWithResult,
Event,
EventCategory,
EventCategoryInput,
EventInput,
EventSettings,
HealthInfo,
ImportConflictDecision,
LiveEvents,
NetworkAddress,
NetworkAddressScope,
NetworkInfo,
NetworkSkippedInterface,
Page,
PageQuery,
PointAdjustment,
ProjectActivateResult,
ProjectInfo,
PublishPayload,
RecordBreak,
RecordBreakInput,
RecordBreakState,
RecordEntry,
RecordEntryInput,
ReopenResultPayload,
Result,
ResultQuery,
RulePack,
RuleSwitches,
SaveDraftPayload,
ScoreChannel,
ScoreChannelInput,
TeamRanksInput,
TeamRanksView,
ScoringProfile,
ScoringProfileInput,
Sport,
SportInput,
StandingRow,
Team,
TeamBreakdown,
TeamInput,
VoidResultPayload,
} from './types'
export type QueryValue = string | number | boolean | null | undefined
export type QueryParams = Record<string, QueryValue | QueryValue[]>
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
/**
* 单次请求的超时覆盖值(毫秒);不传用客户端默认的 `ApiClientOptions.timeoutMs`。
*
* 停机类动作(用备份恢复整库)在服务端要拍安全副本、换文件、校验、再写审计,
* 回执是**全部做完之后**才发出的:默认 15s 会在库已经换掉之后把请求判成超时,
* 于是客户端以为「没执行」而重试。这类接口必须单独放大超时。
*/
timeoutMs?: number
}
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',
'SAMPLE_DATA_BLOCKED',
]
function isApiErrorCode(value: unknown): value is ApiErrorCode {
return typeof value === 'string' && API_ERROR_CODES.includes(value)
}
/** 组别 / 通道随小项整体保存,避免只写了一半的配置被当成有效配置。 */
export interface CrudApi<T, Input> {
list(): Promise<T[]>
get(id: number): Promise<T>
create(input: Input): Promise<T>
// 服务端 PUT 是整行覆盖(repo.go 的 UPDATE 写全部列),局部更新会把未传字段清空,
// 所以这里要求完整 input,不能声明成 Partial。
update(id: number, input: Input): Promise<T>
remove(id: number): Promise<void>
}
export interface AuthApi {
login(username: string, password: string): Promise<CurrentUser>
changePassword(currentPassword: string, newPassword: string): Promise<{ relogin: boolean }>
logout(): Promise<void>
me(): Promise<CurrentUser>
}
export interface SettingsApi {
get(): Promise<EventSettings>
update(patch: Partial<EventSettings>): Promise<EventSettings>
}
/**
* 成绩录入接口的完整读数(`GET /api/events/{id}/entries`)。
*
* 旧服务端只回成绩行数组;设计 17 §七 之后同一响应会随行带上该小项的团体手工名次,
* 录入页一次请求就能把「成绩行 + 团体名次」都拿到,少发一轮。
*/
export interface EntryBoard {
entries: EntryWithResult[]
/** 随行下发的团体名次视图;只有存在团体手工通道的组别才非空,拿不到时为空数组。 */
teamRanks: TeamRanksView[]
}
export interface EventsApi extends CrudApi<Event, EventInput> {
entries(eventId: number): Promise<EntryWithResult[]>
/** 与 entries 同一次请求,但把随行下发的 teamRanks 一并交给调用方。 */
entryBoard(eventId: number): Promise<EntryBoard>
/**
* 读某个组别的团体手工名次(`GET /api/events/{id}/categories/{cid}/team-ranks`)。
*
* 组别下没有团体手工通道时服务端回一组空数组,不是 404——录入页据此隐藏区块,
* 不需要先猜有没有这个接口。
*/
teamRanks(eventId: number, categoryId: number): Promise<TeamRanksView>
/**
* 写某个组别的团体手工名次(`PUT` 同一路径),整组替换。
*
* `ranks[].rank: 0` 表示清空该单位;服务端在同一事务里重算该组名次与积分。
* 返回保存后的视图(口径与 GET 一致),调用方直接拿来回填。
*/
saveTeamRanks(eventId: number, categoryId: number, input: TeamRanksInput): Promise<TeamRanksView>
categories(eventId: number): Promise<EventCategory[]>
saveCategories(eventId: number, categories: EventCategoryInput[]): Promise<EventCategory[]>
/**
* 就地更新单个组别(`PUT /api/events/{id}/categories/{categoryId}`)。
*
* 与 saveCategories 的整表替换不同,服务端只发一条 UPDATE:组别 id 不变,
* 挂在它下面的报名、成绩、积分流水与名次全部原地保留。有报名数据的小项靠它
* 改名 / 改年龄 / 改性别 / 改权重;增删组别仍只能走 saveCategories(有报名数据时会被拒)。
*
* 注意:权重是算分输入——`multiplier` 真的变了时服务端会在同一事务里重算该组已发布成绩的
* 积分与名次(别人的分值/榜单会跟着变);name / gender / ageMin / ageMax / sortOrder 不重算。
* 返回该小项最新的组别列表。
*/
updateCategory(eventId: number, categoryId: number, category: EventCategoryInput): Promise<EventCategory[]>
channels(eventId: number): Promise<ScoreChannel[]>
saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise<ScoreChannel[]>
}
/**
* 彻底删除的规模统计(服务端 `athletePurge`),同时进审计日志。
*
* 名次与积分会随成绩消失而变化,所以 `recomputedEvents` 是给操作员看的重点:
* 删掉一个人之后,哪些小项的榜单被重算了。
*/
export interface AthletePurgeResult {
deletedEntries: number
deletedResults: number
deletedLedger: number
deletedBreaks: number
/** 因成绩删除而重算了积分与名次的小项 ID。 */
recomputedEvents: number[]
}
export interface AthletesApi extends CrudApi<Athlete, AthleteInput> {
/**
* 彻底删除(`DELETE /api/athletes/{id}?purge=1`)。
*
* 与 `remove` 的软删除(停用,`active = 0`,数据都在)不同,这是物理删除:
* 服务端在同一事务里删掉该人员的人员行、报名、成绩与相关积分流水,
* 并按新排序重算受影响小项的积分与名次(别人的名次会变),随后推送大屏刷新。
* 人员已被别人删掉时回 404。
*/
purge(id: number): Promise<AthletePurgeResult>
/**
* 批量停用 / 批量彻底删除(`POST /api/athletes/batch`)。
*
* 逐个调 DELETE 是「一次操作一条请求」,现场清理几十个人时会直接撞上写限流;
* 收成一次请求既少一次配额,响应里还逐条回报成败(有人已被别人删掉、有人 id 不存在)。
* dryRun = true 时只回报「会删掉什么、重算哪些小项」,不改库,供危险确认框先展示。
*/
batch(action: AthleteBatchAction, ids: number[], dryRun?: boolean): Promise<AthleteBatchResult>
}
/** 批量动作的语义:与单条停用 / 单条彻底删除完全一致。 */
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<Team, TeamInput> {
/**
* 单位列表。与 CrudApi.list 的差别只有 includeDeleted 一个开关。
*
* `includeDeleted: true` 时连「彻底删除」(对界面不可见)的单位一并返回,供管理端
* 把它们翻出来恢复;服务端只放 admin 用这个开关,其余角色 403。
*/
list(query?: TeamsListQuery): Promise<Team[]>
/**
* 彻底删除(`DELETE /api/teams/{id}?purge=1`)。
*
* 注意口径与 AthletesApi.purge 不同:这里是「假删除」——只让该单位从默认列表里消失,
* 报名、成绩、积分流水与人工调整一行未动,积分榜不变,也不会有任何重算。
* 响应体是空响应(没有统计对象);单位已被别人删掉时回 404。
*/
purge(id: number): Promise<void>
/**
* 恢复被彻底删除的单位(`POST /api/teams/{id}/restore`)。
*
* 只是把「不可见」的标记撤掉:报名、成绩与积分一行没动,所以不会重算、也不会推榜单。
* 幂等——单位本来就没被隐藏时照样 200,`restored` 为 false;单位不存在时 404。
*/
restore(id: number): Promise<TeamRestoreResult>
}
/** 单位列表的查询开关。 */
export interface TeamsListQuery {
/** true 时连「彻底删除」的单位一并返回(仅 admin)。 */
includeDeleted?: boolean
}
/** 恢复被彻底删除单位的结果;restored 为 false 表示它本来就没被隐藏(幂等命中)。 */
export interface TeamRestoreResult {
team: Team
restored: boolean
}
export interface EntriesApi {
/** 报名表按文件导入,后端解析 xlsx。 */
importFile(file: File): Promise<EntryImportResult>
/** 用原报名表 + 冲突决策重跑导入,服务端按决策保留一侧人员字段。 */
resolveImport(file: File, decisions: ImportConflictDecision[]): Promise<EntryImportResult>
/**
* 批量设置 / 清除批次(heat)。
*
* 幂等;要求 entryIds 非空、都属于同一个小项,heat 为 1-999 的整数或 null(清除)。
* 只改录入分组,不影响任何成绩与积分。
*/
setHeat(payload: EntryHeatPayload): Promise<EntryHeatResult>
/**
* 「按小项 × 组别分工作表」的报名模板(xlsx),服务端按当前赛事的基础数据生成。
*
* language 只决定表头语言(zh 用中文列名、en 用英文规范名),与单表 CSV 模板的两个按钮同一套口径。
* 服务端还会拒绝生成:名字里带 @ 的小项 / 组别、或者还没有任何「小项 + 组别」组合时,
* 回的是一份 {ok:false} 的说明,调用方按普通 ApiError 展示即可。
*/
downloadTemplate(language: 'zh' | 'en'): Promise<Blob>
}
/** POST /api/uploads 的响应:path 是服务端存储后的路径,url 与其相同,可直接放进 <img src>。 */
export interface UploadResult {
path: string
url: string
}
export interface UploadsApi {
/** 仅图片(png / jpeg / webp / gif),单文件上限由服务端限制。 */
upload(file: File): Promise<UploadResult>
}
export interface ResultsApi {
list(query: ResultQuery): Promise<Page<Result>>
saveDraft(payload: SaveDraftPayload): Promise<Result[]>
publish(payload: PublishPayload): Promise<Result[]>
unpublish(payload: PublishPayload): Promise<Result[]>
void(id: number, payload: VoidResultPayload): Promise<Result>
/**
* 把一条已作废的成绩退回草稿(`POST /api/results/{id}/reopen`)。
*
* 与 void 成对:作废只把 `status` 改成 withdrawn 并撤掉这一条的积分,
* 想把这一条改回来只能先 reopen 退回草稿,再用常规的存草稿 / 发布流程重新发。
* 服务端在同一事务里重算推榜,返回更新后的成绩。
*/
reopen(id: number, payload: ReopenResultPayload): Promise<Result>
}
export interface StandingsApi {
teams(): Promise<StandingRow[]>
athletes(): Promise<AthleteStanding[]>
breakdown(teamId: number): Promise<TeamBreakdown>
}
export interface AdjustmentsApi {
list(query?: PageQuery & { teamId?: number }): Promise<Page<PointAdjustment>>
create(input: AdjustmentInput): Promise<PointAdjustment>
void(id: number, reason: string): Promise<PointAdjustment>
}
export interface RecordsApi {
list(query?: { eventId?: number }): Promise<RecordEntry[]>
createBreak(input: RecordBreakInput): Promise<RecordBreak>
approveBreak(id: number): Promise<RecordBreak>
/**
* 破纪录登记列表(`GET /api/record-breaks?state=...`,recorder 及以上可读)。
*
* 不传 state 时由服务端决定(当前返回全部);按 id 降序。
* 「已批准的破纪录」卡片要 state=approved,仪表盘的「待确认」计数要 state=pending。
*/
listBreaks(state?: RecordBreakState): Promise<RecordBreak[]>
/**
* 驳回 / 作废一条登记(`POST /api/record-breaks/{id}/reject`,原因必填)。
*
* 原本是 approved 的登记被驳回时,服务端会撤掉那笔加分并把该级别纪录恢复成上一条。
*/
rejectBreak(id: number, reason: string): Promise<RecordBreak>
/** 登记一条历史纪录(`POST /api/records`);服务端固定把 source 写成 history。 */
createRecord(input: RecordEntryInput): Promise<RecordEntry>
/**
* 删除一条纪录(`DELETE /api/records/{id}`)。
*
* 删掉的若是当前纪录(isCurrent),服务端会把同级别的上一条恢复成当前。
*/
removeRecord(id: number): Promise<void>
}
/**
* 在线大屏实例(`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<void>
/** 当前注册表里的全部实例,含离线(服务端按 TTL 保留一段时间,便于排查)。 */
instances(): Promise<DisplayInstance[]>
/** 「实时赛况」当前项目与其已发布成绩(判定由服务端按设置与时序完成)。 */
live(): Promise<LiveEvents>
}
export interface ExportApi {
/** 把服务端返回的 Blob 存成文件(积分榜 / 结果导出的唯一入口)。 */
save(blob: Blob, filename: string): void
}
export interface AuditApi {
list(query?: AuditLogQuery): Promise<Page<AuditLog>>
}
export interface RulesApi {
/** GET /api/rules/export,返回当前规则包。 */
get(): Promise<RulePack>
/** GET /api/rules/switches,recorder 可读的布尔开关(不含规则内容)。 */
switches(): Promise<RuleSwitches>
}
export interface SystemApi {
/**
* 用一份备份恢复当前库(`POST /api/backup/restore`,仅 admin)。
*
* 这是停机动作:服务端会先留一份恢复前安全副本,再换掉库文件,然后优雅退出、
* 由 Console 拉起。调用方拿到 `restarting: true` 后必须轮询健康检查等服务端回来,
* 不能像普通写操作那样立刻认为已经生效(与切换赛事同一套等待逻辑)。
*/
restore(input: BackupRestoreInput): Promise<BackupRestoreResult>
/**
* 本机局域网地址(`GET /api/system/network`,recorder 及以上可读)。
*
* 交给大屏控制页拼「局域网大屏」地址用;访客请求会吃 403,调用方按权限不足处理。
*/
network(): Promise<NetworkInfo>
}
/**
* 赛事项目切换。
*
* 服务端一次只打开一个赛事项目(启动参数 -project → 记忆文件 → default),
* 其余项目在数据目录里只是「躺着的数据」:导入赛事包并不会让它变成当前赛事,必须显式切换。
*/
export interface ProjectsApi {
/** GET /api/projects:列出数据目录下的赛事项目及当前打开的那个。 */
list(): Promise<ProjectInfo[]>
/** POST /api/projects/activate:切换当前赛事;服务端会退出并由 Console 自动拉起。 */
activate(name: string): Promise<ProjectActivateResult>
}
/** GET /api/system/about 的响应(recorder 及以上可读)。注入字段缺失或未注入时为空串。 */
export interface SystemAbout {
internalSignature?: string
buildTime?: string
}
interface Http {
request<T>(path: string, options?: RequestOptions): Promise<T>
requestVoid(path: string, options?: RequestOptions): Promise<void>
requestJson(path: string, options?: RequestOptions): Promise<unknown>
requestText(path: string, options?: RequestOptions): Promise<string>
requestBlob(path: string, options?: RequestOptions): Promise<Blob>
}
function createCrudApi<T, Input>(http: Http, base: string): CrudApi<T, Input> {
return {
list: () => http.request<T[]>(base),
get: (id) => http.request<T>(`${base}/${id}`),
create: (input) => http.request<T>(base, { method: 'POST', body: input }),
update: (id, input) => http.request<T>(`${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<Team, TeamInput>(http, '/teams'),
// 默认列表不带查询串(服务端对非 admin 的 includeDeleted 回 403,不能默认带上)。
list: (query) =>
http.request<Team[]>('/teams', {
query: query?.includeDeleted === true ? { includeDeleted: 1 } : undefined,
}),
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;和运动员不同,这里是空响应。
purge: (id) => http.requestVoid(`/teams/${id}`, { method: 'DELETE', query: { purge: 1 } }),
// 恢复是写操作:服务端按 X-Request-Id 幂等回放,重复点也只会生效一次。
restore: (id) => http.request<TeamRestoreResult>(`/teams/${id}/restore`, { method: 'POST' }),
}
}
function createAthletesApi(http: Http): AthletesApi {
return {
...createCrudApi<Athlete, AthleteInput>(http, '/athletes'),
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;响应体带删除统计。
purge: async (id) =>
readPurgeResult(await http.request<unknown>(`/athletes/${id}`, { method: 'DELETE', query: { purge: 1 } })),
batch: async (action, ids, dryRun = false) =>
// dryRun 缺省不传:老服务端不认这个字段时仍按真删处理,不能被预览悄悄降级。
readBatchResult(
action,
await http.request<unknown>('/athletes/batch', {
method: 'POST',
body: dryRun ? { action, ids, dryRun: true } : { action, ids },
}),
),
}
}
/** 团体手工名次的路径:GET 读、PUT 写同一个地址。 */
function teamRanksPath(eventId: number, categoryId: number): string {
return `/events/${eventId}/categories/${categoryId}/team-ranks`
}
/**
* 成绩录入接口的原始响应:裸数组(旧服务端)或 `{entries, teamRanks}`(设计 17 之后)。
*
* 两种形状都能读;都不像时抛 PARSE_FAILED 而不是给空成绩单——
* 把「解析不出来」显示成「这个组没有报名项」,现场会以为报名数据丢了。
*/
function readEntryBoard(value: unknown): EntryBoard {
if (Array.isArray(value)) {
return { entries: value as EntryWithResult[], teamRanks: [] }
}
if (isRecord(value) && Array.isArray(value.entries)) {
return {
entries: value.entries as EntryWithResult[],
teamRanks: readTeamRanksViews(value.teamRanks),
}
}
throw new ApiError('PARSE_FAILED', '成绩录入接口响应格式不符', 0, value)
}
function createEventsApi(http: Http): EventsApi {
const listBoard = async (eventId: number): Promise<EntryBoard> =>
readEntryBoard(await http.request<unknown>(`/events/${eventId}/entries`))
return {
...createCrudApi<Event, EventInput>(http, '/events'),
entries: async (eventId) => (await listBoard(eventId)).entries,
entryBoard: listBoard,
teamRanks: async (eventId, categoryId) =>
readTeamRanksView(await http.request<unknown>(teamRanksPath(eventId, categoryId))),
saveTeamRanks: async (eventId, categoryId, input) =>
readTeamRanksView(
await http.request<unknown>(teamRanksPath(eventId, categoryId), { method: 'PUT', body: input }),
),
categories: (eventId) => http.request<EventCategory[]>(`/events/${eventId}/categories`),
saveCategories: (eventId, categories) =>
http.request<EventCategory[]>(`/events/${eventId}/categories`, {
method: 'PUT',
body: { categories },
}),
updateCategory: (eventId, categoryId, category) =>
http.request<EventCategory[]>(`/events/${eventId}/categories/${categoryId}`, {
method: 'PUT',
body: category,
}),
channels: (eventId) => http.request<ScoreChannel[]>(`/events/${eventId}/channels`),
saveChannels: (eventId, channels) =>
http.request<ScoreChannel[]>(`/events/${eventId}/channels`, {
method: 'PUT',
body: { channels },
}),
}
}
function createResultsApi(http: Http): ResultsApi {
return {
list: (query) => http.request<Page<Result>>('/results', { query }),
saveDraft: (payload) => http.request<Result[]>('/results/draft', { method: 'PUT', body: payload }),
publish: (payload) => http.request<Result[]>('/results/publish', { method: 'POST', body: payload }),
unpublish: (payload) =>
http.request<Result[]>('/results/unpublish', { method: 'POST', body: payload }),
void: (id, payload) => http.request<Result>(`/results/${id}/void`, { method: 'POST', body: payload }),
reopen: (id, payload) =>
http.request<Result>(`/results/${id}/reopen`, { method: 'POST', body: payload }),
}
}
/** 导出只剩下「把 Blob 存成文件」这一件事:路径与文件名由各页面按服务端 Content-Disposition 拼。 */
function createExportApi(): ExportApi {
return {
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<RuleSwitches>('/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 readNetworkAddress(value: unknown): NetworkAddress | null {
if (!isRecord(value)) return null
const ip = readString(value, 'ip')
// 没有 ip 的行拼不出地址,留着只会在卡片上多出一条空链接。
if (ip === null || ip === '') return null
return {
ip,
interface: readString(value, 'interface') ?? '',
family: readString(value, 'family') ?? 'ipv4',
// 只认服务端契约里的 low / route 两个「需要额外提示」的值:老服务端没有这个字段
// (它的地址本来就走严格轮),值也不认识时按 high 处理——凭一个没见过的值在卡片上
// 多印一句提示,是凭空造出来的噪声。
confidence: value.confidence === 'low' || value.confidence === 'route' ? value.confidence : 'high',
// 类别同样只认契约里的三个值;认不出(老服务端)时留 undefined,卡片就不加类别说明。
// 绝不按 IP 前缀在这里补一个:分类是服务端的知识,前端猜出来的值会和它打架。
scope: readScope(value.scope),
}
}
function readScope(value: unknown): NetworkAddressScope | undefined {
return value === 'private' || value === 'global' || value === 'link-local' ? value : undefined
}
/** 归一化 `skipped[]` 的一条:没有 code 的行解释不了任何事,直接丢掉。 */
function readNetworkSkipped(value: unknown): NetworkSkippedInterface | null {
if (!isRecord(value)) return null
const code = readString(value, 'code')
if (code === null || code === '') return null
const rawAddresses = Array.isArray(value['addresses']) ? value['addresses'] : []
return {
interface: readString(value, 'interface') ?? '',
code,
addresses: rawAddresses.filter((item): item is string => typeof item === 'string'),
}
}
/**
* 归一化 `GET /api/system/network`。
*
* 端口/路径是拼地址的原料,前端不替服务端猜:缺端口就留 0,由页面判断「读不到可用地址」
* 并给说明——编一个默认端口出来会得到一条看着对、实际打不开的链接。
* addresses 不是数组时按「没检测到地址」处理,交给页面显示空态。
* skipped 不是数组时归一化成空数组(等价于「没有可解释的网卡」):老服务端压根不返回这个
* 字段,卡片据此退回原来那句空态文案,行为与升级前一致。
*/
function readNetworkInfo(value: unknown): NetworkInfo {
const port = firstNumberOrNull(isRecord(value) ? value['port'] : null)
const rawAddresses = isRecord(value) && Array.isArray(value['addresses']) ? value['addresses'] : []
const rawSkipped = isRecord(value) && Array.isArray(value['skipped']) ? value['skipped'] : []
return {
port: port !== null && port > 0 ? Math.floor(port) : 0,
displayPath: normalizePath(readString(value, 'displayPath'), '/display'),
adminPath: normalizePath(readString(value, 'adminPath'), '/admin'),
addresses: rawAddresses
.map(readNetworkAddress)
.filter((item): item is NetworkAddress => item !== null),
skipped: rawSkipped
.map(readNetworkSkipped)
.filter((item): item is NetworkSkippedInterface => item !== null),
}
}
/** 服务端没给路径时退回约定值;补前导斜杠,免得拼出 `http://ip:portdisplay`。 */
function normalizePath(value: string | null, fallback: string): string {
const path = value === null || value.trim() === '' ? fallback : value.trim()
return path.startsWith('/') ? path : `/${path}`
}
function createProjectsApi(http: Http): ProjectsApi {
return {
list: async () => {
const body = await http.request<unknown>('/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<unknown>('/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)}`
}
/**
* 恢复整库的超时(毫秒)。
*
* 服务端的恢复是「结束请求才写回执」:拍安全副本、换文件、复核、写审计全部做完才 writeJSON。
* 而备份体积随赛事数据增长(几百 MB 很常见),默认 15s 会把「已经在换库」的请求判成超时;
* 60s 相对默认留了 4 倍余量,又远小于现场操作员的等待耐心。
*/
export const RESTORE_TIMEOUT_MS = 60_000
export class ApiClient {
readonly auth: AuthApi
readonly settings: SettingsApi
readonly teams: TeamsApi
readonly sports: CrudApi<Sport, SportInput>
readonly events: EventsApi
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
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),
requestBlob: (path, requestOptions) => this.requestBlob(path, requestOptions),
}
this.auth = {
login: (username, password) =>
http.request<CurrentUser>('/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<CurrentUser>('/auth/me'),
}
this.settings = {
get: () => http.request<EventSettings>('/settings'),
update: (patch) => http.request<EventSettings>('/settings', { method: 'PUT', body: patch }),
}
this.teams = createTeamsApi(http)
this.sports = createCrudApi<Sport, SportInput>(http, '/sports')
this.events = createEventsApi(http)
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')
this.athletes = createAthletesApi(http)
this.entries = {
importFile: (file) => {
const form = new FormData()
form.append('file', file)
return http.request<EntryImportResult>('/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<EntryImportResult>('/entries/import/resolve', { method: 'POST', body: form })
},
setHeat: (payload) =>
http.request<EntryHeatResult>('/entries/heat', { method: 'PUT', body: payload }),
downloadTemplate: (language) =>
http.requestBlob('/entries/template.xlsx', { query: { lang: language }, timeoutMs: 60_000 }),
}
this.results = createResultsApi(http)
this.standings = {
teams: () => http.request<StandingRow[]>('/standings'),
athletes: () => http.request<AthleteStanding[]>('/standings/athletes'),
breakdown: (teamId) => http.request<TeamBreakdown>(`/standings/teams/${teamId}/breakdown`),
}
this.adjustments = {
list: (query) => http.request<Page<PointAdjustment>>('/adjustments', { query }),
create: (input) => http.request<PointAdjustment>('/adjustments', { method: 'POST', body: input }),
void: (id, reason) =>
http.request<PointAdjustment>(`/adjustments/${id}/void`, {
method: 'POST',
body: { reason },
}),
}
this.records = {
list: (query) => http.request<RecordEntry[]>('/records', { query }),
createBreak: (input) => http.request<RecordBreak>('/record-breaks', { method: 'POST', body: input }),
approveBreak: (id) =>
http.request<RecordBreak>(`/record-breaks/${id}/approve`, { method: 'POST' }),
// state 缺省时不带查询串:让服务端用自己的默认口径,前端不替它决定「全部」怎么写。
listBreaks: (state) =>
http.request<RecordBreak[]>('/record-breaks', {
query: state === undefined ? undefined : { state },
}),
rejectBreak: (id, reason) =>
http.request<RecordBreak>(`/record-breaks/${id}/reject`, { method: 'POST', body: { reason } }),
createRecord: (input) => http.request<RecordEntry>('/records', { method: 'POST', body: input }),
removeRecord: (id) => http.requestVoid(`/records/${id}`, { method: 'DELETE' }),
}
this.display = {
command: (command) => http.requestVoid('/display/command', { method: 'POST', body: command }),
instances: async () => {
const body = await http.request<unknown>('/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<LiveEvents>('/display/live'),
}
this.exports = createExportApi()
this.auditLogs = {
list: (query) => http.request<Page<AuditLog>>('/audit-logs', { query }),
}
this.rules = createRulesApi(http)
this.system = {
// 恢复是停机动作:服务端先拍安全副本、换库文件、复核新库、写审计,**全部做完才发回执**,
// 库越大这一串越慢。用默认 15s 会在库已经换掉之后把请求判成超时,
// 操作员看到「未执行」而重试,等于把刚恢复好的库再覆盖一遍,所以单独放大超时。
// 幂等(X-Request-Id)照旧:现场断网重试时,重复请求不能把库换两遍。
restore: (input) =>
http.request<BackupRestoreResult>('/backup/restore', {
method: 'POST',
body: input,
timeoutMs: RESTORE_TIMEOUT_MS,
}),
// request() 会把 {ok,data} 拆开,这里拿到的是 data 本体,再按字段归一化。
network: async () => readNetworkInfo(await http.request<unknown>('/system/network')),
}
this.projects = createProjectsApi(http)
this.uploads = {
upload: (file) => {
const form = new FormData()
form.append('file', file)
return http.request<UploadResult>('/uploads', { method: 'POST', body: form })
},
}
}
/** 健康检查不走 {ok,data} 包装,直接返回 `{ok,version}`(05 第 7 节)。 */
async health(): Promise<HealthInfo> {
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<T>(path: string, options: RequestOptions = {}): Promise<T> {
const body = await this.requestJson(path, options)
return this.unwrap<T>(body)
}
private async requestVoid(path: string, options: RequestOptions = {}): Promise<void> {
const body = await this.requestJson(path, options)
// 204 或空响应视为成功;有响应体时仍校验 {ok:true}。
if (body === null || body === undefined) return
this.unwrap<unknown>(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<unknown> {
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<string> {
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()
}
/**
* 二进制下载(xlsx / zip):成功回 Blob,失败仍按 {ok,error} 解析后抛 ApiError。
*
* 不能直接把下载地址塞进 <a href>:服务端拒绝下载时回的是一份 JSON 错误体,
* 浏览器会把它当文件存下来,现场看到的是一个「下载成功了的报错文件」。
*/
private async requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
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<Response> {
const method: HttpMethod = options.method ?? 'GET'
// 一次调用只用**一个**幂等键,header 与 body 共用它。
// 服务端是两层缓存:外层 cached 认 X-Request-Id,内层 idempotent 认 body.requestId,
// 两边各生成一个就永远拼不出同一个 key,幂等缓存从未命中——现场超时后重试
// 「发布 / 撤销发布 / 存草稿 / 移出报名」会真的重复执行一遍。
// 调用方已经在 body 里给了 key(例如重发同一次写操作)就沿用它,否则这里新建一个。
const given = isRecord(options.body) ? options.body['requestId'] : undefined
const requestId = method === 'GET' ? null : (typeof given === 'string' && given !== '' ? given : this.newRequestId())
const payload = this.prepareBody(method, options.body, requestId)
// 断言:二进制 / 表单载荷只能原样透传,一旦被归一化展开就会静默丢内容(报名表导入踩过)。
if (isRawBody(options.body) && payload !== options.body) {
throw new ApiError('INTERNAL', '请求体归一化破坏了 FormData/Blob 载荷', 0, options.body)
}
const headers: Record<string, string> = { 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
// 与 body.requestId 是同一个值:服务端两层幂等缓存(cached / idempotent)才能命中。
if (requestId !== null) headers['X-Request-Id'] = requestId
}
const timeoutMs = options.timeoutMs ?? this.timeoutMs
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 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', `请求超时(${timeoutMs}ms)`, 0, error)
}
throw new ApiError('NETWORK', '无法连接服务端', 0, error)
} finally {
clearTimeout(timer)
}
}
/**
* 写操作注入 requestId:网络抖动重发时服务端能返回首次结果而不是重复执行。
* `requestId` 由 `send()` 生成一次并同时用于 header,两者必须是同一个值(见 send 的注释)。
*/
private prepareBody(method: HttpMethod, body: unknown, requestId: string | null): unknown {
if (method === 'GET' || body === undefined) return undefined
// FormData/Blob/URLSearchParams 不能展开,否则文件/二进制内容会被丢掉。
if (isRawBody(body)) return body
if (requestId !== null && isRecord(body) && !('requestId' in body)) {
return { ...body, requestId }
}
return body
}
private unwrap<T>(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<unknown> {
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)
}