Files
Scoreboard/web/src/display/guards.ts
T
AskaEth 2c9ff65fc4 feat: 补齐第一批已知缺口(关闭握手 / 定时备份 / Origin 白名单 / DTO 收敛)
- httpapi:新增 POST /api/shutdown(admin 会话或 X-Shutdown-Token 常量时间比对)
  与 GET /api/rules/switches(recorder 可读,只回布尔开关);settings/standings 的
  重复 DTO 收敛到 internal/dto;standings.showIndividualMedals 贯通到设置读写与前端
- export:抽出 export.Service,新增自动备份定时任务(backup.intervalMinutes/keep),
  自动、手动、发布前三类备份共用同一把互斥锁与滚动清理
- realtime:/ws 去掉 InsecureSkipVerify,改用 OriginPatterns(同源与无 Origin 放行,
  跨站需命中 server.allowedOrigins)
- console:停止服务改走 POST /api/shutdown 优雅关闭(5 秒宽限后回退强杀),
  服务管理器新增「已连接的大屏实例」卡片
- deploy:build.bat 改用 for /f 读内部署名——set /p 在 chcp 65001 下返回空,
  导致最近这次构建的 exe 里根本没注入署名
- docs:备份策略、关闭超时、Origin 配置的说明与实现对齐
2026-09-09 21:41:08 +08:00

416 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
AthleteStanding,
BreakdownItem,
CompareMode,
Event,
EventResultGroup,
EventResultRow,
EventSettings,
EventStatus,
EventType,
Gender,
LedgerSourceType,
RecordEntry,
RecordLevel,
RecordSource,
ResultMode,
ResultStatus,
ResultType,
SnapshotMessage,
StandingRow,
Team,
TeamBreakdown,
} from '@/shared'
/**
* WebSocket 与 localStorage 里的东西一律先当 unknown 再收窄。
*
* 大屏是无人值守的:一条脏数据不能把整页渲染炸掉,所以这里逐字段兜底,
* 而不是直接断言成后端类型。
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function list(value: unknown): unknown[] {
return Array.isArray(value) ? value : []
}
export function str(value: unknown, fallback = ''): string {
return typeof value === 'string' ? value : fallback
}
export function strOrNull(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
export function num(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
export function numOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
export function bool(value: unknown, fallback = false): boolean {
return typeof value === 'boolean' ? value : fallback
}
/** 三态布尔:字段缺失时保持 undefined,调用方才能区分「未配置」与「显式关闭」。 */
function boolOrUndefined(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
export function strings(value: unknown): string[] | null {
const items = list(value).filter((item): item is string => typeof item === 'string')
return items.length === 0 ? null : items
}
export function oneOf<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
? (value as T)
: fallback
}
const EVENT_TYPES = ['individual', 'team', 'match'] as const satisfies readonly EventType[]
const RESULT_MODES = ['auto', 'manual'] as const satisfies readonly ResultMode[]
const RESULT_TYPES = ['time', 'distance', 'count', 'score', 'none'] as const satisfies readonly ResultType[]
const COMPARE_MODES = ['asc', 'desc', 'none'] as const satisfies readonly CompareMode[]
const EVENT_STATUSES = ['pending', 'ongoing', 'finished', 'published'] as const satisfies readonly EventStatus[]
const RESULT_STATUSES = ['ok', 'dq', 'dns', 'dnf', 'withdrawn'] as const satisfies readonly ResultStatus[]
const GENDERS = ['男', '女'] as const satisfies readonly Gender[]
const RECORD_LEVELS = ['meet', 'city', 'province', 'national'] as const satisfies readonly RecordLevel[]
const RECORD_SOURCES = ['history', 'current'] as const satisfies readonly RecordSource[]
const LEDGER_SOURCES = ['result', 'record_bonus', 'adjustment'] as const satisfies readonly LedgerSourceType[]
function record(source: unknown): Record<string, unknown> {
return isRecord(source) ? source : {}
}
interface TeamRefShape {
teamId: number
teamCode: string
teamName: string
shortName: string | null
color: string | null
logoPath: string | null
}
function parseTeamRef(source: unknown): TeamRefShape {
const rec = record(source)
return {
teamId: num(rec.teamId),
// 快照直接序列化领域行时用的是 code/name,REST 契约用 teamCode/teamName,两种都收。
teamCode: str(rec.teamCode) || str(rec.code),
teamName: str(rec.teamName) || str(rec.name),
shortName: strOrNull(rec.shortName),
color: strOrNull(rec.color),
logoPath: strOrNull(rec.logoPath),
}
}
export function parseTeam(source: unknown): Team {
const rec = record(source)
return {
id: num(rec.id),
code: str(rec.code),
name: str(rec.name),
shortName: strOrNull(rec.shortName),
logoPath: strOrNull(rec.logoPath),
color: strOrNull(rec.color),
sortOrder: num(rec.sortOrder),
active: bool(rec.active, true),
}
}
export function parseEvent(source: unknown): Event {
const rec = record(source)
return {
id: num(rec.id),
sportId: num(rec.sportId),
code: str(rec.code),
name: str(rec.name),
eventType: oneOf(rec.eventType, EVENT_TYPES, 'individual'),
resultMode: oneOf(rec.resultMode, RESULT_MODES, 'auto'),
resultType: oneOf(rec.resultType, RESULT_TYPES, 'none'),
compare: oneOf(rec.compare, COMPARE_MODES, 'none'),
recordEligible: bool(rec.recordEligible),
scored: bool(rec.scored, true),
multiplier: num(rec.multiplier, 1),
status: oneOf(rec.status, EVENT_STATUSES, 'pending'),
scheduledAt: strOrNull(rec.scheduledAt),
venue: strOrNull(rec.venue),
sortOrder: num(rec.sortOrder),
remark: strOrNull(rec.remark),
}
}
export function parseStandingRow(source: unknown): StandingRow {
const rec = record(source)
const gold = num(rec.gold)
const silver = num(rec.silver)
const bronze = num(rec.bronze)
return {
...parseTeamRef(rec),
rank: num(rec.rank),
gold,
silver,
bronze,
medalTotal: num(rec.medalTotal, num(rec.medals, gold + silver + bronze)),
totalPoints: num(rec.totalPoints, num(rec.points)),
rankChange: numOrNull(rec.rankChange),
}
}
export function parseStandingRows(value: unknown): StandingRow[] {
return list(value).map(parseStandingRow)
}
export function parseAthleteStanding(source: unknown): AthleteStanding {
const rec = record(source)
return {
rank: num(rec.rank),
athleteId: num(rec.athleteId),
athleteName: str(rec.athleteName),
gender: rec.gender === null || rec.gender === undefined ? null : oneOf(rec.gender, GENDERS, '男'),
teamId: num(rec.teamId),
teamCode: str(rec.teamCode),
teamName: str(rec.teamName),
shortName: strOrNull(rec.shortName),
totalPoints: num(rec.totalPoints),
gold: num(rec.gold),
silver: num(rec.silver),
bronze: num(rec.bronze),
}
}
export function parseAthleteStandings(value: unknown): AthleteStanding[] {
return list(value).map(parseAthleteStanding)
}
export function parseEventResultRow(source: unknown): EventResultRow {
const rec = record(source)
return {
entryId: num(rec.entryId),
rank: numOrNull(rec.rank),
teamId: num(rec.teamId),
teamCode: str(rec.teamCode),
teamName: str(rec.teamName),
shortName: strOrNull(rec.shortName),
athleteId: numOrNull(rec.athleteId),
athleteName: strOrNull(rec.athleteName),
memberNames: strings(rec.memberNames),
resultText: str(rec.resultText, '—'),
resultValue: numOrNull(rec.resultValue),
unit: str(rec.unit),
isTie: bool(rec.isTie),
status: oneOf(rec.status, RESULT_STATUSES, 'ok'),
}
}
export function parseEventResultGroup(source: unknown): EventResultGroup {
const rec = record(source)
return {
eventId: num(rec.eventId),
eventCode: str(rec.eventCode),
eventName: str(rec.eventName),
categoryId: num(rec.categoryId),
categoryName: str(rec.categoryName),
publishedAt: strOrNull(rec.publishedAt),
rows: list(rec.rows).map(parseEventResultRow),
}
}
function parseFlatResultRow(
rec: Record<string, unknown>,
byTeam: ReadonlyMap<number, Team>,
): EventResultRow {
const row = parseEventResultRow(rec)
const team = byTeam.get(row.teamId)
if (team === undefined) return row
// 平铺行只带 teamId,单位名要从快照的 teams 里补,否则底栏只能显示空白。
return {
...row,
teamCode: row.teamCode === '' ? team.code : row.teamCode,
teamName: row.teamName === '' ? team.name : row.teamName,
shortName: row.shortName ?? team.shortName,
}
}
/**
* 服务端快照的 latestResults 存在「已分组」与「平铺成绩行」两种形态。
*
* 平铺行没有 rows,按分组解析会得到一堆空组,底栏轮播与分项成绩页就只剩空白;
* 这里检测到平铺行时按 eventId+categoryId 就地聚合,两种契约都能渲染。
*/
export function parseEventResultGroups(
value: unknown,
teams: readonly Team[] = [],
): EventResultGroup[] {
const items = list(value)
if (items.length === 0) return []
if (items.some((item) => isRecord(item) && Array.isArray(item.rows))) {
return items.map(parseEventResultGroup)
}
const byTeam = new Map<number, Team>(teams.map((team) => [team.id, team]))
const groups = new Map<string, EventResultGroup>()
for (const item of items) {
const rec = record(item)
const eventId = num(rec.eventId)
const categoryId = num(rec.categoryId)
const key = `${eventId}-${categoryId}`
let group = groups.get(key)
if (group === undefined) {
group = {
eventId,
eventCode: str(rec.eventCode),
eventName: str(rec.eventName),
categoryId,
categoryName: str(rec.categoryName),
publishedAt: strOrNull(rec.publishedAt ?? rec.updatedAt),
rows: [],
}
groups.set(key, group)
}
group.rows.push(parseFlatResultRow(rec, byTeam))
}
return [...groups.values()]
}
export function parseRecordEntry(source: unknown): RecordEntry {
const rec = record(source)
return {
id: num(rec.id),
eventId: num(rec.eventId),
level: oneOf(rec.level, RECORD_LEVELS, 'meet'),
resultValue: num(rec.resultValue),
resultText: str(rec.resultText, '—'),
holderName: strOrNull(rec.holderName),
holderTeamId: numOrNull(rec.holderTeamId),
achievedAt: strOrNull(rec.achievedAt),
isCurrent: bool(rec.isCurrent),
source: oneOf(rec.source, RECORD_SOURCES, 'history'),
}
}
/**
* 大屏侧赛事配置。字段与 shared 契约一致;
* `showIndividualMedals` 缺失时由 parseSettings 回退到 `showIndividualBoard`(旧服务端只下发后者)。
*/
export type DisplaySettings = EventSettings
/**
* 嵌套路径取值。
*
* REST 的 `/api/settings` 是嵌套对象,WS 快照却是 `display.rotate_seconds`
* 这类点号扁平键;同一份配置两种形态都要认,故先走嵌套路径再退回扁平键。
*/
function settingValue(
source: Record<string, unknown>,
nestedPath: string,
flatKey: string,
): unknown {
let node: unknown = source
for (const segment of nestedPath.split('.')) {
if (!isRecord(node)) {
node = undefined
break
}
node = node[segment]
}
if (node !== undefined && node !== null) return node
return source[flatKey]
}
export function parseSettings(source: unknown): DisplaySettings {
const rec = record(source)
const showIndividualBoard = bool(
settingValue(rec, 'standings.showIndividualBoard', 'standings.show_individual_board'),
true,
)
return {
event: {
name: str(settingValue(rec, 'event.name', 'event.name')),
subtitle: strOrNull(settingValue(rec, 'event.subtitle', 'event.subtitle')),
host: strOrNull(settingValue(rec, 'event.host', 'event.organizer')),
logoPath: strOrNull(settingValue(rec, 'event.logoPath', 'event.logo_path')),
primaryColor: strOrNull(settingValue(rec, 'event.primaryColor', 'display.theme_color')),
},
display: {
background: strOrNull(settingValue(rec, 'display.background', 'display.background')),
logo: strOrNull(settingValue(rec, 'display.logo', 'display.logo')),
title: str(settingValue(rec, 'display.title', 'display.title')),
subtitle: strOrNull(settingValue(rec, 'display.subtitle', 'display.subtitle')),
primaryColor: strOrNull(settingValue(rec, 'display.primaryColor', 'display.theme_color')),
rotateSeconds: num(settingValue(rec, 'display.rotateSeconds', 'display.rotate_seconds'), 8),
},
standings: {
individualToTeamWeight: num(
settingValue(rec, 'standings.individualToTeamWeight', 'standings.individual_to_team_weight'),
1,
),
rankingMode: str(
settingValue(rec, 'standings.rankingMode', 'standings.ranking_mode'),
'points',
),
showIndividualBoard,
// 旧服务端只下发 showIndividualBoard,奖牌列跟随它,避免升级后突然多出一列。
showIndividualMedals:
boolOrUndefined(
settingValue(rec, 'standings.showIndividualMedals', 'standings.show_individual_medals'),
) ?? showIndividualBoard,
},
defaultProfileId: numOrNull(rec.defaultProfileId),
requireApproval: bool(rec.requireApproval),
}
}
export function parseBreakdownItem(source: unknown): BreakdownItem {
const rec = record(source)
return {
ledgerId: num(rec.ledgerId),
sourceType: oneOf(rec.sourceType, LEDGER_SOURCES, 'result'),
eventId: numOrNull(rec.eventId),
eventCode: strOrNull(rec.eventCode),
eventName: strOrNull(rec.eventName),
categoryName: strOrNull(rec.categoryName),
athleteId: numOrNull(rec.athleteId),
athleteName: strOrNull(rec.athleteName),
rank: numOrNull(rec.rank),
resultText: strOrNull(rec.resultText),
points: num(rec.points),
createdAt: str(rec.createdAt),
}
}
export function parseTeamBreakdown(value: unknown): TeamBreakdown | null {
if (!isRecord(value)) return null
return {
teamId: num(value.teamId),
teamCode: str(value.teamCode),
teamName: str(value.teamName),
totalPoints: num(value.totalPoints),
items: list(value.items).map(parseBreakdownItem),
}
}
/** 快照是整包信任的入口,字段缺失时补空数组而不是拒绝整包。 */
export function parseSnapshot(value: unknown): SnapshotMessage | null {
if (!isRecord(value)) return null
const version = numOrNull(value.version)
if (version === null) return null
const teams = list(value.teams).map(parseTeam)
return {
version,
settings: parseSettings(value.settings),
teams,
events: list(value.events).map(parseEvent),
standings: parseStandingRows(value.standings),
records: list(value.records).map(parseRecordEntry),
// 平铺成绩行要靠 teams 补单位名,必须传入已解析的单位表。
latestResults: parseEventResultGroups(value.latestResults, teams),
}
}