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:
1 parent
96cf181ae3
commit
13f1f03cd0
124 files changed
+5658
-1820
No files matched your search
+35
-52
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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), '--:--')
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
import { register } from 'node:module'
|
||||
|
||||
register('./resolve.mjs', import.meta.url)
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user