diff --git a/README.md b/README.md index a9ab97d..f55c49f 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,9 @@ ## 文档 -- 使用说明、操作手册:随正式版发布补充 +- [部署指南](./docs/部署指南.md):运行环境、安装与首次启动、端口与配置、大屏接线、备份恢复、故障排查 +- [使用手册](./docs/使用手册.md):角色权限、各功能页面的操作步骤、大屏端使用、常见操作问题 +- [常见问题(FAQ)](./docs/常见问题.md):账号、成绩与积分、大屏、数据备份、导出导入 - 规则包格式:见 [rules/README.md](./rules/README.md) ## 许可证 diff --git a/console/.gitignore b/console/.gitignore new file mode 100644 index 0000000..7b33ac7 --- /dev/null +++ b/console/.gitignore @@ -0,0 +1,3 @@ +# deploy/build.bat 在打包前把 docs/使用说明.txt 暂存到这里(extraFiles 指向该目录) +extra/* +!extra/.gitkeep diff --git a/console/README.md b/console/README.md index a2d3a3a..94c5165 100644 --- a/console/README.md +++ b/console/README.md @@ -1,11 +1,12 @@ # Scoreboard Console -赛事积分榜系统的桌面宿主(Electron)。它只做四件事: +赛事积分榜系统的桌面宿主(Electron)。它只做五件事: 1. 拉起并守护同目录(或配置指定)的 `scoreboard.exe`,把它的 stdout/stderr 接管到管理窗口与日志文件; 2. 用 `screen.getAllDisplays()` 枚举显示器,把大屏页面投到扩展屏; 3. `powerSaveBlocker.start('prevent-display-sleep')` 防熄屏; -4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单。 +4. 提供启动/停止/状态/日志的管理 GUI 和托盘菜单; +5. 打包后随系统启动,现场断电重启后无需人工干预。 规范来源:`devdocs/06-大屏端设计.md` 第 6 节、`devdocs/10-部署与现场方案.md` 第 4 节。 @@ -19,9 +20,13 @@ console/ │ ├── index.js # 应用入口:窗口 / 托盘 / IPC / 生命周期 │ ├── config.js # 配置加载与合并、exe 路径解析 │ ├── service.js # 服务端子进程守护:spawn / 日志转发 / 健康检查 / 自动重启 +│ ├── account.js # 管理员账号重置:参数校验 + 调 scoreboard.exe passwd │ ├── displays.js # 大屏窗口管理:选屏、kiosk、排布、24h 重载 │ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播 │ └── tray-icon.js # 托盘图标(base64 内嵌,无外部资源文件) +├── scripts/ +│ └── check-syntax.js # npm run check:跨平台递归 node --check +├── extra/ # 打包前由 deploy/build.bat 暂存 使用说明.txt(extraFiles 指向它) └── renderer/ ├── index.html # 管理窗口(含自绘标题栏) ├── styles.css @@ -66,16 +71,19 @@ scoreboard-windows-amd64/ | 场景 | 行为 | | --- | --- | | 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不挂** `display-metrics-changed` 等监听(行为冻结);`F11` 手动切换全屏 | -| 多块屏 | 除主屏外每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,窗口 `setIgnoreMouseEvents(true)`,光标由页面 CSS 隐藏 | -| 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认)/ `all` / `specified` | -| 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:8080/display?page=standings&lock=1&screen=&instance=-`,页序按 `display.pages` 轮转 | +| 多块屏 | 除内置屏外的每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,窗口 `setIgnoreMouseEvents(true)`,光标由页面 CSS 隐藏 | +| 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认,用 `display.internal` 排除内置屏)/ `all` / `specified` | +| 页面锁定 | 每块屏各锁一页,URL 形如 `http://127.0.0.1:19540/display?page=standings&lock=1&screen=&instance=-`,页序按 `display.pages` 轮转 | | 防熄屏 | Console 启动即生效,退出时释放;前台后台都算 | | 服务端就绪 | 首次 `/api/health` 通过后开大屏窗口;等待超过 `display.waitForServerMs` 也先开,避免现场只见桌面 | -| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);恢复健康后自动重载大屏 | +| 服务端崩溃 | 按 `restartDelayMs` 递增退避自动重启(`maxRestarts=0` 为无限);持续健康 `healthyResetMs` 后崩溃计数清零;恢复健康后自动重载大屏 | +| 停止服务 | 先 `POST server.shutdownPath` 请求服务端优雅关闭,`shutdownTimeoutMs` 内未退出再强杀;接口不可用时回退进程信号 | | 关管理窗口 | 只隐藏到托盘,服务端继续运行;退出必须走托盘「退出」 | +| 开机自启 | 打包后在 Windows/macOS 上注册(`startup.openAtLogin`,默认开),开发态不注册 | | 窗口外观 | 管理窗口与服务端管理面板窗口都是 `frame: false` 无边框,标题栏与最小化/最大化/关闭三键由页面自绘(拖拽区 `-webkit-app-region: drag`,按钮 `no-drag`,双击标题栏切最大化,关闭键 hover 变红) | | 服务端管理面板 | 用独立无边框 Electron 窗口加载 `http://127.0.0.1:/admin`(`shell-preload.js` 暴露 `window.desktopShell`),不再调系统浏览器;面板页在浏览器里直接打开时标题栏自动不渲染 | -| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`) | +| 管理员账号 | 主进程 `spawn` 同目录 `scoreboard.exe passwd -config <同一份配置> -user <当前用户名> -new-username <新用户名> -password <新密码>`(留空 = 不改 / 随机生成)。本机进程、不经 HTTP,因此免登录验证;用户名/密码含空格、制表符或换行会被主进程拒绝,密码不进日志。`passwd` 会作废该用户全部会话,UI 提交前二次确认并提示 | +| 日志 | 内存环形缓冲(`log.maxLines`)+ 文件 `logs/console.log`(超 `maxFileBytes` 轮转一份 `.1`,轮转前会删掉旧 `.1` 以兼容 Windows) | ## 配置项(`config.json`) @@ -83,20 +91,24 @@ scoreboard-windows-amd64/ | --- | --- | --- | | `server.exe` | `scoreboard.exe` | 相对路径按上文顺序探测,也可写绝对路径 | | `server.args` | `["-config","config.yaml"]` | 传给服务端的参数 | -| `server.host` / `server.port` | `127.0.0.1` / `8080` | 健康检查与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) | +| `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查、关闭握手与管理面板地址(`0.0.0.0` 会自动换成回环地址探测) | | `server.autoStart` | `true` | Console 启动后是否立刻拉起服务端 | | `server.healthIntervalMs` | `5000` | 健康检查间隔 | | `server.restartDelayMs` | `3000` | 重启基础退避,每次翻倍,最多 5 倍 | | `server.maxRestarts` | `0` | 0 = 无限重启 | +| `server.shutdownPath` | `/api/shutdown` | 优雅关闭握手接口(需服务端实现),不可用时回退进程信号 | +| `server.shutdownTimeoutMs` | `12000` | 等待优雅关闭的上限,超过则强杀 | +| `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 | | `display.selection` | `all-secondary` | `all-secondary` / `all` / `specified` | | `display.displays` | `[]` | `specified` 时生效,填 display id 或 0 起序号 | -| `display.pages` | `["standings","medals","individual","results","records"]` | 按屏序轮转锁定 | +| `display.pages` | `["standings","medals","individual","results"]` | 按屏序轮转锁定;只填大屏支持的页名 | | `display.pageParam` | `page` | 页面 URL 参数名 | | `display.kiosk` | `true` | 多屏时是否 kiosk(单屏恒窗口化) | | `display.ignoreMouse` | `true` | 多屏窗口是否忽略鼠标 | | `display.reloadEveryHours` | `24` | 定时重载兜底,0 = 关闭 | | `display.waitForServerMs` | `20000` | 服务端未就绪时开大屏的兜底等待 | | `display.backgroundColor` | `#0b0f1a` | 窗口底色,减少加载白闪 | +| `startup.openAtLogin` | `true` | 打包后随系统启动(开发态不注册) | | `admin.width` / `admin.height` | `1040` / `760` | 管理窗口初始尺寸(服务端管理面板窗口按 `width` 不小于 1100 取用) | | `log.maxLines` / `log.file` / `log.maxFileBytes` | `2000` / `logs/console.log` / `4194304` | 日志缓冲与文件 | @@ -109,12 +121,14 @@ scoreboard-windows-amd64/ ```bash cd console npm install +npm run check # 跨平台语法检查(scripts/check-syntax.js) npm run dist # electron-builder --win --x64 → ../dist/console/ npm run pack # 只出目录,不生成安装包,便于现场调试 ``` - 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.0-x64.*`)。 -- `extraFiles` 会把 `../dist/scoreboard.exe` 与 `../config.yaml` 拷到安装目录,最终形成第 4 节里的部署包形态。 +- `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)。 diff --git a/console/config.json b/console/config.json index 7d09224..8bc026c 100644 --- a/console/config.json +++ b/console/config.json @@ -7,12 +7,15 @@ "autoStart": true, "healthIntervalMs": 5000, "restartDelayMs": 3000, - "maxRestarts": 0 + "maxRestarts": 0, + "shutdownPath": "/api/shutdown", + "shutdownTimeoutMs": 12000, + "healthyResetMs": 60000 }, "display": { "selection": "all-secondary", "displays": [], - "pages": ["standings", "medals", "individual", "results", "records"], + "pages": ["standings", "medals", "individual", "results"], "pageParam": "page", "kiosk": true, "ignoreMouse": true, @@ -20,6 +23,9 @@ "waitForServerMs": 20000, "backgroundColor": "#0b0f1a" }, + "startup": { + "openAtLogin": true + }, "admin": { "width": 1040, "height": 760 diff --git a/console/extra/.gitkeep b/console/extra/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/console/main/account.js b/console/main/account.js new file mode 100644 index 0000000..3737375 --- /dev/null +++ b/console/main/account.js @@ -0,0 +1,77 @@ +'use strict' + +const path = require('path') +const { spawn } = require('child_process') + +// 用户名 / 密码长度上限:passwd 自己也会限制,这里只是把明显不合法的挡在 UI 侧 +const FIELD_MAX = 64 + +// 返回错误文案,null 表示通过。required 为 false 时允许空串(= 不改 / 随机生成) +function validateAccountField(value, label, required) { + if (typeof value !== 'string') return `${label}必须是文本` + if (value === '') return required ? `${label}不能为空` : null + // spawn 用参数数组、不经 shell,不存在命令注入;但空格会把参数拆成两段、 + // 换行会截断输出,所以在入口直接拒掉,别让 passwd 报看不懂的错。 + if (/\s/.test(value)) return `${label}不能包含空格、制表符或换行` + if (value.length > FIELD_MAX) return `${label}最多 ${FIELD_MAX} 个字符` + return null +} + +// 空的新用户名 / 空密码直接传空串:passwd 的语义就是「留空则不改 / 随机生成」 +function buildPasswdArgs({ configArg, currentUsername, newUsername, password }) { + return [ + 'passwd', + '-config', configArg, + '-user', currentUsername, + '-new-username', newUsername, + '-password', password + ] +} + +// 跑一次 passwd,实时把 stdout/stderr 交给 onOutput,结束时返回完整输出与退出码 +function runPasswd({ exePath, args, onOutput }) { + return new Promise((resolve) => { + let child + try { + // cwd 取 exe 所在目录:config.yaml 与 data/ 都跟 exe 平级 + child = spawn(exePath, args, { + cwd: path.dirname(exePath), + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'] + }) + } catch (err) { + resolve({ ok: false, error: `启动 passwd 失败:${err.message}`, output: '' }) + return + } + + let output = '' + let settled = false + const finish = (result) => { + if (settled) return + settled = true + resolve(result) + } + const onChunk = (chunk) => { + const text = String(chunk) + output += text + if (typeof onOutput === 'function') onOutput(text) + } + + if (child.stdout) { + child.stdout.setEncoding('utf8') + child.stdout.on('data', onChunk) + } + if (child.stderr) { + child.stderr.setEncoding('utf8') + child.stderr.on('data', onChunk) + } + + child.on('error', (err) => finish({ ok: false, error: err.message, output })) + // 用 close 而不是 exit:等两个 stdio 都排空,输出才是完整的 + child.on('close', (code, signal) => { + finish({ ok: code === 0, exitCode: code, signal: signal || null, output }) + }) + }) +} + +module.exports = { FIELD_MAX, validateAccountField, buildPasswdArgs, runPasswd } diff --git a/console/main/config.js b/console/main/config.js index 6ba7f63..671f3cb 100644 --- a/console/main/config.js +++ b/console/main/config.js @@ -26,7 +26,8 @@ const DEFAULTS = { // all-secondary | all | specified selection: 'all-secondary', displays: [], - pages: ['standings', 'medals', 'individual', 'results', 'records'], + // 只能写大屏实际支持的页名(见 web/src/display/types.ts 的 PAGE_ALIASES) + pages: ['standings', 'medals', 'individual', 'results'], pageParam: 'page', kiosk: true, ignoreMouse: true, @@ -35,6 +36,10 @@ const DEFAULTS = { waitForServerMs: 20000, backgroundColor: '#0b0f1a' }, + startup: { + // 打包后随系统启动(doc 10 §4.3);开发态不注册 + openAtLogin: true + }, admin: { width: 1040, height: 760 diff --git a/console/main/index.js b/console/main/index.js index cad48f2..9d214d3 100644 --- a/console/main/index.js +++ b/console/main/index.js @@ -1,5 +1,6 @@ 'use strict' +const fs = require('fs') const path = require('path') const { app, @@ -15,6 +16,7 @@ const { LogBus } = require('./log-bus') const { ServiceSupervisor } = require('./service') const { DisplayManager } = require('./displays') const { createTrayIcon } = require('./tray-icon') +const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account') // 现场只允许一个实例:两个 Console 会互相抢同一个服务端端口和同一块大屏 if (!app.requestSingleInstanceLock()) { @@ -36,6 +38,9 @@ function main() { let displaysEnabled = false let quitting = false let shutdownDone = false + let exePath = null + // passwd 子进程同一时刻只允许一个:并发写同一个 sqlite 库没有意义,也会让输出互相串 + let accountBusy = false function resolveConfigPath() { if (process.env.SCOREBOARD_CONSOLE_CONFIG) { @@ -87,7 +92,7 @@ function main() { height, minWidth: 860, minHeight: 600, - title: 'Scoreboard Console · 启动管理', + title: '赛事积分榜 · 服务管理', backgroundColor: '#0f1420', show: false, // 标题栏由页面自绘;titleBarStyle 只是兜底,防止某些平台忽略 frame:false @@ -143,7 +148,7 @@ function main() { height, minWidth: 900, minHeight: 620, - title: 'Scoreboard · 服务端管理面板', + title: '赛事积分榜 · 服务端管理面板', backgroundColor: '#161616', show: false, frame: false, @@ -253,8 +258,71 @@ function main() { } } + // 服务端用什么配置文件,passwd 就得用同一份,否则改的是另一个 data/ 下的账号 + function passwdConfigArg() { + const args = Array.isArray(cfg.values.server.args) ? cfg.values.server.args : [] + const index = args.indexOf('-config') + if (index !== -1 && typeof args[index + 1] === 'string' && args[index + 1]) { + return args[index + 1] + } + return 'config.yaml' + } + + // 账号重置走同目录的 scoreboard.exe passwd 子命令:本机进程、不经 HTTP, + // 所以不需要任何登录态,这就是面板能「免验证」的原因。 + async function resetAdminAccount(event, payload) { + if (accountBusy) return { ok: false, error: '上一次重置还没结束,请稍候再试' } + // IPC 入参一律当不可信处理 + if (payload === null || typeof payload !== 'object') payload = {} + + const currentUsername = typeof payload.currentUsername === 'string' ? payload.currentUsername.trim() : '' + const newUsername = typeof payload.newUsername === 'string' ? payload.newUsername.trim() : '' + const password = typeof payload.password === 'string' ? payload.password : '' + + const invalid = + validateAccountField(currentUsername, '当前用户名', true) || + validateAccountField(newUsername, '新用户名', false) || + validateAccountField(password, '新密码', false) + if (invalid) return { ok: false, error: invalid } + + const exe = exePath || resolveExecutable(cfg.values.server.exe, cfg.path).path + if (!exe || !fs.existsSync(exe)) { + return { ok: false, error: `未找到服务端程序:${exe || cfg.values.server.exe}` } + } + + const configArg = passwdConfigArg() + const args = buildPasswdArgs({ configArg, currentUsername, newUsername, password }) + const sender = event.sender + + accountBusy = true + // 日志里不能带明文密码 + log.push( + 'console', + `重置管理员账号:${exe} passwd -config ${configArg} -user ${currentUsername}` + + `${newUsername ? ` -new-username ${newUsername}` : ''}(密码已隐藏)` + ) + + try { + const result = await runPasswd({ + exePath: exe, + args, + onOutput: (text) => { + if (!sender.isDestroyed()) sender.send('account:output', text) + } + }) + const reason = result.error + ? result.error + : `退出码 ${result.exitCode}${result.signal ? `,信号 ${result.signal}` : ''}` + log.push('console', `重置管理员账号${result.ok ? '完成' : '失败'}(${reason})`) + return result + } finally { + accountBusy = false + } + } + function registerIpc() { ipcMain.handle('service:get-status', () => statusSnapshot()) + ipcMain.handle('account:reset', (event, payload) => resetAdminAccount(event, payload)) ipcMain.handle('service:start', () => { service.start() return statusSnapshot() @@ -323,6 +391,7 @@ function main() { if (cfg.warning) log.push('console', cfg.warning) const resolved = resolveExecutable(cfg.values.server.exe, cfg.path) + exePath = resolved.path log.push('console', `服务端程序:${resolved.path}`) service = new ServiceSupervisor({ diff --git a/console/package.json b/console/package.json index d94de87..80c9430 100644 --- a/console/package.json +++ b/console/package.json @@ -8,7 +8,7 @@ "main": "main/index.js", "scripts": { "start": "electron .", - "check": "node -e \"const {execSync}=require('child_process');execSync('for f in $(find . -name \\\"*.js\\\" -not -path \\\"./node_modules/*\\\"); do node --check \\\"$f\\\"; done',{stdio:'inherit'})\"", + "check": "node scripts/check-syntax.js", "dist": "electron-builder --win --x64", "pack": "electron-builder --dir" }, @@ -39,9 +39,22 @@ "from": "../config.yaml", "to": "config.yaml" }, + { + "from": "config.json", + "to": "config.json" + }, { "from": "scoreboard.png", "to": "scoreboard.png" + }, + { + "from": "../rules", + "to": "rules" + }, + { + "from": "extra", + "to": ".", + "filter": ["**/*", "!.gitkeep"] } ], "win": { diff --git a/console/renderer/app.js b/console/renderer/app.js index aa953f1..da8976a 100644 --- a/console/renderer/app.js +++ b/console/renderer/app.js @@ -21,6 +21,12 @@ const el = { btnRelayout: document.getElementById('btn-relayout'), btnOpenAdmin: document.getElementById('btn-open-admin'), btnClearLog: document.getElementById('btn-clear-log'), + btnResetAccount: document.getElementById('btn-reset-account'), + acctUser: document.getElementById('acct-user'), + acctNewUser: document.getElementById('acct-new-user'), + acctPassword: document.getElementById('acct-password'), + acctStatus: document.getElementById('acct-status'), + acctOutput: document.getElementById('acct-output'), titlebar: document.getElementById('titlebar'), winMin: document.getElementById('win-min'), winMax: document.getElementById('win-max'), @@ -137,6 +143,88 @@ function bindActions() { setLog([]) }) el.btnOpenAdmin.addEventListener('click', () => window.consoleApi.openServerAdmin()) + el.btnResetAccount.addEventListener('click', resetAccount) +} + +let accountStreaming = false + +function setAccountOutput(text) { + el.acctOutput.hidden = !text + el.acctOutput.textContent = text || '' + if (text) el.acctOutput.scrollTop = el.acctOutput.scrollHeight +} + +function appendAccountOutput(text) { + el.acctOutput.hidden = false + el.acctOutput.textContent += text + el.acctOutput.scrollTop = el.acctOutput.scrollHeight +} + +function handleAccountOutput(text) { + // 只在提交中接收流式输出:重置结束后的最终结果由返回值统一渲染,避免重复 + if (accountStreaming) appendAccountOutput(text) +} + +function setAccountStatus(text, kind) { + el.acctStatus.hidden = !text + el.acctStatus.textContent = text || '' + el.acctStatus.className = kind ? `hint ${kind}` : 'hint' +} + +function setAccountBusy(busy) { + el.btnResetAccount.disabled = busy + el.btnResetAccount.textContent = busy ? '正在重置…' : '重置账号' + for (const input of [el.acctUser, el.acctNewUser, el.acctPassword]) input.disabled = busy +} + +async function resetAccount() { + const currentUsername = el.acctUser.value.trim() + const newUsername = el.acctNewUser.value.trim() + const password = el.acctPassword.value + + if (!currentUsername) { + setAccountStatus('请先填写当前用户名', 'err') + return + } + + const target = newUsername ? `用户名改为「${newUsername}」` : '只重置密码' + // 重置会踢掉该用户的所有登录态,属于不可撤销操作,必须先二次确认 + const confirmed = window.confirm( + '确认重置管理员账号?\n\n' + + `当前用户名:${currentUsername}\n` + + `本次操作:${target}\n` + + `新密码:${password ? '使用输入框中的密码' : '由程序随机生成'}\n\n` + + '重置会作废该用户的全部登录会话,正在使用管理面板的人会被强制退出。' + ) + if (!confirmed) return + + accountStreaming = true + setAccountBusy(true) + setAccountOutput('') + setAccountStatus('正在调用 scoreboard.exe passwd …') + + try { + const result = await window.consoleApi.resetAdminAccount({ currentUsername, newUsername, password }) + accountStreaming = false + setAccountOutput(result.output || result.error || '') + + if (result.ok) { + setAccountStatus( + password ? '重置成功,原会话已全部作废。' : '重置成功,密码为随机生成,请从下方输出抄录后立即修改。', + 'ok' + ) + el.acctUser.value = newUsername || currentUsername + el.acctNewUser.value = '' + el.acctPassword.value = '' + } else { + setAccountStatus(`重置失败:${result.error || `passwd 退出码 ${result.exitCode}`}`, 'err') + } + } catch (err) { + setAccountStatus(`重置失败:${err.message}`, 'err') + } finally { + accountStreaming = false + setAccountBusy(false) + } } async function boot() { @@ -144,6 +232,7 @@ async function boot() { bindWindowControls() window.consoleApi.onStatus(renderStatus) window.consoleApi.onLog(appendLog) + window.consoleApi.onAccountOutput(handleAccountOutput) setLog(await window.consoleApi.getLog()) await refresh() } diff --git a/console/renderer/index.html b/console/renderer/index.html index 64aa60f..9a5e7b1 100644 --- a/console/renderer/index.html +++ b/console/renderer/index.html @@ -6,7 +6,7 @@ http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'" /> - Scoreboard Console · 启动管理 + 赛事积分榜 · 服务管理 @@ -15,8 +15,8 @@
-

Scoreboard Console

-

赛事积分榜 · 桌面宿主

+

赛事积分榜 · 服务管理

+

服务端守护 · 大屏投放

@@ -108,6 +108,35 @@ +
+
+

管理员账号

+
+ +
+
+

+ 本机直接调用同目录的 scoreboard.exe passwd,无需登录验证。 + 重置会作废该用户的全部登录会话,已在用的管理面板会被强制退出。 +

+
+ + + +
+ + +
+

运行日志

diff --git a/console/renderer/preload.js b/console/renderer/preload.js index a286c45..96bc020 100644 --- a/console/renderer/preload.js +++ b/console/renderer/preload.js @@ -18,11 +18,13 @@ contextBridge.exposeInMainWorld('consoleApi', { clearLog: () => ipcRenderer.invoke('log:clear'), relayoutDisplays: () => ipcRenderer.invoke('display:relayout'), openServerAdmin: () => ipcRenderer.invoke('app:open-server-admin'), + resetAdminAccount: (payload) => ipcRenderer.invoke('account:reset', payload), minimize: () => ipcRenderer.invoke('window:minimize'), maximize: () => ipcRenderer.invoke('window:maximize'), close: () => ipcRenderer.invoke('window:close'), isMaximized: () => ipcRenderer.invoke('window:is-maximized'), onMaximizeChange: (callback) => subscribe('window:maximize-changed', callback), + onAccountOutput: (callback) => subscribe('account:output', 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 8fe474a..938ad20 100644 --- a/console/renderer/styles.css +++ b/console/renderer/styles.css @@ -152,7 +152,8 @@ main { display: grid; gap: 14px; grid-template-columns: 1fr 1fr; - grid-template-rows: auto auto minmax(160px, 1fr); + /* 四张卡片各占一整行;最后一行(运行日志)吃掉剩余高度 */ + grid-template-rows: auto auto auto minmax(160px, 1fr); overflow: auto; } @@ -334,6 +335,48 @@ h2 { cursor: pointer; } +.form-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px 16px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--muted); +} + +.input { + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: #0a0e17; + color: var(--text); + font: inherit; + font-size: 13px; +} + +.input:focus { + outline: none; + border-color: var(--accent); +} + +.input:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.hint.ok { + color: var(--ok); +} + +.hint.err { + color: var(--err); +} + .log { flex: 1; min-height: 0; @@ -349,3 +392,9 @@ h2 { white-space: pre-wrap; word-break: break-all; } + +/* 账号重置的输出框沿用 .log 的终端样式,但要限高、别把卡片撑开 */ +.account-output { + max-height: 180px; + margin: 10px 0 0; +} diff --git a/console/scripts/check-syntax.js b/console/scripts/check-syntax.js new file mode 100644 index 0000000..563057b --- /dev/null +++ b/console/scripts/check-syntax.js @@ -0,0 +1,38 @@ +'use strict' + +const { spawnSync } = require('child_process') +const fs = require('fs') +const path = require('path') + +// 跨平台的语法检查:原来 package.json 里的 `for f in $(find ...)` 只在 POSIX shell 下能用, +// Windows 构建机上直接报错,这里改用 Node 递归遍历 + node --check。 +const ROOT = path.resolve(__dirname, '..') +const SKIP_DIRS = new Set(['node_modules', '.git']) + +function collect(dir, files = []) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue + collect(path.join(dir, entry.name), files) + } else if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(path.join(dir, entry.name)) + } + } + return files +} + +const files = collect(ROOT).sort() +const failed = [] + +for (const file of files) { + const result = spawnSync(process.execPath, ['--check', file], { stdio: 'inherit' }) + if (result.status !== 0) failed.push(file) +} + +if (failed.length) { + console.error(`\n语法检查失败 ${failed.length}/${files.length} 个文件:`) + for (const file of failed) console.error(` ${path.relative(ROOT, file)}`) + process.exit(1) +} + +console.log(`语法检查通过:${files.length} 个文件`) diff --git a/deploy/backup.bat b/deploy/backup.bat index 5345200..0954a4c 100644 --- a/deploy/backup.bat +++ b/deploy/backup.bat @@ -2,11 +2,11 @@ REM ============================================================================ REM Scoreboard 一键备份(Windows) REM -REM 用 sqlite3.exe 的 VACUUM INTO 生成一致性快照,服务运行中也能安全执行, -REM 不需要停服务、不需要重启。产物落在 data\backups\,滚动保留最近 20 份。 -REM -REM 依赖:sqlite3.exe(放到本脚本同目录、项目根目录,或加入 PATH 均可)。 -REM 下载地址 https://www.sqlite.org/download.html 的 sqlite-tools-win-x64。 +REM 调用 scoreboard.exe 的 backup 子命令(内置 VACUUM INTO),服务运行中也能 +REM 安全执行,不需要停服务、不依赖外部 sqlite3.exe。 +REM 产物落在 data\backups\,服务端按库(system / 各赛事)各自滚动保留 20 份。 +REM 注意:只快照 .db,不含 event_projs\<项目>\uploads\ 里的背景图/会标等资源; +REM 需要连资源一起留档时,请另行拷贝 data\event_projs\<项目>\uploads\。 REM REM 用法:deploy\backup.bat 手动备份(原因记 manual) REM deploy\backup.bat publish 发布前备份(原因记 publish) @@ -17,8 +17,6 @@ chcp 65001 >nul 2>&1 cd /d "%~dp0.." set "ROOT=%CD%" set "DATA=%ROOT%\data" -set "OUT=%DATA%\backups" -set "KEEP=20" set "REASON=manual" if not "%~1"=="" set "REASON=%~1" @@ -33,57 +31,51 @@ if not exist "%DATA%" ( exit /b 1 ) -REM ---- 定位 sqlite3.exe ------------------------------------------------------- -set "SQLITE=" -if exist "%~dp0sqlite3.exe" set "SQLITE=%~dp0sqlite3.exe" -if not defined SQLITE if exist "%ROOT%\sqlite3.exe" set "SQLITE=%ROOT%\sqlite3.exe" -if not defined SQLITE for %%I in (sqlite3.exe) do if exist "%%~$PATH:I" set "SQLITE=%%~$PATH:I" -if not defined SQLITE ( - echo [错误] 找不到 sqlite3.exe。 - echo 请把它放到 deploy\ 目录或加入 PATH 后重试。 +REM ---- 定位 scoreboard.exe ---------------------------------------------------- +set "EXE=" +if exist "%~dp0..\dist\scoreboard.exe" set "EXE=%~dp0..\dist\scoreboard.exe" +if not defined EXE if exist "%ROOT%\scoreboard.exe" set "EXE=%ROOT%\scoreboard.exe" +if not defined EXE if exist "%~dp0scoreboard.exe" set "EXE=%~dp0scoreboard.exe" +if not defined EXE for %%I in (scoreboard.exe) do if exist "%%~$PATH:I" set "EXE=%%~$PATH:I" +if not defined EXE ( + echo [错误] 找不到 scoreboard.exe。 + echo 请先执行 deploy\build.bat,或把它放到项目根目录 / 加入 PATH。 exit /b 1 ) -REM ---- 时间戳:优先 PowerShell,避免受区域设置影响 ----------------------------- -set "TS=" -for /f "usebackq delims=" %%I in (`powershell -NoProfile -Command "Get-Date -Format yyyyMMdd-HHmmss" 2^>nul`) do set "TS=%%I" -if not defined TS call :fallback_ts -if not defined TS ( - echo [错误] 无法生成时间戳。 +REM dataDir 是相对路径,服务端按当前工作目录解析,所以必须在项目根目录调用 +if not exist "%ROOT%\config.yaml" ( + echo [错误] 找不到 %ROOT%\config.yaml,无法定位 dataDir。 exit /b 1 ) -if not exist "%OUT%" mkdir "%OUT%" - echo === Scoreboard 备份 === echo 数据目录:%DATA% -echo 备份目录:%OUT% -echo 时间戳 :%TS% +echo 服务端 :%EXE% +echo 原因 :%REASON% echo. set /a OK=0 set /a FAIL=0 -if exist "%DATA%\system.db" call :backup "system" "%DATA%\system.db" - +REM 服务端一次只备份一个赛事库(system.db 每次都会一并快照),按目录逐个调用; +REM 部署形态通常只有一个 default 项目,这里就只跑一次。 +set "FOUND=" for /d %%D in ("%DATA%\event_projs\*") do ( - if exist "%%~fD\event.db" call :backup "%%~nxD" "%%~fD\event.db" + set "FOUND=1" + echo 备份赛事 %%~nxD + "%EXE%" backup -config config.yaml -project "%%~nxD" -reason "%REASON%" + if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1) ) - -echo. -echo === 滚动清理(保留最近 %KEEP% 份)=== -set /a N=0 -for /f "delims=" %%F in ('dir /b /o-d /a-d "%OUT%\*.db" 2^>nul') do ( - set /a N+=1 - if !N! GTR %KEEP% ( - echo 删除 %%F - del /f /q "%OUT%\%%F" >nul 2>&1 - ) +if not defined FOUND ( + echo 备份默认赛事 + "%EXE%" backup -config config.yaml -reason "%REASON%" + if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1) ) echo. echo === 备份完成:成功 !OK! 个,失败 !FAIL! 个 === -dir /b "%OUT%\*.db" 2>nul +dir /b "%DATA%\backups\*.db" 2>nul if !FAIL! GTR 0 ( endlocal @@ -91,34 +83,3 @@ if !FAIL! GTR 0 ( ) endlocal exit /b 0 - -REM ---- 备份单个库:%1 文件名前缀,%2 数据库路径 ------------------------------- -:backup -set "PREFIX=%~1" -set "DBFILE=%~2" -set "TARGET=%OUT%\%PREFIX%-%REASON%-%TS%.db" -if exist "%TARGET%" set "TARGET=%OUT%\%PREFIX%-%REASON%-%TS%-%RANDOM%.db" - -echo 备份 %DBFILE% -echo -^> %TARGET% -"%SQLITE%" "%DBFILE%" "VACUUM INTO '%TARGET%'" -if errorlevel 1 ( - echo [失败] sqlite3 返回错误 - set /a FAIL+=1 -) else ( - set /a OK+=1 -) -exit /b 0 - -REM ---- %DATE%/%TIME% 兜底:只保证名字不重复 ----------------------------------- -:fallback_ts -set "D=%DATE%" -set "T=%TIME%" -set "D=%D:/=%" -set "D=%D:-=%" -set "D=%D:.=%" -set "T=%T::=%" -set "T=%T:.=%" -set "T=%T: =0%" -set "TS=%D%-%T%" -exit /b 0 diff --git a/deploy/build.bat b/deploy/build.bat index a29f2fe..f0b19c5 100644 --- a/deploy/build.bat +++ b/deploy/build.bat @@ -25,16 +25,19 @@ cd .. echo === [3/4] server build === cd server if not exist ..\dist mkdir ..\dist +REM 版本号注入 /api/health:优先取最近的 git tag,没有 tag 时退到短哈希 +set "APP_VERSION=dev" +for /f "delims=" %%I in ('git describe --tags --always 2^>nul') do set "APP_VERSION=%%I" REM 内部署名从构建机本地文件注入(deploy\signature.local.txt 已 gitignore,不进公开仓库); REM 文件不存在时二进制里也是空的,源码永远不含真实署名。 set "INTERNAL_SIGNATURE=" if exist "..\deploy\signature.local.txt" set /p INTERNAL_SIGNATURE=<"..\deploy\signature.local.txt" if defined INTERNAL_SIGNATURE ( REM 单引号包裹整个 -X:署名里的空格不会被 ldflags 拆开(go 的 ldflags 解析器认单引号) - go build -ldflags "-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.InternalSignature=%INTERNAL_SIGNATURE%'" -o ..\dist\scoreboard.exe . || exit /b 1 + go build -ldflags "-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.InternalSignature=%INTERNAL_SIGNATURE%' -X main.version=%APP_VERSION%" -o ..\dist\scoreboard.exe . || exit /b 1 echo internal signature injected ) else ( - go build -o ..\dist\scoreboard.exe . || exit /b 1 + go build -ldflags "-X main.version=%APP_VERSION%" -o ..\dist\scoreboard.exe . || exit /b 1 echo no internal signature ) cd .. @@ -54,6 +57,25 @@ REM win.icon 由 electron-builder 内部调 rcedit 写进 PE 资源; REM 它需要的 winCodeSign / nsis 从 gh-proxy 取,签名关闭。 taskkill /im "Scoreboard Console.exe" /f >nul 2>&1 taskkill /im scoreboard.exe /f >nul 2>&1 +REM 部署包根目录要带 rules\ 与 使用说明.txt(devdocs 10 §4.4)。 +REM rules\ 由 package.json 的 extraFiles 直接从仓库根拷入; +REM 使用说明.txt 是可选文档,只在 docs\ 下存在时才暂存到 console\extra\, +REM electron-builder 的 extraFiles 会把该目录内容平铺到部署包根目录。 +if not exist "rules" echo 警告:rules\ 不存在,部署包将缺少规则包目录 +if not exist "console\extra" mkdir "console\extra" +if exist "console\extra\使用说明.txt" del /q "console\extra\使用说明.txt" +if exist "docs\使用说明.txt" ( + copy /y "docs\使用说明.txt" "console\extra\使用说明.txt" >nul + echo 使用说明.txt 已加入部署包(来源:docs\使用说明.txt) +) else ( + if exist "docs\使用手册.md" ( + REM 文档目录里只有 Markdown 版手册时,按部署包约定的文件名落盘 + copy /y "docs\使用手册.md" "console\extra\使用说明.txt" >nul + echo 使用说明.txt 已加入部署包(来源:docs\使用手册.md) + ) else ( + echo docs\ 下没有 使用说明.txt / 使用手册.md,跳过(部署包不含使用说明.txt) + ) +) cd console call pnpm install --frozen-lockfile || exit /b 1 call pnpm run dist || exit /b 1 diff --git a/docs/使用手册.md b/docs/使用手册.md new file mode 100644 index 0000000..fc9fb42 --- /dev/null +++ b/docs/使用手册.md @@ -0,0 +1,461 @@ +# 使用手册 + +> 面向现场操作人员与管理员。管理面板地址:`http://<主机IP>:19540/admin`;大屏地址:`http://<主机IP>:19540/display`。 +> +> 具体按钮文案与页面布局以实际界面为准;本文按当前版本整理。 + +--- + +## 1. 角色与权限 + +系统只有三种身份,权限是包含关系:**管理员 ⊇ 录入员 ⊇ 访客**。 + +| 身份 | 说明 | 能做什么 | +| --- | --- | --- | +| 访客 guest | 未登录时的默认身份,不需要建账号 | 只读查看积分榜、成绩、基础数据等公开数据;不能录入、发布、改配置 | +| 录入员 recorder | 现场记录员 | 访客全部权限 + 成绩录入 / 存草稿 / 发布 / 撤销发布 / 破纪录确认 / 积分调整 / 大屏控制 / 变更历史 | +| 管理员 admin | 赛事管理员 | 录入员全部权限 + 系统设置、用户管理、备份、审计日志、规则包与赛事项目包导入导出 | + +> 未登录也能进管理面板,只是所有写操作按钮被禁用,顶部会出现「当前为访客只读视图」提示。 + +### 1.1 登录 + +1. 打开管理面板地址,进入登录页; +2. 输入账号与密码,点「登录」; +3. 首次登录或密码被管理员重置后,会自动跳到「修改密码」,改完才能继续; +4. 也可以点「以访客身份只读浏览」直接查看数据。 + +### 1.2 退出 + +点击右上角用户名旁的**退出**图标,即退出登录并回到只读访客视图。 + +--- + +## 2. 界面总览 + +左侧导航按用途分组: + +| 分组 | 页面 | +| --- | --- | +| 总览 | 仪表盘 | +| 成绩 | 成绩录入、成绩审核、破纪录、积分调整 | +| 榜单 | 积分榜、大屏控制 | +| 数据 | 基础数据、导出、变更历史、日志 | +| 系统 | 系统设置、修改密码、关于 | + +顶部栏显示:实时连接状态、服务端版本、刷新按钮、当前账号与角色、退出按钮。左侧栏底部有快捷键提示:`Ctrl+Enter` 存草稿、`Alt+Enter` 发布。 + +--- + +## 3. 仪表盘 + +用于快速掌握全局: + +- 领先单位、已发布项目数 / 总项目数、纪录条目数、实时连接状态; +- 「当前赛程」列出小项及状态(已发布 / 待录入),点「录入」直接跳到该项目; +- 「实时积分 Top 3」显示前三名与奖牌数; +- 有待录入项目时显示「待办」提醒。 + +--- + +## 4. 成绩录入 + +### 4.1 选择项目 + +1. 左侧「项目」列表按大项分组,点选要录入的小项; +2. 每个小项带状态徽标:**已发布** 或 **待录入**; +3. 多个组别时,表格上方出现组别页签,切换组别分别录入。 + +### 4.2 录入成绩 + +表格列依次为:号码、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注。 + +| 列 | 操作 | +| --- | --- | +| 成绩输入 | 直接输入成绩。时间类项目按 `1:06.33` 格式;工具栏会提示当前单位(秒 / 米 / 次 / 分) | +| 名次 | 自动排名项目由系统自动计算,不可编辑;手动名次项目(录入方式为「手动名次」或比较方式为「不比较」)可直接填名次 | +| 状态 | 下拉选择:**有效**、**DNS 弃赛**、**DQ 取消**、**DNF 未完赛**、**退赛** | +| 积分(自动) | 随成绩实时计算,仅供参考 | +| 备注 | 自由填写 | + +右侧「实时积分预览」显示:若现在发布,各单位积分的变化、最大分差、受影响单位数。 + +### 4.3 键盘流与批量录入 + +| 操作 | 效果 | +| --- | --- | +| `Tab` | 跳到下一个单元格(浏览器原生焦点顺序) | +| `Enter` | 在成绩格之间依次移动 | +| `Ctrl+Enter` | 存草稿 | +| `Alt+Enter` | 发布当前项目 | +| 「按成绩重排」 | 按成绩从小到大 / 从大到小重排表格行(名次仍以比较值为准) | +| 「下一格」 | 按钮方式移动焦点 | + +**从 Excel 粘贴**:复制区域后,在成绩格按 `Ctrl+V`。 + +- 两列及以上:第一列按单位名称 / 简称匹配行,最后一列作为成绩; +- 单列:从当前行开始向下依次填充。 + +### 4.4 防呆校验 + +页面下方「防呆校验」会实时列出问题: + +- **错误(阻止发布)**:缺少成绩、成绩格式无法解析、手动名次项目缺少名次; +- **提醒(不阻止发布)**:成绩 ≤ 0、名次重复(请确认是否并列)、成绩与赛会纪录差距过大。 + +有错误时点「发布」会被拦下并提示第一处问题。 + +### 4.5 存草稿与发布 + +1. **存草稿**:保存当前组别的成绩,草稿**不会上屏**,也不计入积分榜;有未保存修改时页面会显示「有未保存修改」徽标。 +2. **发布**:点击后弹出确认框,显示本次影响的单位数与最大分差;确认后成绩立即上屏并重算积分榜。 + - 发布前系统会自动把当前内容整组保存为草稿再发布,无需手动先存; + - 示例数据模式下「发布」按钮不可用,避免把未落库的改动当成发布成功。 + +### 4.6 撤销发布 + +已发布的组别会出现「撤销发布」按钮: + +1. 点击后在确认框中确认; +2. 系统作废该组已产生的积分流水,大屏回退到发布前状态; +3. 修改后重新发布即可。全程留痕,可在「变更历史」查看。 + +--- + +## 5. 成绩审核 + +用于在发布前统一复核草稿。 + +1. 左侧「待审草稿」按「小项 + 组别」分组,显示每组草稿条数,可搜索小项 / 组别 / 单位; +2. 点选一组后,右侧显示该组成绩明细(单位、运动员 / 队、成绩、名次、状态、积分); +3. 用「整组对比 / 逐条对比」切换,查看发布后各单位积分的变化; +4. 「发布影响」列出受影响单位与分值变化; +5. 操作: + - **发布整组**:确认后整组发布并重算积分榜; + - **退回**:当前版本服务端没有「退回」接口,此操作**仅在管理端本地标记**,不会改动成绩数据;刷新页面后标记可能消失。如需真正退回,请通知录入员到「成绩录入」修改后重新保存。 + +> 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效。 + +--- + +## 6. 破纪录 + +页面顶部会显示破纪录加分开关状态(来自规则包 `recordBonus.enabled`)。当前版本默认关闭;若当前角色读不到规则包,会如实显示「未知」,以服务端校验结果为准。 + +### 6.1 疑似破纪录 + +- 系统用**已发布且有效**的成绩与当前纪录自动比对,推导出疑似破纪录,草稿不参与; +- 列表显示小项、组别、单位 / 运动员、成绩、原纪录、档位(赛会 / 市级 / 省级 / 全国)、预计加分; +- **确认加分**:登记破纪录并生成加分流水、更新纪录库;开关关闭时按钮不可用; +- **拒绝**:当前版本服务端没有拒绝接口,此操作只在本地隐藏该条,刷新后会重新出现。 + +### 6.2 纪录库 + +- 按小项分组展示,可用「全部 / 赛会 / 市级 / 省级 / 全国」页签筛选,也可搜索小项或保持者; +- 每行显示档位、成绩、保持者、单位、达成时间、来源(本届 / 历史)以及是否为当前纪录。 + +--- + +## 7. 积分调整 + +用于人工加减分,全程留痕。 + +页面顶部显示人工调整开关状态(规则包 `manualAdjustment.enabled`)。若已关闭,「新增调整」按钮不可用。 + +### 7.1 新增调整 + +点「新增调整」,填写: + +| 字段 | 说明 | +| --- | --- | +| 单位 * | 下拉选择参赛单位 | +| 分值 * | 正数加分、负数扣分,不能为 0 | +| 类别 * | 处罚 / 补分 / 更正 | +| 关联项目 | 可选,关联到具体小项 | +| 理由 * | 必填,会写入审计日志 | + +保存后积分榜实时更新。 + +### 7.2 查看与作废 + +- 列表可按类别筛选、按单位或理由搜索,分页浏览; +- 点某行的「作废」,填写作废理由并确认,对应积分流水会一并作废; +- 已作废的记录仍保留在列表中,状态显示「已作废」。 + +--- + +## 8. 积分榜 + +顶部页签:**单位总分榜**、**团体奖牌榜**、**个人积分榜**;右侧搜索框可按单位或运动员过滤。 + +### 8.1 单位总分榜 + +- 列:名次、单位、总分、金、银、铜、变化、明细; +- 「变化」列显示名次升降(如 `↑2`、`↓1`); +- 点「展开」下钻该单位的积分明细,右侧面板显示合计与每一条得分来源(项目、组别、运动员、成绩、名次、积分),每一分都可溯源。 + +### 8.2 团体奖牌榜 / 个人积分榜 + +- 团体奖牌榜显示金、银、铜与合计; +- 个人积分榜显示运动员、单位、个人总分与奖牌数。 + +### 8.3 推送明细到大屏 + +1. 展开某个单位的明细; +2. 点「推送到大屏」; +3. 所有大屏会弹出半屏明细覆盖层,**60 秒后自动关闭**; +4. 想立即关闭,点页面顶部「关闭大屏明细」并确认。 + +--- + +## 9. 大屏控制 + +| 区块 | 操作 | +| --- | --- | +| 切页 | 选择「总积分榜 / 团体奖牌榜 / 个人积分榜 / 最新成绩」,点「立即跳转」 | +| 锁定与刷新 | **锁定**:停止自动轮播并停在当前页;**解锁**:恢复轮播;**强制刷新**:让所有大屏重新拉取快照,用于排障 | +| 公告 | 输入公告文字,设置滚动时长(5–120 秒),点「推送公告」 | + +页面顶部徽标显示当前是「轮播中」还是「已锁定」。 + +**在线看板**:当前版本后端未提供大屏实例上报接口,因此在线状态恒显示「未知」,「最近下发」只是本机下发指令的时间,**不代表大屏在线**。 + +--- + +## 10. 基础数据 + +页面分七个分区,从左到右依次是:参赛单位、大项 / 小项、组别、计分通道、积分模板、运动员、报名导入。 + +### 10.1 参赛单位 + +1. 点「新增单位」; +2. 填写代码 *(唯一,字母 / 数字 / `_` / `-`,最长 16 位)、全称 *、简称、主题色、排序、logo 路径; +3. 「编辑」修改;「停用」为软删除——历史成绩与积分流水保留,但新报名不再可选该单位,可随时「启用」。 + +### 10.2 大项 / 小项 + +1. 左侧选择或新增大项(编码、名称、简称、默认积分模板、排序); +2. 右侧维护该大项下的小项:点「新增小项」,填写编码、名称、类型(个人 / 团体 / 对抗比赛)、录入方式(自动排名 / 手动名次)、成绩类型(时间 / 距离 / 计数 / 得分 / 无成绩)、比较方式(越小越好 / 越大越好 / 不比较)、状态、是否计分等; +3. 小项行上的「配置组别」「配置计分通道」可直接跳到对应分区; +4. 删除大项 / 小项会二次确认。 + +### 10.3 组别 + +1. 先在上方选中一个小项; +2. 点「新增组别」,填写名称、权重、性别、年龄上下限; +3. 用「上移 / 下移」调整顺序,「移除」删除; +4. 点「保存全部」整体替换该小项的组别配置。**若该项目已有报名数据,服务端会拒绝替换。** + +### 10.4 计分通道 + +通道 = 「排谁的名次 + 名次从哪来 + 用哪张积分模板」。 + +1. 先选中一个小项; +2. 点「新增通道」逐条配置,同一「名次范围 + 名次来源」只能有一个通道; +3. 点「保存全部」整体替换该小项的通道配置; +4. 标记为「不计分」的小项,通道可以保存但不参与积分计算。 + +### 10.5 积分模板 + +1. 左侧点「新建」创建模板,填写编码、名称、并列处理、破纪录加分、录取名次、末位归零; +2. 在「名次 → 分值」表格里添加 / 移除分值明细; +3. 保存会整体替换该模板的分值明细,请核对后再保存。 + +### 10.6 运动员 + +1. 点「新增运动员」,填写姓名 *、工号、所属单位等; +2. 「编辑」修改,「停用」后不再出现在新报名选择中; +3. 个人项目的报名项会关联到运动员,团体项目可以留空。 + +### 10.7 报名导入 + +1. 点「下载 CSV 模板」获取表头样例; +2. 表头必须包含:`event_code`、`category`、`team_code`、`athlete_name`、`bib`、`seed`、`status`、`member_names`(大小写不敏感); +3. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表); +4. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。 + +--- + +## 11. 导出 + +页面分三个页签。 + +### 11.1 结果导出(xlsx) + +| 导出项 | 内容 | 文件名 | +| --- | --- | --- | +| 总表 | 总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿 | `总积分榜.xlsx` | +| 全部 | 总表 + 全部分项,多 sheet 工作簿,适合归档打印 | `赛事成绩总表.xlsx` | +| 分项成绩表 | 选择一个小项导出成绩与积分 | `分项成绩表.xlsx` | +| 单位积分明细 | 选择一个单位,逐项列出得分来源 | `单位积分明细.xlsx` | + +- 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位; +- 导出文件由浏览器直接下载,表头、合并单元格与字体已按打印模板预设。 + +### 11.2 赛事项目包 + +- **导出 zip**:包内含 `meta.yaml`、`event.db`、`uploads/`、`rules.yaml`,整包拷走即可在另一台机器复现整场赛事; +- **导入 zip**(仅管理员):选择 `.zip`,可填「重命名」,勾选「覆盖同名项目」会先二次确认;导入完成后需重启服务端并指定项目名才会切换到该赛事(详见部署指南第 8.5 节)。 + +### 11.3 规则包 + +- **导出 rules.yaml**:导出积分模板、并列规则、破纪录加分、计分通道与项目覆盖; +- **导入**(仅管理员): + 1. 选择 `.yaml` / `.yml` 文件; + 2. 点「预览差异」,查看格式是否兼容、影响多少个小项、逐条改动; + 3. 确认无误后点「确认导入并全量重算」,服务端会按新规则重算全部已发布积分。 + +> 规则导入会立即改变积分榜与大屏,且**不能一键撤销**,纠错需重新导入旧规则包。 + +--- + +## 12. 变更历史 + +仿 Git 图谱的只读操作记录。 + +1. 顶部可筛选小项,以及「全部时间 / 近 24 小时 / 近 7 天」; +2. 左侧时间轴最新在上,节点显示动作(发布 / 撤销发布 / 编辑)、项目、操作人、时间与备注; +3. 点节点,右侧显示操作人、影响单位与成绩前后对比(diff); +4. 本页**不提供一键回滚**。纠错请到「成绩录入」走「撤销发布 → 修改 → 重新发布」。 + +--- + +## 13. 日志 + +审计日志,只读、不可删除,记录谁在什么时候改了什么。 + +1. 可按「动作」「实体」筛选,点「清除筛选」复位; +2. 列表显示时间、操作人、IP、动作、实体、请求 ID;关键动作会带「关键」标记; +3. 点某行「对比」展开,查看改动前后的 JSON 差异; +4. 筛选与分页作用于最近 200 条记录; +5. 该页面仅管理员可查看。 + +--- + +## 14. 系统设置 + +页面分三个页签,均需要管理员权限。 + +### 14.1 用户管理 + +| 操作 | 说明 | +| --- | --- | +| 新建用户 | 填写账号 *、显示名 *、角色 *(管理员 / 录入员)、初始密码 *(至少 8 位);新用户首次登录强制改密 | +| 编辑 | 修改显示名、角色、启用状态;账号创建后不可改 | +| 停用 | 二次确认后该用户无法登录,正在使用的会话也会失效 | +| 重置密码 | 生成随机密码,**只显示一次**,可复制后交给用户;旧密码立即失效 | + +> 访客身份由系统默认提供,不需要也不允许建「访客账号」。 + +### 14.2 备份 + +- 列表显示备份文件名、来源、大小、创建时间; +- 点「立即备份」马上生成一份; +- 右侧「恢复说明」列出停机恢复步骤(详见部署指南第 8 节)。 + +### 14.3 赛事信息 + +| 字段 | 说明 | +| --- | --- | +| 赛事名称 * | 必填 | +| 副标题 / 主办方 | 可选,显示在大屏顶部 | +| 大屏标题 / 大屏副标题 | 大屏顶部标题区 | +| 轮播间隔(秒) | 控制大屏底部滚动条(奖牌榜 Top3 / 最新完成项目 / 公告)的切换间隔,5–120 秒 | +| 主题色 | 大屏主色,如 `#3482FF` | +| 个人积分榜上屏 | 开关;关闭后大屏不展示个人积分榜 | + +改完点「保存」;未保存时显示「未保存」徽标,点「放弃修改」可还原。 + +> 修改大屏标题、主题色等会立即影响所有在线大屏,请避开颁奖或播报时段。 + +--- + +## 15. 修改密码 + +1. 输入原密码; +2. 输入新密码(至少 8 位)并再次确认; +3. 点「修改密码」; +4. 修改成功后所有会话作废,需要用新密码重新登录。 + +首次登录或密码被管理员重置后,会强制进入本页。 + +--- + +## 16. 关于 + +展示系统名称、版本号、构建时间、运行模式,以及技术栈与开源许可、字体清单、键盘快捷键、部署信息(服务端端口、访问地址、赛事项目名)。 + +--- + +## 17. 大屏端使用 + +### 17.1 页面 + +大屏支持五个页面: + +| 页面 | 内容 | +| --- | --- | +| 总积分榜 | 名次、单位、总分,以及名次升降箭头(约 2 秒后淡出) | +| 团体奖牌榜 | 名次、单位、金 / 银 / 铜、合计 | +| 个人积分奖牌榜 | 名次、姓名、单位、总分、奖牌 | +| 分项成绩 | 最新已完成项目的成绩 | +| 公告 | 管理端手动推送的文字 | + +### 17.2 轮播与锁定 + +- Console 启动的多屏大屏窗口默认**每屏锁定一页**,不轮播; +- 在管理端「大屏控制」点「解锁」后开始整页轮播,**每 20 秒切换一页**; +- 公告页是手动触发的页面,不参与自动轮播;个人积分榜可在「赛事信息」里关闭上屏; +- 点「锁定」可停在当前页。 + +### 17.3 明细覆盖层 + +- 管理端推送单位积分明细后,大屏右侧弹出半屏覆盖层,显示该单位每一分的来源; +- 默认 **60 秒后自动关闭**并恢复轮播; +- 管理端「关闭大屏明细」可立即关闭。 + +### 17.4 断线与自愈 + +- 与后端断开时,大屏顶部显示离线角标,并继续展示本地缓存的最近一份快照; +- 连接恢复后自动拉取最新数据; +- 大屏每 24 小时自动重载一次,Console 同时启用防熄屏。 + +--- + +## 18. 常见操作问题 + +**Q:为什么录入的成绩大屏上没有?** +草稿不会上屏。需要点「发布」(或 `Alt+Enter`),发布成功后立即更新。 + +**Q:发布后发现有错怎么办?** +到「成绩录入」找到该组,点「撤销发布」→ 修改 → 重新发布。撤销会作废该组积分流水,大屏回退;全程可在「变更历史」追溯。 + +**Q:并列名次怎么显示?** +成绩相同(或手动名次相同)即视为并列,显示同一名次,例如两个「3」。积分按规则包里的并列口径计算,默认是「顺延」:并列第 3 各得第 3 名的分,无第 4 名,下一名是第 5 名。奖牌也按并列处理,并列第 1 各记 1 金、无银牌。 + +**Q:为什么「发布」按钮是灰的?** +可能原因:当前是访客未登录、服务端连接中断、处于示例数据模式,或校验存在错误。页面会给出对应提示。 + +**Q:为什么页面显示「示例数据」?** +说明前端连不上后端或接口未就绪,此时操作不会写入真实赛事。请检查 Console 里服务端是否为「运行中」,恢复后刷新页面。 + +**Q:大屏上的积分明细怎么关?** +在「积分榜」页点「关闭大屏明细」,或在「大屏控制」里锁定 / 切换页面;不操作时 60 秒后自动关闭。 + +**Q:公告怎么发?多久消失?** +在「大屏控制 → 公告」输入文字,设置滚动时长(5–120 秒)后点「推送公告」;到时后自动回到之前的页面。 + +**Q:怎么切换到另一个赛事项目?** +当前服务端一次只运行一个赛事项目(默认 `default`)。导入赛事项目包后,需要重启服务端并指定 `-project <项目名>` 才能切换,详见部署指南第 8.5 节。 + +**Q:导出的文件在哪里?** +结果导出和赛事包由浏览器下载到默认下载目录;服务端自身也会在 `data\exports\` 下生成临时包(导出完成后自动删除)。 + +**Q:登录多久会过期?** +会话有效期 12 小时。过期后自动回到只读访客视图,重新登录即可。 + +**Q:为什么登录失败次数多了会被暂时拒绝?** +登录有频率限制(每个 IP 每分钟 5 次),写操作也有频率限制(每个用户每分钟 30 次)。稍等一分钟再试即可。 + +**Q:为什么有些页面 / 按钮看不到或不可用?** +权限按角色控制:访客只能只读;变更历史、日志、系统设置、备份、用户管理、规则包与赛事项目包导入需要相应角色。请用对应账号登录。 diff --git a/docs/常见问题.md b/docs/常见问题.md new file mode 100644 index 0000000..ccc3412 --- /dev/null +++ b/docs/常见问题.md @@ -0,0 +1,135 @@ +# 常见问题(FAQ) + +> 更完整的操作步骤见 [使用手册](./使用手册.md),部署与排障见 [部署指南](./部署指南.md)。 + +--- + +## 账号与权限 + +**Q1:初始密码是多少?** +首次启动时随机生成、只打印一次。用 Console 启动时,在 `logs\console.log` 里搜索「初始密码」即可找到,方法见 [部署指南 · 首次登录](./部署指南.md#5-首次登录)。 + +**Q2:忘记密码怎么办?** +先停止服务,然后在部署目录执行 `scoreboard.exe passwd -config config.yaml`,会打印新的随机密码,并要求下次登录强制改密。 + +**Q3:访客和录入员有什么区别?** +访客只能只读查看;录入员可以录入、存草稿、发布、撤销发布、确认破纪录、做积分调整、控制大屏;管理员在此基础上还能管理用户、备份、看审计日志、导入规则包和赛事包。 + +**Q4:为什么登录后还是只读?** +可能未登录成功、会话已过期(有效期 12 小时),或页面处于示例数据模式。看右上角是否显示账号名与角色。 + +**Q5:可以建「访客账号」吗?** +不可以。访客是未登录时的默认身份,不需要也不允许建账号;可落库的角色只有管理员和录入员。 + +**Q6:管理员能改用户名吗?** +管理界面里账号创建后不可改;命令行可用 `scoreboard.exe passwd -user 旧名 -new-username 新名` 修改。 + +--- + +## 成绩与积分 + +**Q7:为什么录入后大屏没变化?** +草稿不上屏。需要「发布」(或 `Alt+Enter`),发布后 1 秒内大屏更新。 + +**Q8:发布错了怎么改?** +撤销发布 → 修改 → 重新发布。撤销会作废该组积分流水,大屏回退到发布前状态,过程可在「变更历史」查看。 + +**Q9:为什么「发布」是灰的?** +常见原因:未登录、服务端断开、示例数据模式,或存在阻止发布的校验错误(缺少成绩、格式无法解析、手动名次项目缺名次)。 + +**Q10:并列名次怎么处理?** +成绩相同(或手动名次相同)自动按并列处理,显示同一名次。默认口径是「顺延」:并列第 3 各得第 3 名的分,无第 4 名,下一名第 5 名;奖牌同样并列(并列第 1 各记 1 金、无银)。具体口径由规则包配置决定。 + +**Q11:状态里的 DNS / DQ / DNF / 退赛有什么区别?** +都是不参与名次与积分计算的异常状态:DNS = 弃赛,DQ = 取消资格,DNF = 未完赛,退赛 = 主动退出。正常成绩选「有效」。 + +**Q12:时间类成绩怎么写?** +按 `分:秒.毫秒` 形式输入,例如 `1:06.33`;工具栏会显示当前项目的单位。 + +**Q13:能批量录入吗?** +可以。支持从 Excel 复制后粘贴:两列及以上时第一列按单位名称 / 简称匹配行、最后一列作为成绩;单列时从当前行向下依次填充。也可以用 `Tab` / `Enter` 在格子间移动,`Ctrl+Enter` 存草稿。 + +**Q14:破纪录加分为什么点不了?** +规则包里破纪录加分默认关闭(`recordBonus.enabled = false`)时按钮不可用;若当前角色读不到规则包,页面显示「未知」,可提交,由服务端校验决定是否加分。 + +**Q15:人工调整为什么点不了?** +规则包里人工调整默认关闭(`manualAdjustment.enabled = false`)时不可用;以页面顶部显示的开关状态为准。 + +--- + +## 大屏 + +**Q16:大屏不亮 / 只有桌面怎么办?** +检查 HDMI、扩展显示模式、Console「大屏窗口」状态,点「重新排布」;服务端未就绪时最多等 20 秒才开屏;单屏窗口化时按 `F11` 切全屏。详见 [部署指南 · 故障排查](./部署指南.md#9-故障排查)。 + +**Q17:单屏和多屏的显示方式有什么不同?** +单屏时大屏以窗口化方式打开,按 `F11` 切全屏;多屏时除内置屏外每块屏一个 kiosk 全屏窗口,各锁一页,不响应 F11,也不接收鼠标。 + +**Q18:大屏为什么不自动轮播?** +Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点「解锁」后开始轮播,每 20 秒一页;公告页不参与轮播。 + +**Q19:推送的积分明细怎么关?** +60 秒后自动关闭;也可以点「积分榜」页的「关闭大屏明细」立即关闭。 + +**Q20:「在线看板」为什么显示未知?** +当前版本后端没有大屏实例上报接口,因此无法判断每块屏是否在线,「最近下发」只是本机下发指令的时间。以现场大屏实际画面为准。 + +**Q21:安卓电视能用吗?** +可以,用电视自带浏览器访问 `http://<主机IP>:19540/display`。但它不受 Console 的 kiosk、防熄屏控制,字体与动画表现以实际设备为准。 + +**Q22:大屏时间不对怎么办?** +系统没有外网时间同步,以主机时间为准。校准 Windows 时间与时区即可。 + +--- + +## 数据与备份 + +**Q23:备份在哪里?** +`data\backups\`,文件名形如 `system-<时间戳>.db`、`<项目名>-<时间戳>.db`。 + +**Q24:多久备份一次?** +当前版本需要手动触发:管理端「系统设置 → 备份 → 立即备份」,或执行 `scoreboard.exe backup -config config.yaml`。建议在开幕前、发布高峰前、每天结束时各备份一次。 + +**Q25:备份会自动删除旧的么?** +命令行备份会按前缀滚动保留最近 20 份,超出自动清理;管理端「立即备份」当前不自动清理,请定期检查目录占用。 + +**Q26:怎么恢复备份?** +没有一键恢复接口,需停机手工替换数据库文件(复制备份为 `event.db`,删除 `event.db-wal` / `event.db-shm`,再启动),详见 [部署指南 · 备份与恢复](./部署指南.md#8-备份与恢复)。 + +**Q27:换一台电脑怎么迁移?** +把整个部署目录(含 `data\`)拷到新机器,启动 Console 即可。 + +**Q28:怎么切换赛事项目?** +服务端一次只运行一个项目(默认 `default`)。导入赛事包后,需要在 Console 的 `config.json` 里给 `server.args` 加 `-project <项目名>` 并重启,详见部署指南第 8.5 节。 + +--- + +## 导出与导入 + +**Q29:能导出哪些文件?** +四类 Excel:总表、全部(多 sheet)、分项成绩表、单位积分明细;另外可导出赛事项目包(zip)与规则包(YAML)。 + +**Q30:导出文件保存在哪?** +浏览器默认下载目录。服务端生成的临时包位于 `data\exports\`,下载完成后自动删除。 + +**Q31:谁能导入规则包 / 赛事项目包?** +只有管理员。两者都会整体替换数据:规则包导入后全量重算积分且不能一键撤销;赛事包覆盖同名项目前会二次确认。 + +**Q32:导入规则包后积分变了,能撤销吗?** +不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。 + +--- + +## 其他 + +**Q33:系统需要联网吗?** +不需要。完全离线运行,不依赖外网,也不需要在现场安装任何运行时。 + +**Q34:页面提示「示例数据」是什么意思?** +表示后端未就绪或连接中断,此时所有改动只停留在界面,不会写入真实赛事。恢复连接后刷新即可。 + +**Q35:为什么登录 / 写操作偶尔被拒绝?** +系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。 + +**Q36:数据目录在哪?** +由 `config.yaml` 的 `dataDir` 指定,默认 `data`,相对服务端工作目录解析。 diff --git a/docs/部署指南.md b/docs/部署指南.md new file mode 100644 index 0000000..80d1f2d --- /dev/null +++ b/docs/部署指南.md @@ -0,0 +1,429 @@ +# 部署指南 + +> 面向现场操作人员与管理员。本系统完全离线运行,不需要联网,不需要安装任何运行时。 + +--- + +## 1. 运行环境要求 + +| 项目 | 要求 | +| --- | --- | +| 服务端主机 | Windows 10 / 11,64 位(amd64) | +| 大屏 | 通过 HDMI 接入主机;显示模式设为**扩展**;推荐 16:9、1920×1080 或 3840×2160 | +| 网络 | 完全离线即可运行。若需要手机/平板/电视访问,用独立交换机或路由器组成局域网 | +| 磁盘 | 建议预留 ≥ 10GB | +| 运行时 | 无需安装 Go / Node / 数据库等任何运行时,前端已内嵌进可执行文件 | +| 权限 | 首次运行会在部署目录下创建 `data\`、`logs\`,请确保该目录可写 | + +> 服务端只构建 Windows amd64 一个版本。Linux / 其他平台不在交付范围内。 + +--- + +## 2. 获取安装包 + +在**构建机**(具备 Go、Node.js、pnpm、git 环境,并能访问依赖源)上执行: + +```bat +deploy\build.bat +``` + +脚本依次完成:拉取代码 → 构建前端 → 构建服务端 → 打包桌面宿主。产物落在仓库的 `dist\` 目录: + +| 产物 | 类型 | 说明 | +| --- | --- | --- | +| `dist\scoreboard.exe` | 服务端 | 前端已通过内嵌方式打进可执行文件,单独运行也能提供全部页面 | +| `dist\console\Scoreboard Console-0.1.0-x64.exe` | 安装包 | NSIS 安装程序,双击按向导安装 | +| `dist\console\Scoreboard Console-0.1.0-x64.zip` | 免安装包 | 解压即用 | +| `dist\console\win-unpacked\` | 目录 | 未压缩的运行目录,便于现场调试 | + +> 文件名中的 `0.1.0` 来自 `console\package.json` 的版本号,会随版本升级变化;以 `dist\console\` 下的实际文件名为准。 +> +> 目标机器**不需要** Go / Node / pnpm,也不联网;上述工具只用于构建机。 + +--- + +## 3. 安装与首次启动 + +### 3.1 使用安装包 + +1. 双击 `Scoreboard Console-0.1.0-x64.exe`。 +2. 按向导选择安装目录(可自定义)。 +3. 安装完成后,桌面会出现名为**「赛事积分榜」**的快捷方式。 +4. 双击「赛事积分榜」启动。 + +> 程序会在自己的安装目录下创建 `data\` 与 `logs\`,请安装到当前用户有写权限的目录;若装到受保护的系统目录,可能因无写入权限而无法建库。 + +### 3.2 使用免安装包 + +1. 把 `Scoreboard Console-0.1.0-x64.zip` 解压到任意目录(建议使用纯英文路径)。 +2. 双击目录里的 `Scoreboard Console.exe` 启动。 + +### 3.3 首次启动会自动完成 + +- 拉起并守护服务端 `scoreboard.exe`; +- 在 `data\` 下自动建库、建目录; +- 打开服务端管理面板窗口; +- 把大屏页面投到扩展屏; +- 启用防熄屏; +- 注册开机自启(仅打包后的 Windows 版本)。 + +部署目录结构(安装或解压后): + +``` +安装目录\ +├── Scoreboard Console.exe # 桌面宿主,双击启动 +├── resources\app.asar # Console 自身资源 +├── config.json # Console 配置(可编辑) +├── scoreboard.exe # Go 服务端 +├── config.yaml # 服务端配置 +├── scoreboard.png # 应用图标 +├── data\ # 首次运行生成:数据库、备份 +└── logs\console.log # 首次运行生成:运行日志 +``` + +> `data\` 与二进制分离,升级时替换可执行文件即可保留数据。 + +--- + +## 4. 服务管理窗口(Scoreboard Console) + +启动后首先看到的是「Scoreboard Console · 启动管理」窗口。 + +### 4.1 窗口按钮 + +| 位置 | 按钮 / 项 | 作用 | +| --- | --- | --- | +| 服务端状态 | **启动服务** | 手动拉起服务端 | +| 服务端状态 | **停止服务** | 优雅停止服务端(会先请求服务端关闭,超时后强制结束) | +| 服务端状态 | **重启服务** | 停止后重新启动 | +| 服务端状态 | 状态行 | 已停止 / 启动中 / 运行中 / 停止中 / 异常退出 | +| 服务端状态 | 服务地址、进程 PID、自动重启、防熄屏 | 实时运行信息 | +| 服务端状态 | 服务端程序、配置文件 | 实际使用的可执行文件与配置路径 | +| 大屏窗口 | **重新排布** | 重新枚举显示器并重开大屏窗口 | +| 大屏窗口 | **打开服务端管理面板** | 用内置窗口打开 `http://127.0.0.1:<端口>/admin`,无需再开浏览器 | +| 运行日志 | **自动滚动** | 日志是否跟随最新一行 | +| 运行日志 | **清空** | 清空窗口内显示(不影响日志文件) | +| 标题栏 | 最小化 / 最大化 / 关闭 | 关闭只是**隐藏到托盘**,服务端继续运行 | + +### 4.2 托盘用法 + +- 托盘菜单显示当前服务端状态(如「服务端:运行中 · 就绪」); +- 菜单项:**启动服务**、**停止服务**、**重启服务**、**打开管理端**、**打开服务端管理面板**、**退出**; +- 双击托盘图标 = 打开启动管理窗口; +- **要真正退出程序,必须用托盘菜单的「退出」**;直接关窗口不会停服务端。 + +> 同一台机器只允许运行一个 Console 实例,重复启动会自动退出,避免抢占端口和大屏。 + +--- + +## 5. 首次登录 + +### 5.1 访问地址 + +| 用途 | 地址 | +| --- | --- | +| 本机管理面板 | `http://127.0.0.1:19540/admin` | +| 局域网管理面板 | `http://<主机IP>:19540/admin` | +| 大屏 | `http://<主机IP>:19540/display` | +| 导航页 | `http://<主机IP>:19540/` | + +> 端口默认 **19540**,以 `config.yaml` 为准。 + +### 5.2 初始账号 + +- 用户名:`admin` +- 初始密码:**首次启动时随机生成,只打印一次**,数据库里只保存加密后的哈希,无法反查。 + +### 5.3 从日志里找初始密码 + +用 Console 启动时,服务端输出会同时显示在「运行日志」面板并写入 `logs\console.log`。初始密码所在的行形如: + +``` +[server] 初始密码: xxxxxxxxxxxxxxxx +``` + +在部署目录下执行以下任一命令即可找到(推荐 PowerShell,避免中文编码问题): + +```powershell +Select-String -Path .\logs\console.log -Pattern '初始密码' +``` + +```bat +findstr /C:"初始密码" logs\console.log +``` + +如果中文匹配不到,改用 ASCII 关键字(服务端还会输出一条 `password=` 的日志): + +```bat +findstr /C:"password=" logs\console.log +``` + +也可以直接在 Console 窗口的**运行日志**里查找「初始密码」或「已创建默认管理员」。 + +> 如果不用 Console、直接双击 `scoreboard.exe`,密码只会打印在终端窗口里,不会落文件。建议始终通过 Console 启动。 + +### 5.4 修改密码 + +- 首次登录后会自动跳到**「修改密码」**页,必须改密才能继续使用; +- 新密码至少 8 位,需输入两次确认; +- 修改成功后所有会话作废,需用新密码重新登录; +- 管理员也可以在「系统设置 → 用户管理 → 重置密码」为其他用户重置。 + +### 5.5 忘记密码 / 没记下初始密码 + +在 Console 里先**停止服务**,然后在部署目录(`config.yaml` 所在目录)执行: + +```bat +scoreboard.exe passwd -config config.yaml +``` + +该命令会为用户 `admin` 生成一个新的随机密码并打印出来,同时要求下次登录强制改密。可选参数: + +| 参数 | 说明 | +| --- | --- | +| `-user <用户名>` | 指定要重置的用户,默认 `admin` | +| `-password <新密码>` | 直接指定新密码;留空则随机生成并打印 | +| `-new-username <新用户名>` | 同时修改用户名 | + +--- + +## 6. 端口与配置修改 + +### 6.1 服务端 `config.yaml` + +| 项 | 当前默认值 | 说明 | +| --- | --- | --- | +| `server.host` | `0.0.0.0` | 监听地址;`0.0.0.0` 表示局域网内其他设备也能访问 | +| `server.port` | `19540` | HTTP 端口 | +| `dataDir` | `data` | 数据目录,相对服务端工作目录解析 | +| `log.level` | `info` | 日志级别:`debug` / `info` / `warn` / `error` | + +### 6.2 Console `console\config.json` + +| 项 | 当前默认值 | 说明 | +| --- | --- | --- | +| `server.exe` | `scoreboard.exe` | 服务端可执行文件;相对路径会在配置目录、当前目录、上级目录、`dist\` 依次查找 | +| `server.args` | `["-config","config.yaml"]` | 传给服务端的启动参数 | +| `server.host` / `server.port` | `127.0.0.1` / `19540` | 健康检查与打开管理面板用的地址;配成 `0.0.0.0` 时本机探测自动改走回环地址 | +| `server.autoStart` | `true` | Console 启动后是否自动拉起服务端 | +| `server.healthIntervalMs` | `5000` | 健康检查间隔(毫秒) | +| `server.restartDelayMs` | `3000` | 崩溃重启的初始退避,之后逐次翻倍 | +| `server.maxRestarts` | `0` | `0` = 无限重启 | +| `server.shutdownTimeoutMs` | `12000` | 等待服务端优雅退出的上限 | +| `server.healthyResetMs` | `60000` | 连续健康多久后清零崩溃计数 | +| `display.selection` | `all-secondary` | 选屏策略,见第 7 节 | +| `display.displays` | `[]` | 仅 `specified` 策略生效,填显示器 id 或 0 起序号 | +| `display.pages` | `["standings","medals","individual","results"]` | 多屏时按屏序轮转锁定的页面 | +| `display.pageParam` | `page` | 页面 URL 参数名 | +| `display.kiosk` | `true` | 多屏时是否 kiosk 全屏(单屏恒为窗口化) | +| `display.ignoreMouse` | `true` | 多屏窗口是否忽略鼠标 | +| `display.reloadEveryHours` | `24` | 定时重载兜底,`0` = 关闭 | +| `display.waitForServerMs` | `20000` | 服务端未就绪时,最多等这么久也先把大屏开出来 | +| `display.backgroundColor` | `#0b0f1a` | 大屏窗口底色,减少加载白闪 | +| `startup.openAtLogin` | `true` | 打包后随系统启动 | +| `admin.width` / `admin.height` | `1040` / `760` | 启动管理窗口初始尺寸 | +| `log.maxLines` | `2000` | 内存中保留的日志行数 | +| `log.file` | `logs/console.log` | 日志文件,相对 `config.json` 所在目录 | +| `log.maxFileBytes` | `4194304` | 单个日志文件上限,超出后轮转为 `.1` | + +> 配置缺项会自动使用内置默认值;JSON 语法错误时回落到全默认值,并在窗口顶部提示。 + +### 6.3 修改端口 + +端口必须**两处一致**: + +1. 关闭 Console(托盘 → 退出); +2. 修改 `config.yaml` 的 `server.port`; +3. 修改 `console\config.json` 的 `server.port`(保持一致,否则 Console 健康检查和管理面板地址会失效); +4. 重新启动 Console; +5. 之后管理面板地址变为 `http://<主机IP>:<新端口>/admin`。 + +--- + +## 7. 大屏接线 + +### 7.1 主方案:Console 驱动 HDMI + +1. 用 HDMI 线连接主机与大屏; +2. 在 Windows「显示设置」里把显示模式设为**扩展**(不要「复制」); +3. 确认分辨率与缩放(推荐 1920×1080 或 3840×2160,缩放按现场效果调整); +4. 启动 Console,大屏窗口会自动打开。 + +### 7.2 选屏策略(`display.selection`) + +| 取值 | 行为 | +| --- | --- | +| `all-secondary`(默认) | 除内置屏外的每块屏各开一个大屏窗口;若所有屏都报告为非内置屏(纯台式机),退回「排除主屏」 | +| `all` | 所有显示器都开大屏窗口 | +| `specified` | 只对 `display.displays` 里列出的显示器开窗;可填显示器 id 或 0 起序号 | + +### 7.3 单屏:窗口化 + F11 + +- 只有一块屏时,大屏以窗口化方式打开(屏幕 80% 大小、居中),并显示自绘标题栏; +- 在窗口内按 **F11** 切换全屏 / 窗口; +- 单屏模式不跟随显示器变化重排。 + +### 7.4 多屏:每屏锁一页 + +- 除内置屏外的每块屏各开一个 kiosk 全屏窗口; +- 每块屏**锁定一个页面**,按 `display.pages` 的顺序轮转分配; +- 多屏 kiosk 不响应 F11,也不接收鼠标(`ignoreMouse`); +- 可用的页面值:`standings`(总积分榜)、`medals`(团体奖牌榜)、`individual`(个人积分奖牌榜)、`results`(分项成绩)、`announcement`(公告)。 + +### 7.5 大屏窗口的自动行为 + +| 场景 | 行为 | +| --- | --- | +| 防熄屏 | Console 启动即生效,前后台都算 | +| 服务端就绪 | 首次健康检查通过后开大屏 | +| 服务端迟迟不就绪 | 等待超过 `waitForServerMs`(默认 20 秒)也先开,避免只见桌面 | +| 服务端崩溃 | 按退避策略自动重启,恢复后大屏自动重载 | +| 长时间运行 | 每 `reloadEveryHours`(默认 24 小时)自动重载一次 | + +### 7.6 备选方案:安卓 TV / 设备自带浏览器 + +如果不用 Console 驱动 HDMI,也可以让大屏设备用自己的浏览器访问: + +``` +http://<主机IP>:19540/display +``` + +- 需要主机设置固定 IP,且设备与主机在同一局域网; +- 安卓电视一般常亮,无需额外防熄屏; +- 浏览器方案**不受** Console 的 kiosk、防熄屏、选屏控制,字体与动画表现以实际设备为准; +- 现场以 Console + HDMI 为主方案,浏览器访问作为备用。 + +--- + +## 8. 备份与恢复 + +### 8.1 备份目录 + +所有备份都在 `data\backups\` 下,文件名为 `.db` 快照。该目录不进版本库。 + +### 8.2 如何备份 + +| 方式 | 操作 | 产物 | +| --- | --- | --- | +| 管理端 | 「系统设置 → 备份 → 立即备份」 | `system-<时间戳>.db` 与 `event-<时间戳>.db` | +| 命令行 | 在部署目录执行 `scoreboard.exe backup -config config.yaml` | `system-<原因>-<时间戳>.db` 与 `<项目名>-<原因>-<时间戳>.db` | +| 快捷脚本 | `deploy\backup.bat`(原因记 `manual`)或 `deploy\backup.bat publish`(原因记 `publish`) | 同上 | + +- 备份使用 SQLite 的 `VACUUM INTO`,服务运行中也能安全执行,不需要停服务,也不依赖外部 `sqlite3.exe`; +- 命令行方式会按前缀**滚动保留最近 20 份**(`system` 与赛事库各自计算),超出的旧备份自动删除; +- 管理端「立即备份」按钮生成快照,但当前版本不会自动清理旧文件,请定期检查 `data\backups\` 占用; +- 现场建议:开幕前、每次发布高峰前、每天结束时各备份一次。 + +### 8.3 如何恢复 + +服务端目前**没有一键恢复接口**,恢复需停机手工执行: + +1. 在 Console 里**停止服务**,避免恢复过程中继续写入; +2. 把当前的 `data\event_projs\<项目名>\event.db` 改名留档,确认恢复失败还能退回; +3. 选一份目标备份复制为 `event.db`(覆盖原文件); +4. 同时删除同目录下的 `event.db-wal` 与 `event.db-shm`; +5. 重新启动服务,核对积分榜与最近一次发布记录。 + +> `data\system.db` 保存账号与会话信息,一般不需要回退;如需一并回退,替换它的方法与 `event.db` 相同(同时删除 `system.db-wal` / `system.db-shm`)。 +> +> 恢复会丢弃备份时间点之后的所有改动。现场若还有备机,优先切换备机比原地恢复更快。 + +### 8.4 整机迁移 + +把整个部署目录(含 `data\`)拷贝到另一台同版本 Windows 机器,启动 Console 即可,无需重新配置。 + +### 8.5 赛事项目包导出 / 导入 + +| 操作 | 位置 | 说明 | +| --- | --- | --- | +| 导出 | 「导出 → 赛事项目包 → 导出 zip」 | 包内含 `meta.yaml`、`event.db`、`uploads/`、`rules.yaml`;整包拷走即可在另一台机器复现 | +| 导入 | 「导出 → 赛事项目包 → 选择 .zip → 导入」 | **仅管理员**可操作;可填「重命名」,勾选「覆盖同名项目」会先二次确认 | + +- 导入后项目落在 `data\event_projs\<项目名>\`; +- 当前运行的服务端默认使用 `default` 项目。要让新导入的赛事生效,可在 Console 的 `config.json` 里把 `server.args` 改为 `["-config","config.yaml","-project","<项目名>"]`,然后重启 Console;也可以直接用 `scoreboard.exe -config config.yaml -project <项目名>` 启动; +- 导入期间请勿停止服务端进程。 + +--- + +## 9. 故障排查 + +### 9.1 端口被占用,服务端起不来 + +1. 查看占用进程(Windows 命令提示符): + + ```bat + netstat -ano | findstr :19540 + ``` + +2. 根据最后一列的 PID 结束进程: + + ```bat + taskkill /PID /F + ``` + +3. 或按第 6.3 节把端口改到其他值(两处同时改)。 + +> 同一台机器不要启动两个 Console,否则会互相抢占端口和大屏。 + +### 9.2 大屏不亮 / 只有桌面 + +按顺序检查: + +1. HDMI 线是否插好,大屏电源是否打开; +2. Windows 显示设置是否为**扩展**模式; +3. Console「大屏窗口」卡片是否显示已打开;点「重新排布」; +4. 确认 `display.selection` 选屏策略是否漏掉了目标屏; +5. 服务端未就绪时大屏最多等 20 秒才打开,稍等即可; +6. 查看 Console 运行日志里的「大屏排布完成」「大屏加载」记录; +7. 单屏窗口化时按 **F11** 可切全屏。 + +### 9.3 管理面板打不开 + +1. 确认 Console 里服务端状态为「运行中」; +2. 本机访问 `http://127.0.0.1:19540/admin`,其他设备访问 `http://<主机IP>:19540/admin`; +3. 确认 `config.yaml` 与 `console\config.json` 的端口一致; +4. 检查 Windows 防火墙是否拦截了该端口; +5. 用托盘菜单的「打开服务端管理面板」直接打开。 + +### 9.4 服务反复重启 + +- 打开 `logs\console.log`,搜索 `[server!]`,这是服务端错误输出; +- 检查 `data\` 是否可写、磁盘是否已满; +- 检查 `config.yaml` 语法与端口是否合法。 + +### 9.5 页面显示「示例数据」 + +- 表示前端连不上后端或接口未就绪,此时录入、发布等操作**不会写入真实赛事**; +- 确认服务端状态为「运行中」、实时连接显示「实时在线」; +- 恢复连接后刷新页面即可切换到真实数据。 + +### 9.6 大屏时间不对 + +- 系统没有外网 NTP,**以主机时间为准**; +- 修改 Windows 系统时间与时区后,大屏顶部时钟会在下一次刷新时跟随(约 15 秒一次); +- 建议在开幕前校准主机时间。 + +### 9.7 安卓 TV 备选方案打不开 + +1. 确认电视与主机在同一局域网,主机 IP 为固定 IP; +2. 电视浏览器地址填 `http://<主机IP>:19540/display`; +3. 确认 `config.yaml` 的 `server.host` 为 `0.0.0.0`; +4. 部分电视浏览器对 CSS 动画、字体支持有限,显示效果以实际设备为准。 + +### 9.8 忘记管理员密码 + +见第 5.5 节,使用 `scoreboard.exe passwd -config config.yaml` 重置。 + +--- + +## 10. 上线检查清单 + +- [ ] 服务端开机自启、崩溃自动拉起已验证 +- [ ] `data\backups\` 可写,已生成至少一份备份 +- [ ] 大屏 HDMI 输出定位正确、分辨率正确、字体正常 +- [ ] 管理端各角色账号可登录,权限正确 +- [ ] 主机 IP 固定,管理端 / 大屏地址已打印或做成二维码贴在操作台 +- [ ] 防熄屏、屏保、休眠、系统通知已关闭 +- [ ] 主机时间已校准 +- [ ] 磁盘剩余空间充足 +- [ ] 安卓 TV / 浏览器备选通道实测可打开页面 diff --git a/server/internal/domain/helpers_test.go b/server/internal/domain/helpers_test.go index 7dfec98..a3e9e28 100644 --- a/server/internal/domain/helpers_test.go +++ b/server/internal/domain/helpers_test.go @@ -63,8 +63,8 @@ func teamManualChannel(profileID int64) ScoreChannel { return ScoreChannel{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisManual, ProfileID: profileID} } -func teamAggregateChannel(profileID int64, method AggregateMethod) ScoreChannel { - return ScoreChannel{ID: 3, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: profileID, Aggregate: method} +func teamAggregateChannel(profileID int64) ScoreChannel { + return ScoreChannel{ID: 3, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: profileID} } // participant 是构造「报名项 + 成绩」的最小描述。 diff --git a/server/internal/domain/ledger_test.go b/server/internal/domain/ledger_test.go index 64cbccc..af41143 100644 --- a/server/internal/domain/ledger_test.go +++ b/server/internal/domain/ledger_test.go @@ -227,3 +227,50 @@ func TestActiveLedger(t *testing.T) { t.Fatalf("未作废流水 = %+v,期望只有 id=1", got) } } + +// TestRecomputeEventLedgerKeepsAdjustments 验证重算只作废 result/record_bonus, +// 人工调整原样保留——否则每重算一次就把人工扣/加分抹掉。 +func TestRecomputeEventLedgerKeepsAdjustments(t *testing.T) { + existing := []LedgerEntry{ + {ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}, + {ID: 2, EventID: 1, SourceType: SourceRecordBonus, SourceID: 100, TeamID: 1, Points: 2}, + {ID: 3, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3}, + } + fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}} + + out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "成绩更正") + if err != nil { + t.Fatalf("重算失败: %v", err) + } + for _, e := range out { + if e.SourceType == SourceAdjustment { + if e.Voided { + t.Fatalf("人工调整被误作废: %+v", e) + } + } + } + if got := SumByTeam(out, 1)[1].Total; !nearlyEqual(got, 3) { + t.Fatalf("重算后总分 = %v,期望 6(新成绩分)- 3(调整保留)= 3", got) + } +} + +// TestRecomputeEventLedgerIdempotentWithAdjustments 验证带人工调整时重复重算也幂等。 +func TestRecomputeEventLedgerIdempotentWithAdjustments(t *testing.T) { + existing := []LedgerEntry{ + {ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}, + {ID: 2, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3}, + } + fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}} + out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "重复发布") + if err != nil { + t.Fatalf("重算失败: %v", err) + } + if len(out) != len(existing) { + t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(existing)) + } + for _, e := range out { + if e.Voided { + t.Fatalf("幂等重算不应作废任何流水: %+v", e) + } + } +} diff --git a/server/internal/domain/records_test.go b/server/internal/domain/records_test.go index be11f13..eaee158 100644 --- a/server/internal/domain/records_test.go +++ b/server/internal/domain/records_test.go @@ -193,3 +193,41 @@ func TestRecordBonusLedger(t *testing.T) { } }) } + +// TestRecordBonusLedgerFiltersByCategory 验证按「小项+组别」过滤: +// 上层逐组别调用 ScoreEvent,别的组别的破纪录登记不能在本组别重复记一次加分。 +func TestRecordBonusLedgerFiltersByCategory(t *testing.T) { + entries, results := fixture(lineup(2)) + entries[1].CategoryID = 2 // entry2 属于另一个组别 + cfg := bonusConfig(RecordBonusMax) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(1)}, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + RecordBonus: &cfg, + RecordBreaks: []RecordBreak{ + {ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved}, + {ID: 2, EventID: 1, EntryID: 2, AthleteID: 102, TeamID: 1002, Level: "meet", State: RecordBreakApproved}, + }, + }) + + var thisCategory, otherCategory int + for _, e := range es.Ledger { + if e.SourceType != SourceRecordBonus { + continue + } + switch e.EntryID { + case 1: + thisCategory++ + case 2: + otherCategory++ + } + } + if thisCategory != 1 { + t.Fatalf("本组别加分流水 = %d 条,期望 1", thisCategory) + } + if otherCategory != 0 { + t.Fatalf("别的组别的加分不应记入本组别,实际 %d 条", otherCategory) + } +} diff --git a/server/internal/domain/scoring.go b/server/internal/domain/scoring.go index d3b74b4..3a97ea1 100644 --- a/server/internal/domain/scoring.go +++ b/server/internal/domain/scoring.go @@ -63,6 +63,9 @@ type RankedResult struct { func ScoreEvent(in EventScoreInput) (EventScore, error) { var out EventScore rankOnly := len(in.Channels) == 0 + // 只排名不计分:scored=false 或没有计分通道时都不产出任何流水(含破纪录加分)。 + scoreEnabled := in.Event.Scored && !rankOnly + channels := sortedChannels(in.Channels) if rankOnly { channels = []ScoreChannel{rankOnlyChannel(in)} @@ -71,7 +74,7 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) { for _, ch := range channels { // 不计分时不需要积分表:既不查模板,也不可能算出非零分。 profile := ScoringProfile{} - if in.Event.Scored && !rankOnly { + if scoreEnabled { p, ok := in.Profiles[ch.ProfileID] if !ok { return EventScore{}, fmt.Errorf("%w: 通道 %d 引用模板 %d", ErrProfileNotFound, ch.ID, ch.ProfileID) @@ -82,18 +85,18 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) { if err != nil { return EventScore{}, err } - if !in.Event.Scored { + if !scoreEnabled { for i := range ranked { ranked[i].Points = 0 } } out.Ranked = append(out.Ranked, ranked...) - if in.Event.Scored { + if scoreEnabled { out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...) } } - if !in.Event.Scored { + if !scoreEnabled { return out, nil } bonus, err := recordBonusLedger(in) diff --git a/server/internal/domain/scoring_test.go b/server/internal/domain/scoring_test.go index 387200d..53a479f 100644 --- a/server/internal/domain/scoring_test.go +++ b/server/internal/domain/scoring_test.go @@ -191,7 +191,7 @@ func TestScopeAndLedgerAttribution(t *testing.T) { }) } -// TestAggregateChannel 覆盖预留的 aggregate 通道:先汇总、再排名。 +// TestAggregateChannel 覆盖 aggregate 通道:先按主体汇总(求和)、再排名。 func TestAggregateChannel(t *testing.T) { build := func() ([]Entry, []Result) { return fixture([]participant{ @@ -202,19 +202,12 @@ func TestAggregateChannel(t *testing.T) { } cases := []struct { name string - method AggregateMethod compare CompareMode wantA float64 wantB float64 }{ - {"sum 升序", AggregateSum, CompareAsc, 15, 10}, - {"avg 升序", AggregateAvg, CompareAsc, 15, 10}, - {"max 升序", AggregateMax, CompareAsc, 15, 10}, - {"min 升序", AggregateMin, CompareAsc, 15, 10}, - {"count 降序", AggregateCount, CompareDesc, 15, 10}, - {"count 升序", AggregateCount, CompareAsc, 10, 15}, - {"sum 降序", AggregateSum, CompareDesc, 10, 15}, - {"默认方法按 sum", "", CompareAsc, 15, 10}, + {"求和后升序", CompareAsc, 15, 10}, + {"求和后降序", CompareDesc, 10, 15}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -223,7 +216,7 @@ func TestAggregateChannel(t *testing.T) { ev.Compare = tc.compare es := mustScore(t, EventScoreInput{ Event: ev, Category: testCategory(), - Channels: []ScoreChannel{teamAggregateChannel(2, tc.method)}, + Channels: []ScoreChannel{teamAggregateChannel(2)}, Profiles: map[int64]ScoringProfile{2: teamTop6()}, Entries: entries, Results: results, }) @@ -242,7 +235,7 @@ func TestAggregateChannel(t *testing.T) { }) es := mustScore(t, EventScoreInput{ Event: teamEvent(1), Category: testCategory(), - Channels: []ScoreChannel{teamAggregateChannel(2, AggregateSum)}, + Channels: []ScoreChannel{teamAggregateChannel(2)}, Profiles: map[int64]ScoringProfile{2: teamTop6()}, Entries: entries, Results: results, }) @@ -320,7 +313,7 @@ func TestDraftState(t *testing.T) { } } -// TestScoredFlagAndNoChannels 验证「只排名不计分」与「没有通道」都不产生流水。 +// TestScoredFlagAndNoChannels 验证「只排名不计分」:不产生流水,但必须照样产出名次。 func TestScoredFlagAndNoChannels(t *testing.T) { cases := []struct { name string @@ -342,13 +335,50 @@ func TestScoredFlagAndNoChannels(t *testing.T) { Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, }) - if len(es.Ledger) != 0 || len(es.Ranked) != 0 { - t.Fatalf("流水/名次应为空,实际 %d/%d", len(es.Ledger), len(es.Ranked)) + if len(es.Ledger) != 0 { + t.Fatalf("只排名不计分不应产生流水,实际 %d 条: %+v", len(es.Ledger), es.Ledger) + } + if len(es.Ranked) != 3 { + t.Fatalf("名次条数 = %d,期望 3(排名与计分解耦)", len(es.Ranked)) + } + for _, rr := range es.Ranked { + if rr.Points != 0 { + t.Errorf("只排名不计分时 Points = %v,期望 0", rr.Points) + } + } + if ranks := rankByEntry(es.Ranked); ranks[1] != 1 || ranks[2] != 2 || ranks[3] != 3 { + t.Fatalf("名次 = %v,期望 1/2/3", ranks) } }) } } +// TestRankOnlyRespectsResultMode 验证没有通道时按小项自身的录入方式排名(manual 直接读录入名次)。 +func TestRankOnlyRespectsResultMode(t *testing.T) { + ps := lineup(3) + for i := range ps { + ps[i].noValue = true + } + ps[0].rank, ps[1].rank, ps[2].rank = 2, 3, 1 + entries, results := fixture(ps) + + ev := individualEvent(1) + ev.Scored = false + ev.ResultMode = ResultModeManual + ev.Compare = CompareNone + es := mustScore(t, EventScoreInput{ + Event: ev, Category: testCategory(), + Entries: entries, Results: results, + }) + if len(es.Ledger) != 0 { + t.Fatalf("只排名不计分不应产生流水,实际 %d 条", len(es.Ledger)) + } + ranks := rankByEntry(es.Ranked) + if ranks[3] != 1 || ranks[1] != 2 || ranks[2] != 3 { + t.Fatalf("名次 = %v,期望 entry3→1 / entry1→2 / entry2→3", ranks) + } +} + // TestScoreEventErrors 覆盖配置类错误,避免错配置静默算错分。 func TestScoreEventErrors(t *testing.T) { entries, results := fixture(lineup(1)) @@ -376,7 +406,7 @@ func TestScoreEventErrors(t *testing.T) { } noCompareAgg := noCompare - noCompareAgg.Channels = []ScoreChannel{teamAggregateChannel(1, AggregateSum)} + noCompareAgg.Channels = []ScoreChannel{teamAggregateChannel(1)} if _, err := ScoreEvent(noCompareAgg); !errors.Is(err, ErrCompareRequired) { t.Errorf("聚合 compare=none 错误 = %v,期望 ErrCompareRequired", err) } @@ -599,3 +629,25 @@ func TestMissingResultOrEntry(t *testing.T) { assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8, 0}) }) } + +// TestNoChannelsSkipsRecordBonus 验证「0 个通道 = 只排名不计分」: +// 即使项目 scored=true 且破了纪录,也不写任何流水(破纪录加分也跳过)。 +func TestNoChannelsSkipsRecordBonus(t *testing.T) { + entries, results := fixture(lineup(2)) + cfg := bonusConfig(RecordBonusMax) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Entries: entries, Results: results, + RecordBonus: &cfg, + RecordBreaks: []RecordBreak{{ + ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, + Level: "meet", State: RecordBreakApproved, + }}, + }) + if len(es.Ledger) != 0 { + t.Fatalf("没有通道时不应产生流水,实际 %d 条: %+v", len(es.Ledger), es.Ledger) + } + if len(es.Ranked) != 2 { + t.Fatalf("名次条数 = %d,期望 2", len(es.Ranked)) + } +} diff --git a/server/internal/domain/standings_test.go b/server/internal/domain/standings_test.go index e11f564..1f86573 100644 --- a/server/internal/domain/standings_test.go +++ b/server/internal/domain/standings_test.go @@ -245,3 +245,43 @@ func TestSumByTeamIgnoresVoided(t *testing.T) { t.Fatalf("总分 = %v,期望只算未作废的 10", got[1].Total) } } + +// TestTeamStandingsTieBreakConfig 验证 StandingsInput.TieBreak 真正参与排序: +// 配置 [teamCode] 时即使总分落后也按单位编码在前;未配置时仍是总分优先。 +func TestTeamStandingsTieBreakConfig(t *testing.T) { + ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 20)} + + def := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: ledger}) + if def[0].TeamID != 2 { + t.Fatalf("默认口径榜首 = 单位 %d,期望 2(总分 20 优先)", def[0].TeamID) + } + + cfg := BuildTeamStandings(StandingsInput{ + Teams: testTeams(1, 2), Ledger: ledger, TieBreak: []string{TieBreakTeamCode}, + }) + if cfg[0].TeamID != 1 { + t.Fatalf("配置 [teamCode] 榜首 = 单位 %d,期望 1(编码 A < B)", cfg[0].TeamID) + } + if cfg[0].Rank != 1 || cfg[1].Rank != 2 { + t.Fatalf("名次 = %d/%d,期望 1/2", cfg[0].Rank, cfg[1].Rank) + } +} + +// TestAthleteStandingsTieBreakConfig 验证个人榜同样按配置排序。 +func TestAthleteStandingsTieBreakConfig(t *testing.T) { + ledger := []LedgerEntry{ledgerForAthlete(1001, 1, 10), ledgerForAthlete(1002, 2, 10)} + athletes := []Athlete{{ID: 1, Name: "张三", TeamID: 1001}, {ID: 2, Name: "李四", TeamID: 1002}} + teams := []Team{{ID: 1001, Code: "B", Name: "二单位"}, {ID: 1002, Code: "A", Name: "一单位"}} + + def := BuildAthleteStandings(StandingsInput{Teams: teams, Athletes: athletes, Ledger: ledger}) + if def[0].AthleteID != 1 { + t.Fatalf("默认口径榜首 = 运动员 %d,期望 1(姓名 张三 < 李四)", def[0].AthleteID) + } + + cfg := BuildAthleteStandings(StandingsInput{ + Teams: teams, Athletes: athletes, Ledger: ledger, TieBreak: []string{TieBreakTeamCode}, + }) + if cfg[0].AthleteID != 2 { + t.Fatalf("配置 [teamCode] 榜首 = 运动员 %d,期望 2(单位编码 A < B)", cfg[0].AthleteID) + } +} diff --git a/server/internal/domain/types.go b/server/internal/domain/types.go index f903f45..1f8b3cd 100644 --- a/server/internal/domain/types.go +++ b/server/internal/domain/types.go @@ -225,12 +225,12 @@ type EventCategory struct { // basis=aggregate 时固定按「同一主体成绩之和」汇总:汇总口径不可配置, // 库里也没有对应的列,因此不再保留 avg/max/min/count 这类配置项。 type ScoreChannel struct { - ID int64 `json:"id"` - EventID int64 `json:"eventId"` - Scope Scope `json:"scope"` - Basis Basis `json:"basis"` - ProfileID int64 `json:"profileId"` - SortOrder int `json:"sortOrder,omitempty"` + ID int64 `json:"id"` + EventID int64 `json:"eventId"` + Scope Scope `json:"scope"` + Basis Basis `json:"basis"` + ProfileID int64 `json:"profileId"` + SortOrder int `json:"sortOrder,omitempty"` } // ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。 diff --git a/server/internal/httpapi/export_handlers.go b/server/internal/httpapi/export_handlers.go index ff122c6..7abbd2a 100644 --- a/server/internal/httpapi/export_handlers.go +++ b/server/internal/httpapi/export_handlers.go @@ -1,6 +1,7 @@ package httpapi import ( + "context" "io" "net/http" "net/url" @@ -10,6 +11,7 @@ import ( "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" "git.yeij.top/AskaEth/Scoreboard/server/internal/export" + "git.yeij.top/AskaEth/Scoreboard/server/internal/rules" ) const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @@ -98,8 +100,23 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any, name = raw } + opts := export.ProjectExportOptions{Name: name, AppVersion: s.version} + if s.projectDir != "" { + // uploads 可能还没建过;只在目录存在时打包,否则 copyTree 会报错。 + uploads := filepath.Join(s.projectDir, "uploads") + if info, err := os.Stat(uploads); err == nil && info.IsDir() { + opts.UploadsDir = uploads + } + } + rulesPath, cleanup, err := s.writeRulesTempFile(r.Context()) + if err != nil { + return nil, err + } + defer cleanup() + opts.RulesPath = rulesPath + dst := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip") - if _, err := export.ExportProjectZip(s.ev, dst, export.ProjectExportOptions{Name: name}); err != nil { + if _, err := export.ExportProjectZip(s.ev, dst, opts); err != nil { return nil, internal(err) } defer func() { _ = os.Remove(dst) }() @@ -110,6 +127,33 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any, return nil, errHandled } +// writeRulesTempFile 把当前规则包导出成临时 YAML,供赛事项目包内嵌 rules.yaml。 +// 返回的清理函数由调用方 defer,避免临时文件泄漏。 +func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error) { + pack, err := rules.Export(s.ev, rules.Meta{}) + if err != nil { + return "", func() {}, internal(err) + } + body, err := rules.Marshal(pack) + if err != nil { + return "", func() {}, internal(err) + } + f, err := os.CreateTemp("", "scoreboard-rules-*.yaml") + if err != nil { + return "", func() {}, internal(err) + } + if _, err := f.Write(body); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return "", func() {}, internal(err) + } + if err := f.Close(); err != nil { + _ = os.Remove(f.Name()) + return "", func() {}, internal(err) + } + return f.Name(), func() { _ = os.Remove(f.Name()) }, nil +} + func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) { if err := r.ParseMultipartForm(512 << 20); err != nil { return nil, badRequest("解析上传失败", nil) @@ -146,6 +190,7 @@ func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, if err != nil { return nil, badRequest(err.Error(), nil) } + s.audit(r.Context(), "project.import", "project", 0, nil, res) return res, nil } diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go index 316e9b6..9b06736 100644 --- a/server/internal/httpapi/handlers_results.go +++ b/server/internal/httpapi/handlers_results.go @@ -77,19 +77,11 @@ func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any, // ---------- 积分与排名 ---------- func (s *server) standingsHandler(w http.ResponseWriter, r *http.Request) (any, error) { - in, err := s.loadStandingsInput(r.Context()) - if err != nil { - return nil, err - } - teams, err := s.teamMap(r.Context()) - if err != nil { - return nil, err - } - return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil + return s.standingsSnapshot(r.Context(), s.ev) } func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request) (any, error) { - in, err := s.loadStandingsInput(r.Context()) + in, err := s.loadStandingsInput(r.Context(), s.ev) if err != nil { return nil, err } diff --git a/server/internal/httpapi/realtime.go b/server/internal/httpapi/realtime.go new file mode 100644 index 0000000..97e3407 --- /dev/null +++ b/server/internal/httpapi/realtime.go @@ -0,0 +1,102 @@ +package httpapi + +import ( + "context" + "database/sql" + "net/http" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" +) + +// Realtime 是实时层的最小接口;未接入时写操作只落库,不推送。 +type Realtime interface { + Broadcast(changes []realtime.Change) + LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]realtime.Change, error) + Instances() []realtime.Instance +} + +// broadcastChanges 在写请求成功返回后,把本次请求写入 change_log 的变更推给实时层。 +// 版本号取请求开始时的最大 id,因此只推送本次写入,不会重复补发历史变更。 +func (s *server) broadcastChanges(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.realtime == nil || s.ev == nil || !isWriteMethod(r.Method) { + next.ServeHTTP(w, r) + return + } + before, ok := s.changeVersion(r.Context()) + if !ok { + next.ServeHTTP(w, r) + return + } + + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + if rec.status >= http.StatusBadRequest { + return + } + + // 请求结束后 r.Context() 可能已被取消,推送不应因此中断。 + ctx := context.WithoutCancel(r.Context()) + changes, err := s.realtime.LoadChanges(ctx, before, 0) + if err != nil { + s.logger.Error("读取实时变更失败", "err", err) + return + } + if len(changes) > 0 { + s.realtime.Broadcast(changes) + } + }) +} + +// changeVersion 读取 change_log 当前最大 id;失败时调用方跳过本次推送。 +func (s *server) changeVersion(ctx context.Context) (int64, bool) { + var v sql.NullInt64 + if err := s.ev.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil { + s.logger.Error("读取变更版本失败", "err", err) + return 0, false + } + return v.Int64, true +} + +func isWriteMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return false + default: + return true + } +} + +// statusRecorder 记录响应状态码,用于判断写请求是否成功。 +type statusRecorder struct { + http.ResponseWriter + status int + written bool +} + +func (w *statusRecorder) WriteHeader(code int) { + if !w.written { + w.status = code + w.written = true + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusRecorder) Write(b []byte) (int, error) { + if !w.written { + w.written = true + } + return w.ResponseWriter.Write(b) +} + +// displayInstancesHandler 返回在线大屏实例列表(在线看板)。 +func (s *server) displayInstancesHandler(w http.ResponseWriter, r *http.Request) (any, error) { + if s.realtime == nil { + return []realtime.Instance{}, nil + } + list := s.realtime.Instances() + if list == nil { + list = []realtime.Instance{} + } + return list, nil +} diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go index c13315a..9fc1366 100644 --- a/server/internal/httpapi/repo.go +++ b/server/internal/httpapi/repo.go @@ -153,15 +153,26 @@ func (s *server) listTeams(ctx context.Context) ([]teamDTO, error) { } func (s *server) teamMap(ctx context.Context) (map[int64]teamDTO, error) { - list, err := s.listTeams(ctx) + return teamMapQ(ctx, s.ev) +} + +// teamMapQ 与 teamMap 同义,但可在事务内使用,保证榜单快照与写操作看到同一状态。 +func teamMapQ(ctx context.Context, q queryer) (map[int64]teamDTO, error) { + rows, err := q.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`) if err != nil { - return nil, err + return nil, internal(err) } - out := make(map[int64]teamDTO, len(list)) - for _, t := range list { + defer func() { _ = rows.Close() }() + + out := make(map[int64]teamDTO) + for rows.Next() { + t, err := scanTeam(rows) + if err != nil { + return nil, internal(err) + } out[t.ID] = t } - return out, nil + return out, rows.Err() } func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) { diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index 6f48d99..fba03ef 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -225,24 +225,24 @@ func boolDefault(v string, def bool) bool { // ---------- standings ---------- -func (s *server) loadStandingsInput(ctx context.Context) (domain.StandingsInput, error) { - teams, err := s.domainTeams(ctx) +func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) { + teams, err := s.domainTeams(ctx, q) if err != nil { return domain.StandingsInput{}, err } - athletes, err := s.domainAthletes(ctx) + athletes, err := s.domainAthletes(ctx, q) if err != nil { return domain.StandingsInput{}, err } - ledger, err := s.activeLedger(ctx) + ledger, err := s.activeLedger(ctx, q) if err != nil { return domain.StandingsInput{}, err } - ranked, err := s.buildRanked(ctx) + ranked, err := s.buildRanked(ctx, q) if err != nil { return domain.StandingsInput{}, err } - values, err := loadSettingsMap(ctx, s.ev) + values, err := loadSettingsMap(ctx, q) if err != nil { return domain.StandingsInput{}, err } @@ -259,8 +259,31 @@ func (s *server) loadStandingsInput(ctx context.Context) (domain.StandingsInput, }, nil } -func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) { - rows, err := s.ev.QueryContext(ctx, +// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单, +// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。 +func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]standingRowDTO, error) { + in, err := s.loadStandingsInput(ctx, q) + if err != nil { + return nil, err + } + teams, err := teamMapQ(ctx, q) + if err != nil { + return nil, err + } + return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil +} + +// bumpStandings 在写事务内把整张榜单写入 change_log;榜单变化后必须调用,否则大屏不会刷新。 +func (s *server) bumpStandings(ctx context.Context, tx *sql.Tx, actorID int64) error { + snapshot, err := s.standingsSnapshot(ctx, tx) + if err != nil { + return err + } + return bumpVersion(ctx, tx, "standings", 0, "replace", snapshot, actorID) +} + +func (s *server) domainTeams(ctx context.Context, q queryer) ([]domain.Team, error) { + rows, err := q.QueryContext(ctx, `SELECT id, code, name, short_name, color, sort_order, active FROM teams WHERE active = 1`) if err != nil { return nil, internal(err) @@ -284,8 +307,8 @@ func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) { return out, rows.Err() } -func (s *server) domainAthletes(ctx context.Context) ([]domain.Athlete, error) { - rows, err := s.ev.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`) +func (s *server) domainAthletes(ctx context.Context, q queryer) ([]domain.Athlete, error) { + rows, err := q.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`) if err != nil { return nil, internal(err) } @@ -310,8 +333,8 @@ func (s *server) domainAthletes(ctx context.Context) ([]domain.Athlete, error) { // buildRanked 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。 // 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致。 -func (s *server) buildRanked(ctx context.Context) ([]domain.RankedResult, error) { - rows, err := s.ev.QueryContext(ctx, +func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedResult, error) { + rows, err := q.QueryContext(ctx, `SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status, e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id FROM results r @@ -495,9 +518,17 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco } defer func() { _ = tx.Rollback() }() - var state string - var eventID int64 - if err := tx.QueryRowContext(ctx, `SELECT state, event_id FROM record_breaks WHERE id = ?`, id).Scan(&state, &eventID); err != nil { + var ( + state string + eventID int64 + entryID int64 + level string + resultVal sql.NullFloat64 + resultText sql.NullString + ) + if err := tx.QueryRowContext(ctx, + `SELECT state, event_id, entry_id, level, result_value, result_text FROM record_breaks WHERE id = ?`, id). + Scan(&state, &eventID, &entryID, &level, &resultVal, &resultText); err != nil { if errors.Is(err, sql.ErrNoRows) { return recordBreakDTO{}, notFound("破纪录登记不存在") } @@ -514,9 +545,23 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "确认破纪录加分"); err != nil { return recordBreakDTO{}, err } + // 确认即更新纪录库,并把引擎算出的加分回填到登记上(devdocs/02 第 3 节)。 + if err := upsertCurrentRecord(ctx, tx, eventID, entryID, level, resultVal, resultText); err != nil { + return recordBreakDTO{}, err + } + if _, err := tx.ExecContext(ctx, + `UPDATE record_breaks SET bonus_points = COALESCE(( + SELECT SUM(points) FROM score_ledger + WHERE source_type = 'record_bonus' AND event_id = ? AND entry_id = ? AND voided = 0), 0) + WHERE id = ?`, eventID, entryID, id); err != nil { + return recordBreakDTO{}, internal(err) + } if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil { return recordBreakDTO{}, err } + if err := s.bumpStandings(ctx, tx, userID); err != nil { + return recordBreakDTO{}, err + } if err := tx.Commit(); err != nil { return recordBreakDTO{}, internal(err) } @@ -524,6 +569,44 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco return s.getRecordBreak(ctx, id) } +// upsertCurrentRecord 把确认后的破纪录写入 records:同小项同档位的旧纪录改为非当前, +// 新纪录标为 current(devdocs/04 records 表;source=current 表示本届产生)。 +func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64, level string, value sql.NullFloat64, text sql.NullString) error { + var ( + teamID int64 + athleteID sql.NullInt64 + athleteName sql.NullString + teamName sql.NullString + ) + if err := tx.QueryRowContext(ctx, + `SELECT e.team_id, e.athlete_id, a.name, t.name + FROM entries e + LEFT JOIN athletes a ON a.id = e.athlete_id + LEFT JOIN teams t ON t.id = e.team_id + WHERE e.id = ?`, entryID).Scan(&teamID, &athleteID, &athleteName, &teamName); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return notFound("报名项不存在") + } + return internal(err) + } + holder := teamName.String + if athleteID.Valid && athleteName.String != "" { + holder = athleteName.String + } + if _, err := tx.ExecContext(ctx, + `UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`, + eventID, level); err != nil { + return internal(err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO records (event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source) + VALUES (?, ?, ?, ?, ?, ?, datetime('now'), 1, 'current')`, + eventID, level, value, text, nullStr(&holder), nullID(teamID)); err != nil { + return internal(err) + } + return nil +} + // ---------- adjustments ---------- func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) { @@ -614,6 +697,10 @@ func (s *server) createAdjustment(ctx context.Context, in adjustmentInput, userI in.TeamID, nullI64(in.EventID), id, in.Delta, nullID(userID)); err != nil { return adjustmentDTO{}, internal(err) } + // 调整分进积分榜,必须写版本号,否则大屏不会刷新。 + if err := s.bumpStandings(ctx, tx, userID); err != nil { + return adjustmentDTO{}, err + } if err := tx.Commit(); err != nil { return adjustmentDTO{}, internal(err) } @@ -669,7 +756,7 @@ func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason st nullID(userID), reason, id); err != nil { return adjustmentDTO{}, internal(err) } - if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"adjustmentId": id}, userID); err != nil { + if err := s.bumpStandings(ctx, tx, userID); err != nil { return adjustmentDTO{}, err } if err := tx.Commit(); err != nil { diff --git a/server/internal/httpapi/repo_results.go b/server/internal/httpapi/repo_results.go index 2a0e549..50c4fbc 100644 --- a/server/internal/httpapi/repo_results.go +++ b/server/internal/httpapi/repo_results.go @@ -83,7 +83,57 @@ func queryResults(ctx context.Context, q queryer, query string, args ...any) ([] } out = append(out, r) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, internal(err) + } + if err := attachResultPoints(ctx, q, out); err != nil { + return nil, err + } + return out, nil +} + +// attachResultPoints 给已发布成绩补上得分(该成绩产生的 result 流水合计)。 +// 草稿没有流水,保持 nil;录入页据此预览发布后的分值变化。 +func attachResultPoints(ctx context.Context, q queryer, items []resultDTO) error { + ids := make([]any, 0, len(items)) + index := make(map[int64]int, len(items)) + for i := range items { + if items[i].State != "published" { + continue + } + ids = append(ids, items[i].ID) + index[items[i].ID] = i + } + if len(ids) == 0 { + return nil + } + + rows, err := q.QueryContext(ctx, + `SELECT result_id, SUM(points) FROM score_ledger + WHERE voided = 0 AND source_type = 'result' AND result_id IN (`+placeholders(len(ids))+`) + GROUP BY result_id`, ids...) + if err != nil { + return internal(err) + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var ( + resultID int64 + points float64 + ) + if err := rows.Scan(&resultID, &points); err != nil { + return internal(err) + } + if i, ok := index[resultID]; ok { + v := points + items[i].Points = &v + } + } + if err := rows.Err(); err != nil { + return internal(err) + } + return nil } func (s *server) getResult(ctx context.Context, id int64) (resultDTO, error) { @@ -94,7 +144,11 @@ func (s *server) getResult(ctx context.Context, id int64) (resultDTO, error) { if err != nil { return resultDTO{}, internal(err) } - return r, nil + items := []resultDTO{r} + if err := attachResultPoints(ctx, s.ev, items); err != nil { + return resultDTO{}, err + } + return items[0], nil } func (s *server) listResultsPage(ctx context.Context, eventID int64, state string, page, size int) (pageResult[resultDTO], error) { @@ -194,8 +248,8 @@ func joinAnd(parts []string) string { // ---------- ledger ---------- -func (s *server) activeLedger(ctx context.Context) ([]domain.LedgerEntry, error) { - return queryLedger(ctx, s.ev, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id, +func (s *server) activeLedger(ctx context.Context, q queryer) ([]domain.LedgerEntry, error) { + return queryLedger(ctx, q, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, voided, created_at FROM score_ledger WHERE voided = 0`) } diff --git a/server/internal/httpapi/review_fixes_test.go b/server/internal/httpapi/review_fixes_test.go new file mode 100644 index 0000000..553e0ff --- /dev/null +++ b/server/internal/httpapi/review_fixes_test.go @@ -0,0 +1,532 @@ +package httpapi_test + +import ( + "archive/zip" + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" + "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" +) + +// ---------- 实时层替身 ---------- + +type fakeRealtime struct { + db *sql.DB + mu sync.Mutex + batches [][]realtime.Change + instances []realtime.Instance +} + +func (f *fakeRealtime) Broadcast(changes []realtime.Change) { + f.mu.Lock() + defer f.mu.Unlock() + f.batches = append(f.batches, changes) +} + +func (f *fakeRealtime) LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]realtime.Change, error) { + q := `SELECT id, entity_type, entity_id, action, payload_json FROM change_log WHERE id > ? ORDER BY id` + args := []any{sinceVersion} + if limit > 0 { + q += ` LIMIT ?` + args = append(args, limit) + } + rows, err := f.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + out := make([]realtime.Change, 0) + for rows.Next() { + var ( + ch realtime.Change + entity string + entityID sql.NullInt64 + action string + payload string + ) + if err := rows.Scan(&ch.Version, &entity, &entityID, &action, &payload); err != nil { + return nil, err + } + ch.Entity = entity + ch.ID = entityID.Int64 + ch.Action = action + if json.Valid([]byte(payload)) { + ch.Data = json.RawMessage(payload) + } else { + ch.Data = json.RawMessage(`{}`) + } + out = append(out, ch) + } + return out, rows.Err() +} + +func (f *fakeRealtime) Instances() []realtime.Instance { return f.instances } + +func (f *fakeRealtime) firstBatch(t *testing.T) []realtime.Change { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + f.mu.Lock() + if len(f.batches) > 0 { + batch := f.batches[0] + f.mu.Unlock() + return batch + } + f.mu.Unlock() + time.Sleep(10 * time.Millisecond) + } + t.Fatal("等待实时广播超时") + return nil +} + +func (e *testEnv) doRaw(client *http.Client, method, path string, body []byte, contentType string, csrf bool) (int, []byte) { + e.t.Helper() + req, err := http.NewRequest(method, e.server.URL+path, bytes.NewReader(body)) + if err != nil { + e.t.Fatalf("构造请求: %v", err) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if csrf { + req.Header.Set("X-CSRF-Token", e.csrfToken(client)) + } + resp, err := client.Do(req) + if err != nil { + e.t.Fatalf("请求 %s %s: %v", method, path, err) + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + return resp.StatusCode, raw +} + +// ---------- must_change_password ---------- + +// 必须改密的账号除改密/登出外一律禁止写操作,避免初始密码长期可用(devdocs/08 第 1 节)。 +func TestMustChangePasswordBlocksWrites(t *testing.T) { + env := newTestEnv(t) + userID := env.createUser("recorder", "recorder-pass", "recorder") + if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE id = ?`, userID); err != nil { + t.Fatalf("设置 must_change_password: %v", err) + } + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + // 读操作仍放行:前端要能渲染改密页。 + if status, _ := env.do(client, http.MethodGet, "/api/auth/me", nil, false); status != http.StatusOK { + t.Fatalf("改密前读 me 应 200,得到 %d", status) + } + + status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{ + "code": "X", "name": "越权单位", "active": true, + }, true) + if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" { + t.Fatalf("改密前写操作应 403 AUTH_REQUIRED,得到 %d %+v", status, env2.Error) + } + + // 改密本身必须放行,否则用户永远无法解除限制。 + status, env2 = env.do(client, http.MethodPost, "/api/auth/password", map[string]any{ + "currentPassword": "recorder-pass", + "newPassword": "new-pass-123", + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("改密应 200,得到 %d %+v", status, env2.Error) + } + + // 改密后重新登录,写操作恢复。 + env.login(client, "recorder", "new-pass-123") + status, env2 = env.do(client, http.MethodPost, "/api/teams", map[string]any{ + "code": "X", "name": "越权单位", "active": true, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("改密后写操作应 200,得到 %d %+v", status, env2.Error) + } +} + +// 登出也必须放行,否则改密失败的用户无法退出。 +func TestMustChangePasswordAllowsLogout(t *testing.T) { + env := newTestEnv(t) + userID := env.createUser("recorder", "recorder-pass", "recorder") + if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE id = ?`, userID); err != nil { + t.Fatalf("设置 must_change_password: %v", err) + } + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + if status, env2 := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK { + t.Fatalf("改密前登出应 200,得到 %d %+v", status, env2.Error) + } +} + +// ---------- X-Forwarded-For 伪造 ---------- + +// 默认不信任代理头:伪造 XFF 不能绕过登录限流(否则等于没有限流)。 +func TestSpoofedForwardedForDoesNotBypassLoginLimit(t *testing.T) { + env := newTestEnv(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + + for i := 0; i < 5; i++ { + status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", map[string]any{ + "username": "recorder", "password": "wrong-pass", + }, false, map[string]string{"X-Forwarded-For": fmt.Sprintf("203.0.113.%d", i+1)}) + if status != http.StatusUnauthorized { + t.Fatalf("第 %d 次错误登录应 401,得到 %d %+v", i+1, status, env2.Error) + } + } + + status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", map[string]any{ + "username": "recorder", "password": "wrong-pass", + }, false, map[string]string{"X-Forwarded-For": "203.0.113.250"}) + if status != http.StatusTooManyRequests || env2.Error == nil || env2.Error.Code != "RATE_LIMITED" { + t.Fatalf("伪造 XFF 后第 6 次应被限流 429,得到 %d %+v", status, env2.Error) + } +} + +// ---------- 流水幂等 ---------- + +// 重复发布不能累积破纪录加分流水,同时必须保留人工调整流水(devdocs/02 §6)。 +func TestPublishDoesNotAccumulateRecordBonusAndKeepsAdjustments(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "requestId": "draft-1", + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, + }, + }, true) + if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "requestId": "publish-1", + }, true); status != http.StatusOK { + t.Fatalf("首次发布应 200,得到 %d %+v", status, env2.Error) + } + + // 模拟已存在的破纪录加分流水与人工调整流水。 + env.exec(t, `INSERT INTO score_ledger (team_id, event_id, entry_id, source_type, source_id, points) + VALUES (?, ?, ?, 'record_bonus', ?, 5)`, ids.teamA, ids.event, ids.entryA, ids.entryA) + env.exec(t, `INSERT INTO score_ledger (team_id, source_type, source_id, points) + VALUES (?, 'adjustment', 1, 3)`, ids.teamA) + + if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "requestId": "publish-2", + }, true); status != http.StatusOK { + t.Fatalf("再次发布应 200,得到 %d %+v", status, env2.Error) + } + + var activeBonus, activeAdjust, activeResult int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger + WHERE voided = 0 AND source_type = 'record_bonus' AND event_id = ?`, ids.event).Scan(&activeBonus); err != nil { + t.Fatalf("统计破纪录流水: %v", err) + } + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0 AND source_type = 'adjustment'`).Scan(&activeAdjust); err != nil { + t.Fatalf("统计调整流水: %v", err) + } + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger + WHERE voided = 0 AND source_type = 'result' AND event_id = ?`, ids.event).Scan(&activeResult); err != nil { + t.Fatalf("统计成绩流水: %v", err) + } + + if activeBonus != 0 { + t.Fatalf("重算应作废旧破纪录加分流水且不重复累积,得到 %d 条", activeBonus) + } + if activeAdjust != 1 { + t.Fatalf("重算不得作废人工调整,得到 %d 条", activeAdjust) + } + if activeResult != 2 { + t.Fatalf("重算后成绩流水应仍为 2 条,得到 %d", activeResult) + } +} + +// ---------- standings 变更载荷 ---------- + +// standings 的 replace 载荷必须是整张榜单数组(devdocs/05 第 2 节),否则大屏会被清空。 +func TestPublishChangeLogCarriesFullStandingsSnapshot(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "requestId": "draft-1", + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"}, + }, + }, true) + if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "requestId": "publish-1", + }, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error) + } + + var payload string + if err := env.ev.QueryRow(`SELECT payload_json FROM change_log + WHERE entity_type = 'standings' ORDER BY id DESC LIMIT 1`).Scan(&payload); err != nil { + t.Fatalf("读取 standings 变更: %v", err) + } + var rows []map[string]any + if err := json.Unmarshal([]byte(payload), &rows); err != nil { + t.Fatalf("standings 载荷应为数组: %v (%s)", err, payload) + } + if len(rows) != 2 { + t.Fatalf("standings 载荷应含 2 个单位,得到 %d (%s)", len(rows), payload) + } + if rows[0]["teamCode"] != "A" || rows[0]["totalPoints"].(float64) != 10 { + t.Fatalf("standings 载荷内容不对: %+v", rows[0]) + } + + // 发布前必须留备份(08 第 6 节):成功时写 system.backup 审计。 + var backupAudits int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs + WHERE action = 'system.backup' AND after_json LIKE '%publish%'`).Scan(&backupAudits); err != nil { + t.Fatalf("查询备份审计: %v", err) + } + if backupAudits == 0 { + t.Fatalf("发布应写入发布前备份审计") + } +} + +// ---------- 赛事包内容 ---------- + +// 赛事包必须内含 rules.yaml 与 uploads/(devdocs/04 §1.1),否则跨机无法复现。 +func TestExportProjectZipIncludesRulesAndUploads(t *testing.T) { + projectDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(projectDir, "uploads"), 0o755); err != nil { + t.Fatalf("创建 uploads: %v", err) + } + if err := os.WriteFile(filepath.Join(projectDir, "uploads", "logo.txt"), []byte("logo"), 0o644); err != nil { + t.Fatalf("写 uploads 文件: %v", err) + } + + env := newTestEnvWith(t, func(o *httpapi.Options) { o.ProjectDir = projectDir }) + env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + resp, err := client.Get(env.server.URL + "/api/export/project.zip") + if err != nil { + t.Fatalf("导出赛事包: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(resp.Body) + if err != nil || resp.StatusCode != http.StatusOK { + t.Fatalf("导出赛事包失败 status=%d err=%v", resp.StatusCode, err) + } + + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + t.Fatalf("读取 zip: %v", err) + } + names := map[string]bool{} + for _, f := range zr.File { + names[f.Name] = true + } + for _, want := range []string{"meta.yaml", "event.db", "rules.yaml", "uploads/logo.txt"} { + if !names[want] { + t.Fatalf("赛事包缺少 %s,实际含 %v", want, names) + } + } +} + +// ---------- result.points ---------- + +// 已发布成绩必须带得分,供录入页预览与审查页对比。 +func TestPublishedResultsReturnPoints(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "requestId": "draft-1", + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"}, + }, + }, true) + if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "requestId": "publish-1", + }, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error) + } + + status, env2 := env.do(client, http.MethodGet, + fmt.Sprintf("/api/results?eventId=%d&state=published", ids.event), nil, false) + if status != http.StatusOK { + t.Fatalf("读取成绩应 200,得到 %d", status) + } + page := decodeData[struct { + Items []map[string]any `json:"items"` + }](t, env2) + items := page.Items + if len(items) != 2 { + t.Fatalf("应返回 2 条成绩,得到 %d", len(items)) + } + for _, it := range items { + if it["points"] == nil { + t.Fatalf("已发布成绩应返回 points: %+v", it) + } + } +} + +// ---------- records 表写入 ---------- + +// 批准破纪录必须更新纪录库的当前纪录(devdocs/02 §3、devdocs/04 records)。 +func TestApproveRecordBreakUpsertsRecords(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{ + "eventId": ids.event, "entryId": ids.entryA, "level": "meet", + "resultValue": 10.5, "resultText": "10.50", + }, true) + if status != http.StatusOK { + t.Fatalf("登记破纪录应 200,得到 %d %+v", status, env2.Error) + } + created := decodeData[map[string]any](t, env2) + breakID := int64(created["id"].(float64)) + + if status, env2 = env.do(client, http.MethodPost, + fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true); status != http.StatusOK { + t.Fatalf("批准破纪录应 200,得到 %d %+v", status, env2.Error) + } + + var ( + holder sql.NullString + teamID sql.NullInt64 + isCurrent bool + source string + ) + err := env.ev.QueryRow(`SELECT holder_name, holder_team_id, is_current, source + FROM records WHERE event_id = ? AND level = 'meet'`, ids.event). + Scan(&holder, &teamID, &isCurrent, &source) + if err != nil { + t.Fatalf("查询纪录库: %v", err) + } + if !holder.Valid || holder.String != "张伟" { + t.Fatalf("纪录持有者应为运动员姓名,得到 %+v", holder) + } + if !teamID.Valid || teamID.Int64 != ids.teamA { + t.Fatalf("纪录单位应为 %d,得到 %+v", ids.teamA, teamID) + } + if !isCurrent || source != "current" { + t.Fatalf("新纪录应 is_current=1 source=current,得到 isCurrent=%v source=%q", isCurrent, source) + } +} + +// ---------- 规则导入审计 ---------- + +// 规则导入成功后必须留审计(devdocs/02 §7.2、08 §3)。 +func TestRulesImportWritesAudit(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + resp, err := client.Get(env.server.URL + "/api/rules/export") + if err != nil { + t.Fatalf("导出规则包: %v", err) + } + pack, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil || resp.StatusCode != http.StatusOK { + t.Fatalf("导出规则包失败 status=%d err=%v", resp.StatusCode, err) + } + + status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", pack, "application/yaml", true) + if status != http.StatusOK { + t.Fatalf("导入规则包应 200,得到 %d %s", status, raw) + } + + var count int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'rules.import'`).Scan(&count); err != nil { + t.Fatalf("查询审计: %v", err) + } + if count != 1 { + t.Fatalf("规则导入应写 1 条审计,得到 %d", count) + } +} + +// ---------- 实时广播与在线看板 ---------- + +// 写事务提交后要把新变更推给实时层;大屏实例列表对 recorder 开放。 +func TestWritesBroadcastChangesAndListDisplayInstances(t *testing.T) { + fake := &fakeRealtime{} + env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake }) + fake.db = env.ev + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "requestId": "draft-1", + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"}, + }, + }, true) + if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "requestId": "publish-1", + }, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error) + } + + batch := fake.firstBatch(t) + found := false + for _, ch := range batch { + if ch.Entity == "standings" { + found = true + var rows []map[string]any + if err := json.Unmarshal(ch.Data, &rows); err != nil || len(rows) == 0 { + t.Fatalf("广播的 standings 应为非空数组: %v %s", err, ch.Data) + } + } + } + if !found { + t.Fatalf("广播批次应包含 standings 变更: %+v", batch) + } + + fake.instances = []realtime.Instance{{ID: "display-1", Online: true, Page: "standings"}} + status, env2 := env.do(client, http.MethodGet, "/api/display/instances", nil, false) + if status != http.StatusOK { + t.Fatalf("读取大屏实例应 200,得到 %d %+v", status, env2.Error) + } + list := decodeData[[]map[string]any](t, env2) + if len(list) != 1 || list[0]["id"] != "display-1" { + t.Fatalf("大屏实例列表不对: %+v", list) + } + + guest, _ := env.client() + if status, _ := env.do(guest, http.MethodGet, "/api/display/instances", nil, false); status != http.StatusForbidden { + t.Fatalf("guest 读大屏实例应 403,得到 %d", status) + } +} diff --git a/server/internal/httpapi/rules_handlers.go b/server/internal/httpapi/rules_handlers.go index d097b83..4666c11 100644 --- a/server/internal/httpapi/rules_handlers.go +++ b/server/internal/httpapi/rules_handlers.go @@ -67,6 +67,7 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error if err := s.recomputeAllEvents(r.Context(), middlewareUserID(r)); err != nil { return nil, internal(err) } + s.audit(r.Context(), "rules.import", "rules", 0, nil, res) return res, nil } @@ -101,6 +102,11 @@ func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) error { _ = tx.Rollback() return err } + // 重算改动了积分,必须写版本号并推送,否则大屏停在旧比分。 + if err := s.bumpStandings(ctx, tx, actorID); err != nil { + _ = tx.Rollback() + return err + } if err := tx.Commit(); err != nil { return err } diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index 5983627..6118a7a 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -5,9 +5,13 @@ 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" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" + "git.yeij.top/AskaEth/Scoreboard/server/internal/rules" ) // queryer 让计分相关的加载函数既能走连接池(只读)也能走事务(发布时读写一致)。 @@ -224,16 +228,13 @@ func loadResultsQ(ctx context.Context, q queryer, eventID, categoryID int64) ([] } func loadRecordBonusQ(ctx context.Context, q queryer) (domain.RecordBonusConfig, error) { - cfg := domain.RecordBonusConfig{Mode: domain.RecordBonusMax} values, err := loadSettingsMap(ctx, q) if err != nil { - return cfg, err + return domain.RecordBonusConfig{}, err } - cfg.Enabled = values["record_bonus.enabled"] == "true" - if values["record_bonus.mode"] == string(domain.RecordBonusSum) { - cfg.Mode = domain.RecordBonusSum - } - return cfg, nil + // 统一走 rules 的解析:enabled / mode / levels 三个键必须一起读, + // 只读前两个会让 levelPoints 永远查不到档位,破纪录加分恒为 0。 + return rules.RecordBonusFromSettings(values), nil } func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain.RecordBreak, error) { @@ -328,8 +329,9 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64 } // recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。 +// 作废范围是引擎产出的成绩分与破纪录加分;人工调整由 point_adjustments 管理,绝不在此作废。 func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) { - scope := `source_type = 'result' AND event_id = ?` + scope := `source_type IN ('result', 'record_bonus') AND event_id = ?` args := []any{eventID} if categoryID > 0 { scope += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)` @@ -390,6 +392,9 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i catID = *in.CategoryID } + // 发布是不可逆的对外动作,先留一份快照;失败只记日志与审计,不阻塞发布(08 第 6 节)。 + s.backupBeforePublish(ctx) + tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return nil, internal(err) @@ -436,7 +441,7 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { return nil, err } - if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil { + if err := s.bumpStandings(ctx, tx, userID); err != nil { return nil, err } if err := tx.Commit(); err != nil { @@ -447,6 +452,24 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i return s.resultsByEvent(ctx, in.EventID) } +// 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", + }) + if err != nil { + 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}) +} + func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { if in.EventID == 0 { return nil, badRequest("eventId 必填", nil) @@ -495,7 +518,7 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { return nil, err } - if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil { + if err := s.bumpStandings(ctx, tx, userID); err != nil { return nil, err } if err := tx.Commit(); err != nil { @@ -566,7 +589,7 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string, if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil { return resultDTO{}, err } - if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": eventID}, userID); err != nil { + if err := s.bumpStandings(ctx, tx, userID); err != nil { return resultDTO{}, err } if err := tx.Commit(); err != nil { diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index acab65a..bb0a8d5 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -33,21 +33,27 @@ type Options struct { DataDir string // Display 可选,实时层接入后传入。 Display DisplaySender + // Realtime 可选,实时层接入后传入:写事务提交后推送变更,并提供在线大屏实例。 + Realtime Realtime // WS 可选,实时层的 WebSocket 处理器,挂到 /ws。 WS http.Handler + // ProjectDir 可选,当前赛事项目目录(含 uploads/),用于赛事包导出。 + ProjectDir string // WebFS 可选,默认使用内嵌前端资源。 WebFS fs.FS } type server struct { - logger *slog.Logger - version string - sys *sql.DB - ev *sql.DB - auth *auth.Service - dataDir string - display DisplaySender - idem *idempotencyStore + logger *slog.Logger + version string + sys *sql.DB + ev *sql.DB + auth *auth.Service + dataDir string + projectDir string + display DisplaySender + realtime Realtime + idem *idempotencyStore } // New 组装并返回服务端的 HTTP 处理器。 @@ -62,14 +68,16 @@ func New(opts Options) http.Handler { } s := &server{ - logger: logger, - version: opts.Version, - sys: opts.System, - ev: opts.Event, - auth: svc, - dataDir: opts.DataDir, - display: opts.Display, - idem: newIdempotencyStore(10 * time.Minute), + logger: logger, + version: opts.Version, + sys: opts.System, + ev: opts.Event, + auth: svc, + dataDir: opts.DataDir, + projectDir: opts.ProjectDir, + display: opts.Display, + realtime: opts.Realtime, + idem: newIdempotencyStore(10 * time.Minute), } mux := http.NewServeMux() @@ -94,9 +102,11 @@ func New(opts Options) http.Handler { } var h http.Handler = mux + h = s.broadcastChanges(h) h = middleware.NewAuditor(opts.Event, logger).Middleware(h) h = middleware.RateLimitWrite(svc)(h) h = middleware.CSRF(h) + h = middleware.MustChangePassword(h) h = middleware.Authenticate(svc)(h) h = middleware.Log(logger)(h) h = middleware.Recover(logger)(h) @@ -205,6 +215,7 @@ func (s *server) routes(mux *http.ServeMux) { // 大屏控制与运维 mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler)) + mux.HandleFunc("GET /api/display/instances", s.read(auth.RoleRecorder, s.displayInstancesHandler)) mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler)) mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler)) diff --git a/server/internal/httpapi/testenv_test.go b/server/internal/httpapi/testenv_test.go index 72593c3..bde1549 100644 --- a/server/internal/httpapi/testenv_test.go +++ b/server/internal/httpapi/testenv_test.go @@ -32,6 +32,12 @@ type testEnv struct { } func newTestEnv(t *testing.T) *testEnv { + t.Helper() + return newTestEnvWith(t, nil) +} + +// newTestEnvWith 在默认环境基础上允许测试注入 Options(例如实时层)。 +func newTestEnvWith(t *testing.T, customize func(*httpapi.Options)) *testEnv { t.Helper() dir := t.TempDir() @@ -60,15 +66,18 @@ func newTestEnv(t *testing.T) *testEnv { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) svc := auth.New(st.DB(), auth.Options{Logger: logger, BcryptCost: bcrypt.MinCost}) - handler := httpapi.New(httpapi.Options{ + opts := httpapi.Options{ Logger: logger, Version: "test", System: st.DB(), Event: ev, Auth: svc, DataDir: filepath.Join(dir, "data"), - }) - srv := httptest.NewServer(handler) + } + if customize != nil { + customize(&opts) + } + srv := httptest.NewServer(httpapi.New(opts)) t.Cleanup(srv.Close) return &testEnv{t: t, server: srv, sys: st.DB(), ev: ev, auth: svc} diff --git a/server/internal/middleware/context.go b/server/internal/middleware/context.go index abac3d9..71fcbaa 100644 --- a/server/internal/middleware/context.go +++ b/server/internal/middleware/context.go @@ -3,9 +3,11 @@ package middleware import ( "context" + "fmt" "net" "net/http" "strings" + "sync/atomic" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" ) @@ -39,19 +41,86 @@ func GetRequestID(ctx context.Context) string { return "" } -// ClientIP 从代理头或连接地址解析客户端 IP。 -func ClientIP(r *http.Request) string { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" { - return first +// trustedProxies 是可信代理网段白名单;默认空,表示任何代理头都不可信。 +var trustedProxies atomic.Pointer[[]*net.IPNet] + +// SetTrustedProxies 配置可信代理(IP 或 CIDR 列表);传空表示不信任任何代理头。 +func SetTrustedProxies(entries []string) error { + nets := make([]*net.IPNet, 0, len(entries)) + for _, raw := range entries { + entry := strings.TrimSpace(raw) + if entry == "" { + continue + } + if strings.Contains(entry, "/") { + _, n, err := net.ParseCIDR(entry) + if err != nil { + return fmt.Errorf("middleware: 非法可信代理 %q: %w", entry, err) + } + nets = append(nets, n) + continue + } + ip := net.ParseIP(entry) + if ip == nil { + return fmt.Errorf("middleware: 非法可信代理 %q", entry) + } + bits := 128 + if ip.To4() != nil { + bits = 32 + } + nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)}) + } + trustedProxies.Store(&nets) + return nil +} + +func isTrustedProxy(ip net.IP) bool { + nets := trustedProxies.Load() + if nets == nil || ip == nil { + return false + } + for _, n := range *nets { + if n.Contains(ip) { + return true } } - if rip := strings.TrimSpace(r.Header.Get("X-Real-IP")); rip != "" { - return rip - } - host, _, err := net.SplitHostPort(r.RemoteAddr) + return false +} + +func remoteHost(addr string) string { + host, _, err := net.SplitHostPort(addr) if err != nil { - return r.RemoteAddr + return addr } return host } + +// ClientIP 解析客户端 IP。 +// 只有当直连对端(RemoteAddr)属于可信代理白名单时才采信 X-Forwarded-For / X-Real-IP, +// 否则一律用 RemoteAddr——代理头是客户端可伪造的,据此限流等于没有限流。 +func ClientIP(r *http.Request) string { + remote := net.ParseIP(remoteHost(r.RemoteAddr)) + if remote == nil || !isTrustedProxy(remote) { + return remoteHost(r.RemoteAddr) + } + + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + // 从右往左取第一个非代理地址:这是可信代理链上离服务端最近的真实客户端。 + for i := len(parts) - 1; i >= 0; i-- { + ip := net.ParseIP(strings.TrimSpace(parts[i])) + if ip == nil || isTrustedProxy(ip) { + continue + } + return ip.String() + } + // 整条链路都是可信代理时,最左侧即原始客户端。 + if ip := net.ParseIP(strings.TrimSpace(parts[0])); ip != nil { + return ip.String() + } + } + if ip := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ip != nil { + return ip.String() + } + return remoteHost(r.RemoteAddr) +} diff --git a/server/internal/middleware/context_test.go b/server/internal/middleware/context_test.go new file mode 100644 index 0000000..9fa51da --- /dev/null +++ b/server/internal/middleware/context_test.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "net/http/httptest" + "testing" +) + +// 默认不信任任何代理头:X-Forwarded-For 不能改变客户端 IP,否则登录限流可被绕过。 +func TestClientIPIgnoresForwardedHeadersByDefault(t *testing.T) { + t.Cleanup(func() { _ = SetTrustedProxies(nil) }) + if err := SetTrustedProxies(nil); err != nil { + t.Fatalf("清空可信代理: %v", err) + } + + r := httptest.NewRequest("POST", "/api/auth/login", nil) + r.RemoteAddr = "127.0.0.1:54321" + r.Header.Set("X-Forwarded-For", "203.0.113.9") + r.Header.Set("X-Real-IP", "203.0.113.10") + + if got := ClientIP(r); got != "127.0.0.1" { + t.Fatalf("默认应使用 RemoteAddr,得到 %q", got) + } +} + +// 仅当直连对端在可信代理白名单内时才采信代理头,并从右往左取第一个非代理地址。 +func TestClientIPTrustsForwardedHeadersOnlyFromProxies(t *testing.T) { + t.Cleanup(func() { _ = SetTrustedProxies(nil) }) + if err := SetTrustedProxies([]string{"10.0.0.0/8"}); err != nil { + t.Fatalf("设置可信代理: %v", err) + } + + r := httptest.NewRequest("POST", "/api/auth/login", nil) + r.RemoteAddr = "10.0.0.5:12345" + r.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.6") + + if got := ClientIP(r); got != "198.51.100.7" { + t.Fatalf("可信代理应解析出真实客户端,得到 %q", got) + } + + // 直连对端不可信时,即便伪造代理头也以 RemoteAddr 为准。 + r.RemoteAddr = "203.0.113.1:12345" + if got := ClientIP(r); got != "203.0.113.1" { + t.Fatalf("不可信对端应使用 RemoteAddr,得到 %q", got) + } +} diff --git a/server/internal/middleware/middleware.go b/server/internal/middleware/middleware.go index 82f3fdd..3fee2dc 100644 --- a/server/internal/middleware/middleware.go +++ b/server/internal/middleware/middleware.go @@ -131,6 +131,29 @@ func RequireWrite(need auth.Role) func(http.Handler) http.Handler { } } +// MustChangePassword 阻止「必须修改密码」的账号执行写操作。 +// 只放行改密与登出:否则用户会被永久锁在无法改密的死循环里(见 devdocs/08 第 1 节)。 +func MustChangePassword(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := User(r.Context()) + if u.Role == auth.RoleGuest || !u.MustChangePassword { + next.ServeHTTP(w, r) + return + } + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + next.ServeHTTP(w, r) + return + } + switch r.URL.Path { + case "/api/auth/password", "/api/auth/logout": + next.ServeHTTP(w, r) + return + } + writeError(w, http.StatusForbidden, "AUTH_REQUIRED", "请先修改初始密码", nil) + }) +} + // CSRF 校验双提交 Cookie:已登录的写请求必须带 X-CSRF-Token 且与 csrf_token Cookie 一致。 func CSRF(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/server/internal/realtime/changes.go b/server/internal/realtime/changes.go index f0d0a69..e7332e3 100644 --- a/server/internal/realtime/changes.go +++ b/server/internal/realtime/changes.go @@ -8,7 +8,11 @@ import ( ) // currentVersion 返回服务端版本号,即 change_log 的最大 id;空表为 0。 +// NewHub 允许 db 为 nil(降级/测试),此时退回内存版本,避免空指针 panic。 func (h *Hub) currentVersion(ctx context.Context) (int64, error) { + if h.db == nil { + return h.Version(), nil + } var v sql.NullInt64 if err := h.db.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil { return 0, fmt.Errorf("读取 change_log 版本号: %w", err) diff --git a/server/internal/realtime/hub_test.go b/server/internal/realtime/hub_test.go index d030452..f73797f 100644 --- a/server/internal/realtime/hub_test.go +++ b/server/internal/realtime/hub_test.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "log/slog" + "net/http" "net/http/httptest" "path/filepath" "reflect" @@ -17,6 +18,8 @@ import ( "github.com/coder/websocket" _ "modernc.org/sqlite" + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" + "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) @@ -28,12 +31,22 @@ type testEnv struct { srv *httptest.Server } +// newTestEnv 建一个未登录(guest)的服务端:默认身份与大屏端一致。 func newTestEnv(t *testing.T) *testEnv { t.Helper() + return newTestEnvAs(t, &auth.User{Role: auth.RoleGuest}) +} + +// newTestEnvAs 注入会话身份,等价于真实链路里 Authenticate 中间件写进 context 的 User。 +func newTestEnvAs(t *testing.T, u *auth.User) *testEnv { + t.Helper() db := newTestDB(t) h := NewHub(db, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))) - srv := httptest.NewServer(h.Handler()) + handler := h.Handler() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler.ServeHTTP(w, r.WithContext(middleware.WithUser(r.Context(), u))) + })) t.Cleanup(func() { srv.Close() @@ -165,6 +178,24 @@ func (c *wsClient) recvEvent(want string) json.RawMessage { return nil } +// expectClosed 断言连接在 d 内被服务端关闭,且关闭前没有下发任何事件。 +func (c *wsClient) expectClosed(d time.Duration) { + c.t.Helper() + timer := time.NewTimer(d) + defer timer.Stop() + for { + select { + case msg, ok := <-c.msgs: + if !ok || msg.err != nil { + return + } + c.t.Fatalf("连接关闭前仍收到事件 %s", msg.event) + case <-timer.C: + c.t.Fatalf("等待服务端关闭连接超时") + } + } +} + func decode[T any](t *testing.T, raw json.RawMessage) T { t.Helper() var v T @@ -458,12 +489,13 @@ func TestDisplayInstancesRegistry(t *testing.T) { }) } -// 房间隔离:admin: 只发给对应管理端。 +// 房间隔离:admin: 只发给对应管理端,且 userId 来自会话而非客户端自报。 func TestAdminRoomTargeting(t *testing.T) { - env := newTestEnv(t) + env := newTestEnvAs(t, &auth.User{ID: 7, Username: "boss", Role: auth.RoleAdmin, Active: true}) display := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"}) display.recvEvent(EventSnapshot) - admin := env.dial(t, &helloPayload{Role: RoleAdmin, UserID: 7}) + // hello 不再携带 userId:定向房间必须用会话里的 ID。 + admin := env.dial(t, &helloPayload{Role: RoleAdmin}) admin.recvEvent(EventSnapshot) insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`) @@ -478,6 +510,50 @@ func TestAdminRoomTargeting(t *testing.T) { } } +// guest 自报 role=admin 必须被拒绝:管理端房间只能由会话授予。 +func TestGuestCannotSubscribeAdminRoom(t *testing.T) { + env := newTestEnv(t) + c := env.dial(t, &helloPayload{Role: RoleAdmin}) + + c.expectClosed(3 * time.Second) +} + +// guest 只能订阅 display 房间:即使 hello 里塞了 userId 也不能进入定向房间。 +func TestGuestStaysInDisplayRoom(t *testing.T) { + env := newTestEnv(t) + c := env.dial(t, nil) + c.send(EventHello, map[string]any{"role": RoleDisplay, "userId": 99}) + c.recvEvent(EventSnapshot) + + insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`) + changes := loadChanges(t, env.hub, 0, 0) + env.hub.BroadcastTo(RoomAdmin, changes) + env.hub.BroadcastTo(AdminRoom(99), changes) + if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok { + t.Fatalf("guest 不应收到管理端房间的消息,收到 %s", ev) + } + + // display 房间的消息仍然正常到达。 + env.hub.BroadcastTo(RoomDisplay, changes) + if got := decode[changePayload](t, c.recvEvent(EventChange)); got.Version != 1 { + t.Fatalf("大屏应收到 display 房间消息,版本得到 %d", got.Version) + } +} + +// recorder 会话可以进管理端房间(大屏控制/数据同步都由管理端承担)。 +func TestRecorderSessionJoinsAdminRoom(t *testing.T) { + env := newTestEnvAs(t, &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true}) + c := env.dial(t, &helloPayload{Role: RoleAdmin}) + c.recvEvent(EventSnapshot) + + insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`) + env.hub.BroadcastTo(RoomAdmin, loadChanges(t, env.hub, 0, 0)) + + if got := decode[changePayload](t, c.recvEvent(EventChange)); got.Version != 1 { + t.Fatalf("recorder 应进入管理端房间,版本得到 %d", got.Version) + } +} + // 慢客户端:ack 落后超过阈值即被识别并断开。 func TestSlowClientDetected(t *testing.T) { env := newTestEnv(t) diff --git a/server/internal/realtime/snapshot_test.go b/server/internal/realtime/snapshot_test.go index a86b932..dcdb078 100644 --- a/server/internal/realtime/snapshot_test.go +++ b/server/internal/realtime/snapshot_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" + "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" ) // 快照必须与数据库同源:版本号取自 change_log,各列表取真实表, @@ -16,21 +16,17 @@ func TestSnapshotContents(t *testing.T) { env := newTestEnv(t) db := env.db - mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `"测试运动会"`) - mustExec(t, db, `INSERT INTO settings(key, value) VALUES ('display.theme', 'dark')`) - mustExec(t, db, `INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T1', '第一单位', '一队', 1, 1)`) - mustExec(t, db, `INSERT INTO sports(code, name) VALUES ('S1', '田径')`) - mustExec(t, db, `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) - VALUES (1, 'E1', '100米', 'individual', 'auto', 'time', 'asc', 'published')`) - mustExec(t, db, `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`) - mustExec(t, db, `INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`) - mustExec(t, db, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`) - mustExec(t, db, `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status) - VALUES (1, 1, 1, '11.58', 11.58, 's', 'published', 'ok')`) - mustExec(t, db, `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points) - VALUES (1, 1, 1, 1, 1, 'result', 1, 9)`) + mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `测试运动会`) + team1 := insertTeam(t, db, "T1", "第一单位", "一队") + event1 := insertEvent(t, db, "E1", "100米") + cat1 := insertCategory(t, db, event1, "男子") + athlete1 := insertAthlete(t, db, "张三", team1) + entry1 := insertEntry(t, db, event1, cat1, team1, athlete1) + insertChannel(t, db, event1, "athlete") + result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z") + insertLedger(t, db, team1, athlete1, event1, entry1, result1, 9) mustExec(t, db, `INSERT INTO records(event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source) - VALUES (1, 'meet', 11.2, '11.20', '李四', 1, '2025-09-01', 1, 'history')`) + VALUES (?, 'meet', 11.2, '11.20', '李四', ?, '2025-09-01', 1, 'history')`, event1, team1) version := insertChange(t, db, "result", 1, ActionUpsert, `{"rank":1}`) snap, err := env.hub.Snapshot(context.Background()) @@ -40,11 +36,8 @@ func TestSnapshotContents(t *testing.T) { if snap.Version != version { t.Fatalf("快照版本应为 %d,得到 %d", version, snap.Version) } - if got := string(snap.Settings["event.name"]); got != `"测试运动会"` { - t.Fatalf("settings JSON 值应透传,得到 %s", got) - } - if got := string(snap.Settings["display.theme"]); got != `"dark"` { - t.Fatalf("settings 纯文本应编码为 JSON 字符串,得到 %s", got) + if snap.Settings.Event.Name != "测试运动会" { + t.Fatalf("settings 应聚合出嵌套 event.name,得到 %+v", snap.Settings.Event) } if len(snap.Teams) != 1 || snap.Teams[0].Code != "T1" || !snap.Teams[0].Active { @@ -53,28 +46,295 @@ func TestSnapshotContents(t *testing.T) { if len(snap.Events) != 1 { t.Fatalf("小项数应为 1,得到 %d", len(snap.Events)) } - ev := snap.Events[0] - if ev.Name != "100米" || ev.EventType != domain.EventTypeIndividual || !ev.Scored || ev.Compare != domain.CompareAsc { - t.Fatalf("小项不符: %+v", ev) - } - - if len(snap.Standings) != 1 { - t.Fatalf("榜单行数应为 1,得到 %d", len(snap.Standings)) - } - row := snap.Standings[0] - if row.TeamID != 1 || row.Points != 9 || row.Gold != 1 || row.Rank != 1 { - t.Fatalf("榜单不符: %+v", row) - } - if len(snap.Records) != 1 || snap.Records[0].HolderName != "李四" || snap.Records[0].ResultValue == nil { t.Fatalf("纪录不符: %+v", snap.Records) } - if len(snap.LatestResults) != 1 { - t.Fatalf("最新成绩数应为 1,得到 %d", len(snap.LatestResults)) +} + +// 快照的线上 JSON 形状必须与 devdocs/05 第 3 节、web/src/shared/types.ts 一致: +// settings 是嵌套对象、standings 用 totalPoints/teamCode/…、latestResults 按小项+组别分组。 +// 只断言 Go 字段会漏掉 json 标签写错这类线上事故,所以这里直接检查序列化结果。 +func TestSnapshotWireContract(t *testing.T) { + env := newTestEnv(t) + db := env.db + + mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `测试运动会`) + team1 := insertTeam(t, db, "T1", "第一单位", "一队") + event1 := insertEvent(t, db, "E1", "男子 100 米") + cat1 := insertCategory(t, db, event1, "青年组") + athlete1 := insertAthlete(t, db, "张伟", team1) + entry1 := insertEntry(t, db, event1, cat1, team1, athlete1) + insertChannel(t, db, event1, "athlete") + result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z") + insertLedger(t, db, team1, athlete1, event1, entry1, result1, 9) + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) } - lr := snap.LatestResults[0] - if lr.ResultText != "11.58" || lr.TeamID != 1 || lr.AthleteID != 1 || lr.Rank != 1 || lr.State != "published" { - t.Fatalf("最新成绩不符: %+v", lr) + raw, err := json.Marshal(snap) + if err != nil { + t.Fatalf("序列化快照: %v", err) + } + + var top map[string]json.RawMessage + if err := json.Unmarshal(raw, &top); err != nil { + t.Fatalf("解析快照 JSON: %v", err) + } + + // settings 必须是嵌套 EventSettings,不能再是点号扁平 map。 + var settings map[string]json.RawMessage + if err := json.Unmarshal(top["settings"], &settings); err != nil { + t.Fatalf("settings 不是对象: %s", top["settings"]) + } + if _, flat := settings["event.name"]; flat { + t.Fatalf("settings 仍是点号扁平 map: %s", top["settings"]) + } + for _, key := range []string{"event", "display", "standings", "defaultProfileId", "requireApproval"} { + if _, ok := settings[key]; !ok { + t.Fatalf("settings 缺少嵌套字段 %s: %s", key, top["settings"]) + } + } + var parsed dto.EventSettings + if err := json.Unmarshal(top["settings"], &parsed); err != nil { + t.Fatalf("按 EventSettings 解析失败: %v", err) + } + if parsed.Event.Name != "测试运动会" || parsed.Display.Title != "测试运动会" { + t.Fatalf("settings 默认值口径不符: %+v", parsed) + } + if parsed.Display.RotateSecond != 15 || parsed.Standings.IndividualToTeamWeight != 1 { + t.Fatalf("settings 默认值应为 REST 的 15s / 系数 1: %+v", parsed) + } + if parsed.DefaultProfileID == nil || *parsed.DefaultProfileID != 1 { + t.Fatalf("defaultProfileId 应由 scoring.default_profile 反查得到: %+v", parsed.DefaultProfileID) + } + + // standings 必须是 totalPoints/teamCode/teamName/medalTotal/rankChange。 + var rows []map[string]json.RawMessage + if err := json.Unmarshal(top["standings"], &rows); err != nil { + t.Fatalf("standings 不是数组: %s", top["standings"]) + } + if len(rows) != 1 { + t.Fatalf("榜单行数应为 1,得到 %d", len(rows)) + } + for _, key := range []string{"teamId", "teamCode", "teamName", "totalPoints", "medalTotal", "rankChange", "gold", "rank"} { + if _, ok := rows[0][key]; !ok { + t.Fatalf("standings 缺少契约字段 %s: %s", key, top["standings"]) + } + } + for _, banned := range []string{"points", "code", "name", "medals"} { + if _, ok := rows[0][banned]; ok { + t.Fatalf("standings 仍是 domain.StandingsRow 的字段 %s: %s", banned, top["standings"]) + } + } + var typed []dto.StandingRow + if err := json.Unmarshal(top["standings"], &typed); err != nil { + t.Fatalf("按 StandingRow 解析失败: %v", err) + } + if typed[0].TeamCode != "T1" || typed[0].TotalPoints != 9 || typed[0].MedalTotal != 1 { + t.Fatalf("榜单内容不符: %+v", typed[0]) + } + if typed[0].RankChange != nil { + t.Fatalf("快照没有对比基准,rankChange 应为 null: %+v", typed[0].RankChange) + } + + // latestResults 必须按小项 + 组别分组,且行里带契约字段。 + var groups []map[string]json.RawMessage + if err := json.Unmarshal(top["latestResults"], &groups); err != nil { + t.Fatalf("latestResults 不是数组: %s", top["latestResults"]) + } + if len(groups) != 1 { + t.Fatalf("分组数应为 1,得到 %d: %s", len(groups), top["latestResults"]) + } + for _, key := range []string{"eventId", "eventCode", "eventName", "categoryId", "categoryName", "publishedAt", "rows"} { + if _, ok := groups[0][key]; !ok { + t.Fatalf("成绩分组缺少契约字段 %s: %s", key, top["latestResults"]) + } + } + var typedGroups []dto.EventResultGroup + if err := json.Unmarshal(top["latestResults"], &typedGroups); err != nil { + t.Fatalf("按 EventResultGroup 解析失败: %v", err) + } + resultRows := typedGroups[0].Rows + if len(resultRows) != 1 || resultRows[0].EntryID != entry1 || resultRows[0].TeamCode != "T1" || + resultRows[0].AthleteName == nil || *resultRows[0].AthleteName != "张伟" { + t.Fatalf("成绩行不符: %+v", resultRows) + } +} + +// 同一小项 + 组别的多条成绩必须落进同一组,不同组别/小项拆成不同组,组内按名次排序。 +func TestSnapshotLatestResultsGrouped(t *testing.T) { + env := newTestEnv(t) + db := env.db + + team1 := insertTeam(t, db, "T1", "第一单位", "") + team2 := insertTeam(t, db, "T2", "第二单位", "") + event1 := insertEvent(t, db, "E1", "男子 100 米") + event2 := insertEvent(t, db, "E2", "女子 100 米") + catA := insertCategory(t, db, event1, "青年组") + catB := insertCategory(t, db, event1, "中年组") + catC := insertCategory(t, db, event2, "青年组") + + a1 := insertAthlete(t, db, "张伟", team1) + a2 := insertAthlete(t, db, "李强", team2) + a3 := insertAthlete(t, db, "王磊", team1) + a4 := insertAthlete(t, db, "李娜", team2) + + e1 := insertEntry(t, db, event1, catA, team1, a1) + e2 := insertEntry(t, db, event1, catA, team2, a2) + e3 := insertEntry(t, db, event1, catB, team1, a3) + e4 := insertEntry(t, db, event2, catC, team2, a4) + + // 故意先写名次靠后的行,验证组内会按名次重排而不是按取数顺序。 + insertResult(t, db, event1, e2, 2, "11.72", 11.72, "2026-09-09T10:00:02Z") + insertResult(t, db, event1, e1, 1, "11.58", 11.58, "2026-09-09T10:00:01Z") + insertResult(t, db, event1, e3, 1, "12.30", 12.30, "2026-09-09T10:00:03Z") + insertResult(t, db, event2, e4, 1, "13.04", 13.04, "2026-09-09T10:00:04Z") + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + if len(snap.LatestResults) != 3 { + t.Fatalf("分组数应为 3,得到 %d: %+v", len(snap.LatestResults), snap.LatestResults) + } + + byCategory := make(map[int64]dto.EventResultGroup, len(snap.LatestResults)) + for _, g := range snap.LatestResults { + byCategory[g.CategoryID] = g + } + groupA, ok := byCategory[catA] + if !ok { + t.Fatalf("缺少青年组分组: %+v", snap.LatestResults) + } + if groupA.EventID != event1 || groupA.EventCode != "E1" || groupA.EventName != "男子 100 米" || groupA.CategoryName != "青年组" { + t.Fatalf("分组元信息不符: %+v", groupA) + } + if groupA.PublishedAt == nil || *groupA.PublishedAt != "2026-09-09T10:00:02Z" { + t.Fatalf("分组发布时间应取组内最新一条: %+v", groupA.PublishedAt) + } + if len(groupA.Rows) != 2 { + t.Fatalf("青年组应有 2 行,得到 %d: %+v", len(groupA.Rows), groupA.Rows) + } + if groupA.Rows[0].EntryID != e1 || groupA.Rows[1].EntryID != e2 { + t.Fatalf("组内应按名次排序: %+v", groupA.Rows) + } + if groupA.Rows[0].TeamCode != "T1" || groupA.Rows[0].TeamName != "第一单位" || groupA.Rows[0].ResultText != "11.58" { + t.Fatalf("成绩行字段不符: %+v", groupA.Rows[0]) + } + + if len(byCategory[catB].Rows) != 1 || len(byCategory[catC].Rows) != 1 { + t.Fatalf("其余组别应各自成组: %+v", snap.LatestResults) + } +} + +// 快照榜单口径必须与 REST 一致:个人流水按系数折算进单位总分。 +func TestSnapshotStandingsAppliesIndividualWeight(t *testing.T) { + env := newTestEnv(t) + db := env.db + + mustExec(t, db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`) + team1 := insertTeam(t, db, "T1", "第一单位", "") + team2 := insertTeam(t, db, "T2", "第二单位", "") + event1 := insertEvent(t, db, "E1", "100米") + cat1 := insertCategory(t, db, event1, "男子") + athlete1 := insertAthlete(t, db, "张三", team1) + entry1 := insertEntry(t, db, event1, cat1, team1, athlete1) + insertChannel(t, db, event1, "athlete") + result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z") + + // 团体报名项没有运动员,流水只记单位。 + cat2 := insertCategory(t, db, event1, "团体") + entry2 := insertEntry(t, db, event1, cat2, team2, 0) + result2 := insertResult(t, db, event1, entry2, 1, "45.20", 45.20, "2026-09-09T10:00:01Z") + + // 个人流水 10 分按 0.5 折算成 5;仅记单位的流水不受系数影响。 + insertLedger(t, db, team1, athlete1, event1, entry1, result1, 10) + insertLedger(t, db, team2, 0, event1, entry2, result2, 4) + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + points := make(map[string]float64, len(snap.Standings)) + for _, row := range snap.Standings { + points[row.TeamCode] = row.TotalPoints + } + if points["T1"] != 5 { + t.Fatalf("个人流水应按系数 0.5 折算,T1 应得 5 分,得到 %v", points) + } + if points["T2"] != 4 { + t.Fatalf("仅记单位的流水不应折算,T2 应得 4 分,得到 %v", points) + } + if len(snap.Standings) == 0 || snap.Standings[0].TeamCode != "T1" { + t.Fatalf("按总分排序应 T1 在前: %+v", snap.Standings) + } +} + +// 团体奖牌开关与 REST 一致:关闭后团体项目奖牌不计入单位奖牌榜。 +func TestSnapshotStandingsMedalSwitch(t *testing.T) { + env := newTestEnv(t) + db := env.db + + team1 := insertTeam(t, db, "T1", "第一单位", "") + event1 := insertEvent(t, db, "E1", "4x100 米接力") + cat1 := insertCategory(t, db, event1, "男子") + entry1 := insertEntry(t, db, event1, cat1, team1, 0) + insertChannel(t, db, event1, "team") + insertResult(t, db, event1, entry1, 1, "45.20", 45.20, "2026-09-09T10:00:00Z") + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + if len(snap.Standings) != 1 || snap.Standings[0].Gold != 1 { + t.Fatalf("默认应计入团体项目奖牌: %+v", snap.Standings) + } + + mustExec(t, db, `UPDATE settings SET value = 'false' WHERE key = 'standings.medal_counts_team_events'`) + snap, err = env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + if len(snap.Standings) != 1 || snap.Standings[0].Gold != 0 || snap.Standings[0].MedalTotal != 0 { + t.Fatalf("关闭团体奖牌后应清零: %+v", snap.Standings) + } +} + +// 只统计配了计分通道的项目,且多通道不会把同一枚奖牌重复计入。 +func TestSnapshotStandingsOnlyScoredChannels(t *testing.T) { + env := newTestEnv(t) + db := env.db + + team1 := insertTeam(t, db, "T1", "第一单位", "") + team2 := insertTeam(t, db, "T2", "第二单位", "") + + // event1 没有计分通道:即使有已发布名次也不应产生奖牌。 + event1 := insertEvent(t, db, "E1", "表演项目") + cat1 := insertCategory(t, db, event1, "男子") + entry1 := insertEntry(t, db, event1, cat1, team1, 0) + insertResult(t, db, event1, entry1, 1, "1.00", 1, "2026-09-09T10:00:00Z") + + // event2 配了计分通道:名次才会计入奖牌榜。 + event2 := insertEvent(t, db, "E2", "团体项目") + cat2 := insertCategory(t, db, event2, "男子") + entry2 := insertEntry(t, db, event2, cat2, team2, 0) + insertChannel(t, db, event2, "team") + insertResult(t, db, event2, entry2, 1, "9.99", 9.99, "2026-09-09T10:00:01Z") + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + medals := make(map[string]int, len(snap.Standings)) + for _, row := range snap.Standings { + medals[row.TeamCode] = row.Gold + } + if medals["T1"] != 0 { + t.Fatalf("无计分通道的项目不应计奖牌,得到 %v", medals) + } + if medals["T2"] != 1 { + t.Fatalf("有计分通道的项目应计入奖牌,得到 %v", medals) } } @@ -93,6 +353,16 @@ func TestEmptySnapshotMarshalsEmptyLists(t *testing.T) { t.Fatalf("空快照应包含 %s,实际 %s", want, raw) } } + // 空库也要下发结构完整的 settings,前端才能安全地读 settings.event.name。 + var parsed struct { + Settings dto.EventSettings `json:"settings"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("解析空快照: %v", err) + } + if parsed.Settings.Event.Name == "" || parsed.Settings.Display.RotateSecond == 0 { + t.Fatalf("空快照 settings 应带默认值: %+v", parsed.Settings) + } } func mustExec(t *testing.T, db *sql.DB, q string, args ...any) { @@ -101,3 +371,103 @@ func mustExec(t *testing.T, db *sql.DB, q string, args ...any) { t.Fatalf("执行 SQL %q: %v", q, err) } } + +// ---------- 造数助手 ---------- + +func insertTeam(t *testing.T, db *sql.DB, code, name, shortName string) int64 { + t.Helper() + res, err := db.Exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES (?, ?, ?, 0, 1)`, + code, name, shortName) + if err != nil { + t.Fatalf("插入单位 %s: %v", code, err) + } + return lastID(t, res) +} + +func insertEvent(t *testing.T, db *sql.DB, code, name string) int64 { + t.Helper() + if _, err := db.Exec(`INSERT OR IGNORE INTO sports(code, name) VALUES ('S1', '田径')`); err != nil { + t.Fatalf("插入大项: %v", err) + } + res, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (1, ?, ?, 'individual', 'auto', 'time', 'asc', 'published')`, code, name) + if err != nil { + t.Fatalf("插入小项 %s: %v", code, err) + } + return lastID(t, res) +} + +func insertCategory(t *testing.T, db *sql.DB, eventID int64, name string) int64 { + t.Helper() + res, err := db.Exec(`INSERT INTO event_categories(event_id, name) VALUES (?, ?)`, eventID, name) + if err != nil { + t.Fatalf("插入组别 %s: %v", name, err) + } + return lastID(t, res) +} + +func insertAthlete(t *testing.T, db *sql.DB, name string, teamID int64) int64 { + t.Helper() + res, err := db.Exec(`INSERT INTO athletes(name, gender, team_id) VALUES (?, '男', ?)`, name, teamID) + if err != nil { + t.Fatalf("插入运动员 %s: %v", name, err) + } + return lastID(t, res) +} + +// insertEntry 在 athleteID 为 0 时写 NULL,表示团体项目报名项。 +func insertEntry(t *testing.T, db *sql.DB, eventID, categoryID, teamID, athleteID int64) int64 { + t.Helper() + var athlete any + if athleteID != 0 { + athlete = athleteID + } + res, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (?, ?, ?, ?, '101')`, + eventID, categoryID, teamID, athlete) + if err != nil { + t.Fatalf("插入报名项: %v", err) + } + return lastID(t, res) +} + +func insertChannel(t *testing.T, db *sql.DB, eventID int64, scope string) { + t.Helper() + mustExec(t, db, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, ?, 'result', 1, 0)`, eventID, scope) +} + +func insertResult(t *testing.T, db *sql.DB, eventID, entryID int64, rank int, text string, value float64, verifiedAt string) int64 { + t.Helper() + res, err := db.Exec(`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status, verified_at) + VALUES (?, ?, ?, ?, ?, 's', 'published', 'ok', ?)`, eventID, entryID, rank, text, value, verifiedAt) + if err != nil { + t.Fatalf("插入成绩: %v", err) + } + return lastID(t, res) +} + +// insertLedger 写一条积分流水;athleteID 为 0 表示「仅记单位」。 +func insertLedger(t *testing.T, db *sql.DB, teamID, athleteID, eventID, entryID, resultID int64, points float64) { + t.Helper() + var athlete, entry, result any + if athleteID != 0 { + athlete = athleteID + } + if entryID != 0 { + entry = entryID + } + if resultID != 0 { + result = resultID + } + mustExec(t, db, `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (?, ?, ?, ?, ?, 'result', 1, ?)`, teamID, athlete, eventID, entry, result, points) +} + +func lastID(t *testing.T, res sql.Result) int64 { + t.Helper() + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("读取自增 id: %v", err) + } + return id +} diff --git a/server/internal/rules/convert.go b/server/internal/rules/convert.go index ea9230e..ea22948 100644 --- a/server/internal/rules/convert.go +++ b/server/internal/rules/convert.go @@ -55,7 +55,6 @@ func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreC Scope: c.Scope, Basis: c.Basis, ProfileID: profileID, - Aggregate: c.Aggregate, SortOrder: sortOrder, } } @@ -63,10 +62,9 @@ func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreC // ChannelFromDomain 把 domain 通道转回规则包类型;profileCode 由调用方按模板 ID 查出。 func ChannelFromDomain(c domain.ScoreChannel, profileCode string) Channel { return Channel{ - Scope: c.Scope, - Basis: c.Basis, - Profile: profileCode, - Aggregate: c.Aggregate, + Scope: c.Scope, + Basis: c.Basis, + Profile: profileCode, } } @@ -82,6 +80,7 @@ func (p *RulePack) ProfileIndex() map[string]Profile { // ToStandingsInput 把榜单口径映射成 domain 聚合输入。 func (s Standings) ToStandingsInput() domain.StandingsInput { return domain.StandingsInput{ + TieBreak: append([]string(nil), s.TieBreak...), IndividualToTeamWeight: s.IndividualToTeamWeight, ExcludeTeamEventMedals: !s.MedalCountsTeamEventsEnabled(), } diff --git a/server/internal/rules/parse_test.go b/server/internal/rules/parse_test.go index afccf6a..11930e4 100644 --- a/server/internal/rules/parse_test.go +++ b/server/internal/rules/parse_test.go @@ -181,6 +181,10 @@ func TestParseRejectsBadInput(t *testing.T) { {"通道引用不存在的模板", strings.Replace(validPackYAML, "profile: individual_top6 }", "profile: ghost_top6 }", 1), ErrValidation}, {"排序键未知", strings.Replace(validPackYAML, "tieBreak: [points, gold, silver, bronze, teamCode]", "tieBreak: [points, platinum]", 1), ErrValidation}, {"启用破纪录却没有档位", strings.Replace(validPackYAML, "recordBonus:\n enabled: false\n mode: max\n levels:\n - { code: meet, name: 赛会纪录, points: 2 }", "recordBonus:\n enabled: true\n mode: max\n levels: []", 1), ErrValidation}, + // aggregate 的汇总口径(avg/max/min/count)没有落库列,已从类型里移除;写了必须报错而不是静默丢弃。 + {"通道 aggregate 口径已移除", strings.Replace(validPackYAML, "{ scope: team, basis: manual, profile: team_top6 }", "{ scope: team, basis: aggregate, profile: team_top6, aggregate: avg }", 1), nil}, + // 联队归属未实现,规则包里不允许出现相关配置。 + {"联队归属未实现", strings.Replace(validPackYAML, "standings:\n", "standings:\n unionAttribution: split\n", 1), nil}, } for _, tc := range cases { @@ -283,6 +287,9 @@ func TestProfileDomainConversion(t *testing.T) { if in.IndividualToTeamWeight != 1 || in.ExcludeTeamEventMedals { t.Errorf("榜单口径转换错误: %+v", in) } + if !reflect.DeepEqual(in.TieBreak, pack.Standings.TieBreak) { + t.Errorf("tie-break 未传入聚合输入: %+v", in.TieBreak) + } rb := pack.RecordBonus.ToDomain() if rb.Enabled || rb.Mode != domain.RecordBonusMax || len(rb.Levels) != 1 { @@ -292,3 +299,43 @@ func TestProfileDomainConversion(t *testing.T) { t.Errorf("破纪录配置往返不一致: %+v", got) } } + +// TestRecordBonusFromSettings 验证上层能从 settings 键值对读到破纪录档位: +// 只读 enabled/mode 而不读 levels,会让破纪录加分永远算 0。 +func TestRecordBonusFromSettings(t *testing.T) { + settings := map[string]string{ + settingRecordBonusEnabled: "true", + settingRecordBonusMode: "sum", + settingRecordBonusLevels: `[{"code":"meet","name":"赛会纪录","points":2},{"code":"city","name":"市级纪录","points":5}]`, + } + cfg := RecordBonusFromSettings(settings) + if !cfg.Enabled || cfg.Mode != domain.RecordBonusSum { + t.Fatalf("开关/口径错误: %+v", cfg) + } + if len(cfg.Levels) != 2 { + t.Fatalf("档位数 = %d,期望 2(levels 必须被解析)", len(cfg.Levels)) + } + if cfg.Levels[1].Code != "city" || cfg.Levels[1].Points != 5 { + t.Fatalf("档位内容错误: %+v", cfg.Levels[1]) + } + + t.Run("缺省值", func(t *testing.T) { + cfg := RecordBonusFromSettings(map[string]string{}) + if cfg.Enabled || cfg.Mode != domain.RecordBonusMax || len(cfg.Levels) != 0 { + t.Fatalf("缺省配置错误: %+v", cfg) + } + }) +} + +// TestRecordBonusSettingsRoundTrip 验证规则包写入 settings 后还能原样读回档位。 +func TestRecordBonusSettingsRoundTrip(t *testing.T) { + pack, err := Parse([]byte(validPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + got := RecordBonusFromSettings(settingsFromPack(pack)) + want := pack.RecordBonus.ToDomain() + if !reflect.DeepEqual(got, want) { + t.Fatalf("settings 往返后破纪录配置不一致:\n%+v\n%+v", got, want) + } +} diff --git a/server/internal/rules/settings.go b/server/internal/rules/settings.go index 0d48127..5a2f017 100644 --- a/server/internal/rules/settings.go +++ b/server/internal/rules/settings.go @@ -90,6 +90,12 @@ func recordBonusFromSettings(m map[string]string) RecordBonus { return rb } +// RecordBonusFromSettings 从赛事库 settings 键值对还原破纪录加分配置(含档位)。 +// 上层加载器必须经此函数读取 record_bonus.levels,否则档位缺失、破纪录加分恒为 0。 +func RecordBonusFromSettings(m map[string]string) domain.RecordBonusConfig { + return recordBonusFromSettings(m).ToDomain() +} + func manualAdjustmentFromSettings(m map[string]string) ManualAdjustment { return ManualAdjustment{ Enabled: settingBool(m, settingManualEnabled, false), diff --git a/server/internal/rules/types.go b/server/internal/rules/types.go index 024b947..9bc6c22 100644 --- a/server/internal/rules/types.go +++ b/server/internal/rules/types.go @@ -9,13 +9,14 @@ const ( ) // 榜单排序键;规则包里写错键名要报错,而不是静默忽略。 +// 取值来自 domain,保证领域层排序与规则包校验用的是同一套键名。 const ( - TieBreakPoints = "points" - TieBreakGold = "gold" - TieBreakSilver = "silver" - TieBreakBronze = "bronze" - TieBreakTeamCode = "teamCode" - TieBreakName = "name" + TieBreakPoints = domain.TieBreakPoints + TieBreakGold = domain.TieBreakGold + TieBreakSilver = domain.TieBreakSilver + TieBreakBronze = domain.TieBreakBronze + TieBreakTeamCode = domain.TieBreakTeamCode + TieBreakName = domain.TieBreakName ) // DefaultTieBreak 是未配置 tieBreak 时的口径:总分 → 金 → 银 → 铜 → 单位编码。 @@ -95,11 +96,11 @@ type Rule struct { } // Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。 +// basis=aggregate 固定按同一主体成绩之和汇总;汇总口径不可配置(库里没有对应列)。 type Channel struct { - Scope domain.Scope `yaml:"scope" json:"scope"` - Basis domain.Basis `yaml:"basis" json:"basis"` - Profile string `yaml:"profile" json:"profile"` - Aggregate domain.AggregateMethod `yaml:"aggregate,omitempty" json:"aggregate,omitempty"` + Scope domain.Scope `yaml:"scope" json:"scope"` + Basis domain.Basis `yaml:"basis" json:"basis"` + Profile string `yaml:"profile" json:"profile"` } // ChannelMatch 是模板的适用条件;三个条件都为空时匹配全部(校验层会拒绝这种模板)。 @@ -188,31 +189,20 @@ func (p RulePack) Canonical() RulePack { out.ChannelTemplates = make([]ChannelTemplate, len(p.ChannelTemplates)) for i, t := range p.ChannelTemplates { out.ChannelTemplates[i] = t - out.ChannelTemplates[i].Channels = canonicalChannels(t.Channels) + out.ChannelTemplates[i].Channels = append([]Channel(nil), t.Channels...) } out.EventOverrides = make([]EventOverride, len(p.EventOverrides)) for i, o := range p.EventOverrides { out.EventOverrides[i] = o if o.Channels != nil { - cs := canonicalChannels(*o.Channels) + cs := append([]Channel(nil), (*o.Channels)...) out.EventOverrides[i].Channels = &cs } } return out } -func canonicalChannels(in []Channel) []Channel { - out := make([]Channel, len(in)) - for i, ch := range in { - out[i] = ch - if ch.Basis == domain.BasisAggregate && ch.Aggregate == "" { - out[i].Aggregate = domain.AggregateSum - } - } - return out -} - func boolOrDefault(p *bool, def bool) bool { if p == nil { return def diff --git a/server/internal/rules/validate.go b/server/internal/rules/validate.go index b365afa..aa649ee 100644 --- a/server/internal/rules/validate.go +++ b/server/internal/rules/validate.go @@ -226,15 +226,6 @@ func validateChannels(l *issueList, path string, channels []Channel, profiles ma } else if !profiles[ch.Profile] { l.addf(cpath+".profile", "引用了不存在的积分模板 %q", ch.Profile) } - switch ch.Aggregate { - case "": - case domain.AggregateSum, domain.AggregateAvg, domain.AggregateMax, domain.AggregateMin, domain.AggregateCount: - if ch.Basis != domain.BasisAggregate { - l.addf(cpath+".aggregate", "只有 basis=aggregate 的通道才配置 aggregate") - } - default: - l.addf(cpath+".aggregate", "未知的汇总口径 %q", ch.Aggregate) - } // 落库有 UNIQUE(event_id, scope, basis) 约束,这里先挡住,避免导入时才炸。 key := string(ch.Scope) + "/" + string(ch.Basis) if seen[key] { diff --git a/server/main.go b/server/main.go index 88945ca..cc98f2f 100644 --- a/server/main.go +++ b/server/main.go @@ -12,6 +12,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -21,6 +22,7 @@ import ( "git.yeij.top/AskaEth/Scoreboard/server/internal/config" "git.yeij.top/AskaEth/Scoreboard/server/internal/export" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" + "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" "git.yeij.top/AskaEth/Scoreboard/server/internal/store" ) @@ -85,6 +87,15 @@ func main() { hub := realtime.NewHub(eventDB, logger) defer func() { _ = hub.Close() }() + // 可信代理:默认空,表示不采信任何代理头(见 devdocs/08 第 1 节)。 + // 反向代理部署时用 SCOREBOARD_TRUSTED_PROXIES 指定代理 IP/CIDR,逗号分隔。 + if raw := strings.TrimSpace(os.Getenv("SCOREBOARD_TRUSTED_PROXIES")); raw != "" { + if err := middleware.SetTrustedProxies(strings.Split(raw, ",")); err != nil { + logger.Error("可信代理配置非法", "err", err) + os.Exit(1) + } + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -102,14 +113,16 @@ func main() { 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, - Display: displaySender{hub: hub}, - WS: hub.Handler(), + Logger: logger, + Version: version, + System: st.DB(), + Event: eventDB, + Auth: authSvc, + DataDir: cfg.DataDir, + ProjectDir: filepath.Join(cfg.DataDir, "event_projs", *project), + Display: displaySender{hub: hub}, + Realtime: hub, + WS: hub.Handler(), }), ReadHeaderTimeout: 10 * time.Second, } diff --git a/web/src/admin/api/client.ts b/web/src/admin/api/client.ts index e4808d8..74a135a 100644 --- a/web/src/admin/api/client.ts +++ b/web/src/admin/api/client.ts @@ -97,7 +97,7 @@ async function bootstrap(): Promise { client.scoringProfiles.list(), client.standings.teams(), client.standings.athletes(), - ]) + ] as const) // GET /api/settings 是 admin-only,guest/recorder 必然 403。 // 这是权限边界而非服务不可用,不能让整页数据跟着失败,所以 settings 单独降级为 null。 return { diff --git a/web/src/admin/components/DesktopTitleBar.vue b/web/src/admin/components/DesktopTitleBar.vue index 41ceee3..4198534 100644 --- a/web/src/admin/components/DesktopTitleBar.vue +++ b/web/src/admin/components/DesktopTitleBar.vue @@ -90,11 +90,10 @@ function handleDoubleClick(event: MouseEvent): void { min-width: 0; } .desktop-titlebar-mark { - width: 8px; - height: 22px; - flex: 0 0 auto; - border-radius: 4px; - background: linear-gradient(180deg, var(--mx-primary), var(--mx-primary-active)); + width: 20px; + height: 20px; + flex: none; + border-radius: 5px; } .desktop-titlebar-title { font-size: 14px; diff --git a/web/src/admin/layouts/AdminLayout.vue b/web/src/admin/layouts/AdminLayout.vue index 70c6214..2cf116c 100644 --- a/web/src/admin/layouts/AdminLayout.vue +++ b/web/src/admin/layouts/AdminLayout.vue @@ -4,6 +4,7 @@ import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router' import MxIcon from '../components/MxIcon.vue' import ToastHost from '../components/ToastHost.vue' import type { IconName } from '../components/icons' +import { theme, toggleTheme } from '../theme' import { useAuthStore } from '../stores/auth' import { useEventStore } from '../stores/event' import { useToastStore } from '../stores/toast' @@ -65,6 +66,7 @@ const refreshing = ref(false) const currentTitle = computed(() => (typeof route.meta.title === 'string' ? route.meta.title : '管理面板')) const pendingCount = computed(() => data.events.filter((event) => event.status === 'pending').length) +const themeToggleLabel = computed(() => (theme.value === 'dark' ? '切换到浅色模式' : '切换到深色模式')) const connection = computed(() => { if (data.isMock) return { tone: 'warning', text: '示例数据', hint: data.mockLabel ?? '' } @@ -153,6 +155,16 @@ onMounted(async () => { {{ connection.text }} v{{ data.serverHealth }} +
- -
-
- 内部署名 - {{ internalSignature }} + {{ SYSTEM_EN }} · {{ versionText }}
@@ -272,6 +265,17 @@ onMounted(async () => {

本系统为内部赛事使用;MiSans 字体授权仅限内部赛事,对外分发前请替换为 OFL-1.1 字体。

+ + +
+
+ 开发者 +
+
+
+
+
@@ -340,4 +344,9 @@ kbd { font-size: 12px; color: var(--mx-t3); } +.signature { + font-size: 14px; + line-height: 1.6; + color: var(--mx-text); +} diff --git a/web/src/admin/pages/AdjustmentsPage.vue b/web/src/admin/pages/AdjustmentsPage.vue index 8767f24..0edc344 100644 --- a/web/src/admin/pages/AdjustmentsPage.vue +++ b/web/src/admin/pages/AdjustmentsPage.vue @@ -54,7 +54,15 @@ const localItems = ref([]) let nextLocalId = -1 const canWrite = computed(() => !auth.readOnly && data.canWrite) -const adjustmentEnabled = computed(() => rulePack.value?.manualAdjustment.enabled ?? false) +/** + * 人工调整开关:true / false / null(未知)。 + * + * 规则包只有 admin 能读(GET /api/rules/export 是 admin-only),录入员必然 403—— + * 不能据此断言「已关闭」,那会把已启用的功能谎报成关闭。读不到时为 null, + * 界面如实显示「未知」,最终由服务端校验决定是否受理。 + * 后端补一个 recorder 可读的只读开关端点后,把 loadRules 换成读该端点即可。 + */ +const adjustmentEnabled = computed(() => rulePack.value?.manualAdjustment.enabled ?? null) const items = computed(() => [...localItems.value, ...serverItems.value]) @@ -132,7 +140,7 @@ async function load(pageNumber = page.value): Promise { } function openCreate(): void { - if (!adjustmentEnabled.value) { + if (adjustmentEnabled.value === false) { toast.error('本期人工调整已关闭', '规则包 manualAdjustment.enabled = false') return } @@ -254,7 +262,12 @@ onMounted(() => { - @@ -265,22 +278,41 @@ onMounted(() => {
- {{ adjustmentEnabled ? '人工调整已启用' : '本期人工调整已关闭' }} - - manualAdjustment.enabled = {{ adjustmentEnabled ? 'true' : 'false' }} + {{ + adjustmentEnabled === null + ? '人工调整开关未知' + : adjustmentEnabled + ? '人工调整已启用' + : '本期人工调整已关闭' + }} + + manualAdjustment.enabled = {{ adjustmentEnabled === null ? '未知' : adjustmentEnabled ? 'true' : 'false' }}

-