feat: M0-M2 骨架落地(服务端/前端/宿主/数据库/积分引擎)
- server: Go 单二进制骨架,embed 内嵌前端,配置/迁移/HTTP 路由 - store: system.db + event.db 两套迁移,业务表 + 种子数据 - domain: 积分引擎(并列三口径/计分通道/流水/排名/破纪录)+ 68 个测试 - web: Vite + Vue 三入口骨架 + shared 层(类型/API/WebSocket 客户端) - console: Electron 宿主骨架(选屏/防熄屏/进程守护/托盘)
This commit is contained in:
1 parent
a61d793d79
commit
7d5d94f815
76 files changed
+10131
No files matched your search
@@ -0,0 +1,579 @@
|
||||
import { isRecord, readString } from './internal'
|
||||
import type {
|
||||
AdjustmentInput,
|
||||
ApiErrorCode,
|
||||
Athlete,
|
||||
AthleteInput,
|
||||
AthleteStanding,
|
||||
AuditLog,
|
||||
BackupResult,
|
||||
CurrentUser,
|
||||
DisplayCommand,
|
||||
EntryImportResult,
|
||||
EntryWithResult,
|
||||
Event,
|
||||
EventCategory,
|
||||
EventCategoryInput,
|
||||
EventInput,
|
||||
EventSettings,
|
||||
HealthInfo,
|
||||
Page,
|
||||
PageQuery,
|
||||
PointAdjustment,
|
||||
PublishPayload,
|
||||
RecordBreak,
|
||||
RecordBreakInput,
|
||||
RecordEntry,
|
||||
Result,
|
||||
ResultQuery,
|
||||
RulePack,
|
||||
RulePackImportPreview,
|
||||
RulePackImportResult,
|
||||
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<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
|
||||
}
|
||||
|
||||
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<T, Input> {
|
||||
list(): Promise<T[]>
|
||||
get(id: number): Promise<T>
|
||||
create(input: Input): Promise<T>
|
||||
update(id: number, input: Partial<Input>): Promise<T>
|
||||
remove(id: number): Promise<void>
|
||||
}
|
||||
|
||||
export interface AuthApi {
|
||||
login(username: string, password: string): Promise<CurrentUser>
|
||||
logout(): Promise<void>
|
||||
me(): Promise<CurrentUser>
|
||||
}
|
||||
|
||||
export interface SettingsApi {
|
||||
get(): Promise<EventSettings>
|
||||
update(patch: Partial<EventSettings>): Promise<EventSettings>
|
||||
}
|
||||
|
||||
export interface EventsApi extends CrudApi<Event, EventInput> {
|
||||
entries(eventId: number): Promise<EntryWithResult[]>
|
||||
categories(eventId: number): Promise<EventCategory[]>
|
||||
saveCategories(eventId: number, categories: EventCategoryInput[]): Promise<EventCategory[]>
|
||||
channels(eventId: number): Promise<ScoreChannel[]>
|
||||
saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise<ScoreChannel[]>
|
||||
}
|
||||
|
||||
export interface EntriesApi {
|
||||
/** 报名表按文件导入,后端解析 xlsx。 */
|
||||
importFile(file: File): Promise<EntryImportResult>
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
export interface DisplayApi {
|
||||
command(command: DisplayCommand): Promise<void>
|
||||
}
|
||||
|
||||
export interface ExportQuery {
|
||||
eventId?: number
|
||||
teamId?: number
|
||||
}
|
||||
|
||||
export interface ExportApi {
|
||||
/** 返回可直接放进 <a download> 的地址,省掉一次内存中的 Blob。 */
|
||||
standingsUrl(query?: ExportQuery): string
|
||||
breakdownUrl(query: ExportQuery): string
|
||||
resultsUrl(query?: ExportQuery): string
|
||||
standings(query?: ExportQuery): Promise<Blob>
|
||||
breakdown(query: ExportQuery): Promise<Blob>
|
||||
results(query?: ExportQuery): Promise<Blob>
|
||||
save(blob: Blob, filename: string): void
|
||||
}
|
||||
|
||||
export interface AuditApi {
|
||||
list(query?: PageQuery & { entityType?: string; userId?: number }): Promise<Page<AuditLog>>
|
||||
}
|
||||
|
||||
export interface RulesApi {
|
||||
/** GET /api/rules/export,返回当前规则包。 */
|
||||
get(): Promise<RulePack>
|
||||
/** 先预览差异,再决定是否落地(02 第 7.3 节硬性要求)。 */
|
||||
preview(pack: RulePack): Promise<RulePackImportPreview>
|
||||
apply(pack: RulePack): Promise<RulePackImportResult>
|
||||
}
|
||||
|
||||
export interface SystemApi {
|
||||
backup(): Promise<BackupResult>
|
||||
}
|
||||
|
||||
interface Http {
|
||||
request<T>(path: string, options?: RequestOptions): Promise<T>
|
||||
requestVoid(path: string, options?: RequestOptions): Promise<void>
|
||||
requestJson(path: string, options?: RequestOptions): Promise<unknown>
|
||||
url(path: string, query?: object): string
|
||||
blob(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' }),
|
||||
}
|
||||
}
|
||||
|
||||
function createEventsApi(http: Http): EventsApi {
|
||||
return {
|
||||
...createCrudApi<Event, EventInput>(http, '/events'),
|
||||
entries: (eventId) => http.request<EntryWithResult[]>(`/events/${eventId}/entries`),
|
||||
categories: (eventId) => http.request<EventCategory[]>(`/events/${eventId}/categories`),
|
||||
saveCategories: (eventId, categories) =>
|
||||
http.request<EventCategory[]>(`/events/${eventId}/categories`, {
|
||||
method: 'PUT',
|
||||
body: { categories },
|
||||
}),
|
||||
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 }),
|
||||
}
|
||||
}
|
||||
|
||||
function createExportApi(http: Http): ExportApi {
|
||||
const path = {
|
||||
standings: '/export/standings.xlsx',
|
||||
breakdown: '/export/breakdown.xlsx',
|
||||
results: '/export/results.xlsx',
|
||||
}
|
||||
return {
|
||||
standingsUrl: (query) => http.url(path.standings, query),
|
||||
breakdownUrl: (query) => http.url(path.breakdown, query),
|
||||
resultsUrl: (query) => http.url(path.results, query),
|
||||
standings: (query) => http.blob(path.standings, { query }),
|
||||
breakdown: (query) => http.blob(path.breakdown, { query }),
|
||||
results: (query) => http.blob(path.results, { 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 {
|
||||
get: () => http.request<RulePack>('/rules/export'),
|
||||
preview: (pack) =>
|
||||
http.request<RulePackImportPreview>('/rules/import', {
|
||||
method: 'POST',
|
||||
query: { preview: 1 },
|
||||
body: pack,
|
||||
}),
|
||||
apply: (pack) =>
|
||||
http.request<RulePackImportResult>('/rules/import', { method: 'POST', body: pack }),
|
||||
}
|
||||
}
|
||||
|
||||
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: CrudApi<Team, TeamInput>
|
||||
readonly sports: CrudApi<Sport, SportInput>
|
||||
readonly events: EventsApi
|
||||
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
|
||||
readonly athletes: CrudApi<Athlete, AthleteInput>
|
||||
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
|
||||
|
||||
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),
|
||||
url: (path, query) => this.buildUrl(path, query),
|
||||
blob: (path, requestOptions) => this.fetchBlob(path, requestOptions),
|
||||
}
|
||||
|
||||
this.auth = {
|
||||
login: (username, password) =>
|
||||
http.request<CurrentUser>('/auth/login', { method: 'POST', body: { username, password } }),
|
||||
logout: () => http.requestVoid('/auth/logout', { method: 'POST' }),
|
||||
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 = createCrudApi<Team, TeamInput>(http, '/teams')
|
||||
this.sports = createCrudApi<Sport, SportInput>(http, '/sports')
|
||||
this.events = createEventsApi(http)
|
||||
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')
|
||||
this.athletes = createCrudApi<Athlete, AthleteInput>(http, '/athletes')
|
||||
|
||||
this.entries = {
|
||||
importFile: (file) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return http.request<EntryImportResult>('/entries/import', { method: 'POST', body: form })
|
||||
},
|
||||
}
|
||||
|
||||
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' }),
|
||||
}
|
||||
|
||||
this.display = {
|
||||
command: (command) => http.requestVoid('/display/command', { method: 'POST', body: command }),
|
||||
}
|
||||
|
||||
this.exports = createExportApi(http)
|
||||
this.auditLogs = {
|
||||
list: (query) => http.request<Page<AuditLog>>('/audit-logs', { query }),
|
||||
}
|
||||
this.rules = createRulesApi(http)
|
||||
this.system = {
|
||||
backup: () => http.request<BackupResult>('/backup', { method: 'POST' }),
|
||||
}
|
||||
}
|
||||
|
||||
/** 健康检查不走 {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)
|
||||
}
|
||||
|
||||
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.onUnauthorized?.(error)
|
||||
throw error
|
||||
}
|
||||
if (!response.ok) throw this.toApiError(body, response.status)
|
||||
return body
|
||||
}
|
||||
|
||||
private async fetchBlob(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.onUnauthorized?.(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'
|
||||
const payload = this.prepareBody(method, options.body)
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
|
||||
if (payload instanceof FormData) {
|
||||
// 交给浏览器带 multipart boundary,手动设置 Content-Type 会丢 boundary。
|
||||
} 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
|
||||
if (external !== undefined) {
|
||||
if (external.aborted) controller.abort()
|
||||
else external.addEventListener('abort', () => controller.abort(), { once: true })
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.fetchImpl(this.buildUrl(path, options.query), {
|
||||
method,
|
||||
headers,
|
||||
credentials: this.credentials,
|
||||
body:
|
||||
payload === undefined
|
||||
? undefined
|
||||
: payload instanceof FormData
|
||||
? payload
|
||||
: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (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
|
||||
if (isRecord(body) && !('requestId' in body)) {
|
||||
return { ...body, requestId: this.newRequestId() }
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user