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:
AskaEth committed 2026-09-09 18:36:40 +08:00
1 parent a61d793d79
commit 7d5d94f815
76 files changed
+10131

No files matched your search

+579
View File
@@ -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)
}