fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力 - realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份 - httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表 - web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换 - console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复 - docs: 用户文档三份 + README - 主代理: 破纪录加分接线(levels 未加载导致恒 0)
This commit is contained in:
1 parent
8dae63ee70
commit
7f9701c465
61 files changed
+3844
-333
No files matched your search
@@ -33,7 +33,9 @@
|
||||
|
||||
## 文档
|
||||
|
||||
- 使用说明、操作手册:随正式版发布补充
|
||||
- [部署指南](./docs/部署指南.md):运行环境、安装与首次启动、端口与配置、大屏接线、备份恢复、故障排查
|
||||
- [使用手册](./docs/使用手册.md):角色权限、各功能页面的操作步骤、大屏端使用、常见操作问题
|
||||
- [常见问题(FAQ)](./docs/常见问题.md):账号、成绩与积分、大屏、数据备份、导出导入
|
||||
- 规则包格式:见 [rules/README.md](./rules/README.md)
|
||||
|
||||
## 许可证
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# deploy/build.bat 在打包前把 docs/使用说明.txt 暂存到这里(extraFiles 指向该目录)
|
||||
extra/*
|
||||
!extra/.gitkeep
|
||||
+24
-10
@@ -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=<id>&instance=<host>-<id>`,页序按 `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=<id>&instance=<host>-<id>`,页序按 `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:<port>/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)。
|
||||
|
||||
|
||||
+8
-2
@@ -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
|
||||
|
||||
Whitespace-only changes.
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
+71
-2
@@ -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({
|
||||
|
||||
+14
-1
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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'"
|
||||
/>
|
||||
<title>Scoreboard Console · 启动管理</title>
|
||||
<title>赛事积分榜 · 服务管理</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,8 +15,8 @@
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="scoreboard.png" alt="" width="30" height="30" />
|
||||
<div>
|
||||
<h1>Scoreboard Console</h1>
|
||||
<p class="brand-sub">赛事积分榜 · 桌面宿主</p>
|
||||
<h1>赛事积分榜 · 服务管理</h1>
|
||||
<p class="brand-sub">服务端守护 · 大屏投放</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
@@ -108,6 +108,35 @@
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>管理员账号</h2>
|
||||
<div class="actions">
|
||||
<button id="btn-reset-account" class="btn" type="button">重置账号</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">
|
||||
本机直接调用同目录的 <span class="mono">scoreboard.exe passwd</span>,无需登录验证。
|
||||
重置会作废该用户的全部登录会话,已在用的管理面板会被强制退出。
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<label class="field">
|
||||
<span>当前用户名</span>
|
||||
<input id="acct-user" class="input" type="text" value="admin" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>新用户名(留空 = 不改)</span>
|
||||
<input id="acct-new-user" class="input" type="text" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>新密码(留空 = 随机生成)</span>
|
||||
<input id="acct-password" class="input" type="password" autocomplete="new-password" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
<p id="acct-status" class="hint" hidden></p>
|
||||
<pre id="acct-output" class="log account-output" hidden></pre>
|
||||
</section>
|
||||
|
||||
<section class="card log-card">
|
||||
<div class="card-head">
|
||||
<h2>运行日志</h2>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} 个文件`)
|
||||
+31
-70
@@ -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
|
||||
+24
-2
@@ -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
|
||||
|
||||
+461
@@ -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:为什么有些页面 / 按钮看不到或不可用?**
|
||||
权限按角色控制:访客只能只读;变更历史、日志、系统设置、备份、用户管理、规则包与赛事项目包导入需要相应角色。请用对应账号登录。
|
||||
+135
@@ -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`,相对服务端工作目录解析。
|
||||
+429
@@ -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 <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 / 浏览器备选通道实测可打开页面
|
||||
@@ -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 是构造「报名项 + 成绩」的最小描述。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 积分模板的一条明细:名次 → 分值。分值一律来自配置。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:<userId> 只发给对应管理端。
|
||||
// 房间隔离:admin:<userId> 只发给对应管理端,且 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] {
|
||||
|
||||
+21
-8
@@ -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,
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ async function bootstrap(): Promise<Bootstrap> {
|
||||
client.scoringProfiles.list(),
|
||||
client.standings.teams(),
|
||||
client.standings.athletes(),
|
||||
])
|
||||
] as const)
|
||||
// GET /api/settings 是 admin-only,guest/recorder 必然 403。
|
||||
// 这是权限边界而非服务不可用,不能让整页数据跟着失败,所以 settings 单独降级为 null。
|
||||
return {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }}
|
||||
</span>
|
||||
<span v-if="data.serverHealth" class="version mono">v{{ data.serverHealth }}</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-icon"
|
||||
type="button"
|
||||
:title="themeToggleLabel"
|
||||
:aria-label="themeToggleLabel"
|
||||
:aria-pressed="theme === 'light'"
|
||||
@click="toggleTheme()"
|
||||
>
|
||||
<MxIcon name="display" :size="17" />
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-ghost mx-btn-icon"
|
||||
type="button"
|
||||
|
||||
@@ -2,7 +2,10 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import { initTheme } from './theme'
|
||||
import '@/shared/styles/base.css'
|
||||
import './styles/miuix.css'
|
||||
|
||||
initTheme()
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
@@ -108,14 +108,7 @@ onMounted(async () => {
|
||||
<img class="app-icon" src="/scoreboard.png" alt="" width="56" height="56" />
|
||||
<div style="display: flex; flex-direction: column; gap: 2px">
|
||||
<strong style="font-size: 18px">{{ SYSTEM_NAME }}</strong>
|
||||
<span style="font-size: 12px; color: var(--mx-t3)">{{ SYSTEM_EN }} · v{{ versionText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="internalSignature" class="mx-card" style="margin-bottom: 16px">
|
||||
<div class="mx-card-body" style="display: flex; flex-direction: column; gap: 4px">
|
||||
<span style="font-size: 12px; color: var(--mx-t3)">内部署名</span>
|
||||
<strong style="font-size: 15px">{{ internalSignature }}</strong>
|
||||
<span style="font-size: 12px; color: var(--mx-t3)">{{ SYSTEM_EN }} · {{ versionText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -272,6 +265,17 @@ onMounted(async () => {
|
||||
<p class="foot-hint">
|
||||
本系统为内部赛事使用;MiSans 字体授权仅限内部赛事,对外分发前请替换为 OFL-1.1 字体。
|
||||
</p>
|
||||
|
||||
<!-- 值来自构建期注入的内部配置(GET /api/system/about 的 internalSignature),不是用户输入,
|
||||
因此允许用 v-html 渲染其中的标签;为空时整张卡片不渲染。 -->
|
||||
<section v-if="internalSignature" class="mx-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">开发者</span>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<div class="signature" v-html="internalSignature"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -340,4 +344,9 @@ kbd {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.signature {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--mx-text);
|
||||
}
|
||||
</style>
|
||||
@@ -54,7 +54,15 @@ const localItems = ref<PointAdjustment[]>([])
|
||||
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<boolean | null>(() => rulePack.value?.manualAdjustment.enabled ?? null)
|
||||
|
||||
const items = computed(() => [...localItems.value, ...serverItems.value])
|
||||
|
||||
@@ -132,7 +140,7 @@ async function load(pageNumber = page.value): Promise<void> {
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
if (!adjustmentEnabled.value) {
|
||||
if (adjustmentEnabled.value === false) {
|
||||
toast.error('本期人工调整已关闭', '规则包 manualAdjustment.enabled = false')
|
||||
return
|
||||
}
|
||||
@@ -254,7 +262,12 @@ onMounted(() => {
|
||||
<button class="mx-btn mx-btn-secondary" type="button" :disabled="loading" @click="load()">
|
||||
<MxIcon name="refresh" :size="15" /> 刷新
|
||||
</button>
|
||||
<button class="mx-btn mx-btn-primary" type="button" :disabled="!adjustmentEnabled || !canWrite" @click="openCreate">
|
||||
<button
|
||||
class="mx-btn mx-btn-primary"
|
||||
type="button"
|
||||
:disabled="adjustmentEnabled === false || !canWrite"
|
||||
@click="openCreate"
|
||||
>
|
||||
<MxIcon name="adjust" :size="15" /> 新增调整
|
||||
</button>
|
||||
</template>
|
||||
@@ -265,22 +278,41 @@ onMounted(() => {
|
||||
<span class="notice-icon"><MxIcon name="warning" :size="18" /></span>
|
||||
<div>
|
||||
<div class="notice-title">
|
||||
{{ adjustmentEnabled ? '人工调整已启用' : '本期人工调整已关闭' }}
|
||||
<span class="mx-badge" :class="adjustmentEnabled ? 'mx-badge-success' : 'mx-badge-warning'">
|
||||
manualAdjustment.enabled = {{ adjustmentEnabled ? 'true' : 'false' }}
|
||||
{{
|
||||
adjustmentEnabled === null
|
||||
? '人工调整开关未知'
|
||||
: adjustmentEnabled
|
||||
? '人工调整已启用'
|
||||
: '本期人工调整已关闭'
|
||||
}}
|
||||
<span
|
||||
class="mx-badge"
|
||||
:class="
|
||||
adjustmentEnabled === true
|
||||
? 'mx-badge-success'
|
||||
: adjustmentEnabled === false
|
||||
? 'mx-badge-warning'
|
||||
: 'mx-badge-neutral'
|
||||
"
|
||||
>
|
||||
manualAdjustment.enabled = {{ adjustmentEnabled === null ? '未知' : adjustmentEnabled ? 'true' : 'false' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="notice-desc">
|
||||
<template v-if="adjustmentEnabled">
|
||||
<template v-if="adjustmentEnabled === true">
|
||||
分值可正可负,类别为处罚 / 补分 / 更正,理由必填;作废会同时作废对应积分流水。
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-else-if="adjustmentEnabled === false">
|
||||
本次运动会扣分 / 处罚完全在系统外处理,系统只负责成绩分(02 第 4 节)。
|
||||
功能与数据表已保留,以后赛事开一个开关即可启用。
|
||||
</template>
|
||||
<template v-else>
|
||||
当前角色读不到规则包(/api/rules/export 仅管理员可读),无法判断开关状态,不再按「关闭」显示;
|
||||
「新增调整」提交后由服务端校验决定是否受理。
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="ruleError !== null" class="notice-error">
|
||||
读取规则包失败:{{ ruleError }},当前按「关闭」处理。
|
||||
读取规则包失败:{{ ruleError }};开关状态未知,不再按「关闭」处理。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,7 +355,11 @@ onMounted(() => {
|
||||
<div class="mx-empty-icon"><MxIcon name="adjust" :size="26" /></div>
|
||||
<div class="mx-empty-title">没有调整记录</div>
|
||||
<div class="mx-empty-desc">
|
||||
{{ adjustmentEnabled ? '点击「新增调整」录入第一条' : '本期人工调整关闭,故为空' }}
|
||||
{{
|
||||
adjustmentEnabled === false
|
||||
? '本期人工调整关闭,故为空'
|
||||
: '点击「新增调整」录入第一条'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
|
||||
@@ -254,13 +254,14 @@ async function loadSuspected(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function bonusFor(level: RecordLevel): number {
|
||||
const definition = rulePack.value?.recordBonus.levels.find((item) => item.code === level)
|
||||
return definition?.points ?? 0
|
||||
function bonusFor(level: RecordLevel): number | null {
|
||||
if (rulePack.value === null) return null
|
||||
const definition = rulePack.value.recordBonus.levels.find((item) => item.code === level)
|
||||
return definition?.points ?? null
|
||||
}
|
||||
|
||||
function requestApprove(item: SuspectedBreak): void {
|
||||
if (!recordBonusEnabled.value) {
|
||||
if (recordBonusEnabled.value === false) {
|
||||
toast.error('本期破纪录加分已关闭', '规则包 recordBonus.enabled = false,确认后不会生成加分流水')
|
||||
return
|
||||
}
|
||||
@@ -268,6 +269,9 @@ function requestApprove(item: SuspectedBreak): void {
|
||||
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
}
|
||||
if (recordBonusEnabled.value === null) {
|
||||
toast.info('加分开关状态未知', '当前角色读不到规则包,将由服务端校验后决定是否加分')
|
||||
}
|
||||
approveTarget.value = item
|
||||
}
|
||||
|
||||
@@ -286,7 +290,12 @@ async function confirmApprove(): Promise<void> {
|
||||
await adminApi.client.records.approveBreak(created.id)
|
||||
approvedKeys.value = [...approvedKeys.value, item.key]
|
||||
approveTarget.value = null
|
||||
toast.success('已确认破纪录', `+${formatPoints(item.bonusPoints)} 分(${LEVEL_LABELS[item.level]})`)
|
||||
toast.success(
|
||||
'已确认破纪录',
|
||||
item.bonusPoints === null
|
||||
? `${LEVEL_LABELS[item.level]}纪录,加分分值以服务端结果为准`
|
||||
: `+${formatPoints(item.bonusPoints)} 分(${LEVEL_LABELS[item.level]})`,
|
||||
)
|
||||
await Promise.all([loadRecords(), data.refreshStandings()])
|
||||
} catch (caught) {
|
||||
if (isFallback(caught)) {
|
||||
@@ -343,22 +352,41 @@ watch(
|
||||
<span class="notice-icon"><MxIcon name="warning" :size="18" /></span>
|
||||
<div>
|
||||
<div class="notice-title">
|
||||
{{ recordBonusEnabled ? '破纪录加分已启用' : '本期破纪录加分已关闭' }}
|
||||
<span class="mx-badge" :class="recordBonusEnabled ? 'mx-badge-success' : 'mx-badge-warning'">
|
||||
recordBonus.enabled = {{ recordBonusEnabled ? 'true' : 'false' }}
|
||||
{{
|
||||
recordBonusEnabled === null
|
||||
? '破纪录加分开关未知'
|
||||
: recordBonusEnabled
|
||||
? '破纪录加分已启用'
|
||||
: '本期破纪录加分已关闭'
|
||||
}}
|
||||
<span
|
||||
class="mx-badge"
|
||||
:class="
|
||||
recordBonusEnabled === true
|
||||
? 'mx-badge-success'
|
||||
: recordBonusEnabled === false
|
||||
? 'mx-badge-warning'
|
||||
: 'mx-badge-neutral'
|
||||
"
|
||||
>
|
||||
recordBonus.enabled = {{ recordBonusEnabled === null ? '未知' : recordBonusEnabled ? 'true' : 'false' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="notice-desc">
|
||||
<template v-if="recordBonusEnabled">
|
||||
<template v-if="recordBonusEnabled === true">
|
||||
确认破纪录会生成加分流水并更新纪录库;加分档位 {{ bonusMode }},与成绩积分相互独立(不随权重翻倍)。
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-else-if="recordBonusEnabled === false">
|
||||
规则包 `recordBonus.enabled = false`(02 第 3 节)。疑似破纪录仍会列出,但「确认加分」不会生成加分流水;
|
||||
以后开一个开关即可启用,档位与分值已预置。
|
||||
</template>
|
||||
<template v-else>
|
||||
当前角色读不到规则包(/api/rules/export 仅管理员可读),无法判断开关状态,不再按「关闭」显示;
|
||||
疑似破纪录仍会列出,「确认加分」提交后由服务端校验决定是否生成加分流水。
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="ruleError !== null" class="notice-error">
|
||||
读取规则包失败:{{ ruleError }},当前按「关闭」处理。
|
||||
读取规则包失败:{{ ruleError }};开关状态未知,不再按「关闭」处理。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,12 +439,14 @@ watch(
|
||||
<td class="mono highlight">{{ item.resultText }}</td>
|
||||
<td class="mono">{{ item.recordText }}</td>
|
||||
<td><span class="mx-tag mx-tag-primary">{{ LEVEL_LABELS[item.level] }}</span></td>
|
||||
<td class="right mono points">+{{ formatPoints(item.bonusPoints) }}</td>
|
||||
<td class="right mono points">
|
||||
{{ item.bonusPoints === null ? '—' : `+${formatPoints(item.bonusPoints)}` }}
|
||||
</td>
|
||||
<td class="right actions">
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!recordBonusEnabled || !canWrite || approving"
|
||||
:disabled="recordBonusEnabled === false || !canWrite || approving"
|
||||
@click="requestApprove(item)"
|
||||
>
|
||||
确认加分
|
||||
@@ -427,9 +457,12 @@ watch(
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="!recordBonusEnabled && pendingBreaks.length > 0" class="hint">
|
||||
<p v-if="recordBonusEnabled === false && pendingBreaks.length > 0" class="hint">
|
||||
「确认加分」在 recordBonus 关闭时不可用,避免生成无规则的积分流水。
|
||||
</p>
|
||||
<p v-else-if="recordBonusEnabled === null && pendingBreaks.length > 0" class="hint">
|
||||
当前角色读不到规则包,加分开关状态未知;「确认加分」可提交,由服务端校验决定。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -519,7 +552,9 @@ watch(
|
||||
:message="
|
||||
approveTarget === null
|
||||
? ''
|
||||
: `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录,并生成 +${formatPoints(approveTarget.bonusPoints)} 分加分流水。`
|
||||
: approveTarget.bonusPoints === null
|
||||
? `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录;加分分值未知,以服务端结果为准。`
|
||||
: `将登记「${approveTarget.eventName} · ${approveTarget.athleteName}」的 ${LEVEL_LABELS[approveTarget.level]}纪录,并生成 +${formatPoints(approveTarget.bonusPoints)} 分加分流水。`
|
||||
"
|
||||
:details="[
|
||||
`成绩:${approveTarget?.resultText ?? ''}(原纪录 ${approveTarget?.recordText ?? ''})`,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
const STORAGE_KEY = 'scoreboard.admin.theme'
|
||||
|
||||
function readStored(): Theme {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw === 'light' || raw === 'dark') return raw
|
||||
} catch {
|
||||
// 隐私模式等场景 localStorage 会抛错,退回 admin.html 里的静态默认值
|
||||
}
|
||||
return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
/** 当前主题;MIUIx 的浅色令牌挂在 :root,深色令牌挂在 [data-theme="dark"]。 */
|
||||
export const theme = ref<Theme>(readStored())
|
||||
|
||||
export function applyTheme(next: Theme): void {
|
||||
theme.value = next
|
||||
document.documentElement.dataset.theme = next
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch {
|
||||
// 持久化失败不影响本次会话内的切换
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleTheme(): void {
|
||||
applyTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
/** 在应用挂载前调用,避免首屏先渲染静态默认值再跳变。 */
|
||||
export function initTheme(): void {
|
||||
document.documentElement.dataset.theme = theme.value
|
||||
}
|
||||
+62
-6
@@ -1,4 +1,7 @@
|
||||
import { isRecord, readString } from './internal'
|
||||
import { isRawBody, isRecord, readString } from './internal'
|
||||
import { normalizeRulePack } from './rules'
|
||||
import { isGuestRole } from './session'
|
||||
import { parseYaml } from './yaml'
|
||||
import type {
|
||||
AdjustmentInput,
|
||||
ApiErrorCode,
|
||||
@@ -204,6 +207,7 @@ interface Http {
|
||||
request<T>(path: string, options?: RequestOptions): Promise<T>
|
||||
requestVoid(path: string, options?: RequestOptions): Promise<void>
|
||||
requestJson(path: string, options?: RequestOptions): Promise<unknown>
|
||||
requestText(path: string, options?: RequestOptions): Promise<string>
|
||||
url(path: string, query?: object): string
|
||||
blob(path: string, options?: RequestOptions): Promise<Blob>
|
||||
}
|
||||
@@ -276,7 +280,21 @@ function createExportApi(http: Http): ExportApi {
|
||||
|
||||
function createRulesApi(http: Http): RulesApi {
|
||||
return {
|
||||
get: () => http.request<RulePack>('/rules/export'),
|
||||
// 导出接口返回的是裸 YAML 文本(不是 {ok,data}),必须按文本取回再解析。
|
||||
get: async () => {
|
||||
const text = await http.requestText('/rules/export')
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = parseYaml(text)
|
||||
} catch (error) {
|
||||
throw new ApiError('PARSE_FAILED', '规则包 YAML 解析失败', 0, error)
|
||||
}
|
||||
const pack = normalizeRulePack(parsed)
|
||||
if (pack === null) {
|
||||
throw new ApiError('PARSE_FAILED', '规则包格式不符(需要 scoreboard-rules v1)', 0, parsed)
|
||||
}
|
||||
return pack
|
||||
},
|
||||
preview: (pack) =>
|
||||
http.request<RulePackImportPreview>('/rules/import', {
|
||||
method: 'POST',
|
||||
@@ -343,20 +361,27 @@ export class ApiClient {
|
||||
request: (path, requestOptions) => this.request(path, requestOptions),
|
||||
requestVoid: (path, requestOptions) => this.requestVoid(path, requestOptions),
|
||||
requestJson: (path, requestOptions) => this.requestJson(path, requestOptions),
|
||||
requestText: (path, requestOptions) => this.requestText(path, requestOptions),
|
||||
url: (path, query) => this.buildUrl(path, query),
|
||||
blob: (path, requestOptions) => this.fetchBlob(path, requestOptions),
|
||||
}
|
||||
|
||||
this.auth = {
|
||||
login: (username, password) =>
|
||||
http.request<CurrentUser>('/auth/login', { method: 'POST', body: { username, password } }),
|
||||
login: async (username, password) =>
|
||||
markGuest(
|
||||
await http.request<CurrentUser>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username, password },
|
||||
}),
|
||||
),
|
||||
changePassword: (currentPassword, newPassword) =>
|
||||
http.request<{ relogin: boolean }>('/auth/password', {
|
||||
method: 'POST',
|
||||
body: { currentPassword, newPassword },
|
||||
}),
|
||||
logout: () => http.requestVoid('/auth/logout', { method: 'POST' }),
|
||||
me: () => http.request<CurrentUser>('/auth/me'),
|
||||
// 未登录时服务端回 200 + guest DTO,这里补出 guest 标记供 isGuestUser 判定。
|
||||
me: async () => markGuest(await http.request<CurrentUser>('/auth/me')),
|
||||
}
|
||||
|
||||
this.settings = {
|
||||
@@ -452,6 +477,21 @@ export class ApiClient {
|
||||
return body
|
||||
}
|
||||
|
||||
/** 规则包导出等接口返回裸文本(非 JSON),错误体仍按 {ok,error} 解析。 */
|
||||
private async requestText(path: string, options: RequestOptions = {}): Promise<string> {
|
||||
const response = await this.send(path, options)
|
||||
if (!response.ok) {
|
||||
const body = await readBody(response)
|
||||
if (response.status === 401) {
|
||||
const error = this.toApiError(body, response.status)
|
||||
this.onUnauthorized?.(error)
|
||||
throw error
|
||||
}
|
||||
throw this.toApiError(body, response.status)
|
||||
}
|
||||
return response.text()
|
||||
}
|
||||
|
||||
private async fetchBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||
const response = await this.send(path, options)
|
||||
if (!response.ok) {
|
||||
@@ -469,10 +509,16 @@ export class ApiClient {
|
||||
private async send(path: string, options: RequestOptions): Promise<Response> {
|
||||
const method: HttpMethod = options.method ?? 'GET'
|
||||
const payload = this.prepareBody(method, options.body)
|
||||
// 断言:二进制 / 表单载荷只能原样透传,一旦被归一化展开就会静默丢内容(报名表导入踩过)。
|
||||
if (isRawBody(options.body) && payload !== options.body) {
|
||||
throw new ApiError('INTERNAL', '请求体归一化破坏了 FormData/Blob 载荷', 0, options.body)
|
||||
}
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
|
||||
if (payload instanceof FormData) {
|
||||
// 交给浏览器带 multipart boundary,手动设置 Content-Type 会丢 boundary。
|
||||
} else if (isRawBody(payload)) {
|
||||
// Blob / URLSearchParams 自带类型语义,Content-Type 交给 fetch 决定。
|
||||
} else if (payload !== undefined) {
|
||||
headers['Content-Type'] = 'application/json; charset=utf-8'
|
||||
}
|
||||
@@ -499,7 +545,7 @@ export class ApiClient {
|
||||
body:
|
||||
payload === undefined
|
||||
? undefined
|
||||
: payload instanceof FormData
|
||||
: isRawBody(payload)
|
||||
? payload
|
||||
: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
@@ -517,6 +563,8 @@ export class ApiClient {
|
||||
/** 写操作注入 requestId:网络抖动重发时服务端能返回首次结果而不是重复执行。 */
|
||||
private prepareBody(method: HttpMethod, body: unknown): unknown {
|
||||
if (method === 'GET' || body === undefined) return undefined
|
||||
// FormData/Blob/URLSearchParams 不能展开,否则文件/二进制内容会被丢掉。
|
||||
if (isRawBody(body)) return body
|
||||
if (isRecord(body) && !('requestId' in body)) {
|
||||
return { ...body, requestId: this.newRequestId() }
|
||||
}
|
||||
@@ -568,6 +616,14 @@ function isScalar(value: unknown): value is string | number | boolean {
|
||||
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端未登录时 /auth/me 也回 200 + guest DTO,
|
||||
* 这里把 role 结论固化成 guest 字段,调用方用 isGuestUser(user) 判定即可。
|
||||
*/
|
||||
function markGuest(user: CurrentUser): CurrentUser {
|
||||
return { ...user, guest: isGuestRole(user.role) }
|
||||
}
|
||||
|
||||
async function readBody(response: Response): Promise<unknown> {
|
||||
if (response.status === 204) return null
|
||||
const text = await response.text()
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
export * from './types'
|
||||
export { ApiClient, ApiError, createApiClient } from './api'
|
||||
export type { ApiClientOptions, QueryParams, QueryValue } from './api'
|
||||
export { normalizeSettings, normalizeStandingRows } from './normalize'
|
||||
export { GUEST_ROLE, isGuestRole, isGuestUser } from './session'
|
||||
export { RealtimeClient } from './realtime'
|
||||
export type {
|
||||
RealtimeInfo,
|
||||
|
||||
@@ -30,7 +30,9 @@ export function normalizeSettings(raw: unknown): EventSettings {
|
||||
const nestedStandings = isRecord(source.standings) ? source.standings : null
|
||||
const isFlat = nestedEvent === null && nestedDisplay === null && nestedStandings === null
|
||||
|
||||
const name = firstString(isFlat ? source['event.name'] : undefined, nestedEvent?.name) ?? '未命名赛事'
|
||||
const name =
|
||||
emptyToNull(firstString(isFlat ? source['event.name'] : undefined, nestedEvent?.name)) ??
|
||||
'未命名赛事'
|
||||
const subtitle = emptyToNull(
|
||||
firstString(isFlat ? source['event.subtitle'] : undefined, nestedEvent?.subtitle),
|
||||
)
|
||||
@@ -52,7 +54,9 @@ export function normalizeSettings(raw: unknown): EventSettings {
|
||||
firstString(isFlat ? source['display.background'] : undefined, nestedDisplay?.background),
|
||||
),
|
||||
logo: displayLogo ?? logoPath,
|
||||
title: firstString(isFlat ? source['display.title'] : undefined, nestedDisplay?.title) ?? name,
|
||||
title:
|
||||
emptyToNull(firstString(isFlat ? source['display.title'] : undefined, nestedDisplay?.title)) ??
|
||||
name,
|
||||
subtitle:
|
||||
emptyToNull(
|
||||
firstString(isFlat ? source['display.subtitle'] : undefined, nestedDisplay?.subtitle),
|
||||
@@ -71,8 +75,9 @@ export function normalizeSettings(raw: unknown): EventSettings {
|
||||
nestedStandings?.individualToTeamWeight,
|
||||
),
|
||||
rankingMode:
|
||||
firstString(isFlat ? source['standings.mode'] : undefined, nestedStandings?.rankingMode) ??
|
||||
'points',
|
||||
emptyToNull(
|
||||
firstString(isFlat ? source['standings.mode'] : undefined, nestedStandings?.rankingMode),
|
||||
) ?? 'points',
|
||||
showIndividualBoard: firstBool(
|
||||
true,
|
||||
isFlat ? source['standings.show_individual_board'] : undefined,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 规则包归一化。
|
||||
*
|
||||
* `GET /api/rules/export` 返回裸 YAML(服务端 rules.Marshal),这里把解析出的
|
||||
* unknown 收敛成 shared 的 RulePack 契约:补齐缺省值、丢弃未知字段。
|
||||
* 校验口径与服务端 rules.Canonical 对齐,避免前端拿到的规则包和实际生效规则不一致。
|
||||
*/
|
||||
|
||||
import {
|
||||
asRecord,
|
||||
emptyToNull,
|
||||
firstBool,
|
||||
firstNumber,
|
||||
firstNumberOrNull,
|
||||
firstString,
|
||||
isRecord,
|
||||
} from './internal'
|
||||
import type {
|
||||
ChannelBasis,
|
||||
ChannelScope,
|
||||
ChannelTemplate,
|
||||
EventOverride,
|
||||
EventType,
|
||||
RecordBonusLevel,
|
||||
RecordBonusMode,
|
||||
ResultType,
|
||||
RuleChannel,
|
||||
RulePack,
|
||||
RulePackManualAdjustment,
|
||||
RulePackMeta,
|
||||
RulePackRecordBonus,
|
||||
RulePackStandings,
|
||||
RuleProfile,
|
||||
ScoringProfileRule,
|
||||
TieBreakKey,
|
||||
TieMode,
|
||||
} from './types'
|
||||
|
||||
const TIE_BREAK_KEYS: readonly TieBreakKey[] = [
|
||||
'points',
|
||||
'gold',
|
||||
'silver',
|
||||
'bronze',
|
||||
'teamCode',
|
||||
'name',
|
||||
]
|
||||
const DEFAULT_TIE_BREAK: TieBreakKey[] = ['points', 'gold', 'silver', 'bronze', 'teamCode']
|
||||
const TIE_MODES: readonly TieMode[] = ['skip', 'average', 'higher']
|
||||
const RECORD_BONUS_MODES: readonly RecordBonusMode[] = ['max', 'sum']
|
||||
const SCOPES: readonly ChannelScope[] = ['athlete', 'team']
|
||||
const BASES: readonly ChannelBasis[] = ['result', 'manual', 'aggregate']
|
||||
const EVENT_TYPES: readonly EventType[] = ['individual', 'team', 'match']
|
||||
const RESULT_TYPES: readonly ResultType[] = ['time', 'distance', 'count', 'score', 'none']
|
||||
|
||||
export function normalizeRulePack(raw: unknown): RulePack | null {
|
||||
if (!isRecord(raw)) return null
|
||||
if (firstString(raw.format) !== 'scoreboard-rules') return null
|
||||
if (firstNumber(0, raw.formatVersion) !== 1) return null
|
||||
return {
|
||||
format: 'scoreboard-rules',
|
||||
formatVersion: 1,
|
||||
meta: normalizeMeta(raw.meta),
|
||||
standings: normalizeStandings(raw.standings),
|
||||
profiles: normalizeProfiles(raw.profiles),
|
||||
recordBonus: normalizeRecordBonus(raw.recordBonus),
|
||||
manualAdjustment: normalizeManualAdjustment(raw.manualAdjustment),
|
||||
channelTemplates: normalizeTemplates(raw.channelTemplates),
|
||||
eventOverrides: normalizeOverrides(raw.eventOverrides),
|
||||
}
|
||||
}
|
||||
|
||||
function oneOf<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
|
||||
? (value as T)
|
||||
: fallback
|
||||
}
|
||||
|
||||
function optionalOneOf<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value)
|
||||
? (value as T)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function normalizeMeta(raw: unknown): RulePackMeta {
|
||||
const rec = asRecord(raw)
|
||||
return {
|
||||
code: firstString(rec.code) ?? '',
|
||||
name: firstString(rec.name) ?? '',
|
||||
version: firstString(rec.version) ?? '',
|
||||
updatedAt: firstString(rec.updatedAt) ?? '',
|
||||
author: emptyToNull(firstString(rec.author)),
|
||||
note: emptyToNull(firstString(rec.note)),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStandings(raw: unknown): RulePackStandings {
|
||||
const rec = asRecord(raw)
|
||||
const tieBreakRaw = rec.tieBreak
|
||||
const tieBreak = Array.isArray(tieBreakRaw)
|
||||
? tieBreakRaw.filter(
|
||||
(item): item is TieBreakKey =>
|
||||
typeof item === 'string' && (TIE_BREAK_KEYS as readonly string[]).includes(item),
|
||||
)
|
||||
: []
|
||||
return {
|
||||
individualToTeamWeight: firstNumber(1, rec.individualToTeamWeight),
|
||||
tieBreak: tieBreak.length > 0 ? tieBreak : [...DEFAULT_TIE_BREAK],
|
||||
medalCountsTeamEvents: firstBool(true, rec.medalCountsTeamEvents),
|
||||
showIndividualBoard: firstBool(true, rec.showIndividualBoard),
|
||||
showIndividualMedals: firstBool(true, rec.showIndividualMedals),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProfiles(raw: unknown): RuleProfile[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: RuleProfile[] = []
|
||||
for (const item of raw) {
|
||||
const rec = asRecord(item)
|
||||
const code = firstString(rec.code)
|
||||
if (code === null || code === '') continue
|
||||
out.push({
|
||||
code,
|
||||
name: firstString(rec.name) ?? code,
|
||||
awardRanks: firstNumber(0, rec.awardRanks),
|
||||
lastPlaceZero: firstBool(false, rec.lastPlaceZero),
|
||||
tieMode: oneOf(rec.tieMode, TIE_MODES, 'skip'),
|
||||
recordBonusMode: optionalOneOf(rec.recordBonusMode, RECORD_BONUS_MODES),
|
||||
rules: normalizeScoringRules(rec.rules),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeScoringRules(raw: unknown): ScoringProfileRule[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: ScoringProfileRule[] = []
|
||||
for (const item of raw) {
|
||||
const rec = asRecord(item)
|
||||
const rank = firstNumberOrNull(rec.rank)
|
||||
const points = firstNumberOrNull(rec.points)
|
||||
if (rank === null || points === null) continue
|
||||
out.push({ rank, points })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeRecordBonus(raw: unknown): RulePackRecordBonus {
|
||||
const rec = asRecord(raw)
|
||||
return {
|
||||
enabled: firstBool(false, rec.enabled),
|
||||
mode: oneOf(rec.mode, RECORD_BONUS_MODES, 'max'),
|
||||
levels: normalizeLevels(rec.levels),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLevels(raw: unknown): RecordBonusLevel[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: RecordBonusLevel[] = []
|
||||
for (const item of raw) {
|
||||
const rec = asRecord(item)
|
||||
const code = firstString(rec.code)
|
||||
if (code === null || code === '') continue
|
||||
out.push({ code, name: firstString(rec.name) ?? code, points: firstNumber(0, rec.points) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeManualAdjustment(raw: unknown): RulePackManualAdjustment {
|
||||
const rec = asRecord(raw)
|
||||
return {
|
||||
enabled: firstBool(false, rec.enabled),
|
||||
reasonRequired: firstBool(true, rec.reasonRequired),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTemplates(raw: unknown): ChannelTemplate[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((item) => {
|
||||
const rec = asRecord(item)
|
||||
const match = asRecord(rec.match)
|
||||
return {
|
||||
match: {
|
||||
eventType: optionalOneOf(match.eventType, EVENT_TYPES),
|
||||
resultType: optionalOneOf(match.resultType, RESULT_TYPES),
|
||||
sportCode: firstString(match.sportCode) ?? undefined,
|
||||
},
|
||||
channels: normalizeChannels(rec.channels),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeOverrides(raw: unknown): EventOverride[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: EventOverride[] = []
|
||||
for (const item of raw) {
|
||||
const rec = asRecord(item)
|
||||
const eventCode = firstString(rec.eventCode)
|
||||
if (eventCode === null || eventCode === '') continue
|
||||
const override: EventOverride = { eventCode }
|
||||
const name = firstString(rec.name)
|
||||
if (name !== null) override.name = name
|
||||
if (typeof rec.scored === 'boolean') override.scored = rec.scored
|
||||
const multiplier = firstNumberOrNull(rec.multiplier)
|
||||
if (multiplier !== null) override.multiplier = multiplier
|
||||
if (Array.isArray(rec.channels)) override.channels = normalizeChannels(rec.channels)
|
||||
out.push(override)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeChannels(raw: unknown): RuleChannel[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: RuleChannel[] = []
|
||||
for (const item of raw) {
|
||||
const rec = asRecord(item)
|
||||
const profile = firstString(rec.profile)
|
||||
if (profile === null || profile === '') continue
|
||||
const channel: RuleChannel = {
|
||||
scope: oneOf(rec.scope, SCOPES, 'athlete'),
|
||||
basis: oneOf(rec.basis, BASES, 'result'),
|
||||
profile,
|
||||
}
|
||||
const aggregate = firstString(rec.aggregate)
|
||||
if (aggregate !== null && aggregate !== '') channel.aggregate = aggregate
|
||||
out.push(channel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user