diff --git a/config.yaml b/config.yaml index 9f68414..62ffebf 100644 --- a/config.yaml +++ b/config.yaml @@ -2,9 +2,16 @@ server: host: 0.0.0.0 port: 19540 + # WebSocket 跨站 Origin 白名单(可选,path.Match 模式);同源与不带 Origin 的客户端始终放行 + allowedOrigins: [] # 运行时数据目录(数据库 / 备份 / 上传资源) dataDir: data +# 自动备份:按间隔把 system.db 与赛事库快照到 dataDir/backups +backup: + intervalMinutes: 5 # 备份间隔(分钟);<=0 关闭自动备份 + keep: 20 # 每个库滚动保留的份数,超出的旧备份自动删除 + log: level: info # debug | info | warn | error diff --git a/console/README.md b/console/README.md index 94c5165..020853f 100644 --- a/console/README.md +++ b/console/README.md @@ -1,12 +1,13 @@ # Scoreboard Console -赛事积分榜系统的桌面宿主(Electron)。它只做五件事: +赛事积分榜系统的桌面宿主(Electron)。它只做六件事: 1. 拉起并守护同目录(或配置指定)的 `scoreboard.exe`,把它的 stdout/stderr 接管到管理窗口与日志文件; 2. 用 `screen.getAllDisplays()` 枚举显示器,把大屏页面投到扩展屏; 3. `powerSaveBlocker.start('prevent-display-sleep')` 防熄屏; 4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单; -5. 打包后随系统启动,现场断电重启后无需人工干预。 +5. 打包后随系统启动,现场断电重启后无需人工干预; +6. 在服务管理窗口展示服务端在线的大屏实例(`GET /api/display/instances`)。 规范来源:`devdocs/06-大屏端设计.md` 第 6 节、`devdocs/10-部署与现场方案.md` 第 4 节。 @@ -20,6 +21,7 @@ console/ │ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期 │ ├── config.js # 配置加载与合并、exe 路径解析 │ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启 +│ ├── instances.js # 已连接大屏实例看板:复用管理面板登录态读 /api/display/instances │ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd │ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载 │ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播 @@ -77,7 +79,8 @@ scoreboard-windows-amd64/ | 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 | | 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 | | 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 | -| 停止服务 | 先 `POST server.shutdownPath` 请求服务端优雅关闭,`shutdownTimeoutMs` 内未退出再强杀;接口不可用时回退进程信号 | +| 停止服务 | 先 `POST server.shutdownPath` 并带 `X-Shutdown-Token`(启动时用 `crypto.randomBytes(32)` 生成,经环境变量 `SCOREBOARD_SHUTDOWN_TOKEN` 只交给该子进程)请求服务端优雅关闭;受理后最多等 `shutdownTimeoutMs`,超时或接口不可用(404/连接失败)再强杀 | +| 已连接大屏实例 | 管理窗口打开时每 5 秒读一次服务端 `GET /api/display/instances`(复用管理面板的 `scoreboard_session` Cookie,需记录员及以上),显示实例数、页面名与在线状态;窗口隐藏即停表,未登录/服务未启动时显示占位文案 | | 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 | | 开机自启 | 打包后在 Windows/macOS 上注册(`startup.openAtLogin`,默认开),开发态不注册 | | 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) | @@ -96,8 +99,8 @@ scoreboard-windows-amd64/ | `server.healthIntervalMs` | `5000` | 健康检查间隔 | | `server.restartDelayMs` | `3000` | 重启基础退避,每次翻倍,最多 5 倍 | | `server.maxRestarts` | `0` | 0 = 无限重启 | -| `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现),不可用时回退进程信号 | -| `server.shutdownTimeoutMs` | `12000` | 等待优雅关闭的上限,超过则强杀 | +| `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现并校验 `X-Shutdown-Token`),不可用时回退强杀 | +| `server.shutdownTimeoutMs` | `5000` | 服务端受理关闭请求后等待其自行退出的上限,超过则强杀 | | `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 | | `display.selection` | `all-secondary` | `all-secondary` / `all` / `specified` | | `display.displays` | `[]` | `specified` 时生效,填 display id 或 0 起序号 | diff --git a/console/config.json b/console/config.json index 8bc026c..5ad8976 100644 --- a/console/config.json +++ b/console/config.json @@ -9,7 +9,7 @@ "restartDelayMs": 3000, "maxRestarts": 0, "shutdownPath": "/api/shutdown", - "shutdownTimeoutMs": 12000, + "shutdownTimeoutMs": 5000, "healthyResetMs": 60000 }, "display": { diff --git a/console/main/config.js b/console/main/config.js index 671f3cb..ffcf932 100644 --- a/console/main/config.js +++ b/console/main/config.js @@ -16,9 +16,11 @@ const DEFAULTS = { restartDelayMs: 3000, // 0 表示无限重启;服务端是现场唯一进程,崩了必须拉起来 maxRestarts: 0, - // 优雅关闭握手:服务端需实现该回环接口,未实现时回退到进程信号 + // 优雅关闭握手:服务端需实现该回环接口并校验 X-Shutdown-Token, + // 未实现(404/连接失败)时回退到进程信号 shutdownPath: '/api/shutdown', - shutdownTimeoutMs: 12000, + // 服务端受理关闭请求后等待其自行退出的上限,超过则强杀 + shutdownTimeoutMs: 5000, // 连续健康这么久后清零崩溃计数,避免历史崩溃耗尽 maxRestarts healthyResetMs: 60000 }, diff --git a/console/main/index.js b/console/main/index.js index 9d214d3..257e763 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -15,6 +15,7 @@ const { loadConfig, resolveExecutable } = require('./config') const { LogBus } = require('./log-bus') const { ServiceSupervisor } = require('./service') const { DisplayManager } = require('./displays') +const { fetchDisplayInstances } = require('./instances') const { createTrayIcon } = require('./tray-icon') const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account') @@ -39,6 +40,9 @@ function main() { let quitting = false let shutdownDone = false let exePath = null + // 大屏实例看板只在管理窗口可见时轮询,窗口隐藏即停表 + let instancesTimer = null + let instancesLastReason = null // passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串 let accountBusy = false @@ -118,7 +122,12 @@ function main() { log.push('console', '管理窗口已最小化到托盘(服务端继续运行)') }) + // 实例看板只在窗口可见时轮询,隐藏/关闭即停表 + adminWindow.on('show', startInstancesPolling) + adminWindow.on('hide', stopInstancesPolling) + adminWindow.on('closed', () => { + stopInstancesPolling() adminWindow = null }) } @@ -130,6 +139,48 @@ function main() { return `http://${host || '127.0.0.1'}:${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 + } + + // 大屏实例看板:读服务端 GET /api/display/instances 并推给管理窗口。 + // 失败只按状态变化记一次日志,避免未登录/服务未启动时刷屏。 + async function pollDisplayInstances() { + let result + try { + result = await fetchDisplayInstances({ host: serverHost(), port: cfg.values.server.port }) + } catch (err) { + result = { ok: false, reason: 'error', message: `读取大屏实例失败:${err.message}` } + } + + if (result.ok) { + if (instancesLastReason) { + instancesLastReason = null + log.push('console', `大屏实例列表恢复读取(${result.instances.length} 个实例)`) + } + } else if (result.reason !== instancesLastReason) { + instancesLastReason = result.reason + log.push('console', `读取大屏实例失败:${result.message}`) + } + + sendToAdmin('display:instances', result) + return result + } + + function startInstancesPolling() { + if (instancesTimer) return + pollDisplayInstances() + instancesTimer = setInterval(pollDisplayInstances, 5000) + } + + function stopInstancesPolling() { + if (!instancesTimer) return + clearInterval(instancesTimer) + instancesTimer = null + } + function showServerAdmin() { if (!serverAdminWindow || serverAdminWindow.isDestroyed()) { createServerAdminWindow() @@ -352,6 +403,7 @@ function main() { showServerAdmin() return true }) + ipcMain.handle('display:instances-get', () => pollDisplayInstances()) // 窗口控制统一从 event.sender 反查窗口,多个窗口共用同一套通道 ipcMain.handle('window:minimize', (event) => { @@ -476,6 +528,7 @@ function main() { event.preventDefault() const finish = () => { + stopInstancesPolling() if (displays) displays.closeAll() if (powerBlockerId !== null) powerSaveBlocker.stop(powerBlockerId) if (log) log.close() @@ -491,6 +544,7 @@ function main() { }) app.on('quit', () => { + stopInstancesPolling() if (service) service.dispose() }) } diff --git a/console/main/instances.js b/console/main/instances.js new file mode 100644 index 0000000..cf8e81c --- /dev/null +++ b/console/main/instances.js @@ -0,0 +1,87 @@ +'use strict' + +const http = require('http') +const { session } = require('electron') + +// 大屏实例看板:复用服务端管理面板的登录态,不另造一套登录逻辑。 +// 管理面板窗口与 Console 同属默认 session,登录后 scoreboard_session 就存在这里, +// 主进程取同一张 Cookie 访问 GET /api/display/instances(记录员及以上可读)。 +// Cookie 名与服务端 server/internal/auth/auth.go 的 CookieSession 一致。 +const SESSION_COOKIE = 'scoreboard_session' +const REQUEST_TIMEOUT_MS = 3000 + +function requestJSON({ host, port, path, cookie }) { + return new Promise((resolve) => { + const request = http.get( + { + host, + port, + path, + timeout: REQUEST_TIMEOUT_MS, + headers: { + Cookie: cookie, + Accept: 'application/json', + Connection: 'close' + } + }, + (response) => { + const chunks = [] + response.on('data', (chunk) => chunks.push(chunk)) + response.on('end', () => { + resolve({ status: response.statusCode, body: Buffer.concat(chunks).toString('utf8') }) + }) + } + ) + request.on('timeout', () => request.destroy(new Error('请求超时'))) + request.on('error', (err) => resolve({ error: err })) + }) +} + +async function readSessionCookie(url) { + try { + const cookies = await session.defaultSession.cookies.get({ url }) + const hit = cookies.find((item) => item.name === SESSION_COOKIE) + return hit ? `${hit.name}=${hit.value}` : null + } catch { + return null + } +} + +// 成功返回 { ok: true, instances };失败返回 { ok: false, reason, message }, +// message 直接作为界面占位文案,reason 供调用方决定要不要记日志(避免失败时刷屏)。 +async function fetchDisplayInstances({ host, port }) { + const cookie = await readSessionCookie(`http://${host}:${port}/`) + if (!cookie) { + return { ok: false, reason: 'unauthenticated', message: '未登录,请先在服务端管理面板登录(记录员及以上)' } + } + + const result = await requestJSON({ host, port, path: '/api/display/instances', cookie }) + + if (result.error) { + if (result.error.code === 'ECONNREFUSED' || result.error.code === 'ECONNRESET') { + return { ok: false, reason: 'offline', message: '服务端未运行,暂时无法读取大屏实例' } + } + return { ok: false, reason: 'error', message: `读取大屏实例失败:${result.error.message}` } + } + if (result.status === 401) { + return { ok: false, reason: 'unauthenticated', message: '登录已失效,请重新登录服务端管理面板' } + } + if (result.status === 403) { + return { ok: false, reason: 'forbidden', message: '当前账号权限不足(需要记录员及以上)' } + } + if (result.status === 404) { + return { ok: false, reason: 'unsupported', message: '服务端版本较旧,未提供大屏实例接口' } + } + if (result.status !== 200) { + return { ok: false, reason: 'error', message: `读取大屏实例失败:HTTP ${result.status}` } + } + + try { + const payload = JSON.parse(result.body) + return { ok: true, instances: Array.isArray(payload.data) ? payload.data : [] } + } catch (err) { + return { ok: false, reason: 'error', message: `大屏实例响应无法解析:${err.message}` } + } +} + +module.exports = { fetchDisplayInstances } diff --git a/console/main/service.js b/console/main/service.js index fb9a185..4d6b6d0 100644 --- a/console/main/service.js +++ b/console/main/service.js @@ -1,5 +1,6 @@ 'use strict' +const crypto = require('crypto') const fs = require('fs') const http = require('http') const path = require('path') @@ -14,12 +15,14 @@ const STATE_LABEL = { crashed: '异常退出' } -// 优雅关闭握手:POST server.shutdownPath,让服务端自己走 signal.NotifyContext -// 的关闭流程;请求失败或接口未实现时回退到进程信号。 +// 优雅关闭握手:POST server.shutdownPath 并带上启动时下发的 token, +// 让服务端自己走 signal.NotifyContext 的关闭流程;请求失败或接口未实现时回退到强杀。 const DEFAULT_SHUTDOWN_PATH = '/api/shutdown' -const SHUTDOWN_REQUEST_TIMEOUT_MS = 2000 -// 服务端 srv.Shutdown 的超时是 10s,客户端必须留足余量再强杀 -const DEFAULT_SHUTDOWN_TIMEOUT_MS = 12000 +const SHUTDOWN_TOKEN_ENV = 'SCOREBOARD_SHUTDOWN_TOKEN' +const SHUTDOWN_TOKEN_HEADER = 'X-Shutdown-Token' +const SHUTDOWN_REQUEST_TIMEOUT_MS = 3000 +// 服务端收到关闭请求后承诺 3 秒内退出,客户端留 5 秒余量再强杀 +const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5000 // 握手不可用时信号已能结束进程,不必再等满超时 const FALLBACK_FORCE_KILL_MS = 1500 // 连续健康这么久才认为崩溃计数可以清零 @@ -48,6 +51,10 @@ class ServiceSupervisor extends EventEmitter { this.healthTimer = null this.restartTimer = null this.forceKillTimer = null + // 本次进程的关闭握手 token 与握手结果,仅用于日志区分「优雅退出 / 被强杀」 + this.shutdownToken = null + this.gracefulAccepted = false + this.forcedKill = false } get status() { @@ -103,16 +110,23 @@ class ServiceSupervisor extends EventEmitter { this.lastError = null this.healthySince = null + this.gracefulAccepted = false + this.forcedKill = false this.startedAt = new Date().toISOString() this.setState('starting') this.log.push('console', `启动服务端:${this.exePath} ${args.join(' ')}(cwd=${cwd})`) + // 每次拉起都换一把新 token,只通过环境变量交给这一个子进程, + // 停止时凭它调用服务端的 /api/shutdown + this.shutdownToken = crypto.randomBytes(32).toString('hex') + let child try { child = spawn(this.exePath, args, { cwd, windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'] + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, [SHUTDOWN_TOKEN_ENV]: this.shutdownToken } }) } catch (err) { this.setState('crashed', { lastError: `启动失败:${err.message}` }) @@ -157,7 +171,10 @@ class ServiceSupervisor extends EventEmitter { const reason = signal ? `信号 ${signal}` : `退出码 ${code}` if (this.stopRequested) { - this.log.push('console', `服务端已停止(${reason})`) + const how = this.forcedKill ? '已被强制结束' : this.gracefulAccepted ? '已优雅退出' : '已停止' + this.log.push('console', `服务端${how}(${reason})`) + this.gracefulAccepted = false + this.forcedKill = false this.setState('stopped', { lastError: null }) return } @@ -198,24 +215,25 @@ class ServiceSupervisor extends EventEmitter { this.stopRequested = true this.setState('stopping') - this.log.push('console', '正在停止服务端…') + this.log.push('console', '正在优雅关闭服务端…') child.once('exit', () => resolve()) - const timeoutMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS - // 先按最坏情况挂强杀兜底,握手成功后也不撤:服务端可能卡在关闭流程里 - this.armForceKill(timeoutMs) + const graceMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS this.requestShutdown().then((accepted) => { + // 请求期间进程可能已经退了,handleExit 会清掉兜底定时器 if (this.child !== child) return if (accepted) { - this.log.push('console', `服务端已接受关闭请求,最多等待 ${Math.round(timeoutMs / 1000)} 秒`) + this.gracefulAccepted = true + this.log.push('console', `服务端已接受优雅关闭请求,最多等待 ${Math.round(graceMs / 1000)} 秒自行退出`) + this.armForceKill(graceMs) return } - // 握手不可用:服务端还没实现该接口,或进程已无响应。 + // 握手不可用:服务端还是老版本没有该接口,或进程已无响应。 // 退回进程信号;Windows 上 Node 的 kill() 等同强杀,所以这条路只是兜底, - // 真正的优雅关闭依赖服务端补上 shutdownPath 接口。 - this.log.push('console', '关闭接口不可用,退回进程信号') + // 真正的优雅关闭依赖服务端实现 shutdownPath 接口。 + this.log.push('console', '优雅关闭接口不可用,退回强制结束') this.armForceKill(FALLBACK_FORCE_KILL_MS) try { child.kill() @@ -226,9 +244,9 @@ class ServiceSupervisor extends EventEmitter { }) } - // 优雅关闭握手:让服务端自己走 signal.NotifyContext → srv.Shutdown 的流程, - // 10s 内落盘退出。接口按回环地址访问、不鉴权,只在本机有意义。 - // 依赖:服务端实现 POST (2xx 表示已受理)。 + // 优雅关闭握手:带启动时下发的 X-Shutdown-Token 请求服务端退出, + // 2xx 表示已受理。老版本服务端没有该接口(404)或连不上时返回 false, + // 由调用方回退到强杀。 requestShutdown() { return new Promise((resolve) => { const body = Buffer.from('{}') @@ -242,13 +260,14 @@ class ServiceSupervisor extends EventEmitter { headers: { 'Content-Type': 'application/json', 'Content-Length': body.length, + [SHUTDOWN_TOKEN_HEADER]: this.shutdownToken || '', Connection: 'close' } }, (response) => { response.resume() const accepted = response.statusCode >= 200 && response.statusCode < 300 - if (!accepted) this.log.push('console', `关闭接口返回 HTTP ${response.statusCode}`) + if (!accepted) this.log.push('console', `关闭接口返回 HTTP ${response.statusCode}(按老版本处理)`) resolve(accepted) } ) @@ -266,6 +285,7 @@ class ServiceSupervisor extends EventEmitter { this.forceKillTimer = setTimeout(() => { this.forceKillTimer = null if (!this.child || !this.pid) return + this.forcedKill = true this.log.push('console', `服务端未在 ${Math.round(ms / 1000)} 秒内退出,强制结束`) try { this.child.kill('SIGKILL') diff --git a/console/renderer/app.js b/console/renderer/app.js index da8976a..acf80ef 100644 --- a/console/renderer/app.js +++ b/console/renderer/app.js @@ -13,6 +13,8 @@ const el = { config: document.getElementById('k-config'), configWarning: document.getElementById('config-warning'), displayList: document.getElementById('display-list'), + instanceList: document.getElementById('instance-list'), + instancesSummary: document.getElementById('instances-summary'), log: document.getElementById('log'), autoscroll: document.getElementById('autoscroll'), btnStart: document.getElementById('btn-start'), @@ -94,6 +96,45 @@ function renderDisplays(displays) { } } +function renderInstances(payload) { + el.instanceList.textContent = '' + + if (!payload || !payload.ok) { + el.instancesSummary.textContent = '' + const li = document.createElement('li') + li.className = 'muted' + li.textContent = (payload && payload.message) || '暂时无法读取大屏实例' + el.instanceList.append(li) + return + } + + const list = payload.instances || [] + el.instancesSummary.textContent = `共 ${list.length} 个 · ${list.filter((item) => item.online).length} 在线` + + if (!list.length) { + const li = document.createElement('li') + li.className = 'muted' + li.textContent = '当前没有大屏实例连接' + el.instanceList.append(li) + return + } + + for (const item of list) { + const li = document.createElement('li') + const page = document.createElement('span') + page.className = 'page' + page.textContent = item.page || '未知页面' + const id = document.createElement('span') + id.className = 'url' + id.textContent = item.id || '—' + const state = document.createElement('span') + state.className = `state ${item.online ? 'online' : 'offline'}` + state.textContent = item.online ? '在线' : '离线' + li.append(page, id, state) + el.instanceList.append(li) + } +} + function appendLog(line) { const atBottom = el.log.scrollTop + el.log.clientHeight >= el.log.scrollHeight - 8 el.log.textContent += `${line}\n` @@ -233,8 +274,11 @@ async function boot() { window.consoleApi.onStatus(renderStatus) window.consoleApi.onLog(appendLog) window.consoleApi.onAccountOutput(handleAccountOutput) + window.consoleApi.onDisplayInstances(renderInstances) setLog(await window.consoleApi.getLog()) await refresh() + // 主进程只在窗口可见时轮询;这里补一次,保证窗口刚打开就有数据 + renderInstances(await window.consoleApi.getDisplayInstances()) } boot() diff --git a/console/renderer/index.html b/console/renderer/index.html index 9a5e7b1..bf68228 100644 --- a/console/renderer/index.html +++ b/console/renderer/index.html @@ -108,6 +108,20 @@ +
+
+

已连接的大屏实例

+ +
+

+ 来自服务端 GET /api/display/instances,每 5 秒刷新一次; + 沿用服务端管理面板的登录态,需要记录员及以上权限。 +

+ +
+

管理员账号

diff --git a/console/renderer/preload.js b/console/renderer/preload.js index 96bc020..62b8c08 100644 --- a/console/renderer/preload.js +++ b/console/renderer/preload.js @@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld('consoleApi', { getLog: () => ipcRenderer.invoke('log:tail'), clearLog: () => ipcRenderer.invoke('log:clear'), relayoutDisplays: () => ipcRenderer.invoke('display:relayout'), + getDisplayInstances: () => ipcRenderer.invoke('display:instances-get'), openServerAdmin: () => ipcRenderer.invoke('app:open-server-admin'), resetAdminAccount: (payload) => ipcRenderer.invoke('account:reset', payload), minimize: () => ipcRenderer.invoke('window:minimize'), @@ -25,6 +26,7 @@ contextBridge.exposeInMainWorld('consoleApi', { isMaximized: () => ipcRenderer.invoke('window:is-maximized'), onMaximizeChange: (callback) => subscribe('window:maximize-changed', callback), onAccountOutput: (callback) => subscribe('account:output', callback), + onDisplayInstances: (callback) => subscribe('display:instances', callback), onLog: (callback) => subscribe('log:line', callback), onStatus: (callback) => subscribe('service:status', callback) }) diff --git a/console/renderer/styles.css b/console/renderer/styles.css index 938ad20..f2fc8c2 100644 --- a/console/renderer/styles.css +++ b/console/renderer/styles.css @@ -152,8 +152,8 @@ main { display: grid; gap: 14px; grid-template-columns: 1fr 1fr; - /* 四张卡片各占一整行;最后一行(运行日志)吃掉剩余高度 */ - grid-template-rows: auto auto auto minmax(160px, 1fr); + /* 五张卡片各占一整行;最后一行(运行日志)吃掉剩余高度 */ + grid-template-rows: auto auto auto auto minmax(160px, 1fr); overflow: auto; } @@ -326,6 +326,20 @@ h2 { word-break: break-all; } +.display-list .state { + margin-left: auto; + flex: none; + font-weight: 600; +} + +.display-list .state.online { + color: var(--ok); +} + +.display-list .state.offline { + color: var(--muted); +} + .check { display: flex; align-items: center; diff --git a/deploy/build.bat b/deploy/build.bat index a7e2488..ebd639e 100644 --- a/deploy/build.bat +++ b/deploy/build.bat @@ -29,9 +29,10 @@ cd .. echo === [3/4] server build === cd server if not exist ..\dist mkdir ..\dist -REM internal signature is injected from a machine-local file (gitignored, never in repo) +REM internal signature is injected from a machine-local file (gitignored, never in repo). +REM set /p returns empty under chcp 65001, so read the first line with for /f instead. set "INTERNAL_SIGNATURE=" -if exist "..\deploy\signature.local.txt" set /p INTERNAL_SIGNATURE=<"..\deploy\signature.local.txt" +if exist "..\deploy\signature.local.txt" for /f "usebackq delims= eol=" %%A in ("..\deploy\signature.local.txt") do if not defined INTERNAL_SIGNATURE set "INTERNAL_SIGNATURE=%%A" if defined INTERNAL_SIGNATURE ( REM single quotes keep spaces inside the value intact for go's ldflags parser go build -ldflags "-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.InternalSignature=%INTERNAL_SIGNATURE%'" -o ..\dist\scoreboard.exe . || exit /b 1 diff --git a/docs/使用手册.md b/docs/使用手册.md index fc9fb42..cb19c3b 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -351,6 +351,7 @@ - 列表显示备份文件名、来源、大小、创建时间; - 点「立即备份」马上生成一份; +- 服务端默认每 5 分钟自动备份一次,自动、手动、发布前备份都按 `backup.keep`(默认 20 份)滚动保留; - 右侧「恢复说明」列出停机恢复步骤(详见部署指南第 8 节)。 ### 14.3 赛事信息 diff --git a/docs/常见问题.md b/docs/常见问题.md index ccc3412..29c164d 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -85,13 +85,13 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 ## 数据与备份 **Q23:备份在哪里?** -`data\backups\`,文件名形如 `system-<时间戳>.db`、`<项目名>-<时间戳>.db`。 +`data\backups\`,文件名形如 `system-<原因>-<时间戳>.db`、`<项目名>-<原因>-<时间戳>.db`(原因有 `auto` / `manual` / `publish`)。 **Q24:多久备份一次?** -当前版本需要手动触发:管理端「系统设置 → 备份 → 立即备份」,或执行 `scoreboard.exe backup -config config.yaml`。建议在开幕前、发布高峰前、每天结束时各备份一次。 +服务端会自动备份,默认每 5 分钟一次(`config.yaml` 的 `backup.intervalMinutes`,设成 `0` 可关闭)。也可以手动触发:管理端「系统设置 → 备份 → 立即备份」,或执行 `scoreboard.exe backup -config config.yaml`。另外每次发布成绩前会自动备一份。 **Q25:备份会自动删除旧的么?** -命令行备份会按前缀滚动保留最近 20 份,超出自动清理;管理端「立即备份」当前不自动清理,请定期检查目录占用。 +会。自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算。 **Q26:怎么恢复备份?** 没有一键恢复接口,需停机手工替换数据库文件(复制备份为 `event.db`,删除 `event.db-wal` / `event.db-shm`,再启动),详见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。 diff --git a/docs/部署指南.md b/docs/部署指南.md index 80d1f2d..c79c05b 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -197,6 +197,9 @@ scoreboard.exe passwd -config config.yaml | `server.port` | `19540` | HTTP 端口 | | `dataDir` | `data` | 数据目录,相对服务端工作目录解析 | | `log.level` | `info` | 日志级别:`debug` / `info` / `warn` / `error` | +| `backup.intervalMinutes` | `5` | 自动备份间隔(分钟),`<=0` 关闭自动备份 | +| `backup.keep` | `20` | 每个库滚动保留的备份份数 | +| `server.allowedOrigins` | `[]` | 额外放行的 WebSocket Origin 白名单;默认只放行同源与不带 Origin 的客户端 | ### 6.2 Console `console\config.json` @@ -209,7 +212,7 @@ scoreboard.exe passwd -config config.yaml | `server.healthIntervalMs` | `5000` | 健康检查间隔(毫秒) | | `server.restartDelayMs` | `3000` | 崩溃重启的初始退避,之后逐次翻倍 | | `server.maxRestarts` | `0` | `0` = 无限重启 | -| `server.shutdownTimeoutMs` | `12000` | 等待服务端优雅退出的上限 | +| `server.shutdownTimeoutMs` | `5000` | 等待服务端优雅退出的上限 | | `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 | | `display.selection` | `all-secondary` | 选屏策略,见第 7 节 | | `display.displays` | `[]` | 仅 `specified` 策略生效,填显示器 id 或 0 起序号 | @@ -305,14 +308,16 @@ http://<主机IP>:19540/display | 方式 | 操作 | 产物 | | --- | --- | --- | -| 管理端 | 「系统设置 → 备份 → 立即备份」 | `system-<时间戳>.db` 与 `event-<时间戳>.db` | -| 命令行 | 在部署目录执行 `scoreboard.exe backup -config config.yaml` | `system-<原因>-<时间戳>.db` 与 `<项目名>-<原因>-<时间戳>.db` | -| 快捷脚本 | `deploy\backup.bat`(原因记 `manual`)或 `deploy\backup.bat publish`(原因记 `publish`) | 同上 | +| 自动 | 服务端启动后按 `backup.intervalMinutes`(默认 5 分钟) | `system-auto-<时间戳>.db` 与 `event-auto-<时间戳>.db` | +| 管理端 | 「系统设置 → 备份 → 立即备份」 | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` | +| 命令行 | 在部署目录执行 `scoreboard.exe backup -config config.yaml` | `system-manual-<时间戳>.db` 与 `event-manual-<时间戳>.db` | +| 快捷脚本 | `deploy\backup.bat`(原因记 `manual`)或 `deploy\backup.bat publish`(原因记 `publish`) | 同上(`publish` 时原因为 `publish`) | +| 发布前 | 每次发布成绩时自动 | `event-publish-<时间戳>.db` | - 备份使用 SQLite 的 `VACUUM INTO`,服务运行中也能安全执行,不需要停服务,也不依赖外部 `sqlite3.exe`; -- 命令行方式会按前缀**滚动保留最近 20 份**(`system` 与赛事库各自计算),超出的旧备份自动删除; -- 管理端「立即备份」按钮生成快照,但当前版本不会自动清理旧文件,请定期检查 `data\backups\` 占用; -- 现场建议:开幕前、每次发布高峰前、每天结束时各备份一次。 +- 自动、手动、命令行三种方式都按 `backup.keep`(默认 20 份)滚动保留,`system` 库与赛事库各自计算,超出的旧备份自动删除; +- 同一时刻只允许一个备份在进行,自动备份与手动、发布前备份不会互相踩; +- 现场建议:开幕前、每次发布高峰前、每天结束时各手动备份一次(自动备份只是兜底)。 ### 8.3 如何恢复 diff --git a/server/internal/config/config.go b/server/internal/config/config.go index cdb5611..e0ade28 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -14,13 +14,24 @@ import ( type Config struct { Server Server `yaml:"server"` DataDir string `yaml:"dataDir"` + Backup Backup `yaml:"backup"` Log Log `yaml:"log"` } +// Backup 是自动备份配置。 +type Backup struct { + // IntervalMinutes 是自动备份间隔(分钟);<=0 表示关闭自动备份。 + IntervalMinutes int `yaml:"intervalMinutes"` + // Keep 是每个库滚动保留的份数;<=0 时用 export.DefaultBackupKeep。 + Keep int `yaml:"keep"` +} + // Server 是 HTTP 监听配置。 type Server struct { Host string `yaml:"host"` Port int `yaml:"port"` + // AllowedOrigins 是 WebSocket 跨站 Origin 白名单;为空时只放行同源与不带 Origin 的客户端。 + AllowedOrigins []string `yaml:"allowedOrigins"` } // Log 是日志配置。 @@ -33,6 +44,7 @@ func Default() *Config { return &Config{ Server: Server{Host: "0.0.0.0", Port: 19540}, DataDir: "data", + Backup: Backup{IntervalMinutes: 5, Keep: 20}, Log: Log{Level: "info"}, } } diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go new file mode 100644 index 0000000..743e5e9 --- /dev/null +++ b/server/internal/config/config_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestDefaultAllowedOrigins(t *testing.T) { + if got := Default().Server.AllowedOrigins; len(got) != 0 { + t.Fatalf("默认白名单应为空: %v", got) + } +} + +func TestLoadAllowedOrigins(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + raw := "server:\n allowedOrigins:\n - http://a.example\n - https://b.example\n" + if err := os.WriteFile(path, []byte(raw), 0o644); err != nil { + t.Fatalf("写入配置: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("加载配置: %v", err) + } + want := []string{"http://a.example", "https://b.example"} + if !reflect.DeepEqual(cfg.Server.AllowedOrigins, want) { + t.Fatalf("白名单解析错误: got %v want %v", cfg.Server.AllowedOrigins, want) + } +} diff --git a/server/internal/dto/settings.go b/server/internal/dto/settings.go index f9beeeb..6df7b6f 100644 --- a/server/internal/dto/settings.go +++ b/server/internal/dto/settings.go @@ -43,6 +43,18 @@ type EventSettingsStandings struct { IndividualToTeamWeight float64 `json:"individualToTeamWeight"` RankingMode string `json:"rankingMode"` ShowIndividualBoard bool `json:"showIndividualBoard"` + ShowIndividualMedals bool `json:"showIndividualMedals"` +} + +// RuleSwitches 是 GET /api/rules/switches 的线上结构:只暴露规则包里的布尔开关, +// 不包含任何规则内容、模板明细或文件路径,供录入员判断相关功能是否启用。 +type RuleSwitches struct { + RecordBonusEnabled bool `json:"recordBonusEnabled"` + AdjustmentsEnabled bool `json:"adjustmentsEnabled"` + AdjustmentReasonRequired bool `json:"adjustmentReasonRequired"` + MedalCountsTeamEvents bool `json:"medalCountsTeamEvents"` + ShowIndividualBoard bool `json:"showIndividualBoard"` + ShowIndividualMedals bool `json:"showIndividualMedals"` } // ParseEventSettings 把 settings 表的点号键聚合成嵌套对象。 @@ -74,8 +86,10 @@ func ParseEventSettings(values map[string]string) EventSettings { out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1) out.Standings.RankingMode = orDefault(values["standings.mode"], "points") + // showIndividualBoard 是历史别名:未单独配置时沿用旧的 show_individual_medals 取值。 out.Standings.ShowIndividualBoard = boolDefault( firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true) + out.Standings.ShowIndividualMedals = boolDefault(values["standings.show_individual_medals"], true) out.RequireApproval = boolDefault(values["results.review_required"], false) return out diff --git a/server/internal/export/backup.go b/server/internal/export/backup.go index bffc320..8643be3 100644 --- a/server/internal/export/backup.go +++ b/server/internal/export/backup.go @@ -1,13 +1,16 @@ package export import ( + "context" "database/sql" "errors" "fmt" + "log/slog" "os" "path/filepath" "sort" "strings" + "sync" "time" ) @@ -48,7 +51,7 @@ func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) { } dir := strings.TrimSpace(opts.Dir) if dir == "" { - return BackupInfo{}, errors.New("export: 备份目录为空") + return BackupInfo{}, ErrNoDir } if err := os.MkdirAll(dir, 0o755); err != nil { return BackupInfo{}, fmt.Errorf("export: 创建备份目录 %s: %w", dir, err) @@ -100,6 +103,113 @@ func BackupBeforePublish(db *sql.DB, opts BackupOptions) (BackupInfo, error) { return Backup(db, opts) } +// Service 收拢「系统库 + 赛事库」的备份,并串行化手动、发布前、定时三类触发: +// 同一时刻只允许一个 VACUUM INTO 在跑,避免并发快照互相拖慢或写入同一文件。 +type Service struct { + system *sql.DB + event *sql.DB + dir string + keep int + logger *slog.Logger + + mu sync.Mutex +} + +// NewService 构造备份服务;keep<=0 时按 DefaultBackupKeep 滚动保留。 +func NewService(system, event *sql.DB, dir string, keep int, logger *slog.Logger) *Service { + if logger == nil { + logger = slog.Default() + } + return &Service{system: system, event: event, dir: dir, keep: keep, logger: logger} +} + +// Run 备份系统库与赛事库各一份(手动备份、定时备份用)。 +func (s *Service) Run(reason string) ([]BackupInfo, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.snapshotAll(reason, true) +} + +// RunEvent 只备份赛事库(发布前备份用);与 Run 共用同一把锁。 +func (s *Service) RunEvent(reason string) ([]BackupInfo, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.snapshotAll(reason, false) +} + +// StartAuto 跟随 ctx 定时备份:ctx 取消即停止,间隔 <=0 表示关闭。 +// +// 定时备份不写审计:一天下来会产生几百条 system.backup,把审计表刷满; +// 手动备份与发布前备份照旧写审计。 +func (s *Service) StartAuto(ctx context.Context, interval time.Duration) { + if interval <= 0 { + s.logger.Info("自动备份已关闭", "interval", interval.String()) + return + } + if strings.TrimSpace(s.dir) == "" { + s.logger.Warn("未配置数据目录,跳过自动备份") + return + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if ctx.Err() != nil { + return + } + infos, err := s.Run("auto") + if err != nil { + s.logger.Error("自动备份失败", "err", err) + continue + } + for _, info := range infos { + s.logger.Info("自动备份完成", "path", info.Path, "size", info.Size) + } + } + } + }() +} + +func (s *Service) snapshotAll(reason string, withSystem bool) ([]BackupInfo, error) { + if strings.TrimSpace(s.dir) == "" { + return nil, ErrNoDir + } + out := make([]BackupInfo, 0, 2) + if withSystem && s.system != nil { + info, err := s.snapshot(s.system, "system", reason) + if err != nil { + return out, err + } + out = append(out, info) + } + if s.event != nil { + info, err := s.snapshot(s.event, "event", reason) + if err != nil { + return out, err + } + out = append(out, info) + } + return out, nil +} + +func (s *Service) snapshot(db *sql.DB, prefix, reason string) (BackupInfo, error) { + info, err := Backup(db, BackupOptions{Dir: s.dir, Prefix: prefix, Reason: reason, Keep: s.keep}) + if err == nil { + return info, nil + } + // 快照已生成、只是滚动清理旧备份失败时 Backup 仍返回有效 info: + // 删不掉旧文件不算本次备份失败,记日志即可(下次备份会再试)。 + if info.Path != "" { + s.logger.Warn("清理旧备份失败", "prefix", prefix, "err", err) + return info, nil + } + return BackupInfo{}, err +} + // ListBackups 按时间倒序列出备份;prefix 为空时列出全部 .db 备份。 // 不计算校验和(避免每次列表都全量读盘),需要校验时用 Backup 的返回值。 func ListBackups(dir, prefix string) ([]BackupInfo, error) { @@ -150,7 +260,8 @@ func LatestBackup(dir, prefix string) (BackupInfo, bool, error) { return list[0], true, nil } -// PruneBackups 只保留最近 keep 份,返回被删除的文件路径。 +// PruneBackups 只保留最近 keep 份,返回被删除的文件路径; +// 个别文件删不掉只汇总到返回的错误里,不影响继续清理其余旧备份。 func PruneBackups(dir, prefix string, keep int) ([]string, error) { if keep <= 0 { keep = DefaultBackupKeep @@ -164,13 +275,15 @@ func PruneBackups(dir, prefix string, keep int) ([]string, error) { } removed := make([]string, 0, len(list)-keep) + var errs []error for _, b := range list[keep:] { if err := os.Remove(b.Path); err != nil && !os.IsNotExist(err) { - return removed, fmt.Errorf("export: 删除旧备份 %s: %w", b.Path, err) + errs = append(errs, fmt.Errorf("export: 删除旧备份 %s: %w", b.Path, err)) + continue } removed = append(removed, b.Path) } - return removed, nil + return removed, errors.Join(errs...) } func uniqueBackupPath(dir, prefix, reason string, now time.Time) string { @@ -184,15 +297,27 @@ func uniqueBackupPath(dir, prefix, reason string, now time.Time) string { } } -// reasonFromName 从 ---.db 里取回原因。 +// reasonFromName 从 ---[-<序号>].db 里取回原因; +// 同一秒重复备份会多一段序号,所以从右侧找「8 位日期 + 6 位时间」再截断。 func reasonFromName(name, prefix string) string { base := strings.TrimSuffix(name, filepath.Ext(name)) if prefix != "" { base = strings.TrimPrefix(base, prefix+"-") } parts := strings.Split(base, "-") - if len(parts) >= 3 { - return strings.Join(parts[:len(parts)-2], "-") + for i := len(parts) - 2; i >= 0; i-- { + if len(parts[i]) == 8 && len(parts[i+1]) == 6 && allDigits(parts[i]) && allDigits(parts[i+1]) { + return strings.Join(parts[:i], "-") + } } return "" } + +func allDigits(s string) bool { + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return s != "" +} diff --git a/server/internal/export/backup_test.go b/server/internal/export/backup_test.go index ffd3c4d..5884707 100644 --- a/server/internal/export/backup_test.go +++ b/server/internal/export/backup_test.go @@ -1,6 +1,9 @@ package export import ( + "context" + "io" + "log/slog" "os" "path/filepath" "testing" @@ -121,3 +124,133 @@ func TestListBackupsMissingDir(t *testing.T) { t.Errorf("空目录的最近备份: ok=%v err=%v", ok, err) } } + +// 间隔 <=0 关闭任务;未配置备份目录时安全跳过(不 panic、不生成文件)。 +func TestStartAutoDisabled(t *testing.T) { + dir := t.TempDir() + svc := NewService(nil, nil, dir, 20, testLogger()) + ctx := context.Background() + + svc.StartAuto(ctx, 0) + svc.StartAuto(ctx, -time.Minute) + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("读取备份目录: %v", err) + } + if len(entries) != 0 { + t.Fatalf("间隔 <=0 不应生成备份,实际 %d 个文件", len(entries)) + } + + // 未配置备份目录:只记日志并跳过,不能 panic。 + NewService(nil, nil, "", 20, testLogger()).StartAuto(ctx, time.Minute) +} + +// 定时任务按间隔生成 auto 备份。 +func TestStartAutoBacksUp(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + dir := t.TempDir() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + NewService(nil, db, dir, 20, testLogger()).StartAuto(ctx, 10*time.Millisecond) + + deadline := time.Now().Add(10 * time.Second) + for { + list, err := ListBackups(dir, "event") + if err != nil { + t.Fatalf("列出备份: %v", err) + } + if len(list) > 0 { + if list[0].Reason != "auto" { + t.Fatalf("定时备份原因 = %q,期望 auto", list[0].Reason) + } + return + } + if time.Now().After(deadline) { + t.Fatal("10 秒内未生成定时备份") + } + time.Sleep(10 * time.Millisecond) + } +} + +// 超过保留份数时删除最旧的备份。 +func TestServiceRunPrunesOldBackups(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + dir := t.TempDir() + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.Local) + for i := 0; i < 3; i++ { + if _, err := Backup(db, BackupOptions{ + Dir: dir, Prefix: "event", Reason: "auto", SkipPrune: true, + Now: base.Add(time.Duration(i) * time.Minute), + }); err != nil { + t.Fatalf("预置第 %d 份备份: %v", i, err) + } + } + + svc := NewService(nil, db, dir, 2, testLogger()) + if _, err := svc.RunEvent("auto"); err != nil { + t.Fatalf("RunEvent: %v", err) + } + + list, err := ListBackups(dir, "event") + if err != nil { + t.Fatalf("列出备份: %v", err) + } + if len(list) != 2 { + t.Fatalf("保留份数 = %d,期望 2", len(list)) + } + if _, err := os.Stat(filepath.Join(dir, "event-auto-20260101-000000.db")); !os.IsNotExist(err) { + t.Errorf("最旧的备份应被删除,err=%v", err) + } +} + +// 并发触发时必须等待同一把锁,保证同一时刻只有一个 VACUUM INTO 在跑。 +func TestServiceRunMutualExclusion(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + svc := NewService(nil, db, t.TempDir(), 20, testLogger()) + + svc.mu.Lock() + done := make(chan error, 1) + go func() { + _, err := svc.RunEvent("auto") + done <- err + }() + select { + case err := <-done: + t.Fatalf("RunEvent 未等待互斥锁就返回: %v", err) + case <-time.After(100 * time.Millisecond): + } + svc.mu.Unlock() + + select { + case err := <-done: + if err != nil { + t.Fatalf("RunEvent: %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("RunEvent 超时未完成") + } +} + +// 同一秒重复备份会带 -2 之类的序号,原因解析不能把它当成时间的一部分。 +func TestReasonFromNameWithSequence(t *testing.T) { + cases := []struct{ name, prefix, want string }{ + {"event-auto-20260909-213614.db", "event", "auto"}, + {"event-auto-20260909-213614-2.db", "event", "auto"}, + {"event-publish-20260909-213614-10.db", "event", "publish"}, + {"system-manual-20260909-213614.db", "system", "manual"}, + } + for _, c := range cases { + if got := reasonFromName(c.name, c.prefix); got != c.want { + t.Errorf("reasonFromName(%q) = %q,期望 %q", c.name, got, c.want) + } + } +} + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} diff --git a/server/internal/export/doc.go b/server/internal/export/doc.go index 6e5ab77..8d34957 100644 --- a/server/internal/export/doc.go +++ b/server/internal/export/doc.go @@ -14,6 +14,8 @@ import "errors" var ( // ErrNoDatabase 表示调用方传入了空的数据库连接。 ErrNoDatabase = errors.New("export: 数据库连接为空") + // ErrNoDir 表示未配置备份目录。 + ErrNoDir = errors.New("export: 备份目录为空") // ErrInvalidPackage 表示赛事项目包结构非法(缺 meta.yaml、路径穿越、校验信息缺失等)。 ErrInvalidPackage = errors.New("export: 赛事项目包非法") // ErrUnsupportedVersion 表示包内 meta.yaml 的格式版本高于当前程序能读的版本。 diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index d3d6557..34b2d7a 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -1,7 +1,5 @@ package httpapi -import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" - // 本文件是 REST 的 JSON 契约(camelCase,与 web/src/shared/types.ts 一一对应)。 // 数据库列是 snake_case,转换集中放在 repo.go 的扫描函数里。 @@ -135,22 +133,6 @@ type resultDTO struct { Points *float64 `json:"points,omitempty"` } -type standingRowDTO struct { - TeamID int64 `json:"teamId"` - TeamCode string `json:"teamCode"` - TeamName string `json:"teamName"` - ShortName *string `json:"shortName"` - Color *string `json:"color"` - LogoPath *string `json:"logoPath"` - Rank int `json:"rank"` - Gold int `json:"gold"` - Silver int `json:"silver"` - Bronze int `json:"bronze"` - MedalTotal int `json:"medalTotal"` - TotalPoints float64 `json:"totalPoints"` - RankChange *int `json:"rankChange"` -} - type athleteStandingDTO struct { Rank int `json:"rank"` AthleteID int64 `json:"athleteId"` @@ -254,37 +236,7 @@ type backupResultDTO struct { CreatedAt string `json:"createdAt"` } -// eventSettingsDTO 是 GET/PUT /api/settings 的响应;服务端把 settings 表的点号键聚合成嵌套对象。 -type eventSettingsDTO struct { - Event eventSettingsEvent `json:"event"` - Display eventSettingsDisplay `json:"display"` - Standings eventSettingsStandings `json:"standings"` - DefaultProfileID *int64 `json:"defaultProfileId"` - RequireApproval bool `json:"requireApproval"` -} - -type eventSettingsEvent struct { - Name string `json:"name"` - Subtitle *string `json:"subtitle"` - Host *string `json:"host"` - LogoPath *string `json:"logoPath"` - PrimaryColor *string `json:"primaryColor"` -} - -type eventSettingsDisplay struct { - Background *string `json:"background"` - Logo *string `json:"logo"` - Title string `json:"title"` - Subtitle *string `json:"subtitle"` - PrimaryColor *string `json:"primaryColor"` - RotateSecond int `json:"rotateSeconds"` -} - -type eventSettingsStandings struct { - IndividualToTeamWeight float64 `json:"individualToTeamWeight"` - RankingMode string `json:"rankingMode"` - ShowIndividualBoard bool `json:"showIndividualBoard"` -} +// GET/PUT /api/settings 的线上结构见 internal/dto.EventSettings(与 snapshot.settings 共用)。 // ---------- 用户管理 ---------- @@ -362,41 +314,6 @@ type revisionTeamDeltaDTO struct { // ---------- domain → DTO ---------- -func standingRowsDTO(rows []domain.StandingsRow, teams map[int64]teamDTO) []standingRowDTO { - out := make([]standingRowDTO, 0, len(rows)) - for _, r := range rows { - t := teams[r.TeamID] - dto := standingRowDTO{ - TeamID: r.TeamID, - TeamCode: r.Code, - TeamName: r.Name, - ShortName: nilIfEmpty(r.ShortName), - Rank: r.Rank, - Gold: r.Gold, - Silver: r.Silver, - Bronze: r.Bronze, - MedalTotal: r.Medals, - TotalPoints: r.Points, - RankChange: nil, - } - if t.ID != 0 { - dto.Color = t.Color - dto.LogoPath = t.LogoPath - if dto.TeamCode == "" { - dto.TeamCode = t.Code - } - if dto.TeamName == "" { - dto.TeamName = t.Name - } - if dto.ShortName == nil { - dto.ShortName = t.ShortName - } - } - out = append(out, dto) - } - return out -} - func nilIfEmpty(s string) *string { if s == "" { return nil diff --git a/server/internal/httpapi/handlers_system.go b/server/internal/httpapi/handlers_system.go index ca63f8d..242c44d 100644 --- a/server/internal/httpapi/handlers_system.go +++ b/server/internal/httpapi/handlers_system.go @@ -1,16 +1,25 @@ package httpapi import ( + "crypto/subtle" "net/http" + "os" + "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" "git.yeij.top/AskaEth/Scoreboard/server/internal/version" ) -// systemRoutes 挂载系统级只读信息。 +// shutdownTokenHeader 是 Console 优雅关闭握手用的请求头,与环境变量比对。 +const shutdownTokenHeader = "X-Shutdown-Token" + +// systemRoutes 挂载系统级只读信息与优雅关闭握手。 func (s *server) systemRoutes(mux *http.ServeMux) { // 内部署名是编译期注入的,不暴露给匿名访客:至少要登录(recorder 及以上)才能读到 mux.HandleFunc("GET /api/system/about", s.read(auth.RoleRecorder, s.systemAbout)) + // 关闭接口自带鉴权(admin 会话或 X-Shutdown-Token),不能走 s.write 的角色中间件。 + mux.HandleFunc("POST /api/shutdown", s.handle(s.shutdownHandler)) } func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error) { @@ -18,3 +27,41 @@ func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error "internalSignature": version.InternalSignature, }, nil } + +// shutdownHandler 处理 POST /api/shutdown:返回 202 后触发 main 注入的优雅关闭回调。 +func (s *server) shutdownHandler(w http.ResponseWriter, r *http.Request) (any, error) { + method, ok := s.shutdownAuth(r) + if !ok { + // 没带任何凭据的访客按「需登录」处理,其余一律 403,避免探测。 + if middleware.User(r.Context()).Role == auth.RoleGuest && r.Header.Get(shutdownTokenHeader) == "" { + return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"} + } + return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN", message: "无权关闭服务"} + } + if s.shutdown == nil { + return nil, &apiError{status: http.StatusServiceUnavailable, code: "INTERNAL", message: "服务端未接入优雅关闭"} + } + + s.audit(r.Context(), "system.shutdown", "system", 0, nil, map[string]any{"auth": method}) + writeJSON(w, http.StatusAccepted, map[string]any{"ok": true}) + // 关闭放到响应写出之后:srv.Shutdown 会等本次请求(含审计落库)结束,不会截断 202。 + go s.shutdown() + return nil, errHandled +} + +// shutdownAuth 校验关闭凭据:admin 会话,或与环境变量 SCOREBOARD_SHUTDOWN_TOKEN 常量时间相等的请求头。 +// 环境变量为空时只认 admin 会话。 +func (s *server) shutdownAuth(r *http.Request) (string, bool) { + if middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) { + return "session", true + } + expected := strings.TrimSpace(os.Getenv("SCOREBOARD_SHUTDOWN_TOKEN")) + if expected == "" { + return "", false + } + got := r.Header.Get(shutdownTokenHeader) + if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 { + return "", false + } + return "token", true +} diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go index c1b4984..f046fac 100644 --- a/server/internal/httpapi/payload.go +++ b/server/internal/httpapi/payload.go @@ -97,6 +97,7 @@ type settingsStandingsPatch struct { IndividualToTeamWeight *float64 `json:"individualToTeamWeight"` RankingMode *string `json:"rankingMode"` ShowIndividualBoard *bool `json:"showIndividualBoard"` + ShowIndividualMedals *bool `json:"showIndividualMedals"` } type categoriesPayload struct { diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index fba03ef..4d66944 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -4,14 +4,13 @@ import ( "context" "database/sql" "errors" - "fmt" - "os" - "path/filepath" "strconv" "strings" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" + "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" + "git.yeij.top/AskaEth/Scoreboard/server/internal/export" ) // ---------- settings ---------- @@ -34,38 +33,13 @@ func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error) return out, rows.Err() } -func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) { +func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) { values, err := loadSettingsMap(ctx, s.ev) if err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } - var out eventSettingsDTO - out.Event.Name = orDefault(values["event.name"], "未命名赛事") - out.Event.Subtitle = nilIfEmpty(values["event.subtitle"]) - out.Event.Host = nilIfEmpty(values["event.organizer"]) - out.Event.LogoPath = nilIfEmpty(values["event.logo_path"]) - out.Event.PrimaryColor = nilIfEmpty(values["display.theme_color"]) - - out.Display.Background = nilIfEmpty(values["display.background"]) - out.Display.Logo = nilIfEmpty(values["display.logo"]) - if out.Display.Logo == nil { - out.Display.Logo = out.Event.LogoPath - } - out.Display.Title = orDefault(values["display.title"], out.Event.Name) - out.Display.Subtitle = nilIfEmpty(values["display.subtitle"]) - if out.Display.Subtitle == nil { - out.Display.Subtitle = out.Event.Subtitle - } - out.Display.PrimaryColor = out.Event.PrimaryColor - out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15) - - out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1) - out.Standings.RankingMode = orDefault(values["standings.mode"], "points") - out.Standings.ShowIndividualBoard = boolDefault( - firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true) - - out.RequireApproval = boolDefault(values["results.review_required"], false) + out := dto.ParseEventSettings(values) if code := values["scoring.default_profile"]; code != "" { var id int64 @@ -74,7 +48,7 @@ func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) { case errors.Is(err, sql.ErrNoRows): // 配置引用的模板已被删除:返回 null,由管理端提示重新选择。 case err != nil: - return eventSettingsDTO{}, internal(err) + return dto.EventSettings{}, internal(err) default: out.DefaultProfileID = &id } @@ -82,10 +56,10 @@ func (s *server) getSettings(ctx context.Context) (eventSettingsDTO, error) { return out, nil } -func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (eventSettingsDTO, error) { +func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.EventSettings, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { - return eventSettingsDTO{}, internal(err) + return dto.EventSettings{}, internal(err) } defer func() { _ = tx.Rollback() }() @@ -108,40 +82,40 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (event if p := patch.Event; p != nil { if err := setPtr("event.name", p.Name); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } if err := setPtr("event.subtitle", p.Subtitle); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } if err := setPtr("event.organizer", p.Host); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } if err := setPtr("event.logo_path", p.LogoPath); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } if err := setPtr("display.theme_color", p.PrimaryColor); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } } if p := patch.Display; p != nil { if err := setPtr("display.background", p.Background); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } if err := setPtr("display.logo", p.Logo); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } if err := setPtr("display.title", p.Title); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } if err := setPtr("display.subtitle", p.Subtitle); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } if err := setPtr("display.theme_color", p.PrimaryColor); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } if p.RotateSeconds != nil { if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } } } @@ -149,15 +123,20 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (event if p.IndividualToTeamWeight != nil { if err := set("standings.individual_to_team_weight", strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } } if err := setPtr("standings.mode", p.RankingMode); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } if p.ShowIndividualBoard != nil { if err := set("standings.show_individual_board", strconv.FormatBool(*p.ShowIndividualBoard)); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err + } + } + if p.ShowIndividualMedals != nil { + if err := set("standings.show_individual_medals", strconv.FormatBool(*p.ShowIndividualMedals)); err != nil { + return dto.EventSettings{}, err } } } @@ -166,44 +145,27 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (event if *patch.DefaultProfileID != 0 { if err := tx.QueryRowContext(ctx, `SELECT code FROM scoring_profiles WHERE id = ?`, *patch.DefaultProfileID).Scan(&code); err != nil { if errors.Is(err, sql.ErrNoRows) { - return eventSettingsDTO{}, badRequest("defaultProfileId 不存在", nil) + return dto.EventSettings{}, badRequest("defaultProfileId 不存在", nil) } - return eventSettingsDTO{}, internal(err) + return dto.EventSettings{}, internal(err) } } if err := set("scoring.default_profile", code); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } } if patch.RequireApproval != nil { if err := set("results.review_required", strconv.FormatBool(*patch.RequireApproval)); err != nil { - return eventSettingsDTO{}, err + return dto.EventSettings{}, err } } if err := tx.Commit(); err != nil { - return eventSettingsDTO{}, internal(err) + return dto.EventSettings{}, internal(err) } return s.getSettings(ctx) } -func firstNonEmpty(values ...string) string { - for _, v := range values { - if strings.TrimSpace(v) != "" { - return v - } - } - return "" -} - -func atoiDefault(v string, def int) int { - n, err := strconv.Atoi(strings.TrimSpace(v)) - if err != nil { - return def - } - return n -} - func atofDefault(v string, def float64) float64 { n, err := strconv.ParseFloat(strings.TrimSpace(v), 64) if err != nil { @@ -261,7 +223,7 @@ func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.Stan // standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单, // 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。 -func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]standingRowDTO, error) { +func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) { in, err := s.loadStandingsInput(ctx, q) if err != nil { return nil, err @@ -270,7 +232,31 @@ func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]standingRo if err != nil { return nil, err } - return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil + return dto.StandingRows(domain.BuildTeamStandings(in), domainTeamsFromMap(teams)), nil +} + +// domainTeamsFromMap 把 teams 表的 DTO 还原成 domain.Team,供 dto.StandingRows 补齐展示字段。 +// 这里用全量单位表(含停用单位):榜单兜底会带上流水里的停用单位,否则编码/名称会为空。 +func domainTeamsFromMap(teams map[int64]teamDTO) []domain.Team { + out := make([]domain.Team, 0, len(teams)) + for _, t := range teams { + out = append(out, domain.Team{ + ID: t.ID, + Code: t.Code, + Name: t.Name, + ShortName: strValue(t.ShortName), + LogoPath: strValue(t.LogoPath), + Color: strValue(t.Color), + }) + } + return out +} + +func strValue(s *string) string { + if s == nil { + return "" + } + return *s } // bumpStandings 在写事务内把整张榜单写入 change_log;榜单变化后必须调用,否则大屏不会刷新。 @@ -823,25 +809,19 @@ func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType s // ---------- backup ---------- -// backup 用 VACUUM INTO 生成一致性快照(见 devdocs/08 第 6 节)。 +// backup 生成系统库与赛事库的一致性快照(见 devdocs/08 第 6 节)。 +// 手动备份写审计;定时备份不写(见 export.Service.StartAuto 的说明)。 func (s *server) backup(ctx context.Context) (backupResultDTO, error) { - if strings.TrimSpace(s.dataDir) == "" { + infos, err := s.backups.Run("manual") + if err != nil { + if errors.Is(err, export.ErrNoDir) { + return backupResultDTO{}, internal(errors.New("未配置数据目录")) + } + return backupResultDTO{}, internal(err) + } + if len(infos) == 0 { return backupResultDTO{}, internal(errors.New("未配置数据目录")) } - dir := filepath.Join(s.dataDir, "backups") - if err := os.MkdirAll(dir, 0o755); err != nil { - return backupResultDTO{}, internal(err) - } - now := time.Now() - stamp := now.Format("20060102-150405") - path := filepath.Join(dir, fmt.Sprintf("system-%s.db", stamp)) - if _, err := s.sys.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil { - return backupResultDTO{}, internal(err) - } - eventPath := filepath.Join(dir, fmt.Sprintf("event-%s.db", stamp)) - if _, err := s.ev.ExecContext(ctx, `VACUUM INTO ?`, eventPath); err != nil { - return backupResultDTO{}, internal(err) - } - s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": path}) - return backupResultDTO{Path: path, CreatedAt: now.UTC().Format(time.RFC3339)}, nil + s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": infos[0].Path}) + return backupResultDTO{Path: infos[0].Path, CreatedAt: infos[0].CreatedAt.UTC().Format(time.RFC3339)}, nil } diff --git a/server/internal/httpapi/rules_handlers.go b/server/internal/httpapi/rules_handlers.go index 4666c11..9a99f6c 100644 --- a/server/internal/httpapi/rules_handlers.go +++ b/server/internal/httpapi/rules_handlers.go @@ -6,6 +6,7 @@ import ( "net/http" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" "git.yeij.top/AskaEth/Scoreboard/server/internal/rules" ) @@ -13,6 +14,25 @@ import ( func (s *server) rulesRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/rules/export", s.read(auth.RoleAdmin, s.exportRules)) mux.HandleFunc("POST /api/rules/import", s.write(auth.RoleAdmin, s.importRules)) + // 录入员要判断破纪录加分/人工调整是否启用,但无权读 /api/settings,故单开只读开关。 + mux.HandleFunc("GET /api/rules/switches", s.read(auth.RoleRecorder, s.ruleSwitches)) +} + +// ruleSwitches 只返回布尔开关,不下发规则内容、模板明细或文件路径。 +func (s *server) ruleSwitches(w http.ResponseWriter, r *http.Request) (any, error) { + snap, err := rules.Load(s.ev) + if err != nil { + return nil, internal(err) + } + pack := snap.Pack + return dto.RuleSwitches{ + RecordBonusEnabled: pack.RecordBonus.Enabled, + AdjustmentsEnabled: pack.ManualAdjustment.Enabled, + AdjustmentReasonRequired: pack.ManualAdjustment.ReasonRequired, + MedalCountsTeamEvents: pack.Standings.MedalCountsTeamEventsEnabled(), + ShowIndividualBoard: pack.Standings.ShowIndividualBoardEnabled(), + ShowIndividualMedals: pack.Standings.ShowIndividualMedalsEnabled(), + }, nil } func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error) { diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index 6118a7a..5549dca 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -5,8 +5,6 @@ import ( "database/sql" "encoding/json" "errors" - "path/filepath" - "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" "git.yeij.top/AskaEth/Scoreboard/server/internal/export" @@ -454,20 +452,19 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i // backupBeforePublish 在发布前留一份快照;失败只记日志与审计,不阻塞发布。 func (s *server) backupBeforePublish(ctx context.Context) { - if strings.TrimSpace(s.dataDir) == "" { - return - } - info, err := export.BackupBeforePublish(s.ev, export.BackupOptions{ - Dir: filepath.Join(s.dataDir, "backups"), - Prefix: "event", - Reason: "publish", - }) + infos, err := s.backups.RunEvent("publish") if err != nil { + if errors.Is(err, export.ErrNoDir) { + return // 未配置数据目录:静默跳过,不写失败审计 + } s.logger.Error("发布前备份失败", "err", err) s.audit(ctx, "system.backup_failed", "backup", 0, nil, map[string]any{"reason": "publish", "error": err.Error()}) return } - s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"reason": "publish", "path": info.Path}) + if len(infos) == 0 { + return + } + s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"reason": "publish", "path": infos[0].Path}) } func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index bb0a8d5..cfd03a2 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -6,10 +6,12 @@ import ( "io/fs" "log/slog" "net/http" + "path/filepath" "strings" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/export" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" "git.yeij.top/AskaEth/Scoreboard/server/internal/webui" ) @@ -31,6 +33,11 @@ type Options struct { Auth *auth.Service // DataDir 用于备份目录 data/backups。 DataDir string + // Backup 可选,手动/发布前备份共用的服务;为空时按 System/Event/DataDir 自动构建。 + Backup *export.Service + // Shutdown 可选,由 main 注入:触发与 SIGINT/SIGTERM 相同的优雅关闭流程。 + // 为空时 POST /api/shutdown 返回 503,避免把「没接关闭」误报成已受理。 + Shutdown func() // Display 可选,实时层接入后传入。 Display DisplaySender // Realtime 可选,实时层接入后传入:写事务提交后推送变更,并提供在线大屏实例。 @@ -51,6 +58,8 @@ type server struct { auth *auth.Service dataDir string projectDir string + backups *export.Service + shutdown func() display DisplaySender realtime Realtime idem *idempotencyStore @@ -66,6 +75,14 @@ func New(opts Options) http.Handler { if svc == nil { svc = auth.New(opts.System, auth.Options{Logger: logger}) } + backups := opts.Backup + if backups == nil { + dir := "" + if strings.TrimSpace(opts.DataDir) != "" { + dir = filepath.Join(opts.DataDir, "backups") + } + backups = export.NewService(opts.System, opts.Event, dir, 0, logger) + } s := &server{ logger: logger, @@ -75,6 +92,8 @@ func New(opts Options) http.Handler { auth: svc, dataDir: opts.DataDir, projectDir: opts.ProjectDir, + backups: backups, + shutdown: opts.Shutdown, display: opts.Display, realtime: opts.Realtime, idem: newIdempotencyStore(10 * time.Minute), diff --git a/server/internal/httpapi/system_test.go b/server/internal/httpapi/system_test.go new file mode 100644 index 0000000..5ff4b25 --- /dev/null +++ b/server/internal/httpapi/system_test.go @@ -0,0 +1,301 @@ +package httpapi_test + +import ( + "encoding/json" + "net/http" + "strings" + "sync" + "testing" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" +) + +// waitClosed 等待关闭回调触发;没触发就报错。 +func waitClosed(t *testing.T, done <-chan struct{}) { + t.Helper() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("等待关闭回调超时") + } +} + +func assertNotClosed(t *testing.T, done <-chan struct{}) { + t.Helper() + select { + case <-done: + t.Fatal("鉴权失败时不应触发关闭") + default: + } +} + +// waitAuditAction 等待审计落库:Auditor 在 handler 返回后才写,存在极短竞态。 +func waitAuditAction(t *testing.T, env *testEnv, action string) int { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + var count int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action).Scan(&count); err != nil { + t.Fatalf("查询审计: %v", err) + } + if count > 0 { + return count + } + if time.Now().After(deadline) { + t.Fatalf("等待审计 %s 超时", action) + } + time.Sleep(10 * time.Millisecond) + } +} + +func newShutdownEnv(t *testing.T) (*testEnv, chan struct{}) { + t.Helper() + done := make(chan struct{}) + var once sync.Once + return newTestEnvWith(t, func(o *httpapi.Options) { + o.Shutdown = func() { once.Do(func() { close(done) }) } + }), done +} + +// POST /api/shutdown:admin 会话可关闭;guest 401、recorder 403;成功只回 {"ok":true} 且写审计。 +func TestShutdownByAdminSession(t *testing.T) { + t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "") + env, done := newShutdownEnv(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + + // 未登录:401,不触发关闭。 + if status, env2 := env.do(client, http.MethodPost, "/api/shutdown", nil, false); status != http.StatusUnauthorized || + env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" { + t.Fatalf("guest 关闭应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error) + } + assertNotClosed(t, done) + + // recorder:403,不触发关闭。 + env.createUser("recorder", "recorder-pass", "recorder") + rec, _ := env.client() + env.login(rec, "recorder", "recorder-pass") + if status, env2 := env.do(rec, http.MethodPost, "/api/shutdown", nil, true); status != http.StatusForbidden || + env2.Error == nil || env2.Error.Code != "FORBIDDEN" { + t.Fatalf("recorder 关闭应 403 FORBIDDEN,得到 %d %+v", status, env2.Error) + } + assertNotClosed(t, done) + + // admin:202 + 裸 {"ok":true}(不套 data 包装),并触发关闭回调。 + env.login(client, "admin", "admin-pass") + status, raw := env.doRaw(client, http.MethodPost, "/api/shutdown", []byte("{}"), "application/json", true) + if status != http.StatusAccepted { + t.Fatalf("admin 关闭应 202,得到 %d %s", status, raw) + } + if got := strings.TrimSpace(string(raw)); got != `{"ok":true}` { + t.Fatalf("关闭响应应为 {\"ok\":true},得到 %s", got) + } + waitClosed(t, done) + + if count := waitAuditAction(t, env, "system.shutdown"); count != 1 { + t.Fatalf("关闭应写 1 条 system.shutdown 审计,得到 %d", count) + } + var after string + if err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = 'system.shutdown' ORDER BY id DESC LIMIT 1`).Scan(&after); err != nil { + t.Fatalf("读取关闭审计: %v", err) + } + if !strings.Contains(after, `"session"`) { + t.Fatalf("关闭审计应标明凭据来源,得到 %s", after) + } +} + +// 环境变量配置后:X-Shutdown-Token 正确即可关闭(无需会话),错误则 403。 +func TestShutdownByToken(t *testing.T) { + t.Setenv("SCOREBOARD_SHUTDOWN_TOKEN", "s3cret-token") + env, done := newShutdownEnv(t) + client, _ := env.client() + + if status, _ := env.doH(client, http.MethodPost, "/api/shutdown", nil, false, + map[string]string{"X-Shutdown-Token": "wrong"}); status != http.StatusForbidden { + t.Fatalf("错误 token 应 403,得到 %d", status) + } + assertNotClosed(t, done) + + status, env2 := env.doH(client, http.MethodPost, "/api/shutdown", nil, false, + map[string]string{"X-Shutdown-Token": "s3cret-token"}) + if status != http.StatusAccepted { + t.Fatalf("正确 token 应 202,得到 %d %+v", status, env2.Error) + } + waitClosed(t, done) + + // 没注入关闭回调时应拒绝受理,避免谎报成功。 + bare := newTestEnv(t) + bareClient, _ := bare.client() + if status, _ := bare.doH(bareClient, http.MethodPost, "/api/shutdown", nil, false, + map[string]string{"X-Shutdown-Token": "s3cret-token"}); status != http.StatusServiceUnavailable { + t.Fatalf("未接入关闭回调应 503,得到 %d", status) + } +} + +// GET /api/rules/switches:recorder 可读、guest 403,只返回布尔开关。 +func TestRuleSwitchesReadableByRecorder(t *testing.T) { + env := newTestEnv(t) + env.createUser("recorder", "recorder-pass", "recorder") + + guest, _ := env.client() + if status, _ := env.do(guest, http.MethodGet, "/api/rules/switches", nil, false); status != http.StatusForbidden { + t.Fatalf("guest 读规则开关应 403,得到 %d", status) + } + + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + status, env2 := env.do(client, http.MethodGet, "/api/rules/switches", nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("recorder 读规则开关应 200,得到 %d %+v", status, env2.Error) + } + + var switches map[string]any + if err := json.Unmarshal(env2.Data, &switches); err != nil { + t.Fatalf("解析规则开关: %v (%s)", err, env2.Data) + } + want := []string{ + "recordBonusEnabled", "adjustmentsEnabled", "adjustmentReasonRequired", + "medalCountsTeamEvents", "showIndividualBoard", "showIndividualMedals", + } + if len(switches) != len(want) { + t.Fatalf("只应返回布尔开关,得到 %v", switches) + } + for _, key := range want { + v, ok := switches[key] + if !ok { + t.Fatalf("缺少开关 %s: %v", key, switches) + } + if _, isBool := v.(bool); !isBool { + t.Fatalf("开关 %s 应为布尔值,得到 %T", key, v) + } + } + // 默认:破纪录加分与人工调整关闭,理由必填、奖牌统计与两块个人榜开关开启。 + if switches["recordBonusEnabled"] != false || switches["adjustmentsEnabled"] != false || + switches["adjustmentReasonRequired"] != true || switches["medalCountsTeamEvents"] != true || + switches["showIndividualBoard"] != true || switches["showIndividualMedals"] != true { + t.Fatalf("默认开关取值不符: %v", switches) + } + + // 规则导入/配置改动后开关随之变化。 + env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'record_bonus.enabled'`) + env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`) + _, env2 = env.do(client, http.MethodGet, "/api/rules/switches", nil, false) + if err := json.Unmarshal(env2.Data, &switches); err != nil { + t.Fatalf("解析规则开关: %v", err) + } + if switches["recordBonusEnabled"] != true || switches["adjustmentsEnabled"] != true { + t.Fatalf("开关应随 settings 变化: %v", switches) + } +} + +// GET /api/settings 与 /api/standings 的 JSON 字段名是对外契约,收敛 DTO 时不能变。 +func TestSettingsAndStandingsJSONContract(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + _, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false) + var settings map[string]any + if err := json.Unmarshal(env2.Data, &settings); err != nil { + t.Fatalf("解析 settings: %v", err) + } + assertKeys(t, "settings", settings, []string{"event", "display", "standings", "defaultProfileId", "requireApproval"}) + assertKeys(t, "settings.event", asMap(t, settings["event"]), + []string{"name", "subtitle", "host", "logoPath", "primaryColor"}) + assertKeys(t, "settings.display", asMap(t, settings["display"]), + []string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds"}) + assertKeys(t, "settings.standings", asMap(t, settings["standings"]), + []string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"}) + + _, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false) + var rows []map[string]any + if err := json.Unmarshal(env2.Data, &rows); err != nil { + t.Fatalf("解析 standings: %v", err) + } + if len(rows) == 0 { + t.Fatal("榜单不应为空") + } + assertKeys(t, "standings[0]", rows[0], []string{ + "teamId", "teamCode", "teamName", "shortName", "color", "logoPath", + "rank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange", + }) +} + +func asMap(t *testing.T, v any) map[string]any { + t.Helper() + m, ok := v.(map[string]any) + if !ok { + t.Fatalf("期望对象,得到 %T", v) + } + return m +} + +func assertKeys(t *testing.T, name string, got map[string]any, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s 字段集不符:want=%v got=%v", name, want, got) + } + for _, key := range want { + if _, ok := got[key]; !ok { + t.Fatalf("%s 缺少字段 %s:%v", name, key, got) + } + } +} + +type settingsStandings struct { + Standings struct { + ShowIndividualBoard bool `json:"showIndividualBoard"` + ShowIndividualMedals bool `json:"showIndividualMedals"` + } `json:"standings"` +} + +// standings.showIndividualMedals 贯通 /api/settings 读写,且不破坏 showIndividualBoard 的别名语义。 +func TestSettingsShowIndividualMedals(t *testing.T) { + env := newTestEnv(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + status, env2 := env.do(client, http.MethodGet, "/api/settings", nil, false) + if status != http.StatusOK { + t.Fatalf("读配置应 200,得到 %d", status) + } + settings := decodeData[settingsStandings](t, env2) + if !settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals { + t.Fatalf("默认两个开关都应为 true: %+v", settings.Standings) + } + + // 旧客户端只传 showIndividualBoard:不得影响新的 showIndividualMedals。 + status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{ + "standings": map[string]any{"showIndividualBoard": false}, + }, true) + if status != http.StatusOK { + t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error) + } + settings = decodeData[settingsStandings](t, env2) + if settings.Standings.ShowIndividualBoard || !settings.Standings.ShowIndividualMedals { + t.Fatalf("只改 board 不应影响 medals: %+v", settings.Standings) + } + + // 两个开关可以独立配置:board=true + medals=false。 + status, env2 = env.do(client, http.MethodPut, "/api/settings", map[string]any{ + "standings": map[string]any{"showIndividualBoard": true, "showIndividualMedals": false}, + }, true) + if status != http.StatusOK { + t.Fatalf("保存配置应 200,得到 %d %+v", status, env2.Error) + } + settings = decodeData[settingsStandings](t, env2) + if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals { + t.Fatalf("两个开关应可独立配置: %+v", settings.Standings) + } + + // 落库并复读。 + _, env2 = env.do(client, http.MethodGet, "/api/settings", nil, false) + settings = decodeData[settingsStandings](t, env2) + if !settings.Standings.ShowIndividualBoard || settings.Standings.ShowIndividualMedals { + t.Fatalf("复读配置不符: %+v", settings.Standings) + } +} diff --git a/server/internal/realtime/hub.go b/server/internal/realtime/hub.go index d69c477..ae955ac 100644 --- a/server/internal/realtime/hub.go +++ b/server/internal/realtime/hub.go @@ -54,6 +54,7 @@ type Hub struct { slowLag int64 helloTO time.Duration writeTO time.Duration + allowedOrigins []string } // NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。 @@ -160,6 +161,14 @@ func (h *Hub) SetSlowLag(n int64) { h.mu.Unlock() } +// SetAllowedOrigins 设置 WebSocket 跨站 Origin 白名单(path.Match 模式); +// 同源与不带 Origin 的握手始终放行,不受此设置影响。 +func (h *Hub) SetAllowedOrigins(origins []string) { + h.mu.Lock() + h.allowedOrigins = append([]string(nil), origins...) + h.mu.Unlock() +} + func (h *Hub) heartbeatIntervalValue() time.Duration { h.mu.RLock() defer h.mu.RUnlock() @@ -190,16 +199,22 @@ func (h *Hub) writeTimeout() time.Duration { return h.writeTO } +func (h *Hub) allowedOriginsValue() []string { + h.mu.RLock() + defer h.mu.RUnlock() + return h.allowedOrigins +} + // Handler 返回 WebSocket 处理器,由调用方挂到 /ws 上。 func (h *Hub) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - // 大屏常以 IP/别名访问且与管理端同端口,Origin 校验交给反向代理与鉴权层。 - InsecureSkipVerify: true, - CompressionMode: websocket.CompressionDisabled, + // 同源与不带 Origin 的握手由库放行;跨站需命中 server.allowedOrigins 白名单。 + OriginPatterns: h.allowedOriginsValue(), + CompressionMode: websocket.CompressionDisabled, }) if err != nil { - h.log.Debug("WebSocket 握手失败", "remote", r.RemoteAddr, "err", err) + h.log.Debug("WebSocket 握手失败", "remote", r.RemoteAddr, "origin", r.Header.Get("Origin"), "err", err) return } diff --git a/server/internal/realtime/origin_test.go b/server/internal/realtime/origin_test.go new file mode 100644 index 0000000..d1dd3a1 --- /dev/null +++ b/server/internal/realtime/origin_test.go @@ -0,0 +1,66 @@ +package realtime + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" +) + +// dialOrigin 以指定 Origin 发起握手;origin 为空表示不带 Origin 头(非浏览器客户端)。 +func dialOrigin(t *testing.T, srv *httptest.Server, origin string) error { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + opts := &websocket.DialOptions{} + if origin != "" { + opts.HTTPHeader = http.Header{"Origin": []string{origin}} + } + conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", opts) + if err != nil { + return err + } + _ = conn.CloseNow() + return nil +} + +func TestHandlerOrigin(t *testing.T) { + t.Run("同源放行", func(t *testing.T) { + e := newTestEnv(t) + // httptest.Server 的 URL 即页面 Origin:scheme + host 与请求 Host 同源。 + if err := dialOrigin(t, e.srv, e.srv.URL); err != nil { + t.Fatalf("同源 Origin 应放行: %v", err) + } + }) + + t.Run("空 Origin 放行", func(t *testing.T) { + e := newTestEnv(t) + if err := dialOrigin(t, e.srv, ""); err != nil { + t.Fatalf("不带 Origin 的客户端应放行: %v", err) + } + }) + + t.Run("跨站拒绝", func(t *testing.T) { + e := newTestEnv(t) + if err := dialOrigin(t, e.srv, "http://evil.example"); err == nil { + t.Fatal("跨站 Origin 应拒绝") + } + }) + + t.Run("白名单放行", func(t *testing.T) { + e := newTestEnv(t) + e.hub.SetAllowedOrigins([]string{"http://evil.example"}) + if err := dialOrigin(t, e.srv, "http://evil.example"); err != nil { + t.Fatalf("白名单内 Origin 应放行: %v", err) + } + if err := dialOrigin(t, e.srv, "http://other.example"); err == nil { + t.Fatal("白名单外的跨站 Origin 应拒绝") + } + }) +} diff --git a/server/main.go b/server/main.go index cc98f2f..fdd0155 100644 --- a/server/main.go +++ b/server/main.go @@ -85,6 +85,7 @@ func main() { authSvc := auth.New(st.DB(), auth.Options{Logger: logger}) hub := realtime.NewHub(eventDB, logger) + hub.SetAllowedOrigins(cfg.Server.AllowedOrigins) defer func() { _ = hub.Close() }() // 可信代理:默认空,表示不采信任何代理头(见 devdocs/08 第 1 节)。 @@ -110,15 +111,20 @@ func main() { logger.Warn("已创建默认管理员", "username", username, "password", password) } + backupSvc := export.NewService(st.DB(), eventDB, filepath.Join(cfg.DataDir, "backups"), cfg.Backup.Keep, logger) + srv := &http.Server{ Addr: cfg.Server.Addr(), Handler: httpapi.New(httpapi.Options{ - Logger: logger, - Version: version, - System: st.DB(), - Event: eventDB, - Auth: authSvc, - DataDir: cfg.DataDir, + Logger: logger, + Version: version, + System: st.DB(), + Event: eventDB, + Auth: authSvc, + DataDir: cfg.DataDir, + Backup: backupSvc, + // POST /api/shutdown 复用同一个关闭信号:取消 ctx 后走下面的 Shutdown + 关库。 + Shutdown: stop, ProjectDir: filepath.Join(cfg.DataDir, "event_projs", *project), Display: displaySender{hub: hub}, Realtime: hub, @@ -127,6 +133,9 @@ func main() { ReadHeaderTimeout: 10 * time.Second, } + // 定时备份跟随服务端生命周期:收到退出信号(ctx 取消)即停止。 + backupSvc.StartAuto(ctx, time.Duration(cfg.Backup.IntervalMinutes)*time.Minute) + go func() { logger.Info("服务启动", "addr", srv.Addr, "version", version) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -138,7 +147,8 @@ func main() { <-ctx.Done() logger.Info("正在关闭…") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + // Console 的关闭握手要求 3 秒内完成,超时后仍会关库并退出(defer 顺序保证)。 + shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("关闭超时", "err", err) diff --git a/web/src/admin/api/mock.ts b/web/src/admin/api/mock.ts index 1c30c0d..1c70979 100644 --- a/web/src/admin/api/mock.ts +++ b/web/src/admin/api/mock.ts @@ -237,7 +237,12 @@ const SETTINGS: EventSettings = { primaryColor: '#3482FF', rotateSeconds: 15, }, - standings: { individualToTeamWeight: 1, rankingMode: 'points', showIndividualBoard: true }, + standings: { + individualToTeamWeight: 1, + rankingMode: 'points', + showIndividualBoard: true, + showIndividualMedals: true, + }, defaultProfileId: 1, requireApproval: false, } diff --git a/web/src/admin/components/system/EventInfoPanel.vue b/web/src/admin/components/system/EventInfoPanel.vue index 108d6bd..e25a04f 100644 --- a/web/src/admin/components/system/EventInfoPanel.vue +++ b/web/src/admin/components/system/EventInfoPanel.vue @@ -184,6 +184,16 @@ async function save(): Promise { {{ form.standings.showIndividualBoard ? '展示' : '隐藏' }}
+
+ 个人榜奖牌列 + + + {{ form.standings.showIndividualMedals ? '展示' : '隐藏' }} + +

diff --git a/web/src/admin/pages/AdjustmentsPage.vue b/web/src/admin/pages/AdjustmentsPage.vue index 0edc344..0a5e4a1 100644 --- a/web/src/admin/pages/AdjustmentsPage.vue +++ b/web/src/admin/pages/AdjustmentsPage.vue @@ -7,6 +7,7 @@ import { type AdjustmentCategory, type PointAdjustment, type RulePack, + type RuleSwitches, } from '@/shared' import MxIcon from '../components/MxIcon.vue' import PageHeader from '../components/PageHeader.vue' @@ -32,6 +33,7 @@ const size = 20 const loading = ref(false) const error = ref(null) const rulePack = ref(null) +const ruleSwitches = ref(null) const ruleError = ref(null) const search = ref('') const categoryFilter = ref('all') @@ -57,12 +59,12 @@ const canWrite = computed(() => !auth.readOnly && data.canWrite) /** * 人工调整开关:true / false / null(未知)。 * - * 规则包只有 admin 能读(GET /api/rules/export 是 admin-only),录入员必然 403—— - * 不能据此断言「已关闭」,那会把已启用的功能谎报成关闭。读不到时为 null, - * 界面如实显示「未知」,最终由服务端校验决定是否受理。 - * 后端补一个 recorder 可读的只读开关端点后,把 loadRules 换成读该端点即可。 + * 优先取 GET /api/rules/switches(recorder 可读);读不到时再退回完整规则包(admin)。 + * 两者都没有则为 null,界面如实显示「未知」,最终由服务端校验决定是否受理。 */ -const adjustmentEnabled = computed(() => rulePack.value?.manualAdjustment.enabled ?? null) +const adjustmentEnabled = computed( + () => ruleSwitches.value?.adjustmentsEnabled ?? rulePack.value?.manualAdjustment.enabled ?? null, +) const items = computed(() => [...localItems.value, ...serverItems.value]) @@ -122,6 +124,12 @@ async function loadRules(): Promise { rulePack.value = null ruleError.value = caught instanceof Error ? caught.message : '无法读取规则包' } + // 规则包只有 admin 能读;录入员改用只读开关端点,避免把已启用功能误报成关闭。 + try { + ruleSwitches.value = await adminApi.client.rules.switches() + } catch { + ruleSwitches.value = null + } } async function load(pageNumber = page.value): Promise { diff --git a/web/src/admin/pages/RecordsPage.vue b/web/src/admin/pages/RecordsPage.vue index eef11d8..41201e7 100644 --- a/web/src/admin/pages/RecordsPage.vue +++ b/web/src/admin/pages/RecordsPage.vue @@ -8,6 +8,7 @@ import { type RecordEntry, type RecordLevel, type RulePack, + type RuleSwitches, } from '@/shared' import MxIcon from '../components/MxIcon.vue' import PageHeader from '../components/PageHeader.vue' @@ -64,6 +65,7 @@ const suspected = ref([]) const suspectedLoading = ref(false) const suspectedError = ref(null) const rulePack = ref(null) +const ruleSwitches = ref(null) const ruleError = ref(null) const rulesLoading = ref(false) const search = ref('') @@ -79,12 +81,12 @@ const canWrite = computed(() => !auth.readOnly && data.canWrite) /** * 破纪录加分开关:true / false / null(未知)。 * - * 规则包只有 admin 能读(GET /api/rules/export 是 admin-only), - * 录入员必然 403——不能据此断言「已关闭」,那会把已启用的功能谎报成关闭。 - * 读不到时为 null,界面如实显示「未知」,最终由服务端校验决定是否加分。 - * 后端补一个 recorder 可读的只读开关端点后,把 loadRules 换成读该端点即可。 + * 优先取 GET /api/rules/switches(recorder 可读);读不到时再退回完整规则包(admin)。 + * 两者都没有则为 null,界面如实显示「未知」,最终由服务端校验决定是否加分。 */ -const recordBonusEnabled = computed(() => rulePack.value?.recordBonus.enabled ?? null) +const recordBonusEnabled = computed( + () => ruleSwitches.value?.recordBonusEnabled ?? rulePack.value?.recordBonus.enabled ?? null, +) const bonusMode = computed(() => rulePack.value === null ? '—' : rulePack.value.recordBonus.mode === 'sum' ? '多档累加' : '取最高档', ) @@ -167,6 +169,12 @@ async function loadRules(): Promise { } finally { rulesLoading.value = false } + // 规则包只有 admin 能读;录入员改用只读开关端点,避免把已启用功能误报成关闭。 + try { + ruleSwitches.value = await adminApi.client.rules.switches() + } catch { + ruleSwitches.value = null + } } async function loadCategoryLabels(eventIds: number[]): Promise { diff --git a/web/src/display/components/pages/AthleteStandingsPage.vue b/web/src/display/components/pages/AthleteStandingsPage.vue index f8b0696..c3480be 100644 --- a/web/src/display/components/pages/AthleteStandingsPage.vue +++ b/web/src/display/components/pages/AthleteStandingsPage.vue @@ -8,11 +8,8 @@ import PageFrame from '../PageFrame.vue' const { athletes, settings } = useScoreboard() const rows = computed(() => athletes.value) -// 奖牌列由独立开关控制;旧配置没有 showIndividualMedals 时回退到「个人榜上屏」开关,避免升级后突然多出一列。 -const showMedals = computed(() => { - const standings = settings.value?.standings - return standings?.showIndividualMedals ?? standings?.showIndividualBoard ?? true -}) +// 奖牌列由独立开关控制;缺字段时的回退已在 guards.parseSettings 里按「跟随个人榜上屏」处理。 +const showMedals = computed(() => settings.value?.standings.showIndividualMedals ?? true) const rowFont = computed(() => fitRowFont(rows.value.length, 560)) diff --git a/web/src/display/guards.ts b/web/src/display/guards.ts index 4e64bff..2bd44fc 100644 --- a/web/src/display/guards.ts +++ b/web/src/display/guards.ts @@ -296,14 +296,10 @@ export function parseRecordEntry(source: unknown): RecordEntry { } /** - * 大屏侧扩展的赛事配置。 - * - * `standings.showIndividualMedals` 还没进入 shared 契约,但大屏需要它独立控制奖牌列; - * 声明成可选,旧配置(服务端只给 showIndividualBoard)仍可赋值,页面再回退。 + * 大屏侧赛事配置。字段与 shared 契约一致; + * `showIndividualMedals` 缺失时由 parseSettings 回退到 `showIndividualBoard`(旧服务端只下发后者)。 */ -export type DisplaySettings = EventSettings & { - standings: EventSettings['standings'] & { showIndividualMedals?: boolean } -} +export type DisplaySettings = EventSettings /** * 嵌套路径取值。 @@ -330,6 +326,10 @@ function settingValue( export function parseSettings(source: unknown): DisplaySettings { const rec = record(source) + const showIndividualBoard = bool( + settingValue(rec, 'standings.showIndividualBoard', 'standings.show_individual_board'), + true, + ) return { event: { name: str(settingValue(rec, 'event.name', 'event.name')), @@ -355,13 +355,12 @@ export function parseSettings(source: unknown): DisplaySettings { settingValue(rec, 'standings.rankingMode', 'standings.ranking_mode'), 'points', ), - showIndividualBoard: bool( - settingValue(rec, 'standings.showIndividualBoard', 'standings.show_individual_board'), - true, - ), - showIndividualMedals: boolOrUndefined( - settingValue(rec, 'standings.showIndividualMedals', 'standings.show_individual_medals'), - ), + showIndividualBoard, + // 旧服务端只下发 showIndividualBoard,奖牌列跟随它,避免升级后突然多出一列。 + showIndividualMedals: + boolOrUndefined( + settingValue(rec, 'standings.showIndividualMedals', 'standings.show_individual_medals'), + ) ?? showIndividualBoard, }, defaultProfileId: numOrNull(rec.defaultProfileId), requireApproval: bool(rec.requireApproval), diff --git a/web/src/display/mock.ts b/web/src/display/mock.ts index 6607bd2..1a32342 100644 --- a/web/src/display/mock.ts +++ b/web/src/display/mock.ts @@ -271,6 +271,7 @@ export function createMockSnapshot(): SnapshotMessage { individualToTeamWeight: 1, rankingMode: 'points', showIndividualBoard: true, + showIndividualMedals: true, }, defaultProfileId: null, requireApproval: false, diff --git a/web/src/shared/api.ts b/web/src/shared/api.ts index 7103052..bc2ee36 100644 --- a/web/src/shared/api.ts +++ b/web/src/shared/api.ts @@ -32,6 +32,7 @@ import type { RulePack, RulePackImportPreview, RulePackImportResult, + RuleSwitches, SaveDraftPayload, ScoreChannel, ScoreChannelInput, @@ -194,6 +195,8 @@ export interface AuditApi { export interface RulesApi { /** GET /api/rules/export,返回当前规则包。 */ get(): Promise + /** GET /api/rules/switches,recorder 可读的布尔开关(不含规则内容)。 */ + switches(): Promise /** 先预览差异,再决定是否落地(02 第 7.3 节硬性要求)。 */ preview(pack: RulePack): Promise apply(pack: RulePack): Promise @@ -295,6 +298,7 @@ function createRulesApi(http: Http): RulesApi { } return pack }, + switches: () => http.request('/rules/switches'), preview: (pack) => http.request('/rules/import', { method: 'POST', diff --git a/web/src/shared/normalize.ts b/web/src/shared/normalize.ts index ad20fd8..e8585d3 100644 --- a/web/src/shared/normalize.ts +++ b/web/src/shared/normalize.ts @@ -84,6 +84,11 @@ export function normalizeSettings(raw: unknown): EventSettings { isFlat ? source['standings.show_individual_medals'] : undefined, nestedStandings?.showIndividualBoard, ), + showIndividualMedals: firstBool( + true, + isFlat ? source['standings.show_individual_medals'] : undefined, + nestedStandings?.showIndividualMedals, + ), }, // 扁平 map 里只有模板编码(scoring.default_profile),解析不出数据库 id,只能置空。 defaultProfileId: isFlat ? null : firstNumberOrNull(source.defaultProfileId), diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 1f047fa..7c5b51a 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -372,6 +372,8 @@ export interface EventSettings { /** 排名模式;文档未枚举取值,用 string 保留后端扩展空间。 */ rankingMode: string showIndividualBoard: boolean + /** 个人榜奖牌列开关;旧服务端不下发时按 true 处理。 */ + showIndividualMedals: boolean } defaultProfileId: number | null requireApproval: boolean @@ -468,6 +470,19 @@ export interface RulePackManualAdjustment { reasonRequired: boolean } +/** + * GET /api/rules/switches:规则包里布尔开关的只读视图。 + * 录入员读不到完整规则包(export 是 admin-only),页面用这些布尔值判断功能是否启用。 + */ +export interface RuleSwitches { + recordBonusEnabled: boolean + adjustmentsEnabled: boolean + adjustmentReasonRequired: boolean + medalCountsTeamEvents: boolean + showIndividualBoard: boolean + showIndividualMedals: boolean +} + /** 规则包里的通道用 profile code 引用模板,而不是数据库 id,便于跨赛事复用。 */ export interface RuleChannel { scope: ChannelScope