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
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user