三件事合成一次提交(其中两件都落在 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/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
1234 lines
50 KiB
TypeScript
1234 lines
50 KiB
TypeScript
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)
|
||
}
|