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

+80 -103
View File
@@ -1,29 +1,19 @@
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { RealtimeClient, createApiClient } from '@/shared'
import type {
AthleteStanding,
Change,
DisplayCommand,
Event,
EventResultGroup,
RealtimeStatus,
RecordEntry,
SnapshotMessage,
StandingRow,
Team,
} from '@/shared'
import {
isRecord,
list,
numOrNull,
parseAthleteStandings,
parseEvent,
parseEventResultGroups,
parseSettings,
parseSnapshot,
parseStandingRow,
parseStandingRows,
parseTeam,
parseTeamBreakdown,
} from './guards'
import type { DisplaySettings } from './guards'
@@ -51,15 +41,13 @@ const source = ref<DisplaySource>('live')
const status = ref<RealtimeStatus>('idle')
const version = ref<number | null>(null)
const settings = ref<DisplaySettings | null>(null)
const teams = ref<Team[]>([])
const events = ref<Event[]>([])
const standings = ref<StandingRow[]>([])
const athletes = ref<AthleteStanding[]>([])
const latestResults = ref<EventResultGroup[]>([])
const records = ref<RecordEntry[]>([])
const rankDelta = ref<Record<number, number>>({})
const page = ref<DisplayPageKey>('standings')
const locked = ref(true)
const authRequired = ref(false)
const options = ref<DisplayUrlOptions>({
page: null,
locked: true,
@@ -76,6 +64,8 @@ let rankTimer: ReturnType<typeof setTimeout> | null = null
let detailTimer: ReturnType<typeof setTimeout> | null = null
let announceTimer: ReturnType<typeof setTimeout> | null = null
let pageTimer: ReturnType<typeof setInterval> | null = null
let announceReturnPage: DisplayPageKey | null = null
let athletesLoading = false
const showAthleteBoard = computed(() => settings.value?.standings.showIndividualBoard ?? true)
@@ -95,6 +85,12 @@ function clearTimer(timer: ReturnType<typeof setTimeout> | null): null {
return null
}
// 翻页(含整页轮播)后把当前页同步给服务端,管理端实例看板才不会一直显示旧页。
// 模块级单例与页面同生命周期,不需要随组件卸载停表。
watch(page, (next) => {
client?.setPage(next)
})
// ---------------------------------------------------------------------------
// 数据落地
// ---------------------------------------------------------------------------
@@ -129,31 +125,12 @@ function applyStandings(rows: StandingRow[]): void {
function applySnapshot(snapshot: SnapshotMessage, kind: DisplaySource): void {
settings.value = snapshot.settings
teams.value = snapshot.teams
events.value = snapshot.events
latestResults.value = snapshot.latestResults
records.value = snapshot.records
version.value = snapshot.version
applyStandings(snapshot.standings)
source.value = kind
}
function mergeById<T extends { id: number }>(
current: T[],
change: Change,
parse: (value: unknown) => T,
): T[] {
if (change.action === 'replace') return list(change.data).map(parse)
if (change.action === 'void') {
const id = change.id ?? (isRecord(change.data) ? numOrNull(change.data.id) : null)
return id === null ? current : current.filter((item) => item.id !== id)
}
const item = parse(change.data)
return [...current.filter((entry) => entry.id !== item.id), item]
}
function mergeStanding(row: StandingRow): void {
applyStandings([...standings.value.filter((item) => item.teamId !== row.teamId), row])
}
@@ -161,54 +138,51 @@ function mergeStanding(row: StandingRow): void {
/**
* 应用增量变更。
*
* 只有榜单类实体能在客户端可靠合并;其余实体(如逐条成绩)合并规则在服务端,
* 只有总积分榜能在客户端可靠合并;其余实体(逐条成绩、分项成绩等)的合并规则在服务端,
* 这里直接 resync 拉全量——宁可多拉一次,也不允许大屏上出现算错的分数。
* 个人榜不在快照 / 增量里,总榜一变就单独重拉一次,否则它会停在开屏那一刻。
*/
function applyChanges(changes: Change[]): void {
let resyncNeeded = false
let standingsTouched = false
for (const change of changes) {
switch (change.entity) {
case 'standings':
if (change.action === 'replace') {
// 服务端当前下发的是 {eventId:N} 这类定位信息而不是榜单数组;
// 直接当空数组用会把大屏清空,所以忽略并拉全量。
if (!Array.isArray(change.data)) {
resyncNeeded = true
break
}
applyStandings(parseStandingRows(change.data))
} else if (change.action === 'upsert') {
mergeStanding(parseStandingRow(change.data))
}
break
case 'athleteStandings':
case 'athlete_standings':
if (change.action === 'replace') athletes.value = parseAthleteStandings(change.data)
break
case 'latestResults':
case 'eventResults':
if (change.action === 'replace') {
latestResults.value = parseEventResultGroups(change.data, teams.value)
}
break
case 'settings':
if (change.action !== 'void') settings.value = parseSettings(change.data)
break
case 'teams':
teams.value = mergeById(teams.value, change, parseTeam)
break
case 'events':
events.value = mergeById(events.value, change, parseEvent)
break
default:
resyncNeeded = true
if (change.entity !== 'standings') {
resyncNeeded = true
continue
}
if (change.action === 'replace' && Array.isArray(change.data)) {
applyStandings(parseStandingRows(change.data))
standingsTouched = true
} else if (change.action === 'upsert') {
mergeStanding(parseStandingRow(change.data))
standingsTouched = true
} else {
// replace 载荷不是榜单数组时不能当空数组用(会把大屏清空),回退全量。
resyncNeeded = true
}
}
if (standingsTouched) void refreshAthletes()
if (resyncNeeded) client?.requestResync()
}
/**
* 个人榜单独走 REST:服务端快照不含 athletes,增量也不写这个 entity。
* 拉取失败时保留上一份,不能因为附属榜单把实时主数据挡住。
*/
async function refreshAthletes(): Promise<void> {
if (athletesLoading) return
athletesLoading = true
try {
athletes.value = await api.standings.athletes()
} catch {
// 静默保留旧数据,下一次榜单变更会再试。
} finally {
athletesLoading = false
}
}
// ---------------------------------------------------------------------------
// 断线兜底与启动
// ---------------------------------------------------------------------------
@@ -220,23 +194,20 @@ function hydrateFromCache(): void {
}
/**
* REST 兜底:快照不含个人榜,所以个人榜必须单独取;
* 但快照比 REST 权威(REST 没有 version),已收到快照时不用 REST 覆盖。
* REST 兜底。
*
* 大屏不请求 /api/settings(admin-only,guest 必然 403),配置只认 WS 快照与本地缓存;
* 快照比 REST 权威(REST 没有 version),且请求飞行途中可能已有快照到达,
* 所以是否套用 REST 必须在 await 之后重新判断,不能沿用发请求前的结论。
*/
async function loadRest(): Promise<boolean> {
const hasSnapshot = version.value !== null
const [settingsResult, standingsResult, athletesResult] = await Promise.allSettled([
api.settings.get(),
const [standingsResult, athletesResult] = await Promise.allSettled([
api.standings.teams(),
api.standings.athletes(),
])
const hasSnapshot = version.value !== null
let ok = false
if (settingsResult.status === 'fulfilled' && !hasSnapshot) {
// REST 载荷同样过解析层,保证嵌套结构与默认值和大屏其余入口一致。
settings.value = parseSettings(settingsResult.value)
ok = true
}
if (standingsResult.status === 'fulfilled' && !hasSnapshot) {
applyStandings(standingsResult.value)
ok = true
@@ -251,10 +222,7 @@ async function loadRest(): Promise<boolean> {
function activateMock(): void {
const snapshot = createMockSnapshot()
settings.value = parseSettings(snapshot.settings)
teams.value = snapshot.teams
events.value = snapshot.events
latestResults.value = snapshot.latestResults
records.value = snapshot.records
athletes.value = createMockAthletes()
standings.value = snapshot.standings
source.value = 'mock'
@@ -281,7 +249,8 @@ function readUrlOptions(): DisplayUrlOptions {
locked: lockParam !== '0' && lockParam !== 'false',
screen: params.get('screen'),
instance: params.get('instance'),
resultsCount: clampInt(params.get('n'), 1, 6, 3),
// 分项成绩每组最小宽度约 450px,5 组起就超出 1920 舞台,上限压到 4。
resultsCount: clampInt(params.get('n'), 1, 4, 3),
}
}
@@ -305,7 +274,8 @@ function stopPageRotation(): void {
function startPageRotation(): void {
stopPageRotation()
if (locked.value || reducedMotion.value) return
// 公告不在轮播页清单里,轮播会把公告切走;公告期间一律不启动。
if (locked.value || reducedMotion.value || page.value === 'announcement') return
const order = rotationPages.value
if (order.length < 2) return
@@ -316,10 +286,6 @@ function startPageRotation(): void {
}, PAGE_ROTATE_MS)
}
function setPage(next: DisplayPageKey): void {
page.value = next
}
function setLocked(value: boolean): void {
locked.value = value
if (value) stopPageRotation()
@@ -377,15 +343,23 @@ function announce(text: string, seconds: number): void {
const trimmed = text.trim()
if (trimmed === '') return
// 第二条公告到来时 page 已经是 announcement,必须复用「进入公告前的真实页」,
// 否则返回页会被记成公告页自身,计时结束后永远停在公告页。
if (page.value !== 'announcement') announceReturnPage = page.value
const returnPage = announceReturnPage ?? 'standings'
announcement.value = { text: trimmed, seconds, startedAt: Date.now() }
const previous = page.value
page.value = 'announcement'
// 公告不在轮播页清单里,20s 整页轮播会把它切走;公告期间必须停轮播。
stopPageRotation()
announceTimer = clearTimer(announceTimer)
if (seconds > 0) {
announceTimer = setTimeout(() => {
announceTimer = null
if (page.value === 'announcement') page.value = previous
if (page.value === 'announcement') page.value = returnPage
announceReturnPage = null
if (!locked.value) startPageRotation()
}, seconds * 1000)
}
}
@@ -397,6 +371,8 @@ function handleCommand(command: DisplayCommand): void {
if (target !== null) {
closeDetail()
page.value = target
// 公告期间轮播是停着的,goto 抢回控制权后按锁定状态恢复。
if (!locked.value) startPageRotation()
}
break
}
@@ -430,14 +406,17 @@ function connect(): void {
client = new RealtimeClient({
role: 'display',
displayKey: options.value.instance ?? options.value.screen ?? undefined,
instanceId: options.value.instance ?? undefined,
page: page.value,
initialVersion: version.value,
onSnapshot: (message) => {
// 服务端快照的 settings 是点号扁平 map,原始载荷直写 ref 会让 display 变 undefined;
// 必须过解析层归一,解析失败则整包丢弃(心跳/重连会再给一次)。
// 快照载荷一律过解析层逐字段兜底,解析失败则整包丢弃(心跳/重连会再给一次)。
const snapshot = parseSnapshot(message)
if (snapshot === null) return
applySnapshot(snapshot, 'live')
writeCachedSnapshot(snapshot)
// 个人榜不在快照里,每次拿到快照(含 resync 结果)都单独补拉一次。
void refreshAthletes()
},
onCatchup: (message) => {
applyChanges(message.changes)
@@ -448,10 +427,18 @@ function connect(): void {
onCommand: handleCommand,
onStatusChange: (next) => {
status.value = next
if (next === 'online') authRequired.value = false
},
onVersionChange: (next) => {
version.value = next
},
onAuthRequired: () => {
authRequired.value = true
},
onError: (error) => {
// 心跳超时 / 非 JSON / 握手失败都只在这里可见,现场排障要靠它。
console.warn('[大屏实时]', error.message)
},
})
client.connect()
}
@@ -498,27 +485,17 @@ export function useScoreboard() {
status,
version,
settings,
teams,
events,
standings,
athletes,
latestResults,
records,
rankDelta,
page,
locked,
authRequired,
options,
detail,
announcement,
booted,
rotationPages,
bootstrap,
dispose,
setPage,
setLocked,
startPageRotation,
stopPageRotation,
closeDetail,
rankDeltaFor,
}
}