- 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 配置的说明与实现对齐
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
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),
|
||
}
|
||
}
|