fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)

P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
This commit is contained in:
AskaEth committed 2026-09-09 22:16:58 +08:00
1 parent 96cf181ae3
commit 13f1f03cd0
124 files changed
+5658 -1820

No files matched your search

+35 -52
View File
@@ -1,6 +1,5 @@
import { isRawBody, isRecord, readString } from './internal'
import { normalizeRulePack } from './rules'
import { isGuestRole } from './session'
import { parseYaml } from './yaml'
import type {
AdjustmentInput,
@@ -30,8 +29,6 @@ import type {
Result,
ResultQuery,
RulePack,
RulePackImportPreview,
RulePackImportResult,
RuleSwitches,
SaveDraftPayload,
ScoreChannel,
@@ -113,7 +110,9 @@ 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>
// 服务端 PUT 是整行覆盖(repo.go 的 UPDATE 写全部列),局部更新会把未传字段清空,
// 所以这里要求完整 input,不能声明成 Partial。
update(id: number, input: Input): Promise<T>
remove(id: number): Promise<void>
}
@@ -180,11 +179,7 @@ export interface ExportQuery {
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
}
@@ -197,9 +192,6 @@ export interface RulesApi {
get(): Promise<RulePack>
/** GET /api/rules/switches,recorder 可读的布尔开关(不含规则内容)。 */
switches(): Promise<RuleSwitches>
/** 先预览差异,再决定是否落地(02 第 7.3 节硬性要求)。 */
preview(pack: RulePack): Promise<RulePackImportPreview>
apply(pack: RulePack): Promise<RulePackImportResult>
}
export interface SystemApi {
@@ -256,18 +248,12 @@ function createResultsApi(http: Http): ResultsApi {
}
function createExportApi(http: Http): ExportApi {
const path = {
standings: '/export/standings.xlsx',
breakdown: '/export/breakdown.xlsx',
results: '/export/results.xlsx',
}
// 服务端只注册了 /export/standings.xlsx 与 /export/all.xlsx 等;
// 单位明细 / 分项成绩导出分别是 /export/teams/{id}、/export/events/{id},由各自页面拼地址。
const standingsPath = '/export/standings.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 }),
standingsUrl: (query) => http.url(standingsPath, query),
standings: (query) => http.blob(standingsPath, { query }),
save: (blob, filename) => {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
@@ -299,14 +285,6 @@ function createRulesApi(http: Http): RulesApi {
return pack
},
switches: () => http.request<RuleSwitches>('/rules/switches'),
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 }),
}
}
@@ -371,21 +349,19 @@ export class ApiClient {
}
this.auth = {
login: async (username, password) =>
markGuest(
await http.request<CurrentUser>('/auth/login', {
method: 'POST',
body: { username, password },
}),
),
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,这里补出 guest 标记供 isGuestUser 判定。
me: async () => markGuest(await http.request<CurrentUser>('/auth/me')),
// 未登录时服务端回 200 + guest DTO,调用方用 isGuestUser(user) 判定,不能只看 null。
me: () => http.request<CurrentUser>('/auth/me'),
}
this.settings = {
@@ -469,12 +445,20 @@ export class ApiClient {
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.onUnauthorized?.(error)
this.notifyUnauthorized(error)
throw error
}
if (!response.ok) throw this.toApiError(body, response.status)
@@ -488,7 +472,7 @@ export class ApiClient {
const body = await readBody(response)
if (response.status === 401) {
const error = this.toApiError(body, response.status)
this.onUnauthorized?.(error)
this.notifyUnauthorized(error)
throw error
}
throw this.toApiError(body, response.status)
@@ -502,7 +486,7 @@ export class ApiClient {
const body = await readBody(response)
if (response.status === 401) {
const error = this.toApiError(body, response.status)
this.onUnauthorized?.(error)
this.notifyUnauthorized(error)
throw error
}
throw this.toApiError(body, response.status)
@@ -536,9 +520,14 @@ export class ApiClient {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
const external = options.signal
let abortedByCaller = false
if (external !== undefined) {
if (external.aborted) controller.abort()
else external.addEventListener('abort', () => controller.abort(), { once: true })
const abortFromCaller = (): void => {
abortedByCaller = true
controller.abort()
}
if (external.aborted) abortFromCaller()
else external.addEventListener('abort', abortFromCaller, { once: true })
}
try {
@@ -555,6 +544,8 @@ export class ApiClient {
signal: controller.signal,
})
} catch (error) {
// 外部取消与服务端超时共用同一个 signal,必须分开归类,调用方才能决定要不要提示。
if (abortedByCaller) throw new ApiError('ABORTED', '请求已取消', 0, error)
if (controller.signal.aborted) {
throw new ApiError('TIMEOUT', `请求超时(${this.timeoutMs}ms)`, 0, error)
}
@@ -620,14 +611,6 @@ function isScalar(value: unknown): value is string | number | boolean {
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
}
/**
* 服务端未登录时 /auth/me 也回 200 + guest DTO,
* 这里把 role 结论固化成 guest 字段,调用方用 isGuestUser(user) 判定即可。
*/
function markGuest(user: CurrentUser): CurrentUser {
return { ...user, guest: isGuestRole(user.role) }
}
async function readBody(response: Response): Promise<unknown> {
if (response.status === 204) return null
const text = await response.text()
+11 -2
View File
@@ -24,7 +24,9 @@ export function formatSeconds(seconds: number, fractionDigits = 2): string {
rest = (0).toFixed(fractionDigits)
}
const body = minutes > 0 ? `${minutes}:${rest.padStart(3 + fractionDigits, '0')}` : rest
// 秒数要补足两位;fractionDigits=0 时没有小数点,补位宽度是 2 而不是 3。
const secondsWidth = fractionDigits > 0 ? 3 + fractionDigits : 2
const body = minutes > 0 ? `${minutes}:${rest.padStart(secondsWidth, '0')}` : rest
return `${sign}${body}`
}
@@ -104,8 +106,15 @@ export function formatClock(iso: string | null): string {
return `${DATE_PAD(date.getHours())}:${DATE_PAD(date.getMinutes())}`
}
/** 带时区后缀(Z 或 ±HH:MM)的时间串按原样解析。 */
const HAS_TIMEZONE = /(Z|[+-]\d{2}:?\d{2})$/i
function toDate(iso: string | null): Date | null {
if (iso === null || iso === '') return null
const date = new Date(iso)
// SQLite 的 datetime('now') 是 UTC 但不带时区,new Date() 会按本地时区解析,
// 东八区会整体早 8 小时;先把空格分隔符换成 T,无时区的串再补 Z 按 UTC 解析。
const normalized = iso.replace(' ', 'T')
const text = HAS_TIMEZONE.test(normalized) ? normalized : `${normalized}Z`
const date = new Date(text)
return Number.isNaN(date.getTime()) ? null : date
}
-1
View File
@@ -8,7 +8,6 @@
export * from './types'
export { ApiClient, ApiError, createApiClient } from './api'
export type { ApiClientOptions, QueryParams, QueryValue } from './api'
export { normalizeSettings, normalizeStandingRows } from './normalize'
export { GUEST_ROLE, isGuestRole, isGuestUser } from './session'
export { RealtimeClient } from './realtime'
export type {
-135
View File
@@ -1,135 +0,0 @@
/**
* 服务端两类载荷的归一化。
*
* REST 与 WebSocket 快照对同一份数据用了两套字段名(快照直接序列化 domain 类型),
* 归一化放在 shared 层,display 与 admin 各自只认归一后的契约类型。
*/
import {
asRecord,
emptyToNull,
firstBool,
firstNumber,
firstNumberOrNull,
firstString,
isRecord,
} from './internal'
import type { EventSettings, StandingRow } from './types'
/**
* 赛事配置归一化。
*
* `GET /api/settings` 是嵌套对象,而 WebSocket 快照的 `settings` 是
* settings 表的点号扁平 map(`display.rotate_seconds`、`standings.mode`…)。
* 两种形状都接受,缺省值对齐服务端 `getSettings`,避免大屏首包读不到字段。
*/
export function normalizeSettings(raw: unknown): EventSettings {
const source = asRecord(raw)
const nestedEvent = isRecord(source.event) ? source.event : null
const nestedDisplay = isRecord(source.display) ? source.display : null
const nestedStandings = isRecord(source.standings) ? source.standings : null
const isFlat = nestedEvent === null && nestedDisplay === null && nestedStandings === null
const name =
emptyToNull(firstString(isFlat ? source['event.name'] : undefined, nestedEvent?.name)) ??
'未命名赛事'
const subtitle = emptyToNull(
firstString(isFlat ? source['event.subtitle'] : undefined, nestedEvent?.subtitle),
)
const host = emptyToNull(firstString(isFlat ? source['event.organizer'] : undefined, nestedEvent?.host))
const logoPath = emptyToNull(
firstString(isFlat ? source['event.logo_path'] : undefined, nestedEvent?.logoPath),
)
const primaryColor = emptyToNull(
firstString(isFlat ? source['display.theme_color'] : undefined, nestedEvent?.primaryColor),
)
const displayLogo = emptyToNull(
firstString(isFlat ? source['display.logo'] : undefined, nestedDisplay?.logo),
)
return {
event: { name, subtitle, host, logoPath, primaryColor },
display: {
background: emptyToNull(
firstString(isFlat ? source['display.background'] : undefined, nestedDisplay?.background),
),
logo: displayLogo ?? logoPath,
title:
emptyToNull(firstString(isFlat ? source['display.title'] : undefined, nestedDisplay?.title)) ??
name,
subtitle:
emptyToNull(
firstString(isFlat ? source['display.subtitle'] : undefined, nestedDisplay?.subtitle),
) ?? subtitle,
primaryColor,
rotateSeconds: firstNumber(
15,
isFlat ? source['display.rotate_seconds'] : undefined,
nestedDisplay?.rotateSeconds,
),
},
standings: {
individualToTeamWeight: firstNumber(
1,
isFlat ? source['standings.individual_to_team_weight'] : undefined,
nestedStandings?.individualToTeamWeight,
),
rankingMode:
emptyToNull(
firstString(isFlat ? source['standings.mode'] : undefined, nestedStandings?.rankingMode),
) ?? 'points',
showIndividualBoard: firstBool(
true,
isFlat ? source['standings.show_individual_board'] : undefined,
isFlat ? source['standings.show_individual_medals'] : undefined,
nestedStandings?.showIndividualBoard,
),
showIndividualMedals: firstBool(
true,
isFlat ? source['standings.show_individual_medals'] : undefined,
nestedStandings?.showIndividualMedals,
),
},
// 扁平 map 里只有模板编码(scoring.default_profile),解析不出数据库 id,只能置空。
defaultProfileId: isFlat ? null : firstNumberOrNull(source.defaultProfileId),
requireApproval: firstBool(
false,
isFlat ? source['results.review_required'] : undefined,
source.requireApproval,
),
}
}
/**
* 总积分榜归一化。
*
* 快照用 domain.StandingsRow(`code/name/points/medals`),
* REST 用 standingRowDTO(`teamCode/teamName/totalPoints/medalTotal`),两种都接受。
*/
export function normalizeStandingRows(raw: unknown): StandingRow[] {
if (!Array.isArray(raw)) return []
return raw.map(normalizeStandingRow)
}
function normalizeStandingRow(raw: unknown): StandingRow {
const rec = asRecord(raw)
const gold = firstNumber(0, rec.gold)
const silver = firstNumber(0, rec.silver)
const bronze = firstNumber(0, rec.bronze)
const rankChange = rec.rankChange
return {
rank: firstNumber(0, rec.rank),
teamId: firstNumber(0, rec.teamId),
teamCode: firstString(rec.teamCode, rec.code) ?? '',
teamName: firstString(rec.teamName, rec.name) ?? '',
shortName: emptyToNull(firstString(rec.shortName)),
color: emptyToNull(firstString(rec.color)),
logoPath: emptyToNull(firstString(rec.logoPath)),
gold,
silver,
bronze,
medalTotal: firstNumber(gold + silver + bronze, rec.medalTotal, rec.medals),
totalPoints: firstNumber(0, rec.totalPoints, rec.points),
rankChange: typeof rankChange === 'number' && Number.isFinite(rankChange) ? rankChange : null,
}
}
+68 -4
View File
@@ -35,11 +35,17 @@ export interface RealtimeOptions {
url?: string
role: ClientRole
displayKey?: string
/** 大屏实例 id,hello 时上报,供管理端「已连接的大屏实例」定位。 */
instanceId?: string
/** 当前页名,hello 时上报;翻页后调用 setPage 再补一次。 */
page?: string
/** 大屏从 localStorage 恢复快照后传入,可让服务端只补增量。 */
initialVersion?: number | null
reconnect?: ReconnectOptions
/** 超过该时间没有任何服务端消息即判定半开连接,主动断开重连。 */
watchdogMs?: number
/** 握手超时:半开握手不触发 onopen/onclose,必须自己判死后重连。 */
handshakeMs?: number
/** resync 最小间隔,避免版本持续落后时反复打断服务端。 */
resyncCooldownMs?: number
onSnapshot?: (snapshot: SnapshotMessage) => void
@@ -48,6 +54,8 @@ export interface RealtimeOptions {
onCommand?: (command: DisplayCommand) => void
onStatusChange?: (status: RealtimeStatus, info: RealtimeInfo) => void
onVersionChange?: (version: number) => void
/** 策略性断开(如未登录申请管理端房间):重连也会被同样拒绝,调用方应引导登录。 */
onAuthRequired?: () => void
onError?: (error: Error) => void
}
@@ -81,8 +89,10 @@ export class RealtimeClient {
private readonly url: string
private readonly role: ClientRole
private readonly displayKey: string | undefined
private readonly instanceId: string | undefined
private readonly reconnect: Required<ReconnectOptions>
private readonly watchdogMs: number
private readonly handshakeMs: number
private readonly resyncCooldownMs: number
private readonly handlers: Pick<
RealtimeOptions,
@@ -92,25 +102,31 @@ export class RealtimeClient {
| 'onCommand'
| 'onStatusChange'
| 'onVersionChange'
| 'onAuthRequired'
| 'onError'
>
private socket: WebSocket | null = null
private currentStatus: RealtimeStatus = 'idle'
private currentVersion: number | null
private currentPage: string | undefined
private attempt = 0
private serverTime: string | null = null
private closedByUser = false
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
private watchdogTimer: ReturnType<typeof setTimeout> | null = null
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
private lastResyncAt = 0
constructor(options: RealtimeOptions) {
this.url = options.url ?? defaultUrl()
this.role = options.role
this.displayKey = options.displayKey
this.instanceId = options.instanceId
this.currentPage = options.page
this.currentVersion = options.initialVersion ?? null
this.watchdogMs = options.watchdogMs ?? 25_000
this.handshakeMs = options.handshakeMs ?? 10_000
this.resyncCooldownMs = options.resyncCooldownMs ?? 3_000
this.reconnect = {
baseDelayMs: options.reconnect?.baseDelayMs ?? 1_000,
@@ -125,6 +141,7 @@ export class RealtimeClient {
onCommand: options.onCommand,
onStatusChange: options.onStatusChange,
onVersionChange: options.onVersionChange,
onAuthRequired: options.onAuthRequired,
onError: options.onError,
}
}
@@ -163,7 +180,11 @@ export class RealtimeClient {
}
this.socket = socket
// 握手超时从「发起连接」就开始算:半开握手不会触发 onopen 也不会触发 onclose,
// 只在 onopen 武装 watchdog 会让状态永远停在 connecting。
this.armHandshakeTimeout(socket)
socket.onopen = () => {
this.clearHandshakeTimer()
this.attempt = 0
this.setStatus('online')
this.sendHello()
@@ -176,17 +197,34 @@ export class RealtimeClient {
// 错误之后必然跟一次 close,重连交给 onclose 统一处理。
this.handlers.onError?.(new Error('WebSocket 连接出错'))
}
socket.onclose = () => {
socket.onclose = (event: CloseEvent) => {
this.socket = null
this.clearWatchdog()
if (!this.closedByUser) this.scheduleReconnect()
this.clearHandshakeTimer()
if (this.closedByUser) return
// 1008 = 服务端策略性拒绝(如未登录却申请管理端房间)。重连也会被同样拒绝,
// 停止重试并交给调用方引导登录,避免无限重连把服务端刷满。
if (event.code === 1008) {
this.setStatus('offline')
this.handlers.onAuthRequired?.()
return
}
this.scheduleReconnect()
}
}
/** 翻页后补一条 hello,让服务端注册表里的「当前页」跟上(协议只定义了 hello)。 */
setPage(page: string): void {
if (this.currentPage === page) return
this.currentPage = page
if (this.currentStatus === 'online') this.sendHello()
}
close(): void {
this.closedByUser = true
this.clearReconnectTimer()
this.clearWatchdog()
this.clearHandshakeTimer()
const socket = this.socket
this.socket = null
@@ -251,14 +289,21 @@ export class RealtimeClient {
}
private applySnapshot(data: SnapshotMessage): void {
if (this.currentVersion !== null && data.version < this.currentVersion) return
// 快照是权威状态,必须无条件接受:服务端在「客户端版本超前」时也会下发全量快照
// (换赛事库 / 还原备份后 change_log 归零)。若按版本号丢弃,大屏会永久冻结在旧库数据上。
// 版本回退即视为服务端已重置,直接接受并把本地版本对齐到快照。
this.setVersion(data.version)
this.handlers.onSnapshot?.(data)
this.sendAck()
}
private applyCatchup(data: CatchupMessage): void {
if (this.currentVersion !== null && data.version <= this.currentVersion) return
// 没有快照基准时不能直接合并增量,与 applyChange 保持一致,先要全量。
if (this.currentVersion === null) {
this.requestResync()
return
}
if (data.version <= this.currentVersion) return
this.setVersion(data.version)
this.handlers.onCatchup?.(data)
this.sendAck()
@@ -295,6 +340,8 @@ export class RealtimeClient {
const hello: HelloMessage = { role: this.role }
if (this.currentVersion !== null) hello.lastVersion = this.currentVersion
if (this.displayKey !== undefined) hello.displayKey = this.displayKey
if (this.instanceId !== undefined) hello.instanceId = this.instanceId
if (this.currentPage !== undefined) hello.page = this.currentPage
this.send('hello', hello)
}
@@ -325,6 +372,23 @@ export class RealtimeClient {
}
}
private armHandshakeTimeout(socket: WebSocket): void {
this.clearHandshakeTimer()
this.handshakeTimer = setTimeout(() => {
this.handshakeTimer = null
if (this.socket !== socket) return
this.handlers.onError?.(new Error('WebSocket 握手超时'))
socket.close()
}, this.handshakeMs)
}
private clearHandshakeTimer(): void {
if (this.handshakeTimer !== null) {
clearTimeout(this.handshakeTimer)
this.handshakeTimer = null
}
}
private scheduleReconnect(): void {
if (this.closedByUser || this.reconnectTimer !== null) return
this.setStatus('offline')
+2 -5
View File
@@ -215,14 +215,11 @@ function normalizeChannels(raw: unknown): RuleChannel[] {
const rec = asRecord(item)
const profile = firstString(rec.profile)
if (profile === null || profile === '') continue
const channel: RuleChannel = {
out.push({
scope: oneOf(rec.scope, SCOPES, 'athlete'),
basis: oneOf(rec.basis, BASES, 'result'),
profile,
}
const aggregate = firstString(rec.aggregate)
if (aggregate !== null && aggregate !== '') channel.aggregate = aggregate
out.push(channel)
})
}
return out
}
+41
View File
@@ -0,0 +1,41 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ApiClient, ApiError } from '../api'
function errorResponse(status: number, code: string): Response {
return new Response(JSON.stringify({ ok: false, error: { code, message: 'boom' } }), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
// P1-32:401 也用于「原密码不正确」(AUTH_FAILED),不能借它清空登录态。
test('401 AUTH_FAILED 不触发 onUnauthorized', async () => {
let called = 0
const client = new ApiClient({
fetchImpl: async () => errorResponse(401, 'AUTH_FAILED'),
onUnauthorized: () => {
called += 1
},
})
await assert.rejects(client.auth.changePassword('old', 'new'), (error: unknown) => {
return error instanceof ApiError && error.code === 'AUTH_FAILED'
})
assert.equal(called, 0)
})
test('401 AUTH_REQUIRED 触发 onUnauthorized', async () => {
let called = 0
const client = new ApiClient({
fetchImpl: async () => errorResponse(401, 'AUTH_REQUIRED'),
onUnauthorized: () => {
called += 1
},
})
await assert.rejects(client.auth.me(), (error: unknown) => {
return error instanceof ApiError && error.code === 'AUTH_REQUIRED'
})
assert.equal(called, 1)
})
+28
View File
@@ -0,0 +1,28 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { formatClock, formatDateTime, formatSeconds } from '../format'
// 服务端 datetime('now') 存的是 UTC 且不带时区,必须按 UTC 解析,否则东八区整体早 8 小时。
test('formatDateTime 把无时区时间串按 UTC 解析', () => {
assert.equal(formatDateTime('2026-09-09 12:34:56'), formatDateTime('2026-09-09T12:34:56Z'))
assert.equal(formatClock('2026-09-09 12:34:56'), formatClock('2026-09-09T12:34:56Z'))
})
test('formatDateTime 保留带时区时间串的原始时刻', () => {
const withOffset = new Date('2026-09-09T12:34:56+08:00')
assert.equal(formatDateTime('2026-09-09T12:34:56+08:00'), formatDateTime(withOffset.toISOString()))
assert.equal(formatClock('2026-09-09T12:34:56+08:00'), formatClock(withOffset.toISOString()))
})
test('formatSeconds 在 fractionDigits=0 时秒数仍补足两位', () => {
assert.equal(formatSeconds(66, 0), '1:06')
assert.equal(formatSeconds(66.33, 1), '1:06.3')
assert.equal(formatSeconds(66), '1:06.00')
assert.equal(formatSeconds(6.33), '6.33')
assert.equal(formatSeconds(60, 0), '1:00')
})
test('formatDateTime 对空值返回占位', () => {
assert.equal(formatDateTime(null), '—')
assert.equal(formatClock(null), '--:--')
})
+164
View File
@@ -0,0 +1,164 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { RealtimeClient } from '../realtime'
class FakeSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSED = 3
static instances: FakeSocket[] = []
readyState = FakeSocket.CONNECTING
onopen: (() => void) | null = null
onmessage: ((event: { data: unknown }) => void) | null = null
onerror: (() => void) | null = null
onclose: ((event: { code: number }) => void) | null = null
readonly url: string
readonly sent: string[] = []
constructor(url: string) {
this.url = url
FakeSocket.instances.push(this)
}
send(data: string): void {
this.sent.push(data)
}
close(): void {
this.readyState = FakeSocket.CLOSED
this.onclose?.({ code: 1006 })
}
open(): void {
this.readyState = FakeSocket.OPEN
this.onopen?.()
}
emit(event: string, data: unknown): void {
this.onmessage?.({ data: JSON.stringify({ event, data }) })
}
drop(code: number): void {
this.readyState = FakeSocket.CLOSED
this.onclose?.({ code })
}
}
function installFakeWebSocket(): void {
FakeSocket.instances = []
;(globalThis as { WebSocket?: unknown }).WebSocket = FakeSocket
}
function firstSocket(): FakeSocket {
const socket = FakeSocket.instances[0]
assert.ok(socket)
return socket
}
// P0-1:服务端在客户端版本超前(换库 / 还原备份后 change_log 归零)时也会发快照,
// 若按版本号丢弃,大屏会永久冻结在旧数据上。
test('快照版本低于本地缓存也接受,并把版本重置到快照', () => {
installFakeWebSocket()
const seen: number[] = []
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
initialVersion: 500,
onSnapshot: (snapshot) => seen.push(snapshot.version),
})
client.connect()
firstSocket().open()
firstSocket().emit('snapshot', { version: 3 })
assert.deepEqual(seen, [3])
assert.equal(client.version, 3)
client.close()
})
// P1-22:Console 的「已连接大屏实例」靠 hello 里的 page / instanceId 定位。
test('hello 上报 page 与 instanceId,翻页后补发', () => {
installFakeWebSocket()
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
instanceId: 'screen-1',
page: 'standings',
})
client.connect()
const socket = firstSocket()
socket.open()
assert.deepEqual(JSON.parse(socket.sent[0] ?? '{}'), {
event: 'hello',
data: { role: 'display', instanceId: 'screen-1', page: 'standings' },
})
socket.sent.length = 0
client.setPage('team-medals')
assert.equal(JSON.parse(socket.sent[0] ?? '{}').data.page, 'team-medals')
client.close()
})
// P1-17:策略性拒绝(1008)重连也会被同样拒绝,必须停重连并提示登录。
test('close 1008 停止重连并回调 onAuthRequired', async () => {
installFakeWebSocket()
let authRequired = 0
const client = new RealtimeClient({
role: 'admin',
url: 'ws://test/ws',
reconnect: { baseDelayMs: 5, maxDelayMs: 5, jitterRatio: 0 },
onAuthRequired: () => {
authRequired += 1
},
})
client.connect()
const socket = firstSocket()
socket.open()
socket.drop(1008)
assert.equal(authRequired, 1)
assert.equal(client.status, 'offline')
await new Promise((resolve) => setTimeout(resolve, 30))
assert.equal(FakeSocket.instances.length, 1)
client.close()
})
// P1-34:半开握手不触发 onopen/onclose,必须靠握手超时判死。
test('握手超时主动断开并重连', async () => {
installFakeWebSocket()
const errors: string[] = []
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
handshakeMs: 10,
reconnect: { baseDelayMs: 5, maxDelayMs: 5, jitterRatio: 0 },
onError: (error) => errors.push(error.message),
})
client.connect()
await new Promise((resolve) => setTimeout(resolve, 40))
assert.ok(errors.some((message) => message.includes('握手超时')))
assert.ok(FakeSocket.instances.length >= 2)
client.close()
})
// P2-13:没有快照基准时不能直接合并 catchup。
test('没有快照基准时收到 catchup 先请求全量', () => {
installFakeWebSocket()
const catchups: unknown[] = []
const client = new RealtimeClient({
role: 'display',
url: 'ws://test/ws',
onCatchup: (message) => catchups.push(message),
})
client.connect()
const socket = firstSocket()
socket.open()
socket.sent.length = 0
socket.emit('catchup', { fromVersion: 0, version: 5, changes: [] })
assert.equal(catchups.length, 0)
assert.ok(socket.sent.some((raw) => raw.includes('"resync"')))
client.close()
})
+3
View File
@@ -0,0 +1,3 @@
import { register } from 'node:module'
register('./resolve.mjs', import.meta.url)
+16
View File
@@ -0,0 +1,16 @@
/**
* `node --test` 专用的解析钩子:把 TS 源码里的无扩展名相对导入补成 .ts。
* 只服务于测试,不参与 Vite 构建。
*/
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
export async function resolve(specifier, context, next) {
if (specifier.startsWith('.') && !/\.[cm]?[jt]s$/.test(specifier)) {
for (const ext of ['.ts', '.mjs', '.js']) {
const candidate = new URL(specifier + ext, context.parentURL)
if (existsSync(fileURLToPath(candidate))) return next(specifier + ext, context)
}
}
return next(specifier, context)
}
+6 -33
View File
@@ -385,11 +385,6 @@ export interface CurrentUser {
username: string
displayName: string
role: string
/**
* 由客户端按 role 补出:true 表示这是未登录时 /auth/me 返回的 guest DTO。
* 服务端未登录也回 200,不能用「对象是否为 null」判访客,见 shared/session.ts。
*/
guest?: boolean
/** 首次登录或管理员重置密码后为 true,前端应强制引导改密 */
mustChangePassword: boolean
lastLoginAt: string | null
@@ -488,8 +483,6 @@ export interface RuleChannel {
scope: ChannelScope
basis: ChannelBasis
profile: string
/** basis 为 aggregate 时的汇总口径(avg/max/min/count),服务端缺省 sum。 */
aggregate?: string
}
export interface ChannelTemplateMatch {
@@ -524,31 +517,6 @@ export interface RulePack {
eventOverrides: EventOverride[]
}
export type RulePackDiffKind = 'profile' | 'channelTemplate' | 'eventOverride' | 'standings'
export type RulePackDiffChange = 'added' | 'removed' | 'modified' | 'unchanged'
export interface RulePackDiffEntry {
kind: RulePackDiffKind
target: string
change: RulePackDiffChange
detail: string
}
/** 导入必须先预览再落地(02 第 7.3 节),所以预览结果是独立类型。 */
export interface RulePackImportPreview {
formatVersion: number
/** false 表示 formatVersion 不兼容,此时不允许落地。 */
compatible: boolean
diffs: RulePackDiffEntry[]
affectedEventCount: number
}
export interface RulePackImportResult extends RulePackImportPreview {
applied: boolean
/** 导入后服务端全量重算的积分流水条数,用于确认重算确实发生。 */
recalculatedLedgerRows: number
}
// ---------------------------------------------------------------------------
// REST 统一响应
// ---------------------------------------------------------------------------
@@ -562,9 +530,10 @@ export type ApiErrorCode =
| 'STATE_INVALID'
| 'RATE_LIMITED'
| 'INTERNAL'
// 以下三个由客户端产生,服务端不会返回,但前端要能统一处理
// 以下四个由客户端产生,服务端不会返回,但前端要能统一处理
| 'NETWORK'
| 'TIMEOUT'
| 'ABORTED'
| 'PARSE_FAILED'
export interface ApiErrorBody {
@@ -598,6 +567,10 @@ export interface HelloMessage {
/** 上一次已应用的版本,服务端据此决定发增量还是整包快照。 */
lastVersion?: number
displayKey?: string
/** 大屏实例 id;缺省时服务端退回 displayKey 或自行生成。 */
instanceId?: string
/** 当前页名,写入服务端大屏实例注册表供管理端查看。 */
page?: string
}
export interface AckMessage {