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

+18 -2
View File
@@ -28,8 +28,11 @@ function buildPasswdArgs({ configArg, currentUsername, newUsername, password })
]
}
// passwd 正常是秒级完成;卡住(等待输入等)时必须自己收尾,否则 UI 重置按钮永久禁用
const DEFAULT_TIMEOUT_MS = 30000
// 跑一次 passwd,实时把 stdout/stderr 交给 onOutput,结束时返回完整输出与退出码
function runPasswd({ exePath, args, onOutput }) {
function runPasswd({ exePath, args, onOutput, timeoutMs = DEFAULT_TIMEOUT_MS }) {
return new Promise((resolve) => {
let child
try {
@@ -46,9 +49,11 @@ function runPasswd({ exePath, args, onOutput }) {
let output = ''
let settled = false
let timer = null
const finish = (result) => {
if (settled) return
settled = true
if (timer) clearTimeout(timer)
resolve(result)
}
const onChunk = (chunk) => {
@@ -66,6 +71,17 @@ function runPasswd({ exePath, args, onOutput }) {
child.stderr.on('data', onChunk)
}
if (timeoutMs > 0) {
timer = setTimeout(() => {
try {
child.kill('SIGKILL')
} catch {
// 进程可能已经没了
}
finish({ ok: false, error: `passwd 执行超过 ${Math.ceil(timeoutMs / 1000)} 秒,已终止`, output })
}, timeoutMs)
}
child.on('error', (err) => finish({ ok: false, error: err.message, output }))
// 用 close 而不是 exit:等两个 stdio 都排空,输出才是完整的
child.on('close', (code, signal) => {
@@ -74,4 +90,4 @@ function runPasswd({ exePath, args, onOutput }) {
})
}
module.exports = { FIELD_MAX, validateAccountField, buildPasswdArgs, runPasswd }
module.exports = { validateAccountField, buildPasswdArgs, runPasswd }
+1 -1
View File
@@ -104,4 +104,4 @@ function resolveExecutable(exe, configPath) {
return { path: found || candidates[0], candidates: [...new Set(candidates)] }
}
module.exports = { DEFAULTS, loadConfig, merge, resolveExecutable }
module.exports = { loadConfig, resolveExecutable }
+19 -3
View File
@@ -2,6 +2,7 @@
const os = require('os')
const { BrowserWindow, screen } = require('electron')
const { healthHost } = require('./service')
const SELECTION = {
ALL_SECONDARY: 'all-secondary',
@@ -97,7 +98,9 @@ class DisplayManager {
displays.forEach((display, index) => {
const page = this.pageFor(index)
const existing = this.windows.get(display.id)
if (existing) {
// 窗口可能被外部关闭/崩溃销毁而没走 destroyEntry,这里当作不存在重建,
// 否则 applyGeometry/loadPage 对 destroyed 窗口静默 return,现场只剩余黑屏
if (existing && existing.win && !existing.win.isDestroyed()) {
this.applyGeometry(existing.win, display, single)
if (existing.page !== page) {
existing.page = page
@@ -105,6 +108,10 @@ class DisplayManager {
}
return
}
if (existing) {
if (existing.reloadTimer) clearInterval(existing.reloadTimer)
this.windows.delete(display.id)
}
this.windows.set(display.id, this.createWindow(display, index, single, page))
})
@@ -160,6 +167,14 @@ class DisplayManager {
const entry = { win, page, reloadTimer: null }
// 窗口销毁(外部关闭/渲染进程崩溃)后必须把 entry 从 map 摘掉,
// 否则 layout/relayout 一直拿到已销毁窗口而静默跳过,现场只剩余黑屏
win.on('closed', () => {
if (entry.reloadTimer) clearInterval(entry.reloadTimer)
// 只有 map 里还是这个 entry 时才删,避免旧窗口的 closed 误删重建后的新窗口
if (this.windows.get(display.id) === entry) this.windows.delete(display.id)
})
// 无人值守跑 24h 以上,定时重载兜底内存与连接泄漏
const hours = Number(this.display.reloadEveryHours) || 0
if (hours > 0) {
@@ -218,7 +233,8 @@ class DisplayManager {
params.set('instance', `${os.hostname()}-${display.id}`)
// 只有单屏窗口化才画标题栏;多屏 kiosk 全屏无人值守,不能占高度
if (single) params.set('windowed', '1')
return `http://127.0.0.1:${this.server.port}/display?${params.toString()}`
// 与健康检查/管理面板同一套 host 归一:0.0.0.0/:: 走回环,其余(如内网 IP)原样
return `http://${healthHost(this.server)}:${this.server.port}/display?${params.toString()}`
}
// 单屏模式要「行为冻结」:不跟随屏幕变化重排,所以连监听都不挂
@@ -288,4 +304,4 @@ class DisplayManager {
}
}
module.exports = { DisplayManager, SELECTION }
module.exports = { DisplayManager }
+16 -16
View File
@@ -13,7 +13,7 @@ const {
const { loadConfig, resolveExecutable } = require('./config')
const { LogBus } = require('./log-bus')
const { ServiceSupervisor } = require('./service')
const { ServiceSupervisor, healthHost } = require('./service')
const { DisplayManager } = require('./displays')
const { fetchDisplayInstances } = require('./instances')
const { createTrayIcon } = require('./tray-icon')
@@ -36,7 +36,6 @@ function main() {
let tray = null
let powerBlockerId = null
let displayFallbackTimer = null
let displaysEnabled = false
let quitting = false
let shutdownDone = false
let exePath = null
@@ -133,16 +132,14 @@ function main() {
}
function adminPanelUrl() {
// 复用服务端的地址推导:host 配成 0.0.0.0 时本机也要走回环
// 复用服务端的地址推导:host 配成 0.0.0.0/:: 时本机也要走回环
if (service) return service.status.adminUrl
const host = (cfg.values.server.host || '127.0.0.1') === '0.0.0.0' ? '127.0.0.1' : cfg.values.server.host
return `http://${host || '127.0.0.1'}:${cfg.values.server.port}/admin`
return `http://${healthHost(cfg.values.server)}:${cfg.values.server.port}/admin`
}
function serverHost() {
if (service) return service.healthHost()
const host = cfg.values.server.host || '127.0.0.1'
return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
return healthHost(cfg.values.server)
}
// 大屏实例看板:读服务端 GET /api/display/instances 并推给管理窗口。
@@ -248,8 +245,7 @@ function main() {
}
function ensureDisplays() {
if (displaysEnabled || !displays) return
displaysEnabled = true
if (!displays || displays.enabled) return
if (displayFallbackTimer) {
clearTimeout(displayFallbackTimer)
displayFallbackTimer = null
@@ -392,7 +388,7 @@ function main() {
return true
})
ipcMain.handle('display:relayout', () => {
if (displays && displaysEnabled) displays.relayout()
if (displays && displays.enabled) displays.relayout()
return statusSnapshot()
})
ipcMain.handle('app:open-admin-window', () => {
@@ -471,17 +467,16 @@ function main() {
clearTimeout(displayFallbackTimer)
displayFallbackTimer = null
}
if (displays && displaysEnabled) {
if (displays && displays.enabled) {
displays.closeAll()
log.push('console', '服务已停止,大屏窗口已关闭')
}
displaysEnabled = false
return
}
if (!status.healthy) return
// 服务端恢复后必须让大屏重新加载,否则页面还停在断线兜底画面上
if (displaysEnabled) displays.relayout()
if (displays.enabled) displays.relayout()
else ensureDisplays()
})
@@ -497,8 +492,13 @@ function main() {
applyOpenAtLogin()
// 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠
powerBlockerId = powerSaveBlocker.start('prevent-display-sleep')
log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`)
try {
powerBlockerId = powerSaveBlocker.start('prevent-display-sleep')
log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`)
} catch (err) {
powerBlockerId = null
log.push('console', `防熄屏启用失败:${err.message}`)
}
if (cfg.values.server.autoStart) {
service.start()
@@ -511,7 +511,7 @@ function main() {
if (wait > 0) {
displayFallbackTimer = setTimeout(() => {
displayFallbackTimer = null
if (!displaysEnabled) {
if (displays && !displays.enabled) {
log.push('console', `等待服务端就绪超过 ${Math.round(wait / 1000)} 秒,先打开大屏窗口`)
ensureDisplays()
}
+2
View File
@@ -30,6 +30,8 @@ function requestJSON({ host, port, path, cookie }) {
response.on('end', () => {
resolve({ status: response.statusCode, body: Buffer.concat(chunks).toString('utf8') })
})
// 连接中途出错时 end 不会触发,必须自己收尾,否则每次轮询都泄漏一个 pending Promise
response.on('error', (err) => resolve({ error: err }))
}
)
request.on('timeout', () => request.destroy(new Error('请求超时')))
+52 -11
View File
@@ -15,29 +15,61 @@ class LogBus {
// 子进程的输出是流式分片的,可能把一行拆到两次 data 事件里
this.pending = new Map()
this.stream = null
this.file = null
this.bytesWritten = 0
this.rotating = false
this.closed = false
if (file) this.openFile(file)
}
openFile(file) {
this.file = file
try {
fs.mkdirSync(path.dirname(file), { recursive: true })
if (fs.existsSync(file) && fs.statSync(file).size > this.maxFileBytes) {
// Windows 上 rename 到已存在的目标会失败,先删掉上一份轮转文件
const rotated = `${file}.1`
try {
fs.rmSync(rotated, { force: true })
} catch {
// 删不掉就让 rename 失败,落到下面的「仅内存日志」兜底
}
fs.renameSync(file, rotated)
}
if (fs.existsSync(file) && fs.statSync(file).size > this.maxFileBytes) this.rotateFile()
this.stream = fs.createWriteStream(file, { flags: 'a' })
this.bytesWritten = fs.existsSync(file) ? fs.statSync(file).size : 0
} catch (err) {
this.stream = null
this.push('console', `日志文件不可写,仅保留内存日志:${err.message}`)
}
}
// 把当前日志改名成 .1:Windows 上 rename 到已存在的目标会失败,先删掉旧 .1
rotateFile() {
const rotated = `${this.file}.1`
try {
fs.rmSync(rotated, { force: true })
} catch {
// 删不掉就让 rename 失败,由调用方落到「仅内存日志」兜底
}
if (fs.existsSync(this.file)) fs.renameSync(this.file, rotated)
}
// 运行中轮转:push 时按累计字节数触发,否则长赛事跑满一天文件会无限增长。
// 必须先关流把缓冲刷盘再改名(Windows 下打开中的文件不能改名),
// 轮转窗口内新日志只进内存缓冲,窗口很短。
rotate() {
if (!this.file || this.rotating) return
this.rotating = true
const stream = this.stream
this.stream = null
this.bytesWritten = 0
const reopen = () => {
this.rotating = false
if (this.closed) return
try {
this.rotateFile()
} catch (err) {
this.push('console', `日志文件轮转失败,仅保留内存日志:${err.message}`)
return
}
this.stream = fs.createWriteStream(this.file, { flags: 'a' })
}
if (stream) stream.end(reopen)
else reopen()
}
append(source, chunk) {
const text = `${this.pending.get(source) || ''}${String(chunk)}`
const parts = text.split(/\r?\n/)
@@ -52,7 +84,15 @@ class LogBus {
if (this.lines.length > this.maxLines) {
this.lines.splice(0, this.lines.length - this.maxLines)
}
if (this.stream) this.stream.write(`${line}\n`)
if (this.stream) {
const text = `${line}\n`
const bytes = Buffer.byteLength(text)
if (this.maxFileBytes > 0 && this.bytesWritten + bytes > this.maxFileBytes) this.rotate()
if (this.stream) {
this.stream.write(text)
this.bytesWritten += bytes
}
}
for (const listener of this.listeners) {
try {
listener(line)
@@ -76,6 +116,7 @@ class LogBus {
}
close() {
this.closed = true
if (this.stream) {
this.stream.end()
this.stream = null
+57 -8
View File
@@ -28,6 +28,12 @@ const FALLBACK_FORCE_KILL_MS = 1500
// 连续健康这么久才认为崩溃计数可以清零
const HEALTHY_RESET_MS = 60000
// 服务端可能监听 0.0.0.0/::,本机访问(健康检查、关闭握手、大屏、管理面板)统一走回环地址
function healthHost(server) {
const host = (server && server.host) || '127.0.0.1'
return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
}
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
class ServiceSupervisor extends EventEmitter {
@@ -51,6 +57,7 @@ class ServiceSupervisor extends EventEmitter {
this.healthTimer = null
this.restartTimer = null
this.forceKillTimer = null
this.stopTimer = null
// 本次进程的关闭握手 token 与握手结果,仅用于日志区分「优雅退出 / 被强杀」
this.shutdownToken = null
this.gracefulAccepted = false
@@ -75,8 +82,7 @@ class ServiceSupervisor extends EventEmitter {
// 服务端可能监听 0.0.0.0,本机探测要走回环地址
healthHost() {
const host = this.server.host || '127.0.0.1'
return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
return healthHost(this.server)
}
emitStatus() {
@@ -148,17 +154,33 @@ class ServiceSupervisor extends EventEmitter {
child.on('error', (err) => {
this.log.push('console', `服务端进程错误:${err.message}`)
this.setState('crashed', { lastError: err.message })
this.handleSpawnError(child, err)
})
child.on('exit', (code, signal) => this.handleExit(code, signal))
child.on('exit', (code, signal) => this.handleExit(child, code, signal))
this.setState('running')
this.startHealthCheck()
return this.status
}
handleExit(code, signal) {
// spawn 失败(ENOENT/EACCES 等)时 Node 只发 error、不发 exit:
// 必须在这里清掉 child/pid 并调度重启,否则 start() 因 child 非空直接返回、
// stop() 永远等不到 exit,守护器彻底卡死。
handleSpawnError(child, err) {
if (this.child !== child) return
this.stopHealthCheck()
this.child = null
this.pid = null
this.healthy = false
this.healthySince = null
this.setState('crashed', { lastError: `服务端进程错误:${err.message}` })
if (!this.stopRequested) this.scheduleRestart()
}
handleExit(child, code, signal) {
// 迟到的 exit(error 分支已收尾或已换进程)不能动当前状态
if (this.child !== child) return
if (this.forceKillTimer) {
clearTimeout(this.forceKillTimer)
this.forceKillTimer = null
@@ -217,9 +239,35 @@ class ServiceSupervisor extends EventEmitter {
this.setState('stopping')
this.log.push('console', '正在优雅关闭服务端…')
child.once('exit', () => resolve())
const graceMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS
let settled = false
const finish = () => {
if (settled) return
settled = true
if (this.stopTimer) {
clearTimeout(this.stopTimer)
this.stopTimer = null
}
resolve()
}
// 兜底:握手与强杀链路都可能因进程状态异常收不到 exit,
// 退出流程不能永远挂住,超时后无条件放行并补一刀强杀。
this.stopTimer = setTimeout(() => {
this.stopTimer = null
this.log.push('console', '等待服务端退出超时,停止流程强制返回')
if (this.child === child) {
this.forcedKill = true
try {
child.kill('SIGKILL')
} catch {
// 进程可能已经没了
}
}
finish()
}, SHUTDOWN_REQUEST_TIMEOUT_MS + graceMs + FALLBACK_FORCE_KILL_MS + 2000)
child.once('exit', () => finish())
this.requestShutdown().then((accepted) => {
// 请求期间进程可能已经退了,handleExit 会清掉兜底定时器
@@ -365,9 +413,10 @@ class ServiceSupervisor extends EventEmitter {
dispose() {
if (this.restartTimer) clearTimeout(this.restartTimer)
if (this.forceKillTimer) clearTimeout(this.forceKillTimer)
if (this.stopTimer) clearTimeout(this.stopTimer)
this.stopHealthCheck()
this.removeAllListeners()
}
}
module.exports = { ServiceSupervisor, STATE_LABEL }
module.exports = { ServiceSupervisor, healthHost }