From 13f1f03cd0041a1fa08f06aca7473a20b0c950fe Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 9 Sep 2026 22:16:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E6=8E=89=E5=8D=81=E8=B7=AF?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=209=20=E4=B8=AA?= =?UTF-8?q?=20P0=20=E4=B8=8E=E4=B8=80=E6=89=B9=20P1=EF=BC=88=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=AD=A3=E7=A1=AE=E6=80=A7=20/=20=E8=B6=8A=E6=9D=83?= =?UTF-8?q?=20/=20=E7=8E=B0=E5=9C=BA=E5=8F=AF=E7=94=A8=E6=80=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 文件通过。 --- README.md | 4 +- console/README.md | 22 +- console/main/account.js | 20 +- console/main/config.js | 2 +- console/main/displays.js | 22 +- console/main/index.js | 32 +- console/main/instances.js | 2 + console/main/log-bus.js | 63 ++- console/main/service.js | 65 ++- console/renderer/index.html | 2 +- deploy/backup.bat | 54 +-- deploy/build.bat | 8 +- server/internal/auth/auth.go | 28 +- server/internal/auth/auth_test.go | 32 ++ server/internal/domain/helpers_test.go | 2 +- server/internal/domain/ledger.go | 228 ----------- server/internal/domain/ledger_test.go | 243 +----------- server/internal/domain/matrix_test.go | 152 +------ server/internal/domain/records.go | 55 ++- server/internal/domain/records_test.go | 117 ++++++ server/internal/domain/standings.go | 73 +++- server/internal/domain/standings_test.go | 75 ++++ server/internal/dto/events.go | 48 +++ server/internal/dto/settings.go | 17 + server/internal/dto/settings_test.go | 94 +++++ server/internal/dto/teams.go | 41 ++ server/internal/export/backup.go | 30 +- server/internal/export/backup_test.go | 83 ++++ server/internal/export/load.go | 206 ++++++---- server/internal/export/load_test.go | 193 +++++++++ server/internal/export/project.go | 69 +++- server/internal/export/project_test.go | 111 +++++- server/internal/export/team.go | 21 +- server/internal/httpapi/export_handlers.go | 47 ++- .../httpapi/export_handlers_internal_test.go | 22 ++ .../internal/httpapi/export_handlers_test.go | 148 +++++++ server/internal/httpapi/handlers_config.go | 13 +- .../internal/httpapi/handlers_config_test.go | 87 ++++ server/internal/httpapi/handlers_results.go | 16 +- .../internal/httpapi/handlers_results_test.go | 48 +++ server/internal/httpapi/handlers_users.go | 6 + .../internal/httpapi/handlers_users_test.go | 44 +++ server/internal/httpapi/idempotency.go | 48 ++- server/internal/httpapi/idempotency_test.go | 47 +++ server/internal/httpapi/repo.go | 66 +++- server/internal/httpapi/repo_events.go | 68 +++- server/internal/httpapi/repo_misc.go | 106 ++++- server/internal/httpapi/repo_misc_test.go | 208 ++++++++++ server/internal/httpapi/repo_results.go | 17 +- server/internal/httpapi/repo_results_test.go | 113 ++++++ server/internal/httpapi/repo_test.go | 87 ++++ server/internal/httpapi/review_fixes_test.go | 4 + server/internal/httpapi/rules_handlers.go | 134 +++++-- .../internal/httpapi/rules_handlers_test.go | 243 ++++++++++++ server/internal/httpapi/scoring.go | 60 ++- server/internal/httpapi/scoring_test.go | 120 ++++++ server/internal/httpapi/server.go | 2 +- .../internal/httpapi/users_revisions_test.go | 3 + server/internal/middleware/audit.go | 13 +- server/internal/middleware/middleware.go | 51 ++- server/internal/middleware/middleware_test.go | 105 +++++ server/internal/realtime/client.go | 25 +- server/internal/realtime/hub.go | 44 ++- server/internal/realtime/hub_test.go | 157 +++++++- server/internal/realtime/instances.go | 42 +- server/internal/realtime/snapshot.go | 62 ++- server/internal/realtime/snapshot_test.go | 162 ++++++++ server/internal/rules/apply_test.go | 42 ++ server/internal/rules/convert.go | 49 --- server/internal/rules/diff.go | 12 +- server/internal/rules/diff_test.go | 56 +++ server/internal/rules/parse_test.go | 19 - server/internal/store/store_test.go | 157 ++++++++ server/main.go | 6 +- server/main_test.go | 49 +++ .../event/0012_ledger_entry_index_and_fk.sql | 142 +++++++ web/src/admin/api/client.ts | 67 ++-- web/src/admin/api/mock.ts | 2 + web/src/admin/api/scoring.ts | 372 +++++++++++++----- .../components/history/revisionsSource.ts | 40 +- web/src/admin/components/history/types.ts | 8 +- .../components/system/EventInfoPanel.vue | 24 +- web/src/admin/components/system/systemApi.ts | 75 ++-- web/src/admin/layouts/AdminLayout.vue | 17 +- web/src/admin/pages/AboutPage.vue | 5 +- web/src/admin/pages/AdjustmentsPage.vue | 35 +- web/src/admin/pages/EntryPage.vue | 113 ++++-- web/src/admin/pages/HistoryPage.vue | 94 ++++- web/src/admin/pages/LogsPage.vue | 14 +- web/src/admin/pages/PlaceholderPage.vue | 159 -------- web/src/admin/pages/RecordsPage.vue | 37 +- web/src/admin/pages/ReviewPage.vue | 50 ++- web/src/admin/pages/SystemPage.vue | 20 +- web/src/admin/router.ts | 19 +- web/src/admin/stores/auth.ts | 1 + web/src/admin/stores/event.ts | 9 - web/src/admin/styles/miuix.css | 7 - web/src/admin/tests/scoring.test.ts | 230 +++++++++++ web/src/display/App.vue | 21 +- web/src/display/components/OfflineBadge.vue | 11 +- web/src/display/components/TickerBar.vue | 11 +- .../components/pages/AthleteStandingsPage.vue | 26 +- .../components/pages/EventResultsPage.vue | 2 +- .../components/pages/StandingsPage.vue | 4 +- .../components/pages/TeamMedalsPage.vue | 4 +- web/src/display/fitFont.ts | 6 + web/src/display/guards.ts | 4 +- web/src/display/mock.ts | 7 +- web/src/display/storage.ts | 8 +- web/src/display/tests/fitFont.test.ts | 15 + web/src/display/types.ts | 8 - web/src/display/useScoreboard.ts | 183 ++++----- web/src/shared/api.ts | 87 ++-- web/src/shared/format.ts | 13 +- web/src/shared/index.ts | 1 - web/src/shared/normalize.ts | 135 ------- web/src/shared/realtime.ts | 72 +++- web/src/shared/rules.ts | 7 +- web/src/shared/tests/api.test.ts | 41 ++ web/src/shared/tests/format.test.ts | 28 ++ web/src/shared/tests/realtime.test.ts | 164 ++++++++ web/src/shared/tests/register.mjs | 3 + web/src/shared/tests/resolve.mjs | 16 + web/src/shared/types.ts | 39 +- 124 files changed, 5658 insertions(+), 1820 deletions(-) create mode 100644 server/internal/auth/auth_test.go create mode 100644 server/internal/dto/events.go create mode 100644 server/internal/dto/settings_test.go create mode 100644 server/internal/dto/teams.go create mode 100644 server/internal/export/load_test.go create mode 100644 server/internal/httpapi/export_handlers_internal_test.go create mode 100644 server/internal/httpapi/export_handlers_test.go create mode 100644 server/internal/httpapi/handlers_config_test.go create mode 100644 server/internal/httpapi/handlers_results_test.go create mode 100644 server/internal/httpapi/handlers_users_test.go create mode 100644 server/internal/httpapi/idempotency_test.go create mode 100644 server/internal/httpapi/repo_misc_test.go create mode 100644 server/internal/httpapi/repo_results_test.go create mode 100644 server/internal/httpapi/repo_test.go create mode 100644 server/internal/httpapi/rules_handlers_test.go create mode 100644 server/internal/httpapi/scoring_test.go create mode 100644 server/internal/middleware/middleware_test.go create mode 100644 server/main_test.go create mode 100644 server/migrations/event/0012_ledger_entry_index_and_fk.sql delete mode 100644 web/src/admin/pages/PlaceholderPage.vue create mode 100644 web/src/admin/tests/scoring.test.ts create mode 100644 web/src/display/tests/fitFont.test.ts delete mode 100644 web/src/shared/normalize.ts create mode 100644 web/src/shared/tests/api.test.ts create mode 100644 web/src/shared/tests/format.test.ts create mode 100644 web/src/shared/tests/realtime.test.ts create mode 100644 web/src/shared/tests/register.mjs create mode 100644 web/src/shared/tests/resolve.mjs diff --git a/README.md b/README.md index f55c49f..64fe59c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ## 快速开始 1. 解压部署包 -2. 双击 `scoreboard.exe`(Linux 下执行 `./scoreboard`) +2. 双击 `Scoreboard Console.exe`(现场推荐:守护服务端、把大屏投到扩展屏、防熄屏、开机自启) 3. 浏览器打开终端提示的地址 ``` @@ -29,6 +29,8 @@ 管理面板 http://<主机IP>:19540/admin ``` +> 也可以直接双击 `scoreboard.exe`(Linux 下执行 `./scoreboard`)只跑服务端,大屏投放与进程守护需自行接管。 +> > 完全离线运行,不依赖外网,不需要安装任何运行时。 ## 文档 diff --git a/console/README.md b/console/README.md index 020853f..b72a9e4 100644 --- a/console/README.md +++ b/console/README.md @@ -27,7 +27,7 @@ console/ │ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播 │ └── tray-icon.js # 托盘图标(base64 内嵌,无外部资源文件) ├── scripts/ -│ └── check-syntax.js # npm run check:跨平台递归 node --check +│ └── check-syntax.js # pnpm check:跨平台递归 node --check ├── extra/ # 打包前由 deploy/build.bat 暂存 使用说明.txt(extraFiles 指向它) └── renderer/ ├── index.html # 管理窗口(含自绘标题栏) @@ -41,8 +41,8 @@ console/ ```bash cd console -npm install # 首次需要下载 Electron -npm start # 等价于 electron . +pnpm install # 首次需要下载 Electron +pnpm start # 等价于 electron . ``` 开发时把 `scoreboard.exe` 放在以下任一位置都能被找到(`config.json` 里的相对路径会按顺序探测): @@ -117,23 +117,23 @@ scoreboard-windows-amd64/ 配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。 -## 打包计划(electron-builder,尚未跑通) +## 打包(electron-builder) `package.json` 里已写好 `build` 段: ```bash cd console -npm install -npm run check # 跨平台语法检查(scripts/check-syntax.js) -npm run dist # electron-builder --win --x64 → ../dist/console/ -npm run pack # 只出目录,不生成安装包,便于现场调试 +pnpm install +pnpm check # 跨平台语法检查(scripts/check-syntax.js) +pnpm dist # electron-builder --win --x64 → ../dist/console/ +pnpm pack # 只出目录,不生成安装包,便于现场调试 ``` - 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.0-x64.*`)。 - `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`(exe 旁的可编辑配置,缺失时 Console 会回落到内置默认值)与 `../rules` 拷到安装目录,最终形成第 4 节里的部署包形态。 - `使用说明.txt` 是可选的:`deploy/build.bat` 在打包前依次找 `docs/使用说明.txt`、`docs/使用手册.md`,命中就暂存到 `console/extra/使用说明.txt`(`extraFiles` 会把该目录内容平铺到部署包根目录),都没有就跳过并在构建日志里注明。 -- 图标资源目录 `build/` 尚未提供,暂用 Electron 默认图标;补上 `build/icon.ico` 后自动生效。 -- **未在 Windows 构建机上实际执行过 `npm run dist`**(本机没有安装 Electron/electron-builder,也按要求未执行 npm install)。 +- 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。 +- 已在 Windows 构建机上通过 `deploy\build.bat` 实跑(含 `pnpm dist`,产出 NSIS 安装包 + zip);Linux 开发机未安装 Electron/electron-builder。 ## 尚未实机验证 @@ -143,4 +143,4 @@ npm run pack # 只出目录,不生成安装包,便于现场调试 - `powerSaveBlocker` 在长时间后台下的实际效果。 - 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。 - `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。 -- electron-builder 打包与 NSIS 安装。 +- electron-builder 打包已通过 `deploy\build.bat` 实跑;NSIS 安装包在真机上的安装 / 卸载流程尚未验证。 diff --git a/console/main/account.js b/console/main/account.js index 3737375..bb9aa79 100644 --- a/console/main/account.js +++ b/console/main/account.js @@ -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 } diff --git a/console/main/config.js b/console/main/config.js index ffcf932..4c13358 100644 --- a/console/main/config.js +++ b/console/main/config.js @@ -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 } diff --git a/console/main/displays.js b/console/main/displays.js index 1c424e1..c5c8b07 100644 --- a/console/main/displays.js +++ b/console/main/displays.js @@ -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 } diff --git a/console/main/index.js b/console/main/index.js index 257e763..672dac4 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -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() } diff --git a/console/main/instances.js b/console/main/instances.js index cf8e81c..53eeb73 100644 --- a/console/main/instances.js +++ b/console/main/instances.js @@ -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('请求超时'))) diff --git a/console/main/log-bus.js b/console/main/log-bus.js index 9233bd0..51b898d 100644 --- a/console/main/log-bus.js +++ b/console/main/log-bus.js @@ -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 diff --git a/console/main/service.js b/console/main/service.js index 4d6b6d0..0a22c52 100644 --- a/console/main/service.js +++ b/console/main/service.js @@ -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 } diff --git a/console/renderer/index.html b/console/renderer/index.html index bf68228..8ae1002 100644 --- a/console/renderer/index.html +++ b/console/renderer/index.html @@ -101,7 +101,7 @@

- 单屏时窗口化、按 F11 切换全屏;多屏时除主屏外每块屏一个 kiosk 窗口,各锁一页。 + 单屏时窗口化、按 F11 切换全屏;多屏时除内置屏外(无内置屏时除主屏外)每块屏一个 kiosk 窗口,各锁一页。