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
+18
-2
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
Reference in new issue
Block a user