diff --git a/README.md b/README.md
index f55c49f..64fe59c 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
## 快速开始
1. 解压部署包
-2. 双击 `scoreboard.exe`(Linux 下执行 `./scoreboard`)
+2. 双击 `Scoreboard Console.exe`(现场推荐:守护服务端、把大屏投到扩展屏、防熄屏、开机自启)
3. 浏览器打开终端提示的地址
```
@@ -29,6 +29,8 @@
管理面板 http://<主机IP>:19540/admin
```
+> 也可以直接双击 `scoreboard.exe`(Linux 下执行 `./scoreboard`)只跑服务端,大屏投放与进程守护需自行接管。
+>
> 完全离线运行,不依赖外网,不需要安装任何运行时。
## 文档
diff --git a/console/README.md b/console/README.md
index 020853f..b72a9e4 100644
--- a/console/README.md
+++ b/console/README.md
@@ -27,7 +27,7 @@ console/
│ ├── log-bus.js # 日志环形缓冲 + 落文件 + 广播
│ └── tray-icon.js # 托盘图标(base64 内嵌,无外部资源文件)
├── scripts/
-│ └── check-syntax.js # npm run check:跨平台递归 node --check
+│ └── check-syntax.js # pnpm check:跨平台递归 node --check
├── extra/ # 打包前由 deploy/build.bat 暂存 使用说明.txt(extraFiles 指向它)
└── renderer/
├── index.html # 管理窗口(含自绘标题栏)
@@ -41,8 +41,8 @@ console/
```bash
cd console
-npm install # 首次需要下载 Electron
-npm start # 等价于 electron .
+pnpm install # 首次需要下载 Electron
+pnpm start # 等价于 electron .
```
开发时把 `scoreboard.exe` 放在以下任一位置都能被找到(`config.json` 里的相对路径会按顺序探测):
@@ -117,23 +117,23 @@ scoreboard-windows-amd64/
配置缺项自动用内置默认值,文件不存在或 JSON 语法错误时回落到全默认值,并在管理窗口顶部给出提示。
-## 打包计划(electron-builder,尚未跑通)
+## 打包(electron-builder)
`package.json` 里已写好 `build` 段:
```bash
cd console
-npm install
-npm run check # 跨平台语法检查(scripts/check-syntax.js)
-npm run dist # electron-builder --win --x64 → ../dist/console/
-npm run pack # 只出目录,不生成安装包,便于现场调试
+pnpm install
+pnpm check # 跨平台语法检查(scripts/check-syntax.js)
+pnpm dist # electron-builder --win --x64 → ../dist/console/
+pnpm pack # 只出目录,不生成安装包,便于现场调试
```
- 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.0-x64.*`)。
- `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`(exe 旁的可编辑配置,缺失时 Console 会回落到内置默认值)与 `../rules` 拷到安装目录,最终形成第 4 节里的部署包形态。
- `使用说明.txt` 是可选的:`deploy/build.bat` 在打包前依次找 `docs/使用说明.txt`、`docs/使用手册.md`,命中就暂存到 `console/extra/使用说明.txt`(`extraFiles` 会把该目录内容平铺到部署包根目录),都没有就跳过并在构建日志里注明。
-- 图标资源目录 `build/` 尚未提供,暂用 Electron 默认图标;补上 `build/icon.ico` 后自动生效。
-- **未在 Windows 构建机上实际执行过 `npm run dist`**(本机没有安装 Electron/electron-builder,也按要求未执行 npm install)。
+- 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。
+- 已在 Windows 构建机上通过 `deploy\build.bat` 实跑(含 `pnpm dist`,产出 NSIS 安装包 + zip);Linux 开发机未安装 Electron/electron-builder。
## 尚未实机验证
@@ -143,4 +143,4 @@ npm run pack # 只出目录,不生成安装包,便于现场调试
- `powerSaveBlocker` 在长时间后台下的实际效果。
- 托盘图标在 Windows 任务栏的显示效果(16px 缩放)。
- `scoreboard.exe` 真实拉起、日志转发、健康检查与崩溃重启的端到端链路。
-- electron-builder 打包与 NSIS 安装。
+- electron-builder 打包已通过 `deploy\build.bat` 实跑;NSIS 安装包在真机上的安装 / 卸载流程尚未验证。
diff --git a/console/main/account.js b/console/main/account.js
index 3737375..bb9aa79 100644
--- a/console/main/account.js
+++ b/console/main/account.js
@@ -28,8 +28,11 @@ function buildPasswdArgs({ configArg, currentUsername, newUsername, password })
]
}
+// passwd 正常是秒级完成;卡住(等待输入等)时必须自己收尾,否则 UI 重置按钮永久禁用
+const DEFAULT_TIMEOUT_MS = 30000
+
// 跑一次 passwd,实时把 stdout/stderr 交给 onOutput,结束时返回完整输出与退出码
-function runPasswd({ exePath, args, onOutput }) {
+function runPasswd({ exePath, args, onOutput, timeoutMs = DEFAULT_TIMEOUT_MS }) {
return new Promise((resolve) => {
let child
try {
@@ -46,9 +49,11 @@ function runPasswd({ exePath, args, onOutput }) {
let output = ''
let settled = false
+ let timer = null
const finish = (result) => {
if (settled) return
settled = true
+ if (timer) clearTimeout(timer)
resolve(result)
}
const onChunk = (chunk) => {
@@ -66,6 +71,17 @@ function runPasswd({ exePath, args, onOutput }) {
child.stderr.on('data', onChunk)
}
+ if (timeoutMs > 0) {
+ timer = setTimeout(() => {
+ try {
+ child.kill('SIGKILL')
+ } catch {
+ // 进程可能已经没了
+ }
+ finish({ ok: false, error: `passwd 执行超过 ${Math.ceil(timeoutMs / 1000)} 秒,已终止`, output })
+ }, timeoutMs)
+ }
+
child.on('error', (err) => finish({ ok: false, error: err.message, output }))
// 用 close 而不是 exit:等两个 stdio 都排空,输出才是完整的
child.on('close', (code, signal) => {
@@ -74,4 +90,4 @@ function runPasswd({ exePath, args, onOutput }) {
})
}
-module.exports = { FIELD_MAX, validateAccountField, buildPasswdArgs, runPasswd }
+module.exports = { validateAccountField, buildPasswdArgs, runPasswd }
diff --git a/console/main/config.js b/console/main/config.js
index ffcf932..4c13358 100644
--- a/console/main/config.js
+++ b/console/main/config.js
@@ -104,4 +104,4 @@ function resolveExecutable(exe, configPath) {
return { path: found || candidates[0], candidates: [...new Set(candidates)] }
}
-module.exports = { DEFAULTS, loadConfig, merge, resolveExecutable }
+module.exports = { loadConfig, resolveExecutable }
diff --git a/console/main/displays.js b/console/main/displays.js
index 1c424e1..c5c8b07 100644
--- a/console/main/displays.js
+++ b/console/main/displays.js
@@ -2,6 +2,7 @@
const os = require('os')
const { BrowserWindow, screen } = require('electron')
+const { healthHost } = require('./service')
const SELECTION = {
ALL_SECONDARY: 'all-secondary',
@@ -97,7 +98,9 @@ class DisplayManager {
displays.forEach((display, index) => {
const page = this.pageFor(index)
const existing = this.windows.get(display.id)
- if (existing) {
+ // 窗口可能被外部关闭/崩溃销毁而没走 destroyEntry,这里当作不存在重建,
+ // 否则 applyGeometry/loadPage 对 destroyed 窗口静默 return,现场只剩余黑屏
+ if (existing && existing.win && !existing.win.isDestroyed()) {
this.applyGeometry(existing.win, display, single)
if (existing.page !== page) {
existing.page = page
@@ -105,6 +108,10 @@ class DisplayManager {
}
return
}
+ if (existing) {
+ if (existing.reloadTimer) clearInterval(existing.reloadTimer)
+ this.windows.delete(display.id)
+ }
this.windows.set(display.id, this.createWindow(display, index, single, page))
})
@@ -160,6 +167,14 @@ class DisplayManager {
const entry = { win, page, reloadTimer: null }
+ // 窗口销毁(外部关闭/渲染进程崩溃)后必须把 entry 从 map 摘掉,
+ // 否则 layout/relayout 一直拿到已销毁窗口而静默跳过,现场只剩余黑屏
+ win.on('closed', () => {
+ if (entry.reloadTimer) clearInterval(entry.reloadTimer)
+ // 只有 map 里还是这个 entry 时才删,避免旧窗口的 closed 误删重建后的新窗口
+ if (this.windows.get(display.id) === entry) this.windows.delete(display.id)
+ })
+
// 无人值守跑 24h 以上,定时重载兜底内存与连接泄漏
const hours = Number(this.display.reloadEveryHours) || 0
if (hours > 0) {
@@ -218,7 +233,8 @@ class DisplayManager {
params.set('instance', `${os.hostname()}-${display.id}`)
// 只有单屏窗口化才画标题栏;多屏 kiosk 全屏无人值守,不能占高度
if (single) params.set('windowed', '1')
- return `http://127.0.0.1:${this.server.port}/display?${params.toString()}`
+ // 与健康检查/管理面板同一套 host 归一:0.0.0.0/:: 走回环,其余(如内网 IP)原样
+ return `http://${healthHost(this.server)}:${this.server.port}/display?${params.toString()}`
}
// 单屏模式要「行为冻结」:不跟随屏幕变化重排,所以连监听都不挂
@@ -288,4 +304,4 @@ class DisplayManager {
}
}
-module.exports = { DisplayManager, SELECTION }
+module.exports = { DisplayManager }
diff --git a/console/main/index.js b/console/main/index.js
index 257e763..672dac4 100644
--- a/console/main/index.js
+++ b/console/main/index.js
@@ -13,7 +13,7 @@ const {
const { loadConfig, resolveExecutable } = require('./config')
const { LogBus } = require('./log-bus')
-const { ServiceSupervisor } = require('./service')
+const { ServiceSupervisor, healthHost } = require('./service')
const { DisplayManager } = require('./displays')
const { fetchDisplayInstances } = require('./instances')
const { createTrayIcon } = require('./tray-icon')
@@ -36,7 +36,6 @@ function main() {
let tray = null
let powerBlockerId = null
let displayFallbackTimer = null
- let displaysEnabled = false
let quitting = false
let shutdownDone = false
let exePath = null
@@ -133,16 +132,14 @@ function main() {
}
function adminPanelUrl() {
- // 复用服务端的地址推导:host 配成 0.0.0.0 时本机也要走回环
+ // 复用服务端的地址推导:host 配成 0.0.0.0/:: 时本机也要走回环
if (service) return service.status.adminUrl
- const host = (cfg.values.server.host || '127.0.0.1') === '0.0.0.0' ? '127.0.0.1' : cfg.values.server.host
- return `http://${host || '127.0.0.1'}:${cfg.values.server.port}/admin`
+ return `http://${healthHost(cfg.values.server)}:${cfg.values.server.port}/admin`
}
function serverHost() {
if (service) return service.healthHost()
- const host = cfg.values.server.host || '127.0.0.1'
- return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
+ return healthHost(cfg.values.server)
}
// 大屏实例看板:读服务端 GET /api/display/instances 并推给管理窗口。
@@ -248,8 +245,7 @@ function main() {
}
function ensureDisplays() {
- if (displaysEnabled || !displays) return
- displaysEnabled = true
+ if (!displays || displays.enabled) return
if (displayFallbackTimer) {
clearTimeout(displayFallbackTimer)
displayFallbackTimer = null
@@ -392,7 +388,7 @@ function main() {
return true
})
ipcMain.handle('display:relayout', () => {
- if (displays && displaysEnabled) displays.relayout()
+ if (displays && displays.enabled) displays.relayout()
return statusSnapshot()
})
ipcMain.handle('app:open-admin-window', () => {
@@ -471,17 +467,16 @@ function main() {
clearTimeout(displayFallbackTimer)
displayFallbackTimer = null
}
- if (displays && displaysEnabled) {
+ if (displays && displays.enabled) {
displays.closeAll()
log.push('console', '服务已停止,大屏窗口已关闭')
}
- displaysEnabled = false
return
}
if (!status.healthy) return
// 服务端恢复后必须让大屏重新加载,否则页面还停在断线兜底画面上
- if (displaysEnabled) displays.relayout()
+ if (displays.enabled) displays.relayout()
else ensureDisplays()
})
@@ -497,8 +492,13 @@ function main() {
applyOpenAtLogin()
// 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠
- powerBlockerId = powerSaveBlocker.start('prevent-display-sleep')
- log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`)
+ try {
+ powerBlockerId = powerSaveBlocker.start('prevent-display-sleep')
+ log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`)
+ } catch (err) {
+ powerBlockerId = null
+ log.push('console', `防熄屏启用失败:${err.message}`)
+ }
if (cfg.values.server.autoStart) {
service.start()
@@ -511,7 +511,7 @@ function main() {
if (wait > 0) {
displayFallbackTimer = setTimeout(() => {
displayFallbackTimer = null
- if (!displaysEnabled) {
+ if (displays && !displays.enabled) {
log.push('console', `等待服务端就绪超过 ${Math.round(wait / 1000)} 秒,先打开大屏窗口`)
ensureDisplays()
}
diff --git a/console/main/instances.js b/console/main/instances.js
index cf8e81c..53eeb73 100644
--- a/console/main/instances.js
+++ b/console/main/instances.js
@@ -30,6 +30,8 @@ function requestJSON({ host, port, path, cookie }) {
response.on('end', () => {
resolve({ status: response.statusCode, body: Buffer.concat(chunks).toString('utf8') })
})
+ // 连接中途出错时 end 不会触发,必须自己收尾,否则每次轮询都泄漏一个 pending Promise
+ response.on('error', (err) => resolve({ error: err }))
}
)
request.on('timeout', () => request.destroy(new Error('请求超时')))
diff --git a/console/main/log-bus.js b/console/main/log-bus.js
index 9233bd0..51b898d 100644
--- a/console/main/log-bus.js
+++ b/console/main/log-bus.js
@@ -15,29 +15,61 @@ class LogBus {
// 子进程的输出是流式分片的,可能把一行拆到两次 data 事件里
this.pending = new Map()
this.stream = null
+ this.file = null
+ this.bytesWritten = 0
+ this.rotating = false
+ this.closed = false
if (file) this.openFile(file)
}
openFile(file) {
+ this.file = file
try {
fs.mkdirSync(path.dirname(file), { recursive: true })
- if (fs.existsSync(file) && fs.statSync(file).size > this.maxFileBytes) {
- // Windows 上 rename 到已存在的目标会失败,先删掉上一份轮转文件
- const rotated = `${file}.1`
- try {
- fs.rmSync(rotated, { force: true })
- } catch {
- // 删不掉就让 rename 失败,落到下面的「仅内存日志」兜底
- }
- fs.renameSync(file, rotated)
- }
+ if (fs.existsSync(file) && fs.statSync(file).size > this.maxFileBytes) this.rotateFile()
this.stream = fs.createWriteStream(file, { flags: 'a' })
+ this.bytesWritten = fs.existsSync(file) ? fs.statSync(file).size : 0
} catch (err) {
this.stream = null
this.push('console', `日志文件不可写,仅保留内存日志:${err.message}`)
}
}
+ // 把当前日志改名成 .1:Windows 上 rename 到已存在的目标会失败,先删掉旧 .1
+ rotateFile() {
+ const rotated = `${this.file}.1`
+ try {
+ fs.rmSync(rotated, { force: true })
+ } catch {
+ // 删不掉就让 rename 失败,由调用方落到「仅内存日志」兜底
+ }
+ if (fs.existsSync(this.file)) fs.renameSync(this.file, rotated)
+ }
+
+ // 运行中轮转:push 时按累计字节数触发,否则长赛事跑满一天文件会无限增长。
+ // 必须先关流把缓冲刷盘再改名(Windows 下打开中的文件不能改名),
+ // 轮转窗口内新日志只进内存缓冲,窗口很短。
+ rotate() {
+ if (!this.file || this.rotating) return
+ this.rotating = true
+ const stream = this.stream
+ this.stream = null
+ this.bytesWritten = 0
+ const reopen = () => {
+ this.rotating = false
+ if (this.closed) return
+ try {
+ this.rotateFile()
+ } catch (err) {
+ this.push('console', `日志文件轮转失败,仅保留内存日志:${err.message}`)
+ return
+ }
+ this.stream = fs.createWriteStream(this.file, { flags: 'a' })
+ }
+ if (stream) stream.end(reopen)
+ else reopen()
+ }
+
append(source, chunk) {
const text = `${this.pending.get(source) || ''}${String(chunk)}`
const parts = text.split(/\r?\n/)
@@ -52,7 +84,15 @@ class LogBus {
if (this.lines.length > this.maxLines) {
this.lines.splice(0, this.lines.length - this.maxLines)
}
- if (this.stream) this.stream.write(`${line}\n`)
+ if (this.stream) {
+ const text = `${line}\n`
+ const bytes = Buffer.byteLength(text)
+ if (this.maxFileBytes > 0 && this.bytesWritten + bytes > this.maxFileBytes) this.rotate()
+ if (this.stream) {
+ this.stream.write(text)
+ this.bytesWritten += bytes
+ }
+ }
for (const listener of this.listeners) {
try {
listener(line)
@@ -76,6 +116,7 @@ class LogBus {
}
close() {
+ this.closed = true
if (this.stream) {
this.stream.end()
this.stream = null
diff --git a/console/main/service.js b/console/main/service.js
index 4d6b6d0..0a22c52 100644
--- a/console/main/service.js
+++ b/console/main/service.js
@@ -28,6 +28,12 @@ const FALLBACK_FORCE_KILL_MS = 1500
// 连续健康这么久才认为崩溃计数可以清零
const HEALTHY_RESET_MS = 60000
+// 服务端可能监听 0.0.0.0/::,本机访问(健康检查、关闭握手、大屏、管理面板)统一走回环地址
+function healthHost(server) {
+ const host = (server && server.host) || '127.0.0.1'
+ return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
+}
+
// 服务端守护:拉起 scoreboard.exe、转发日志、健康探测、崩溃自动重启。
// 现场无人值守,所以「崩了自动拉起」比「暴露复杂控制」更重要。
class ServiceSupervisor extends EventEmitter {
@@ -51,6 +57,7 @@ class ServiceSupervisor extends EventEmitter {
this.healthTimer = null
this.restartTimer = null
this.forceKillTimer = null
+ this.stopTimer = null
// 本次进程的关闭握手 token 与握手结果,仅用于日志区分「优雅退出 / 被强杀」
this.shutdownToken = null
this.gracefulAccepted = false
@@ -75,8 +82,7 @@ class ServiceSupervisor extends EventEmitter {
// 服务端可能监听 0.0.0.0,本机探测要走回环地址
healthHost() {
- const host = this.server.host || '127.0.0.1'
- return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
+ return healthHost(this.server)
}
emitStatus() {
@@ -148,17 +154,33 @@ class ServiceSupervisor extends EventEmitter {
child.on('error', (err) => {
this.log.push('console', `服务端进程错误:${err.message}`)
- this.setState('crashed', { lastError: err.message })
+ this.handleSpawnError(child, err)
})
- child.on('exit', (code, signal) => this.handleExit(code, signal))
+ child.on('exit', (code, signal) => this.handleExit(child, code, signal))
this.setState('running')
this.startHealthCheck()
return this.status
}
- handleExit(code, signal) {
+ // spawn 失败(ENOENT/EACCES 等)时 Node 只发 error、不发 exit:
+ // 必须在这里清掉 child/pid 并调度重启,否则 start() 因 child 非空直接返回、
+ // stop() 永远等不到 exit,守护器彻底卡死。
+ handleSpawnError(child, err) {
+ if (this.child !== child) return
+ this.stopHealthCheck()
+ this.child = null
+ this.pid = null
+ this.healthy = false
+ this.healthySince = null
+ this.setState('crashed', { lastError: `服务端进程错误:${err.message}` })
+ if (!this.stopRequested) this.scheduleRestart()
+ }
+
+ handleExit(child, code, signal) {
+ // 迟到的 exit(error 分支已收尾或已换进程)不能动当前状态
+ if (this.child !== child) return
if (this.forceKillTimer) {
clearTimeout(this.forceKillTimer)
this.forceKillTimer = null
@@ -217,9 +239,35 @@ class ServiceSupervisor extends EventEmitter {
this.setState('stopping')
this.log.push('console', '正在优雅关闭服务端…')
- child.once('exit', () => resolve())
-
const graceMs = Number(this.server.shutdownTimeoutMs) || DEFAULT_SHUTDOWN_TIMEOUT_MS
+ let settled = false
+ const finish = () => {
+ if (settled) return
+ settled = true
+ if (this.stopTimer) {
+ clearTimeout(this.stopTimer)
+ this.stopTimer = null
+ }
+ resolve()
+ }
+
+ // 兜底:握手与强杀链路都可能因进程状态异常收不到 exit,
+ // 退出流程不能永远挂住,超时后无条件放行并补一刀强杀。
+ this.stopTimer = setTimeout(() => {
+ this.stopTimer = null
+ this.log.push('console', '等待服务端退出超时,停止流程强制返回')
+ if (this.child === child) {
+ this.forcedKill = true
+ try {
+ child.kill('SIGKILL')
+ } catch {
+ // 进程可能已经没了
+ }
+ }
+ finish()
+ }, SHUTDOWN_REQUEST_TIMEOUT_MS + graceMs + FALLBACK_FORCE_KILL_MS + 2000)
+
+ child.once('exit', () => finish())
this.requestShutdown().then((accepted) => {
// 请求期间进程可能已经退了,handleExit 会清掉兜底定时器
@@ -365,9 +413,10 @@ class ServiceSupervisor extends EventEmitter {
dispose() {
if (this.restartTimer) clearTimeout(this.restartTimer)
if (this.forceKillTimer) clearTimeout(this.forceKillTimer)
+ if (this.stopTimer) clearTimeout(this.stopTimer)
this.stopHealthCheck()
this.removeAllListeners()
}
}
-module.exports = { ServiceSupervisor, STATE_LABEL }
+module.exports = { ServiceSupervisor, healthHost }
diff --git a/console/renderer/index.html b/console/renderer/index.html
index bf68228..8ae1002 100644
--- a/console/renderer/index.html
+++ b/console/renderer/index.html
@@ -101,7 +101,7 @@
- 单屏时窗口化、按 F11 切换全屏;多屏时除主屏外每块屏一个 kiosk 窗口,各锁一页。
+ 单屏时窗口化、按 F11 切换全屏;多屏时除内置屏外(无内置屏时除主屏外)每块屏一个 kiosk 窗口,各锁一页。
- 尚未打开大屏窗口
diff --git a/deploy/backup.bat b/deploy/backup.bat
index 0954a4c..a77b835 100644
--- a/deploy/backup.bat
+++ b/deploy/backup.bat
@@ -1,15 +1,20 @@
@echo off
REM ============================================================================
-REM Scoreboard 一键备份(Windows)
+REM Scoreboard one-click backup (Windows)
REM
-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 Calls scoreboard.exe's backup subcommand (VACUUM INTO), so it is safe to
+REM run while the service is up and does not need an external sqlite3.exe.
+REM Output goes to data\backups\; the server keeps the newest 20 per DB
+REM (system / each event).
+REM NOTE: only .db snapshots are taken. Background images / emblems under
+REM data\event_projs\\uploads\ are NOT included; copy that
+REM directory separately if you need them on the archive.
REM
-REM 用法:deploy\backup.bat 手动备份(原因记 manual)
-REM deploy\backup.bat publish 发布前备份(原因记 publish)
+REM Usage: deploy\backup.bat manual backup (reason=manual)
+REM deploy\backup.bat publish pre-publish backup (reason=publish)
+REM
+REM Keep this file ASCII-only. cmd.exe mis-parses UTF-8 non-ASCII text inside
+REM batch files (especially inside if(...) blocks). See deploy\build.bat.
REM ============================================================================
setlocal enabledelayedexpansion
chcp 65001 >nul 2>&1
@@ -26,55 +31,56 @@ set "REASON=%REASON:\=-%"
set "REASON=%REASON:"=%"
if not exist "%DATA%" (
- echo [错误] 找不到数据目录:%DATA%
- echo 请在项目根目录执行本脚本。
+ echo [ERROR] data directory not found: %DATA%
+ echo Run this script from the project root.
exit /b 1
)
-REM ---- 定位 scoreboard.exe ----------------------------------------------------
+REM ---- locate 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。
+ echo [ERROR] scoreboard.exe not found.
+ echo Run deploy\build.bat first, or put it in the project root / PATH.
exit /b 1
)
-REM dataDir 是相对路径,服务端按当前工作目录解析,所以必须在项目根目录调用
+REM dataDir is relative and resolved against the current directory by the server,
+REM so this script must run from the project root.
if not exist "%ROOT%\config.yaml" (
- echo [错误] 找不到 %ROOT%\config.yaml,无法定位 dataDir。
+ echo [ERROR] %ROOT%\config.yaml not found, cannot locate dataDir.
exit /b 1
)
-echo === Scoreboard 备份 ===
-echo 数据目录:%DATA%
-echo 服务端 :%EXE%
-echo 原因 :%REASON%
+echo === Scoreboard backup ===
+echo Data dir : %DATA%
+echo Server : %EXE%
+echo Reason : %REASON%
echo.
set /a OK=0
set /a FAIL=0
-REM 服务端一次只备份一个赛事库(system.db 每次都会一并快照),按目录逐个调用;
-REM 部署形态通常只有一个 default 项目,这里就只跑一次。
+REM The server backs up one event DB at a time (system.db is always included),
+REM so loop over the project directories. A typical deployment has one project.
set "FOUND="
for /d %%D in ("%DATA%\event_projs\*") do (
set "FOUND=1"
- echo 备份赛事 %%~nxD
+ echo backup event %%~nxD
"%EXE%" backup -config config.yaml -project "%%~nxD" -reason "%REASON%"
if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1)
)
if not defined FOUND (
- echo 备份默认赛事
+ echo backup default event
"%EXE%" backup -config config.yaml -reason "%REASON%"
if errorlevel 1 (set /a FAIL+=1) else (set /a OK+=1)
)
echo.
-echo === 备份完成:成功 !OK! 个,失败 !FAIL! 个 ===
+echo === backup done: ok=!OK! fail=!FAIL! ===
dir /b "%DATA%\backups\*.db" 2>nul
if !FAIL! GTR 0 (
diff --git a/deploy/build.bat b/deploy/build.bat
index ebd639e..bda250f 100644
--- a/deploy/build.bat
+++ b/deploy/build.bat
@@ -31,8 +31,9 @@ cd server
if not exist ..\dist mkdir ..\dist
REM internal signature is injected from a machine-local file (gitignored, never in repo).
REM set /p returns empty under chcp 65001, so read the first line with for /f instead.
+REM PowerShell reads the file as text and strips a BOM; for /f file-read does not.
set "INTERNAL_SIGNATURE="
-if exist "..\deploy\signature.local.txt" for /f "usebackq delims= eol=" %%A in ("..\deploy\signature.local.txt") do if not defined INTERNAL_SIGNATURE set "INTERNAL_SIGNATURE=%%A"
+if exist "..\deploy\signature.local.txt" for /f "usebackq delims= eol=" %%A in (`powershell -NoProfile -Command "Get-Content -LiteralPath '..\deploy\signature.local.txt' -TotalCount 1"`) do if not defined INTERNAL_SIGNATURE set "INTERNAL_SIGNATURE=%%A"
if defined INTERNAL_SIGNATURE (
REM single quotes keep spaces inside the value intact for go's ldflags parser
go build -ldflags "-X 'git.yeij.top/AskaEth/Scoreboard/server/internal/version.InternalSignature=%INTERNAL_SIGNATURE%'" -o ..\dist\scoreboard.exe . || exit /b 1
@@ -48,7 +49,10 @@ REM win.icon is written into the PE resources by electron-builder (via rcedit);
REM it needs winCodeSign / nsis, fetched through the gh proxy above.
taskkill /im "Scoreboard Console.exe" /f >nul 2>&1
taskkill /im scoreboard.exe /f >nul 2>&1
-if not exist "rules" echo [warn] rules dir missing
+if not exist "rules" (
+ echo [ERROR] rules dir missing - electron-builder extraFiles needs it
+ exit /b 1
+)
node deploy\prepare-extra.mjs || exit /b 1
cd console
REM console\pnpm-workspace.yaml uses allowBuilds so electron's install script is
diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go
index 21dcff9..888fedf 100644
--- a/server/internal/auth/auth.go
+++ b/server/internal/auth/auth.go
@@ -31,6 +31,8 @@ const (
DefaultLoginPerMinute = 5
// DefaultWritePerMinute 写操作限流:30 次/分钟/用户。
DefaultWritePerMinute = 30
+ // MaxPasswordBytes 是 bcrypt 的输入上限;超过会被静默截断,必须显式拒绝。
+ MaxPasswordBytes = 72
)
// dummyBcryptHash 是固定 cost=12 的哈希,用于「用户不存在」时也做一次同价比较,
@@ -177,6 +179,9 @@ func (s *Service) SetPassword(ctx context.Context, username, password string, mu
if password == "" {
return fmt.Errorf("密码不能为空")
}
+ if len(password) > MaxPasswordBytes {
+ return fmt.Errorf("密码不能超过 %d 字节", MaxPasswordBytes)
+ }
hash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
if err != nil {
return fmt.Errorf("生成密码哈希: %w", err)
@@ -445,15 +450,26 @@ func randomToken(n int) (string, error) {
}
// randomPassword 生成去掉易混淆字符的随机密码。
+// 用拒绝采样消除取模偏差,避免某些字符出现概率更高。
func randomPassword(n int) (string, error) {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
+ // 只接受小于 256-(256%len) 的字节,落在尾部的字节直接丢弃重取。
+ const limit = 256 - (256 % len(alphabet))
buf := make([]byte, n)
- if _, err := rand.Read(buf); err != nil {
- return "", fmt.Errorf("生成随机密码: %w", err)
- }
- out := make([]byte, n)
- for i, b := range buf {
- out[i] = alphabet[int(b)%len(alphabet)]
+ out := make([]byte, 0, n)
+ for len(out) < n {
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("生成随机密码: %w", err)
+ }
+ for _, b := range buf {
+ if int(b) >= limit {
+ continue
+ }
+ out = append(out, alphabet[int(b)%len(alphabet)])
+ if len(out) == n {
+ break
+ }
+ }
}
return string(out), nil
}
diff --git a/server/internal/auth/auth_test.go b/server/internal/auth/auth_test.go
new file mode 100644
index 0000000..f3b4af2
--- /dev/null
+++ b/server/internal/auth/auth_test.go
@@ -0,0 +1,32 @@
+package auth
+
+import (
+ "strings"
+ "testing"
+)
+
+// 随机密码只允许使用去混淆字符集,且长度精确可控。
+func TestRandomPasswordCharsetAndLength(t *testing.T) {
+ const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
+ for i := 0; i < 50; i++ {
+ pwd, err := RandomPassword(16)
+ if err != nil {
+ t.Fatalf("生成随机密码: %v", err)
+ }
+ if len(pwd) != 16 {
+ t.Fatalf("密码长度应为 16,得到 %d", len(pwd))
+ }
+ for _, r := range pwd {
+ if !strings.ContainsRune(alphabet, r) {
+ t.Fatalf("密码含字符集外字符 %q: %s", r, pwd)
+ }
+ }
+ }
+}
+
+// bcrypt 只取前 72 字节,超出必须显式拒绝而不是静默截断。
+func TestMaxPasswordBytesMatchesBcrypt(t *testing.T) {
+ if MaxPasswordBytes != 72 {
+ t.Fatalf("MaxPasswordBytes 应为 bcrypt 的 72 字节上限,得到 %d", MaxPasswordBytes)
+ }
+}
diff --git a/server/internal/domain/helpers_test.go b/server/internal/domain/helpers_test.go
index a3e9e28..396e36d 100644
--- a/server/internal/domain/helpers_test.go
+++ b/server/internal/domain/helpers_test.go
@@ -40,7 +40,7 @@ func testCategory() EventCategory {
func individualEvent(multiplier float64) Event {
return Event{
ID: 1, Code: "E1", Name: "100米跑", EventType: EventTypeIndividual,
- Compare: CompareAsc, Scored: true, Multiplier: multiplier, Status: EventStatusPublished,
+ Compare: CompareAsc, RecordEligible: true, Scored: true, Multiplier: multiplier, Status: EventStatusPublished,
}
}
diff --git a/server/internal/domain/ledger.go b/server/internal/domain/ledger.go
index 599d083..84d96df 100644
--- a/server/internal/domain/ledger.go
+++ b/server/internal/domain/ledger.go
@@ -1,28 +1,5 @@
package domain
-import (
- "fmt"
- "strings"
-)
-
-// LedgerOpKind 增量操作类型。
-type LedgerOpKind string
-
-const (
- LedgerOpAppend LedgerOpKind = "append" // 追加一批流水
- LedgerOpVoidSource LedgerOpKind = "void_source" // 按来源作废
-)
-
-// LedgerOp 是一条可乱序应用的积分变更,用于「增量同步」场景。
-type LedgerOp struct {
- Kind LedgerOpKind
- Entries []LedgerEntry
- SourceType SourceType
- SourceID int64
- By string
- Reason string
-}
-
// TeamPoints 一个单位的分项汇总;系数只在汇总时折算,历史流水保持原始分。
type TeamPoints struct {
Total float64 // 单位总分
@@ -30,103 +7,6 @@ type TeamPoints struct {
Individual float64 // 记到个人的流水(原始分,未乘系数)
}
-// AppendLedger 追加流水。只追加、不修改已有行;id 为 0 时按当前最大 id 续号。
-func AppendLedger(existing []LedgerEntry, adds ...LedgerEntry) ([]LedgerEntry, error) {
- out := make([]LedgerEntry, len(existing), len(existing)+len(adds))
- copy(out, existing)
-
- maxID := int64(0)
- used := make(map[int64]bool, len(out)+len(adds))
- for _, e := range out {
- if e.ID > maxID {
- maxID = e.ID
- }
- used[e.ID] = true
- }
- for _, a := range adds {
- if a.ID == 0 {
- continue
- }
- if used[a.ID] {
- return nil, fmt.Errorf("%w: %d", ErrDuplicateLedgerID, a.ID)
- }
- used[a.ID] = true
- if a.ID > maxID {
- maxID = a.ID
- }
- }
- for _, a := range adds {
- if a.ID == 0 {
- maxID++
- a.ID = maxID
- }
- out = append(out, a)
- }
- return out, nil
-}
-
-// VoidLedger 作废一条流水;重复作废视为错误,避免掩盖重复撤销。
-func VoidLedger(entries []LedgerEntry, id int64, by, reason string) ([]LedgerEntry, error) {
- out := append([]LedgerEntry(nil), entries...)
- for i := range out {
- if out[i].ID != id {
- continue
- }
- if out[i].Voided {
- return nil, fmt.Errorf("%w: %d", ErrLedgerAlreadyVoided, id)
- }
- out[i].Voided = true
- out[i].VoidedBy = by
- out[i].VoidReason = reason
- return out, nil
- }
- return nil, fmt.Errorf("%w: %d", ErrLedgerNotFound, id)
-}
-
-// VoidBySource 作废某来源产生的全部未作废流水,返回新的流水集合与作废条数。
-// 找不到时是空操作:撤销发布重复执行应当幂等。
-func VoidBySource(entries []LedgerEntry, sourceType SourceType, sourceID int64, by, reason string) ([]LedgerEntry, int) {
- out := append([]LedgerEntry(nil), entries...)
- count := 0
- for i := range out {
- if out[i].Voided || out[i].SourceType != sourceType || out[i].SourceID != sourceID {
- continue
- }
- out[i].Voided = true
- out[i].VoidedBy = by
- out[i].VoidReason = reason
- count++
- }
- return out, count
-}
-
-// VoidByEvent 作废某个小项的全部未作废流水(含成绩分、破纪录加分、人工调整)。
-func VoidByEvent(entries []LedgerEntry, eventID int64, by, reason string) ([]LedgerEntry, int) {
- out := append([]LedgerEntry(nil), entries...)
- count := 0
- for i := range out {
- if out[i].Voided || out[i].EventID != eventID {
- continue
- }
- out[i].Voided = true
- out[i].VoidedBy = by
- out[i].VoidReason = reason
- count++
- }
- return out, count
-}
-
-// ActiveLedger 返回未作废的流水。
-func ActiveLedger(entries []LedgerEntry) []LedgerEntry {
- out := make([]LedgerEntry, 0, len(entries))
- for _, e := range entries {
- if !e.Voided {
- out = append(out, e)
- }
- }
- return out
-}
-
// SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。
// weight <= 0 时按 1 处理,避免零值把个人分抹掉。
func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
@@ -161,111 +41,3 @@ func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
}
return out
}
-
-// RecomputeEventLedger 重算某个小项的成绩分与破纪录加分:旧流水整项作废,再追加新流水。
-// 人工调整不参与重算,原样保留——否则每重算一次就把人工扣/加分抹掉。
-// 新旧集合一致时原样返回——重复发布同一组必须幂等,不能凭空多出流水。
-func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerEntry, by, reason string) ([]LedgerEntry, error) {
- active := make([]LedgerEntry, 0, len(existing))
- for _, e := range existing {
- if e.EventID == eventID && !e.Voided && recomputableSource(e.SourceType) {
- active = append(active, e)
- }
- }
- if sameLedgerSet(active, fresh) {
- return append([]LedgerEntry(nil), existing...), nil
- }
-
- out := append([]LedgerEntry(nil), existing...)
- for i := range out {
- if out[i].Voided || out[i].EventID != eventID || !recomputableSource(out[i].SourceType) {
- continue
- }
- out[i].Voided = true
- out[i].VoidedBy = by
- out[i].VoidReason = reason
- }
- return AppendLedger(out, fresh...)
-}
-
-// recomputableSource 判断流水来源是否属于「重算范围」:只有引擎产出的成绩分与破纪录加分,
-// 人工调整由人负责,重算不得触碰。
-func recomputableSource(t SourceType) bool {
- return t == SourceResult || t == SourceRecordBonus
-}
-
-// ApplyLedgerOps 按给定顺序应用增量操作。
-// 各操作只按来源/事件定位流水,互不依赖顺序,因此乱序到达也能收敛到同一结果。
-func ApplyLedgerOps(existing []LedgerEntry, ops []LedgerOp) ([]LedgerEntry, error) {
- out := append([]LedgerEntry(nil), existing...)
- for _, op := range ops {
- switch op.Kind {
- case LedgerOpAppend:
- next, err := AppendLedger(out, op.Entries...)
- if err != nil {
- return nil, err
- }
- out = next
- case LedgerOpVoidSource:
- next, _ := VoidBySource(out, op.SourceType, op.SourceID, op.By, op.Reason)
- out = next
- default:
- return nil, fmt.Errorf("%w: %q", ErrUnknownLedgerOp, op.Kind)
- }
- }
- return out, nil
-}
-
-// AdjustmentsToLedger 把人工调整转成积分流水;本期默认关闭,启用后分值可正可负。
-func AdjustmentsToLedger(cfg ManualAdjustmentConfig, adjustments []PointAdjustment) ([]LedgerEntry, error) {
- if !cfg.Enabled {
- return nil, ErrManualAdjustmentDisabled
- }
- out := make([]LedgerEntry, 0, len(adjustments))
- for _, a := range adjustments {
- if a.Voided {
- continue
- }
- if cfg.ReasonRequired && strings.TrimSpace(a.Reason) == "" {
- return nil, fmt.Errorf("%w: 调整 %d", ErrAdjustmentReasonRequired, a.ID)
- }
- out = append(out, LedgerEntry{
- TeamID: a.TeamID,
- EventID: a.EventID,
- SourceType: SourceAdjustment,
- SourceID: a.ID,
- Points: a.Delta,
- CreatedBy: a.CreatedBy,
- CreatedAt: a.CreatedAt,
- })
- }
- return out, nil
-}
-
-// sameLedgerSet 比较两组流水是否等价:只比「来源 + 归属 + 分值」,不比 id 与创建信息。
-func sameLedgerSet(a, b []LedgerEntry) bool {
- if len(a) != len(b) {
- return false
- }
- am := ledgerSetMap(a)
- bm := ledgerSetMap(b)
- if len(am) != len(bm) {
- return false
- }
- for k, v := range am {
- w, ok := bm[k]
- if !ok || !nearlyEqual(v, w) {
- return false
- }
- }
- return true
-}
-
-func ledgerSetMap(entries []LedgerEntry) map[string]float64 {
- out := make(map[string]float64, len(entries))
- for _, e := range entries {
- key := fmt.Sprintf("%s|%d|%d|%d|%d", e.SourceType, e.SourceID, e.ChannelID, e.TeamID, e.AthleteID)
- out[key] += e.Points
- }
- return out
-}
diff --git a/server/internal/domain/ledger_test.go b/server/internal/domain/ledger_test.go
index af41143..3ae285d 100644
--- a/server/internal/domain/ledger_test.go
+++ b/server/internal/domain/ledger_test.go
@@ -1,89 +1,6 @@
package domain
-import (
- "errors"
- "testing"
-)
-
-func TestAppendLedger(t *testing.T) {
- t.Run("自动续号", func(t *testing.T) {
- out, err := AppendLedger([]LedgerEntry{{ID: 3, Points: 1}}, LedgerEntry{Points: 2}, LedgerEntry{Points: 3})
- if err != nil {
- t.Fatalf("追加失败: %v", err)
- }
- if len(out) != 3 || out[1].ID != 4 || out[2].ID != 5 {
- t.Fatalf("id 续号错误: %+v", out)
- }
- })
- t.Run("保留已有行", func(t *testing.T) {
- base := []LedgerEntry{{ID: 1, Points: 5}}
- out, _ := AppendLedger(base, LedgerEntry{Points: 2})
- if base[0].Points != 5 || len(out) != 2 {
- t.Fatalf("原流水被改动: base=%+v out=%+v", base, out)
- }
- })
- t.Run("显式 id 冲突报错", func(t *testing.T) {
- if _, err := AppendLedger([]LedgerEntry{{ID: 1}}, LedgerEntry{ID: 1}); !errors.Is(err, ErrDuplicateLedgerID) {
- t.Fatalf("错误 = %v,期望 ErrDuplicateLedgerID", err)
- }
- })
-}
-
-func TestVoidLedger(t *testing.T) {
- base := []LedgerEntry{{ID: 1, Points: 5}, {ID: 2, Points: 3}}
-
- t.Run("作废指定流水", func(t *testing.T) {
- out, err := VoidLedger(base, 1, "admin", "录入错误")
- if err != nil {
- t.Fatalf("作废失败: %v", err)
- }
- if !out[0].Voided || out[0].VoidedBy != "admin" || out[0].VoidReason != "录入错误" {
- t.Fatalf("作废信息不全: %+v", out[0])
- }
- if out[1].Voided {
- t.Fatalf("误作废了其他流水: %+v", out[1])
- }
- if base[0].Voided {
- t.Fatal("作废不应原地修改入参")
- }
- })
- t.Run("重复作废报错", func(t *testing.T) {
- out, _ := VoidLedger(base, 1, "admin", "x")
- if _, err := VoidLedger(out, 1, "admin", "y"); !errors.Is(err, ErrLedgerAlreadyVoided) {
- t.Fatalf("错误 = %v,期望 ErrLedgerAlreadyVoided", err)
- }
- })
- t.Run("流水不存在报错", func(t *testing.T) {
- if _, err := VoidLedger(base, 404, "admin", "x"); !errors.Is(err, ErrLedgerNotFound) {
- t.Fatalf("错误 = %v,期望 ErrLedgerNotFound", err)
- }
- })
-}
-
-func TestVoidBySource(t *testing.T) {
- base := []LedgerEntry{
- {ID: 1, SourceType: SourceResult, SourceID: 100, Points: 10},
- {ID: 2, SourceType: SourceResult, SourceID: 100, Points: 8},
- {ID: 3, SourceType: SourceResult, SourceID: 200, Points: 6},
- }
- out, count := VoidBySource(base, SourceResult, 100, "admin", "撤销")
- if count != 2 {
- t.Fatalf("作废条数 = %d,期望 2", count)
- }
- if !out[0].Voided || !out[1].Voided || out[2].Voided {
- t.Fatalf("作废范围错误: %+v", out)
- }
-
- t.Run("重复作废是空操作", func(t *testing.T) {
- again, count := VoidBySource(out, SourceResult, 100, "admin", "再撤销")
- if count != 0 {
- t.Fatalf("重复作废条数 = %d,期望 0", count)
- }
- if len(again) != len(out) {
- t.Fatalf("流水条数变了: %d", len(again))
- }
- })
-}
+import "testing"
func TestSumByTeam(t *testing.T) {
ledger := []LedgerEntry{
@@ -116,161 +33,3 @@ func TestSumByAthlete(t *testing.T) {
t.Fatalf("个人汇总 = %+v,期望只有 101:12", got)
}
}
-
-func TestRecomputeEventLedger(t *testing.T) {
- old := []LedgerEntry{
- {ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10},
- {ID: 2, EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 1, Points: 8},
- }
- fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
-
- t.Run("有变化时旧流水作废并追加新流水", func(t *testing.T) {
- out, err := RecomputeEventLedger(old, 1, fresh, "judge", "更正")
- if err != nil {
- t.Fatalf("重算失败: %v", err)
- }
- if len(out) != 3 {
- t.Fatalf("流水条数 = %d,期望 3(旧2 + 新1)", len(out))
- }
- if !out[0].Voided || out[1].Voided {
- t.Fatalf("作废范围错误: %+v", out)
- }
- if !nearlyEqual(SumByTeam(out, 1)[1].Total, 14) {
- t.Fatalf("重算后总分 = %v,期望 6 + 8 = 14", SumByTeam(out, 1)[1].Total)
- }
- })
-
- t.Run("无变化时原样返回", func(t *testing.T) {
- same := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}}
- out, err := RecomputeEventLedger(old, 1, same, "judge", "重复发布")
- if err != nil {
- t.Fatalf("重算失败: %v", err)
- }
- if len(out) != len(old) {
- t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(old))
- }
- })
-
- t.Run("只影响目标小项", func(t *testing.T) {
- out, _ := RecomputeEventLedger(old, 1, fresh, "judge", "更正")
- if out[1].Voided {
- t.Fatalf("其他小项的流水被误作废: %+v", out[1])
- }
- })
-}
-
-func TestApplyLedgerOps(t *testing.T) {
- a := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}}
- b := []LedgerEntry{{EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 2, Points: 8}}
-
- t.Run("追加与作废可乱序收敛", func(t *testing.T) {
- ops := []LedgerOp{
- {Kind: LedgerOpAppend, Entries: b},
- {Kind: LedgerOpAppend, Entries: a},
- {Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: 100, By: "admin", Reason: "撤销"},
- }
- out, err := ApplyLedgerOps(nil, ops)
- if err != nil {
- t.Fatalf("应用失败: %v", err)
- }
- got := SumByTeam(out, 1)
- if !nearlyEqual(got[1].Total, 0) || !nearlyEqual(got[2].Total, 8) {
- t.Fatalf("最终状态 = %+v,期望 1:0 / 2:8", got)
- }
- })
-
- t.Run("未知操作报错", func(t *testing.T) {
- if _, err := ApplyLedgerOps(nil, []LedgerOp{{Kind: "magic"}}); !errors.Is(err, ErrUnknownLedgerOp) {
- t.Fatalf("错误 = %v,期望 ErrUnknownLedgerOp", err)
- }
- })
-}
-
-func TestAdjustmentsToLedger(t *testing.T) {
- adj := PointAdjustment{ID: 1, TeamID: 5, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规"}
-
- t.Run("未启用时报错", func(t *testing.T) {
- if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{}, []PointAdjustment{adj}); !errors.Is(err, ErrManualAdjustmentDisabled) {
- t.Fatalf("错误 = %v,期望 ErrManualAdjustmentDisabled", err)
- }
- })
- t.Run("启用后转成流水", func(t *testing.T) {
- out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{adj})
- if err != nil {
- t.Fatalf("转换失败: %v", err)
- }
- if len(out) != 1 || out[0].SourceType != SourceAdjustment || !nearlyEqual(out[0].Points, -3) {
- t.Fatalf("流水 = %+v,期望一条 -3 的 adjustment", out)
- }
- })
- t.Run("理由必填", func(t *testing.T) {
- bad := adj
- bad.Reason = " "
- if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{bad}); !errors.Is(err, ErrAdjustmentReasonRequired) {
- t.Fatalf("错误 = %v,期望 ErrAdjustmentReasonRequired", err)
- }
- })
- t.Run("已作废的调整被跳过", func(t *testing.T) {
- voided := adj
- voided.Voided = true
- out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true}, []PointAdjustment{voided})
- if err != nil || len(out) != 0 {
- t.Fatalf("流水 = %+v err=%v,期望空", out, err)
- }
- })
-}
-
-func TestActiveLedger(t *testing.T) {
- ledger := []LedgerEntry{{ID: 1, Points: 1}, {ID: 2, Points: 2, Voided: true}}
- got := ActiveLedger(ledger)
- if len(got) != 1 || got[0].ID != 1 {
- t.Fatalf("未作废流水 = %+v,期望只有 id=1", got)
- }
-}
-
-// TestRecomputeEventLedgerKeepsAdjustments 验证重算只作废 result/record_bonus,
-// 人工调整原样保留——否则每重算一次就把人工扣/加分抹掉。
-func TestRecomputeEventLedgerKeepsAdjustments(t *testing.T) {
- existing := []LedgerEntry{
- {ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10},
- {ID: 2, EventID: 1, SourceType: SourceRecordBonus, SourceID: 100, TeamID: 1, Points: 2},
- {ID: 3, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3},
- }
- fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
-
- out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "成绩更正")
- if err != nil {
- t.Fatalf("重算失败: %v", err)
- }
- for _, e := range out {
- if e.SourceType == SourceAdjustment {
- if e.Voided {
- t.Fatalf("人工调整被误作废: %+v", e)
- }
- }
- }
- if got := SumByTeam(out, 1)[1].Total; !nearlyEqual(got, 3) {
- t.Fatalf("重算后总分 = %v,期望 6(新成绩分)- 3(调整保留)= 3", got)
- }
-}
-
-// TestRecomputeEventLedgerIdempotentWithAdjustments 验证带人工调整时重复重算也幂等。
-func TestRecomputeEventLedgerIdempotentWithAdjustments(t *testing.T) {
- existing := []LedgerEntry{
- {ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6},
- {ID: 2, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3},
- }
- fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
- out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "重复发布")
- if err != nil {
- t.Fatalf("重算失败: %v", err)
- }
- if len(out) != len(existing) {
- t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(existing))
- }
- for _, e := range out {
- if e.Voided {
- t.Fatalf("幂等重算不应作废任何流水: %+v", e)
- }
- }
-}
diff --git a/server/internal/domain/matrix_test.go b/server/internal/domain/matrix_test.go
index 686f39f..a2f7431 100644
--- a/server/internal/domain/matrix_test.go
+++ b/server/internal/domain/matrix_test.go
@@ -131,66 +131,6 @@ func TestMatrix07_TiedFirstMedalCount(t *testing.T) {
}
}
-// case 8:发布后撤销 → 流水作废,总分回退。
-func TestMatrix08_UnpublishRevertsTotal(t *testing.T) {
- entries, results := fixture(lineup(3))
- es := mustScore(t, EventScoreInput{
- Event: individualEvent(1), Category: testCategory(),
- Channels: []ScoreChannel{athleteResultChannel(1)},
- Profiles: map[int64]ScoringProfile{1: individualTop6()},
- Entries: entries, Results: results,
- })
-
- published := SumByTeam(es.Ledger, 1)
- if !nearlyEqual(published[1001].Total, 10) {
- t.Fatalf("发布后总分 = %v,期望 10", published[1001].Total)
- }
-
- voided, count := VoidByEvent(es.Ledger, 1, "admin", "撤销发布")
- if count != len(es.Ledger) {
- t.Fatalf("作废条数 = %d,期望 %d", count, len(es.Ledger))
- }
- after := SumByTeam(voided, 1)
- for id, p := range after {
- if p.Total != 0 {
- t.Errorf("单位 %d 撤销后总分 = %v,期望 0", id, p.Total)
- }
- }
-}
-
-// case 9:修改已发布成绩 → 旧流水作废 + 新流水,审计可查。
-func TestMatrix09_EditPublishedResult(t *testing.T) {
- entries, results := fixture(lineup(3))
- in := EventScoreInput{
- Event: individualEvent(1), Category: testCategory(),
- Channels: []ScoreChannel{athleteResultChannel(1)},
- Profiles: map[int64]ScoringProfile{1: individualTop6()},
- Entries: entries, Results: results,
- }
- first := mustScore(t, in)
-
- // 改判:entry1 变成最后一名,entry2、entry3 各前进一位。
- entries[0].ID = 1
- results[0].ResultValue = ptr(10)
- results[1].ResultValue = ptr(1)
- results[2].ResultValue = ptr(2)
- fresh := mustScore(t, in)
-
- rebuilt, err := RecomputeEventLedger(first.Ledger, 1, fresh.Ledger, "judge", "成绩更正")
- if err != nil {
- t.Fatalf("重算流水失败: %v", err)
- }
- if len(rebuilt) <= len(first.Ledger) {
- t.Fatalf("重算后流水条数 = %d,期望大于 %d(旧作废 + 新追加)", len(rebuilt), len(first.Ledger))
- }
- for _, e := range rebuilt {
- if e.Voided && (e.VoidedBy != "judge" || e.VoidReason != "成绩更正") {
- t.Errorf("作废流水缺少审计信息: %+v", e)
- }
- }
- assertEntryPoints(t, pointsByEntry(rebuilt), []float64{6, 10, 8})
-}
-
// case 10:弃权者不占名次 → 名次顺延。
func TestMatrix10_WithdrawnDoesNotOccupyRank(t *testing.T) {
ps := lineup(5)
@@ -230,14 +170,10 @@ func TestMatrix12_ManualPenaltyReorders(t *testing.T) {
t.Fatalf("扣分前榜首 = 单位 %d,期望 1(同分时编码小者在前)", before[0].TeamID)
}
- adjustments, err := AdjustmentsToLedger(
- ManualAdjustmentConfig{Enabled: true, ReasonRequired: true},
- []PointAdjustment{{ID: 1, TeamID: 1, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规扣分"}},
- )
- if err != nil {
- t.Fatalf("人工调整转换失败: %v", err)
- }
- in.Ledger = append(in.Ledger, adjustments...)
+ // 人工扣分:单位1 -3 分(本期入口关闭,这里直接构造调整流水验证榜单口径)。
+ in.Ledger = append(in.Ledger, LedgerEntry{
+ TeamID: 1, SourceType: SourceAdjustment, SourceID: 1, Points: -3,
+ })
after := BuildTeamStandings(in)
if after[0].TeamID != 2 {
@@ -375,83 +311,3 @@ func TestMatrix16_UnionEntry(t *testing.T) {
t.Fatalf("整体归属 = %v,期望 1001:10 / 1002:0", byTeam)
}
}
-
-// case 17:重复发布同一组 → 幂等,不产生重复流水。
-func TestMatrix17_RepublishIdempotent(t *testing.T) {
- entries, results := fixture(lineup(3))
- in := EventScoreInput{
- Event: individualEvent(1), Category: testCategory(),
- Channels: []ScoreChannel{athleteResultChannel(1)},
- Profiles: map[int64]ScoringProfile{1: individualTop6()},
- Entries: entries, Results: results,
- }
- first := mustScore(t, in)
- second := mustScore(t, in)
-
- rebuilt, err := RecomputeEventLedger(first.Ledger, 1, second.Ledger, "admin", "重复发布")
- if err != nil {
- t.Fatalf("重算流水失败: %v", err)
- }
- if len(rebuilt) != len(first.Ledger) {
- t.Fatalf("重复发布后流水条数 = %d,期望 %d(幂等)", len(rebuilt), len(first.Ledger))
- }
- before := SumByTeam(first.Ledger, 1)
- after := SumByTeam(rebuilt, 1)
- for id, p := range before {
- if !nearlyEqual(after[id].Total, p.Total) {
- t.Errorf("单位 %d 总分 = %v,期望 %v", id, after[id].Total, p.Total)
- }
- }
-}
-
-// case 18:乱序应用增量 → 最终状态一致。
-func TestMatrix18_OutOfOrderIncrements(t *testing.T) {
- makeScore := func(eventID int64) []LedgerEntry {
- entries, results := fixture([]participant{{entryID: eventID, athleteID: eventID, teamID: eventID, value: 1}})
- entries[0].EventID = eventID
- results[0].EventID = eventID
- return mustScore(t, EventScoreInput{
- Event: Event{ID: eventID, Compare: CompareAsc, Scored: true, Multiplier: 1},
- Category: testCategory(),
- Channels: []ScoreChannel{athleteResultChannel(1)},
- Profiles: map[int64]ScoringProfile{1: individualTop6()},
- Entries: entries, Results: results,
- }).Ledger
- }
- a := makeScore(11)
- b := makeScore(22)
-
- appendA := LedgerOp{Kind: LedgerOpAppend, Entries: a}
- appendB := LedgerOp{Kind: LedgerOpAppend, Entries: b}
- voidA := LedgerOp{Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: a[0].SourceID, By: "admin", Reason: "撤销"}
-
- orders := map[string][]LedgerOp{
- "A→B→撤销A": {appendA, appendB, voidA},
- "B→A→撤销A": {appendB, appendA, voidA},
- "A→撤销A→B": {appendA, voidA, appendB},
- }
-
- var baseline map[int64]TeamPoints
- for name, ops := range orders {
- out, err := ApplyLedgerOps(nil, ops)
- if err != nil {
- t.Fatalf("%s: %v", name, err)
- }
- got := SumByTeam(out, 1)
- if baseline == nil {
- baseline = got
- continue
- }
- if len(got) != len(baseline) {
- t.Fatalf("%s: 单位数 %d,期望 %d", name, len(got), len(baseline))
- }
- for id, p := range baseline {
- if !nearlyEqual(got[id].Total, p.Total) {
- t.Errorf("%s: 单位 %d 总分 = %v,期望 %v", name, id, got[id].Total, p.Total)
- }
- }
- }
- if !nearlyEqual(baseline[11].Total, 0) || !nearlyEqual(baseline[22].Total, 10) {
- t.Fatalf("最终状态 = %+v,期望 A 作废 0 分、B 保留 10 分", baseline)
- }
-}
diff --git a/server/internal/domain/records.go b/server/internal/domain/records.go
index b3750cf..04a58f2 100644
--- a/server/internal/domain/records.go
+++ b/server/internal/domain/records.go
@@ -16,6 +16,12 @@ func (c RecordBonusConfig) levelPoints(code string) (float64, bool) {
// 同一人同时破多级时,max 取最高档、sum 累加,口径由配置决定。
// 未配置的档位视为无加分,避免脏 level 凭空加分。
func RecordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak) []RecordBonus {
+ return recordBonusPoints(cfg, breaks, nil)
+}
+
+// recordBonusPoints 是聚合实现;modeByEntry 给出每个报名项命中通道所属模板的口径,
+// 缺失时退回全局 cfg.Mode,保证直接调用 RecordBonusPoints 的旧口径不变。
+func recordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak, modeByEntry map[int64]RecordBonusMode) []RecordBonus {
if !cfg.Enabled || len(breaks) == 0 {
return nil
}
@@ -65,7 +71,11 @@ func RecordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak) []RecordBonu
TeamID: g.first.TeamID,
Levels: g.levels,
}
- if cfg.Mode == RecordBonusSum {
+ mode := cfg.Mode
+ if m, ok := modeByEntry[entryID]; ok {
+ mode = m
+ }
+ if mode == RecordBonusSum {
for _, p := range g.points {
bonus.Points += p
}
@@ -89,6 +99,10 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
if in.RecordBonus == nil || !in.RecordBonus.Enabled || len(in.RecordBreaks) == 0 {
return nil, nil
}
+ // 只对「可破纪录」的项目加分;登记接口另有一层校验,这里是引擎侧的最后防线。
+ if !in.Event.RecordEligible {
+ return nil, nil
+ }
breaks := make([]RecordBreak, 0, len(in.RecordBreaks))
for _, b := range in.RecordBreaks {
@@ -97,7 +111,16 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
}
breaks = append(breaks, b)
}
- bonuses := RecordBonusPoints(*in.RecordBonus, breaks)
+ modes := make(map[int64]RecordBonusMode, len(in.Entries))
+ for _, e := range in.Entries {
+ if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
+ continue
+ }
+ if m, ok := recordBonusModeFor(in, e); ok {
+ modes[e.ID] = m
+ }
+ }
+ bonuses := recordBonusPoints(*in.RecordBonus, breaks, modes)
if len(bonuses) == 0 {
return nil, nil
}
@@ -138,3 +161,31 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
}
return out, nil
}
+
+// recordBonusModeFor 取报名项「命中通道」所属模板的破纪录口径。
+// 命中通道按 channelBefore 的优先级挑选(athlete → team → 通道 id 升序),
+// 与奖牌去重同一口径;没有可命中通道或模板缺失时返回 false,由调用方退回全局口径。
+func recordBonusModeFor(in EventScoreInput, e Entry) (RecordBonusMode, bool) {
+ var (
+ picked Scope
+ pickedID int64
+ profileID int64
+ found bool
+ )
+ for _, ch := range in.Channels {
+ if ch.Scope == ScopeAthlete && e.AthleteID == 0 {
+ continue
+ }
+ if !found || channelBefore(ch.Scope, ch.ID, picked, pickedID) {
+ picked, pickedID, profileID, found = ch.Scope, ch.ID, ch.ProfileID, true
+ }
+ }
+ if !found {
+ return "", false
+ }
+ p, ok := in.Profiles[profileID]
+ if !ok {
+ return "", false
+ }
+ return p.bonusMode(), true
+}
diff --git a/server/internal/domain/records_test.go b/server/internal/domain/records_test.go
index eaee158..2d7a5a9 100644
--- a/server/internal/domain/records_test.go
+++ b/server/internal/domain/records_test.go
@@ -194,6 +194,123 @@ func TestRecordBonusLedger(t *testing.T) {
})
}
+// recordBonusPointsOf 统计一次计分产出的破纪录加分总和。
+func recordBonusPointsOf(t *testing.T, in EventScoreInput) float64 {
+ t.Helper()
+ var got float64
+ for _, e := range mustScore(t, in).Ledger {
+ if e.SourceType == SourceRecordBonus {
+ got += e.Points
+ }
+ }
+ return got
+}
+
+// TestRecordBonusRequiresEligibleEvent 验证只有「可破纪录」的项目才产生加分(P1-5)。
+func TestRecordBonusRequiresEligibleEvent(t *testing.T) {
+ entries, results := fixture(lineup(1))
+ cfg := bonusConfig(RecordBonusMax)
+ in := 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,
+ }},
+ }
+
+ in.Event.RecordEligible = false
+ if got := recordBonusPointsOf(t, in); got != 0 {
+ t.Fatalf("不可破纪录项目的加分 = %v,期望 0", got)
+ }
+
+ in.Event.RecordEligible = true
+ if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
+ t.Fatalf("可破纪录项目的加分 = %v,期望 2", got)
+ }
+}
+
+// TestRecordBonusUsesHitChannelProfileMode 验证 max/sum 口径取「命中通道」所属模板的配置,
+// 而不是全局 cfg.Mode(P1-3)。
+func TestRecordBonusUsesHitChannelProfileMode(t *testing.T) {
+ entries, results := fixture(lineup(1))
+ athleteProfile := individualTop6()
+ athleteProfile.RecordBonusMode = RecordBonusSum
+ teamProfile := teamTop6()
+ teamProfile.RecordBonusMode = RecordBonusMax
+ cfg := bonusConfig(RecordBonusMax)
+ in := EventScoreInput{
+ Event: individualEvent(1), Category: testCategory(),
+ // 团体通道排在前面、通道 id 更小,个人通道仍应优先命中。
+ Channels: []ScoreChannel{
+ {ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
+ {ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
+ },
+ Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
+ 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: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
+ },
+ }
+ if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
+ t.Fatalf("加分 = %v,期望 7(个人通道 sum:2+5)", got)
+ }
+}
+
+// TestRecordBonusProfileModeOverridesGlobal 验证模板口径优先于全局口径。
+func TestRecordBonusProfileModeOverridesGlobal(t *testing.T) {
+ entries, results := fixture(lineup(1))
+ profile := individualTop6()
+ profile.RecordBonusMode = RecordBonusMax
+ cfg := bonusConfig(RecordBonusSum)
+ in := EventScoreInput{
+ Event: individualEvent(1), Category: testCategory(),
+ Channels: []ScoreChannel{athleteResultChannel(1)},
+ Profiles: map[int64]ScoringProfile{1: profile},
+ 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: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
+ },
+ }
+ if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 5) {
+ t.Fatalf("加分 = %v,期望 5(模板 max 优先于全局 sum)", got)
+ }
+}
+
+// TestRecordBonusTeamEntryUsesTeamChannelMode 验证没有运动员的报名项命中团体通道的模板口径。
+func TestRecordBonusTeamEntryUsesTeamChannelMode(t *testing.T) {
+ entries, results := fixture([]participant{{entryID: 1, teamID: 1001, value: 1, rank: 1}})
+ athleteProfile := individualTop6()
+ athleteProfile.RecordBonusMode = RecordBonusMax
+ teamProfile := teamTop6()
+ teamProfile.RecordBonusMode = RecordBonusSum
+ cfg := bonusConfig(RecordBonusMax)
+ in := EventScoreInput{
+ Event: teamEvent(1), Category: testCategory(),
+ Channels: []ScoreChannel{
+ {ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
+ {ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
+ },
+ Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
+ Entries: entries, Results: results,
+ RecordBonus: &cfg,
+ RecordBreaks: []RecordBreak{
+ {ID: 1, EventID: 1, EntryID: 1, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
+ {ID: 2, EventID: 1, EntryID: 1, TeamID: 1001, Level: "city", State: RecordBreakApproved},
+ },
+ }
+ if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
+ t.Fatalf("加分 = %v,期望 7(团体通道 sum:2+5)", got)
+ }
+}
+
// TestRecordBonusLedgerFiltersByCategory 验证按「小项+组别」过滤:
// 上层逐组别调用 ScoreEvent,别的组别的破纪录登记不能在本组别重复记一次加分。
func TestRecordBonusLedgerFiltersByCategory(t *testing.T) {
diff --git a/server/internal/domain/standings.go b/server/internal/domain/standings.go
index a3c2c64..84ba86b 100644
--- a/server/internal/domain/standings.go
+++ b/server/internal/domain/standings.go
@@ -204,14 +204,77 @@ func buildTeamRow(t Team, p TeamPoints, m medalTally) StandingsRow {
}
}
+// channelBefore 是计分通道的优先级比较(口径固定,必须确定性):
+// 个人通道优先于团体通道;同为个人/团体时取通道 id 小者。
+// 奖牌去重与破纪录口径共用这套优先级,保证同一份输入每次选出同一个通道。
+func channelBefore(scopeA Scope, idA int64, scopeB Scope, idB int64) bool {
+ if scopeA != scopeB {
+ return scopeA == ScopeAthlete
+ }
+ return idA < idB
+}
+
+// medalKey 是奖牌去重键:同一报名项在同一小项、同一组别只记一枚奖牌。
+// 聚合通道不对应单条报名项(EntryID=0),改用名次主体(个人/单位)去重。
+type medalKey struct {
+ eventID int64
+ categoryID int64
+ entryID int64
+ scope Scope
+ subject int64
+ unique int // 退化数据(EntryID 与主体都为空)没有可识别身份,按输入顺序各占一个键
+}
+
+// medalKeyOf 计算一条名次明细的去重键;index 用于给无法识别主体的退化数据兜底。
+func medalKeyOf(rr RankedResult, index int) medalKey {
+ k := medalKey{eventID: rr.EventID, categoryID: rr.CategoryID, entryID: rr.EntryID}
+ if k.entryID != 0 {
+ return k
+ }
+ k.scope = rr.Scope
+ if rr.Scope == ScopeAthlete {
+ k.subject = rr.AthleteID
+ } else {
+ k.subject = rr.TeamID
+ }
+ if k.subject == 0 {
+ k.unique = index + 1
+ }
+ return k
+}
+
+// pickMedalChannels 把按通道展开的名次明细去重成「每个报名项一条」:
+// 只保留优先级最高的通道名次,否则双通道项目一条成绩会拿两枚奖牌。
+func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
+ best := make(map[medalKey]RankedResult, len(ranked))
+ order := make([]medalKey, 0, len(ranked))
+ for i, rr := range ranked {
+ if excludeTeam && rr.Scope == ScopeTeam {
+ continue
+ }
+ k := medalKeyOf(rr, i)
+ cur, ok := best[k]
+ if !ok {
+ best[k] = rr
+ order = append(order, k)
+ continue
+ }
+ if channelBefore(rr.Scope, rr.ChannelID, cur.Scope, cur.ChannelID) {
+ best[k] = rr
+ }
+ }
+ out := make([]RankedResult, 0, len(order))
+ for _, k := range order {
+ out = append(out, best[k])
+ }
+ return out
+}
+
// teamMedals 统计团体奖牌榜。联队拆分时两个单位各记一枚:
// 联合组队拿到的名次对每个成员单位都是「本单位取得的名次」。
func teamMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally)
- for _, rr := range in.Ranked {
- if rr.Scope == ScopeTeam && in.ExcludeTeamEventMedals {
- continue
- }
+ for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals) {
medal := rankMedal(rr.Rank)
if medal == 0 {
continue
@@ -235,7 +298,7 @@ func teamMedals(in StandingsInput) map[int64]medalTally {
// athleteMedals 只统计个人项目(scope=athlete)的奖牌。
func athleteMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally)
- for _, rr := range in.Ranked {
+ for _, rr := range pickMedalChannels(in.Ranked, false) {
if rr.Scope != ScopeAthlete || rr.AthleteID == 0 {
continue
}
diff --git a/server/internal/domain/standings_test.go b/server/internal/domain/standings_test.go
index 1f86573..9e05b82 100644
--- a/server/internal/domain/standings_test.go
+++ b/server/internal/domain/standings_test.go
@@ -267,6 +267,81 @@ func TestTeamStandingsTieBreakConfig(t *testing.T) {
}
}
+// TestMedalDedupAcrossChannels 验证多通道项目一条成绩只记一枚奖牌(P0-2):
+// buildRanked 会按通道把一条成绩展开成多条名次明细,去重后只保留优先级最高的通道名次。
+func TestMedalDedupAcrossChannels(t *testing.T) {
+ ranked := func(scope Scope, channelID int64, rank int) RankedResult {
+ return RankedResult{
+ EventID: 1, CategoryID: 1, EntryID: 7, ChannelID: channelID, Scope: scope,
+ TeamID: 1, AthleteID: 101, Rank: rank,
+ Shares: []TeamShare{{TeamID: 1, Weight: 1}},
+ }
+ }
+ teams := testTeams(1)
+ athletes := []Athlete{{ID: 101, Name: "甲", TeamID: 1}}
+
+ t.Run("双通道一条成绩只记一枚", func(t *testing.T) {
+ in := StandingsInput{Teams: teams, Athletes: athletes, Ranked: []RankedResult{
+ ranked(ScopeTeam, 2, 1), ranked(ScopeAthlete, 1, 1),
+ }}
+ rows := BuildTeamStandings(in)
+ if rows[0].Gold != 1 || rows[0].Medals != 1 {
+ t.Fatalf("单位奖牌 = %+v,期望只记 1 金", rows[0])
+ }
+ arows := BuildAthleteStandings(in)
+ if len(arows) != 1 || arows[0].Gold != 1 {
+ t.Fatalf("个人奖牌 = %+v,期望只记 1 金", arows)
+ }
+ })
+
+ t.Run("个人通道优先于团体通道", func(t *testing.T) {
+ in := StandingsInput{Teams: teams, Ranked: []RankedResult{
+ ranked(ScopeTeam, 1, 1), // 团体通道名次更靠前,但优先级更低
+ ranked(ScopeAthlete, 9, 2), // 个人通道优先,取银牌
+ }}
+ rows := BuildTeamStandings(in)
+ if rows[0].Gold != 0 || rows[0].Silver != 1 {
+ t.Fatalf("单位奖牌 = %+v,期望取个人通道的 1 银", rows[0])
+ }
+ })
+
+ t.Run("同 scope 取通道 id 小者", func(t *testing.T) {
+ in := StandingsInput{Teams: teams, Ranked: []RankedResult{
+ ranked(ScopeAthlete, 5, 3),
+ ranked(ScopeAthlete, 2, 1),
+ }}
+ rows := BuildTeamStandings(in)
+ if rows[0].Gold != 1 || rows[0].Bronze != 0 {
+ t.Fatalf("单位奖牌 = %+v,期望取通道 id=2 的 1 金", rows[0])
+ }
+ })
+
+ t.Run("不同报名项各记一枚", func(t *testing.T) {
+ other := ranked(ScopeAthlete, 1, 1)
+ other.EntryID = 8
+ other.AthleteID = 102
+ rows := BuildTeamStandings(StandingsInput{Teams: teams, Ranked: []RankedResult{
+ ranked(ScopeAthlete, 1, 1), other,
+ }})
+ if rows[0].Gold != 2 {
+ t.Fatalf("单位金牌 = %d,期望 2(两条成绩各一枚)", rows[0].Gold)
+ }
+ })
+
+ t.Run("聚合通道按主体去重", func(t *testing.T) {
+ a := RankedResult{
+ EventID: 1, CategoryID: 1, ChannelID: 1, Scope: ScopeAthlete, AthleteID: 101,
+ TeamID: 1, Rank: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}},
+ }
+ b := a
+ b.ChannelID = 2
+ rows := BuildTeamStandings(StandingsInput{Teams: teams, Ranked: []RankedResult{a, b}})
+ if rows[0].Gold != 1 {
+ t.Fatalf("单位金牌 = %d,期望 1(同一主体只记一枚)", rows[0].Gold)
+ }
+ })
+}
+
// TestAthleteStandingsTieBreakConfig 验证个人榜同样按配置排序。
func TestAthleteStandingsTieBreakConfig(t *testing.T) {
ledger := []LedgerEntry{ledgerForAthlete(1001, 1, 10), ledgerForAthlete(1002, 2, 10)}
diff --git a/server/internal/dto/events.go b/server/internal/dto/events.go
new file mode 100644
index 0000000..5dea382
--- /dev/null
+++ b/server/internal/dto/events.go
@@ -0,0 +1,48 @@
+package dto
+
+import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
+
+// Event 是 GET /api/events 与 snapshot.events 的线上结构(列表口径,不含组别与通道)。
+//
+// 与 REST 的 eventDTO 逐字段对齐:scheduledAt/venue/remark 可空、sortOrder 必填,
+// 不能沿用 domain.Event 的 omitempty,否则前端会同时遇到「字段缺失」和「null」。
+type Event struct {
+ ID int64 `json:"id"`
+ SportID int64 `json:"sportId"`
+ Code string `json:"code"`
+ Name string `json:"name"`
+ EventType string `json:"eventType"`
+ ResultMode string `json:"resultMode"`
+ ResultType string `json:"resultType"`
+ Compare string `json:"compare"`
+ RecordEligible bool `json:"recordEligible"`
+ Scored bool `json:"scored"`
+ Multiplier float64 `json:"multiplier"`
+ Status string `json:"status"`
+ ScheduledAt *string `json:"scheduledAt"`
+ Venue *string `json:"venue"`
+ SortOrder int `json:"sortOrder"`
+ Remark *string `json:"remark"`
+}
+
+// EventFromDomain 把领域小项转换成线上结构。
+func EventFromDomain(e domain.Event) Event {
+ return Event{
+ ID: e.ID,
+ SportID: e.SportID,
+ Code: e.Code,
+ Name: e.Name,
+ EventType: string(e.EventType),
+ ResultMode: string(e.ResultMode),
+ ResultType: string(e.ResultType),
+ Compare: string(e.Compare),
+ RecordEligible: e.RecordEligible,
+ Scored: e.Scored,
+ Multiplier: e.Multiplier,
+ Status: string(e.Status),
+ ScheduledAt: nilIfEmpty(e.ScheduledAt),
+ Venue: nilIfEmpty(e.Venue),
+ SortOrder: e.SortOrder,
+ Remark: nilIfEmpty(e.Remark),
+ }
+}
diff --git a/server/internal/dto/settings.go b/server/internal/dto/settings.go
index 6df7b6f..4960cab 100644
--- a/server/internal/dto/settings.go
+++ b/server/internal/dto/settings.go
@@ -46,6 +46,23 @@ type EventSettingsStandings struct {
ShowIndividualMedals bool `json:"showIndividualMedals"`
}
+// WithoutAdminFields 返回非 admin 会话可见的配置子集。
+//
+// GET /api/settings 是 admin-only,而 /ws 快照对所有角色下发;guest/recorder
+// 只需要渲染大屏的标题、主题与榜单开关,默认积分模板、审核开关与个人积分折算
+// 系数属于系统配置,必须置空/清零后再上线。
+func (s EventSettings) WithoutAdminFields() EventSettings {
+ return EventSettings{
+ Event: s.Event,
+ Display: s.Display,
+ Standings: EventSettingsStandings{
+ RankingMode: s.Standings.RankingMode,
+ ShowIndividualBoard: s.Standings.ShowIndividualBoard,
+ ShowIndividualMedals: s.Standings.ShowIndividualMedals,
+ },
+ }
+}
+
// RuleSwitches 是 GET /api/rules/switches 的线上结构:只暴露规则包里的布尔开关,
// 不包含任何规则内容、模板明细或文件路径,供录入员判断相关功能是否启用。
type RuleSwitches struct {
diff --git a/server/internal/dto/settings_test.go b/server/internal/dto/settings_test.go
new file mode 100644
index 0000000..0480751
--- /dev/null
+++ b/server/internal/dto/settings_test.go
@@ -0,0 +1,94 @@
+package dto
+
+import (
+ "encoding/json"
+ "testing"
+
+ "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
+)
+
+// 非 admin 会话只能看到大屏渲染所需字段:admin-only 字段必须清零,
+// 且序列化后仍是显式的 null / 0,不能变成字段缺失。
+func TestEventSettingsWithoutAdminFields(t *testing.T) {
+ profileID := int64(3)
+ full := EventSettings{
+ Event: EventSettingsEvent{Name: "运动会"},
+ Display: EventSettingsDisplay{Title: "大屏", RotateSecond: 15},
+ Standings: EventSettingsStandings{IndividualToTeamWeight: 0.5, RankingMode: "points", ShowIndividualBoard: true, ShowIndividualMedals: false},
+ DefaultProfileID: &profileID,
+ RequireApproval: true,
+ }
+
+ got := full.WithoutAdminFields()
+ if got.DefaultProfileID != nil {
+ t.Fatalf("defaultProfileId 应置空,得到 %v", got.DefaultProfileID)
+ }
+ if got.RequireApproval {
+ t.Fatalf("requireApproval 应清零")
+ }
+ if got.Standings.IndividualToTeamWeight != 0 {
+ t.Fatalf("individualToTeamWeight 应清零,得到 %v", got.Standings.IndividualToTeamWeight)
+ }
+ if got.Event.Name != "运动会" || got.Display.Title != "大屏" || got.Display.RotateSecond != 15 {
+ t.Fatalf("event/display 不应被裁剪: %+v", got)
+ }
+ if got.Standings.RankingMode != "points" || !got.Standings.ShowIndividualBoard || got.Standings.ShowIndividualMedals {
+ t.Fatalf("榜单开关不应被裁剪: %+v", got.Standings)
+ }
+
+ raw, err := json.Marshal(got)
+ if err != nil {
+ t.Fatalf("序列化: %v", err)
+ }
+ var top map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &top); err != nil {
+ t.Fatalf("解析: %v", err)
+ }
+ if string(top["defaultProfileId"]) != "null" {
+ t.Fatalf("defaultProfileId 应显式序列化为 null: %s", raw)
+ }
+ if string(top["requireApproval"]) != "false" {
+ t.Fatalf("requireApproval 应显式序列化为 false: %s", raw)
+ }
+ var standings map[string]json.RawMessage
+ if err := json.Unmarshal(top["standings"], &standings); err != nil {
+ t.Fatalf("解析 standings: %v", err)
+ }
+ if string(standings["individualToTeamWeight"]) != "0" {
+ t.Fatalf("individualToTeamWeight 应显式序列化为 0: %s", raw)
+ }
+}
+
+// 单位/小项的 DTO 必须把可空列写成显式 null,不能沿用 domain 的 omitempty。
+func TestTeamAndEventFromDomainNullableFields(t *testing.T) {
+ teamRaw, err := json.Marshal(TeamFromDomain(domain.Team{ID: 1, Code: "T1", Name: "第一单位"}))
+ if err != nil {
+ t.Fatalf("序列化单位: %v", err)
+ }
+ var team map[string]json.RawMessage
+ if err := json.Unmarshal(teamRaw, &team); err != nil {
+ t.Fatalf("解析单位: %v", err)
+ }
+ for _, key := range []string{"shortName", "logoPath", "color"} {
+ if got, ok := team[key]; !ok || string(got) != "null" {
+ t.Fatalf("单位字段 %s 应为显式 null: %s", key, teamRaw)
+ }
+ }
+
+ eventRaw, err := json.Marshal(EventFromDomain(domain.Event{ID: 1, Code: "E1", Name: "100米"}))
+ if err != nil {
+ t.Fatalf("序列化小项: %v", err)
+ }
+ var event map[string]json.RawMessage
+ if err := json.Unmarshal(eventRaw, &event); err != nil {
+ t.Fatalf("解析小项: %v", err)
+ }
+ for _, key := range []string{"scheduledAt", "venue", "remark"} {
+ if got, ok := event[key]; !ok || string(got) != "null" {
+ t.Fatalf("小项字段 %s 应为显式 null: %s", key, eventRaw)
+ }
+ }
+ if string(event["sortOrder"]) != "0" {
+ t.Fatalf("sortOrder 应保留 0: %s", eventRaw)
+ }
+}
diff --git a/server/internal/dto/teams.go b/server/internal/dto/teams.go
new file mode 100644
index 0000000..95f993d
--- /dev/null
+++ b/server/internal/dto/teams.go
@@ -0,0 +1,41 @@
+package dto
+
+import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
+
+// Team 是 GET /api/teams 与 snapshot.teams 的线上结构。
+//
+// 与 REST 的 teamDTO 逐字段对齐:可空列必须是显式 null,不能靠 domain 的
+// omitempty 把空串省成字段缺失,否则前端解析同一份契约会拿到两种形状。
+type Team struct {
+ ID int64 `json:"id"`
+ Code string `json:"code"`
+ Name string `json:"name"`
+ ShortName *string `json:"shortName"`
+ LogoPath *string `json:"logoPath"`
+ Color *string `json:"color"`
+ SortOrder int `json:"sortOrder"`
+ Active bool `json:"active"`
+}
+
+// TeamFromDomain 把领域单位转换成线上结构。
+func TeamFromDomain(t domain.Team) Team {
+ return Team{
+ ID: t.ID,
+ Code: t.Code,
+ Name: t.Name,
+ ShortName: nilIfEmpty(t.ShortName),
+ LogoPath: nilIfEmpty(t.LogoPath),
+ Color: nilIfEmpty(t.Color),
+ SortOrder: t.SortOrder,
+ Active: t.Active,
+ }
+}
+
+// TeamsFromDomain 批量转换单位;空输入返回空切片,序列化为 [] 而不是 null。
+func TeamsFromDomain(ts []domain.Team) []Team {
+ out := make([]Team, 0, len(ts))
+ for _, t := range ts {
+ out = append(out, TeamFromDomain(t))
+ }
+ return out
+}
diff --git a/server/internal/export/backup.go b/server/internal/export/backup.go
index 8643be3..b196470 100644
--- a/server/internal/export/backup.go
+++ b/server/internal/export/backup.go
@@ -67,7 +67,10 @@ func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
}
now := nowOr(opts.Now)
- path := uniqueBackupPath(dir, prefix, reason, now)
+ path, err := uniqueBackupPath(dir, prefix, reason, now)
+ if err != nil {
+ return BackupInfo{}, err
+ }
if err := vacuumInto(db, path); err != nil {
return BackupInfo{}, err
}
@@ -286,23 +289,40 @@ func PruneBackups(dir, prefix string, keep int) ([]string, error) {
return removed, errors.Join(errs...)
}
-func uniqueBackupPath(dir, prefix, reason string, now time.Time) string {
+// uniqueBackupPath 为备份选一个不重名的路径。
+// 重试次数有上限:目录不可读等非 NotExist 错误不能让备份线程死循环(它持锁会阻塞全部备份)。
+func uniqueBackupPath(dir, prefix, reason string, now time.Time) (string, error) {
base := fmt.Sprintf("%s-%s-%s", prefix, reason, now.Format("20060102-150405"))
p := filepath.Join(dir, base+".db")
- for i := 2; ; i++ {
- if _, err := os.Stat(p); os.IsNotExist(err) {
- return p
+ for i := 2; i <= 100; i++ {
+ _, err := os.Stat(p)
+ if os.IsNotExist(err) {
+ return p, nil
+ }
+ if err != nil {
+ return "", fmt.Errorf("export: 检查备份路径 %s: %w", p, err)
}
p = filepath.Join(dir, fmt.Sprintf("%s-%d.db", base, i))
}
+ return "", fmt.Errorf("export: 备份文件名 %s 冲突过多,放弃重试", base)
}
+// knownBackupPrefixes 是系统内置的备份前缀;prefix 为空(列出全部备份)时用它识别并剥掉前缀。
+var knownBackupPrefixes = []string{"event", "system", "db"}
+
// reasonFromName 从 ---[-<序号>].db 里取回原因;
// 同一秒重复备份会多一段序号,所以从右侧找「8 位日期 + 6 位时间」再截断。
func reasonFromName(name, prefix string) string {
base := strings.TrimSuffix(name, filepath.Ext(name))
if prefix != "" {
base = strings.TrimPrefix(base, prefix+"-")
+ } else {
+ for _, p := range knownBackupPrefixes {
+ if strings.HasPrefix(base, p+"-") {
+ base = strings.TrimPrefix(base, p+"-")
+ break
+ }
+ }
}
parts := strings.Split(base, "-")
for i := len(parts) - 2; i >= 0; i-- {
diff --git a/server/internal/export/backup_test.go b/server/internal/export/backup_test.go
index 5884707..dfd7f35 100644
--- a/server/internal/export/backup_test.go
+++ b/server/internal/export/backup_test.go
@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
)
@@ -236,6 +237,88 @@ func TestServiceRunMutualExclusion(t *testing.T) {
}
}
+// prefix 为空(列出全部备份)时要剥掉已识别的前缀,不能把 event-/system- 拼进原因。
+func TestReasonFromNameWithoutPrefix(t *testing.T) {
+ cases := []struct{ name, want string }{
+ {"event-auto-20260909-213614.db", "auto"},
+ {"event-publish-20260909-213614-2.db", "publish"},
+ {"system-manual-20260909-213614.db", "manual"},
+ {"db-manual-20260909-213614.db", "manual"},
+ }
+ for _, c := range cases {
+ if got := reasonFromName(c.name, ""); got != c.want {
+ t.Errorf("reasonFromName(%q, \"\") = %q,期望 %q", c.name, got, c.want)
+ }
+ }
+}
+
+// ListBackups 不传 prefix 时也要解析出可读原因(管理端「备份」面板按原因分列)。
+func TestListBackupsParsesReasonWithoutPrefix(t *testing.T) {
+ db := openEventDB(t)
+ seedEvent(t, db)
+ dir := t.TempDir()
+ base := time.Date(2026, 9, 9, 12, 0, 0, 0, time.Local)
+
+ if _, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "auto", Now: base, SkipPrune: true}); err != nil {
+ t.Fatalf("赛事库备份: %v", err)
+ }
+ if _, err := Backup(db, BackupOptions{Dir: dir, Prefix: "system", Reason: "manual", Now: base.Add(time.Minute), SkipPrune: true}); err != nil {
+ t.Fatalf("系统库备份: %v", err)
+ }
+
+ list, err := ListBackups(dir, "")
+ if err != nil {
+ t.Fatalf("列出全部备份: %v", err)
+ }
+ if len(list) != 2 {
+ t.Fatalf("备份数 = %d,期望 2", len(list))
+ }
+ want := map[string]string{"event": "auto", "system": "manual"}
+ for _, b := range list {
+ var prefix string
+ for p := range want {
+ if strings.HasPrefix(b.Name, p+"-") {
+ prefix = p
+ }
+ }
+ if prefix == "" {
+ t.Fatalf("无法识别备份前缀: %s", b.Name)
+ }
+ if b.Reason != want[prefix] {
+ t.Errorf("%s 的原因 = %q,期望 %q", b.Name, b.Reason, want[prefix])
+ }
+ }
+}
+
+// 非 NotExist 错误不能让选名函数死循环(它持锁会阻塞全部备份)。
+func TestUniqueBackupPathStopsOnError(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("root 不受目录权限限制")
+ }
+ dir := filepath.Join(t.TempDir(), "locked")
+ if err := os.Mkdir(dir, 0o755); err != nil {
+ t.Fatalf("创建目录: %v", err)
+ }
+ if err := os.Chmod(dir, 0o000); err != nil {
+ t.Fatalf("改权限: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := uniqueBackupPath(dir, "event", "auto", time.Now())
+ done <- err
+ }()
+ select {
+ case err := <-done:
+ if err == nil {
+ t.Fatal("目录不可读应返回错误")
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("uniqueBackupPath 在非 NotExist 错误上死循环")
+ }
+}
+
// 同一秒重复备份会带 -2 之类的序号,原因解析不能把它当成时间的一部分。
func TestReasonFromNameWithSequence(t *testing.T) {
cases := []struct{ name, prefix, want string }{
diff --git a/server/internal/export/load.go b/server/internal/export/load.go
index 0e8d23b..6da3b58 100644
--- a/server/internal/export/load.go
+++ b/server/internal/export/load.go
@@ -85,7 +85,13 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
return StandingsWorkbook{}, err
}
- teams, err := loadTeams(db)
+ // 排名输入只含启用单位,与 REST 一致。
+ teams, err := loadTeams(db, true)
+ if err != nil {
+ return StandingsWorkbook{}, err
+ }
+ // 展示字段用全量单位表:停用但有流水的单位由 domain 兜底出行,名称不能是空的。
+ allTeams, err := loadTeams(db, false)
if err != nil {
return StandingsWorkbook{}, err
}
@@ -107,24 +113,29 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
Athletes: athletes,
Ledger: ledger,
Ranked: ranked,
+ TieBreak: splitSettingList(settings["standings.tie_break"]),
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"),
}
- return StandingsWorkbook{
- Meta: meta,
- Team: domain.BuildTeamStandings(in),
- Medal: domain.BuildMedalStandings(in),
- Athlete: domain.BuildAthleteStandings(in),
- }, nil
+ team := domain.BuildTeamStandings(in)
+ medal := domain.BuildMedalStandings(in)
+ athlete := domain.BuildAthleteStandings(in)
+ fillTeamDisplay(team, medal, athlete, allTeams)
+ return StandingsWorkbook{Meta: meta, Team: team, Medal: medal, Athlete: athlete}, nil
}
-func loadTeams(db *sql.DB) ([]domain.Team, error) {
- // 只取启用中的单位;已软删除但仍有流水的单位由 domain 兜底补一行,分数不会被吞掉。
- rows, err := db.Query(`
+// loadTeams 读取单位表;activeOnly 为 true 时只取启用中的单位(排名输入口径)。
+func loadTeams(db *sql.DB, activeOnly bool) ([]domain.Team, error) {
+ query := `
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
- FROM teams WHERE active = 1
- ORDER BY sort_order, code`)
+ FROM teams`
+ if activeOnly {
+ query += ` WHERE active = 1`
+ }
+ query += ` ORDER BY sort_order, code`
+
+ rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("export: 读取单位: %w", err)
}
@@ -146,6 +157,66 @@ func loadTeams(db *sql.DB) ([]domain.Team, error) {
return out, nil
}
+// fillTeamDisplay 用全量单位表补齐榜单行的展示字段。
+// 排名输入只含启用单位,停用但有流水的单位由 domain 兜底出行,编码/名称原本是空的。
+func fillTeamDisplay(team []domain.StandingsRow, medal []domain.MedalRow, athlete []domain.AthleteStandingsRow, teams []domain.Team) {
+ byID := make(map[int64]domain.Team, len(teams))
+ for _, t := range teams {
+ byID[t.ID] = t
+ }
+ for i := range team {
+ if t, ok := byID[team[i].TeamID]; ok {
+ if team[i].Code == "" {
+ team[i].Code = t.Code
+ }
+ if team[i].Name == "" {
+ team[i].Name = t.Name
+ }
+ if team[i].ShortName == "" {
+ team[i].ShortName = t.ShortName
+ }
+ }
+ }
+ for i := range medal {
+ if t, ok := byID[medal[i].TeamID]; ok {
+ if medal[i].Code == "" {
+ medal[i].Code = t.Code
+ }
+ if medal[i].Name == "" {
+ medal[i].Name = t.Name
+ }
+ if medal[i].ShortName == "" {
+ medal[i].ShortName = t.ShortName
+ }
+ }
+ }
+ for i := range athlete {
+ if t, ok := byID[athlete[i].TeamID]; ok {
+ if athlete[i].TeamCode == "" {
+ athlete[i].TeamCode = t.Code
+ }
+ if athlete[i].TeamName == "" {
+ athlete[i].TeamName = t.Name
+ }
+ }
+ }
+}
+
+// splitSettingList 解析逗号分隔的设置值(如 standings.tie_break),空值返回 nil。
+func splitSettingList(raw string) []string {
+ if strings.TrimSpace(raw) == "" {
+ return nil
+ }
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if p = strings.TrimSpace(p); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
func loadAthletes(db *sql.DB) ([]domain.Athlete, error) {
// 停用的运动员也可能有历史积分,名字要能显示,所以这里不过滤 active。
rows, err := db.Query(`
@@ -201,24 +272,22 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
}
// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。
-// 奖牌只看「名次 + 通道 scope」,积分以 score_ledger 为准,所以这里不需要
-// 重算分值,也就能保证与正式计分结果完全一致。
+// 与 REST 的 buildRanked、快照的 loadRanked 同源:一条成绩在每个计分通道下各产出一条明细,
+// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌。
+// 积分以 score_ledger 为准,这里只取名次,不需要重算分值。
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
- scopes, err := loadChannelScopes(db)
- if err != nil {
- return nil, err
- }
-
rows, err := db.Query(`
- SELECT r.id, r.entry_id, r.event_id, en.category_id, en.team_id,
- COALESCE(en.athlete_id, 0), COALESCE(a.name, ''), r.rank, r.result_value
- FROM results r
- JOIN entries en ON en.id = r.entry_id
- JOIN events e ON e.id = r.event_id
- LEFT JOIN athletes a ON a.id = en.athlete_id
- WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0
- AND e.scored = 1 AND en.status = 'registered' AND r.status = 'ok'
- ORDER BY r.event_id, r.rank, r.id`)
+ SELECT r.id, r.event_id, en.category_id, r.rank, r.result_value, r.status,
+ en.id, en.team_id, en.athlete_id, COALESCE(a.name, ''), c.scope, c.id
+ FROM results r
+ JOIN entries en ON en.id = r.entry_id
+ JOIN events ev ON ev.id = r.event_id
+ JOIN event_score_channels c ON c.event_id = r.event_id
+ LEFT JOIN athletes a ON a.id = en.athlete_id
+ WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
+ AND ev.scored = 1
+ AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)
+ ORDER BY r.id, c.sort_order, c.id`)
if err != nil {
return nil, fmt.Errorf("export: 读取名次: %w", err)
}
@@ -227,32 +296,23 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
var out []domain.RankedResult
for rows.Next() {
var (
- rr domain.RankedResult
- resultValue sql.NullFloat64
+ rr domain.RankedResult
+ value sql.NullFloat64
+ athleteID sql.NullInt64
+ rank int
)
- if err := rows.Scan(&rr.ResultID, &rr.EntryID, &rr.EventID, &rr.CategoryID, &rr.TeamID,
- &rr.AthleteID, &rr.AthleteName, &rr.Rank, &resultValue); err != nil {
+ if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
+ &rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
return nil, fmt.Errorf("export: 扫描名次: %w", err)
}
- if resultValue.Valid {
- v := resultValue.Float64
+ rr.Rank = rank
+ if value.Valid {
+ v := value.Float64
rr.ResultValue = &v
}
- if rr.TeamID == 0 {
- continue
- }
- // 一个项目可能同时配个人通道与团体通道,同一成绩要在两个榜各记一枚,
- // 因此按 scope 展开成多条明细。
- for _, sc := range scopes[rr.EventID] {
- if sc == domain.ScopeAthlete && rr.AthleteID == 0 {
- continue
- }
- item := rr
- item.Scope = sc
- item.Basis = domain.BasisResult
- item.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
- out = append(out, item)
- }
+ rr.AthleteID = athleteID.Int64
+ rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
+ out = append(out, rr)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历名次: %w", err)
@@ -260,39 +320,6 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
return out, nil
}
-func loadChannelScopes(db *sql.DB) (map[int64][]domain.Scope, error) {
- rows, err := db.Query(`SELECT event_id, scope FROM event_score_channels ORDER BY event_id, sort_order, id`)
- if err != nil {
- return nil, fmt.Errorf("export: 读取计分通道: %w", err)
- }
- defer func() { _ = rows.Close() }()
-
- out := make(map[int64][]domain.Scope)
- for rows.Next() {
- var eventID int64
- var scope domain.Scope
- if err := rows.Scan(&eventID, &scope); err != nil {
- return nil, fmt.Errorf("export: 扫描计分通道: %w", err)
- }
- if !containsScope(out[eventID], scope) {
- out[eventID] = append(out[eventID], scope)
- }
- }
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("export: 遍历计分通道: %w", err)
- }
- return out, nil
-}
-
-func containsScope(list []domain.Scope, scope domain.Scope) bool {
- for _, s := range list {
- if s == scope {
- return true
- }
- }
- return false
-}
-
// eventInfo 是导出分项成绩表需要的项目信息。
type eventInfo struct {
id int64
@@ -471,7 +498,7 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
if db == nil {
return TeamDetailWorkbook{}, ErrNoDatabase
}
- meta, _, err := resolveMeta(db, opts.Meta)
+ meta, settings, err := resolveMeta(db, opts.Meta)
if err != nil {
return TeamDetailWorkbook{}, err
}
@@ -483,7 +510,12 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
if err != nil {
return TeamDetailWorkbook{}, err
}
- return TeamDetailWorkbook{Meta: meta, Team: team, Rows: rows}, nil
+ return TeamDetailWorkbook{
+ Meta: meta,
+ Team: team,
+ Rows: rows,
+ IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
+ }, nil
}
func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) {
@@ -508,7 +540,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
rows, err := db.Query(`
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''),
COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''),
- l.source_type, l.points, COALESCE(l.void_reason, '')
+ l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0)
FROM score_ledger l
LEFT JOIN events e ON e.id = l.event_id
LEFT JOIN sports s ON s.id = e.sport_id
@@ -527,11 +559,13 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
for rows.Next() {
var r TeamDetailRow
var source string
+ var athleteID int64
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
- &r.ResultText, &source, &r.Points, &r.Remark); err != nil {
+ &r.ResultText, &source, &r.Points, &r.Remark, &athleteID); err != nil {
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
}
r.Source = sourceLabel(source)
+ r.individual = athleteID != 0
out = append(out, r)
}
if err := rows.Err(); err != nil {
diff --git a/server/internal/export/load_test.go b/server/internal/export/load_test.go
new file mode 100644
index 0000000..4ae470f
--- /dev/null
+++ b/server/internal/export/load_test.go
@@ -0,0 +1,193 @@
+package export
+
+import (
+ "context"
+ "testing"
+
+ "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
+)
+
+// 导出与 WS 快照必须同源:同一赛事库上,总积分榜的总分、奖牌与名次不能对不上。
+func TestExportStandingsMatchesSnapshot(t *testing.T) {
+ db := openEventDB(t)
+ seedEvent(t, db)
+
+ wb, err := LoadStandingsWorkbook(db, Options{})
+ if err != nil {
+ t.Fatalf("导出总表: %v", err)
+ }
+ hub := realtime.NewHub(db, testLogger())
+ defer func() { _ = hub.Close() }()
+ snap, err := hub.Snapshot(context.Background())
+ if err != nil {
+ t.Fatalf("读取 WS 快照: %v", err)
+ }
+
+ if len(wb.Team) != len(snap.Standings) {
+ t.Fatalf("单位数不一致:导出 %d,快照 %d", len(wb.Team), len(snap.Standings))
+ }
+ for _, s := range snap.Standings {
+ idx := -1
+ for i := range wb.Team {
+ if wb.Team[i].TeamID == s.TeamID {
+ idx = i
+ break
+ }
+ }
+ if idx < 0 {
+ t.Fatalf("快照中的单位 %d 不在导出结果里", s.TeamID)
+ }
+ row := wb.Team[idx]
+ if row.Points != s.TotalPoints || row.Gold != s.Gold || row.Silver != s.Silver ||
+ row.Bronze != s.Bronze || row.Medals != s.MedalTotal || row.Rank != s.Rank {
+ t.Errorf("单位 %d 口径不一致:导出 %+v,快照 %+v", s.TeamID, row, s)
+ }
+ }
+}
+
+// 只排名不计分(scored=0)的项目不进奖牌榜,即使它配了计分通道。
+func TestLoadRankedExcludesRankOnlyEvents(t *testing.T) {
+ db := openEventDB(t)
+ seedEvent(t, db)
+
+ stmts := []string{
+ `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, status)
+ VALUES (1, 'E2', '飞行人员战斗体能', 'individual', 'auto', 'score', 'desc', 0, 'published')`,
+ `INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
+ `INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
+ SELECT 2, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
+ `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (2, 2, 1, 1, '201')`,
+ `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
+ VALUES (2, 3, 1, '100', 100, 'published')`,
+ }
+ for _, s := range stmts {
+ if _, err := db.Exec(s); err != nil {
+ t.Fatalf("造数据 %q: %v", s, err)
+ }
+ }
+
+ ranked, err := loadRanked(db)
+ if err != nil {
+ t.Fatalf("读取名次: %v", err)
+ }
+ for _, rr := range ranked {
+ if rr.EventID == 2 {
+ t.Fatalf("scored=0 的项目不应产出奖牌名次: %+v", rr)
+ }
+ }
+
+ wb, err := LoadStandingsWorkbook(db, Options{})
+ if err != nil {
+ t.Fatalf("导出总表: %v", err)
+ }
+ for _, row := range wb.Team {
+ if row.TeamID == 1 && row.Gold != 1 {
+ t.Errorf("T1 金牌 = %d,期望 1(只排名项目不得计牌)", row.Gold)
+ }
+ }
+}
+
+// 排序口径取自 settings.standings.tie_break,不再是硬编码默认序。
+func TestLoadStandingsHonoursTieBreak(t *testing.T) {
+ db := openEventDB(t)
+ seedEvent(t, db)
+ // T1 金牌 1 但总分 5,T2 无牌但总分 10。
+ for _, s := range []string{
+ `UPDATE score_ledger SET points = 5 WHERE team_id = 1`,
+ `UPDATE score_ledger SET points = 10 WHERE team_id = 2`,
+ } {
+ if _, err := db.Exec(s); err != nil {
+ t.Fatalf("造数据 %q: %v", s, err)
+ }
+ }
+
+ wb, err := LoadStandingsWorkbook(db, Options{})
+ if err != nil {
+ t.Fatalf("默认口径导出: %v", err)
+ }
+ if wb.Team[0].Code != "T2" {
+ t.Fatalf("默认按总分排序,第一名 = %q,期望 T2", wb.Team[0].Code)
+ }
+
+ if _, err := db.Exec(`INSERT INTO settings(key, value) VALUES ('standings.tie_break', 'gold,points')
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`); err != nil {
+ t.Fatalf("写入 tie_break: %v", err)
+ }
+ wb, err = LoadStandingsWorkbook(db, Options{})
+ if err != nil {
+ t.Fatalf("金牌优先导出: %v", err)
+ }
+ if wb.Team[0].Code != "T1" {
+ t.Errorf("tie_break=gold,points 时第一名 = %q,期望 T1", wb.Team[0].Code)
+ }
+}
+
+// 停用但仍有积分的单位要显示编码/名称,不能是空行。
+func TestLoadStandingsShowsInactiveTeam(t *testing.T) {
+ db := openEventDB(t)
+ seedEvent(t, db)
+ for _, s := range []string{
+ `INSERT INTO teams(code, name, short_name, sort_order, active)
+ VALUES ('T3', '第三分公司', '三公司', 3, 0)`,
+ `INSERT INTO score_ledger(team_id, source_type, source_id, points)
+ VALUES (3, 'adjustment', 99, 7)`,
+ } {
+ if _, err := db.Exec(s); err != nil {
+ t.Fatalf("造数据 %q: %v", s, err)
+ }
+ }
+
+ wb, err := LoadStandingsWorkbook(db, Options{})
+ if err != nil {
+ t.Fatalf("导出总表: %v", err)
+ }
+ idx := -1
+ for i := range wb.Team {
+ if wb.Team[i].TeamID == 3 {
+ idx = i
+ break
+ }
+ }
+ if idx < 0 {
+ t.Fatalf("停用单位未出现在榜单:%+v", wb.Team)
+ }
+ row := wb.Team[idx]
+ if row.Code != "T3" || row.Name != "第三分公司" || row.ShortName != "三公司" {
+ t.Errorf("停用单位展示字段不完整: %+v", row)
+ }
+ if row.Points != 7 {
+ t.Errorf("停用单位总分 = %v,期望 7", row.Points)
+ }
+}
+
+// 单位积分明细的「合计」与总积分榜同口径:个人分要乘 individual_to_team_weight。
+func TestTeamDetailTotalAppliesWeight(t *testing.T) {
+ db := openEventDB(t)
+ seedEvent(t, db)
+ if _, err := db.Exec(`UPDATE settings SET value = '2' WHERE key = 'standings.individual_to_team_weight'`); err != nil {
+ t.Fatalf("修改折算系数: %v", err)
+ }
+
+ wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
+ if err != nil {
+ t.Fatalf("读取积分明细: %v", err)
+ }
+ if wb.IndividualToTeamWeight != 2 {
+ t.Fatalf("折算系数 = %v,期望 2", wb.IndividualToTeamWeight)
+ }
+ rows := teamDetailRows(wb.Rows, wb.IndividualToTeamWeight)
+ if len(rows) < 2 {
+ t.Fatalf("明细行数 = %d,期望含合计行", len(rows))
+ }
+ // 与 REST 一致:个人分逐行折算,合计等于各行之和。
+ if got, ok := rows[0][8].(float64); !ok || got != 20 {
+ t.Errorf("明细行积分 = %v,期望 20(10 × 系数 2)", rows[0][8])
+ }
+ total, ok := rows[len(rows)-1][8].(float64)
+ if !ok {
+ t.Fatalf("合计列类型异常: %T", rows[len(rows)-1][8])
+ }
+ if total != 20 {
+ t.Errorf("合计 = %v,期望 20(10 × 系数 2)", total)
+ }
+}
diff --git a/server/internal/export/project.go b/server/internal/export/project.go
index ece4a54..21fa781 100644
--- a/server/internal/export/project.go
+++ b/server/internal/export/project.go
@@ -26,10 +26,12 @@ const (
projectFormatVersion = 1
// eventDBName 是包内赛事库的固定文件名。
eventDBName = "event.db"
- // maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。
- maxExtractBytes = 4 << 30
)
+// maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。
+// 定义为变量便于测试用较小的上限验证限流逻辑。
+var maxExtractBytes int64 = 4 << 30
+
// ProjectFile 描述包内一个文件的校验信息。
type ProjectFile struct {
Path string `yaml:"path"`
@@ -253,18 +255,8 @@ func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, e
}
target := filepath.Join(projRoot, name)
- if _, err := os.Stat(target); err == nil {
- if !opts.Overwrite {
- return ImportResult{}, fmt.Errorf("%w: %s", ErrProjectExists, name)
- }
- stale := target + ".replaced-" + time.Now().Format("20060102-150405")
- if err := os.Rename(target, stale); err != nil {
- return ImportResult{}, fmt.Errorf("export: 暂存旧项目 %s: %w", name, err)
- }
- defer func() { _ = os.RemoveAll(stale) }()
- }
- if err := os.Rename(tmp, target); err != nil {
- return ImportResult{}, fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err)
+ if err := swapProjectDir(tmp, target, opts.Overwrite, os.Rename); err != nil {
+ return ImportResult{}, err
}
return ImportResult{
Name: name,
@@ -274,6 +266,38 @@ func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, e
}, nil
}
+// swapProjectDir 用 src 目录替换 target 目录。
+// 覆盖时先把旧目录改名暂存,src 落盘成功后才删除暂存目录;src 落盘失败则把旧目录改回来,
+// 绝不出现「旧项目已删、新项目没成」的空档。rename 注入仅为测试模拟第二次改名失败。
+func swapProjectDir(src, target string, overwrite bool, rename func(old, new string) error) error {
+ name := filepath.Base(target)
+ stale := ""
+ if _, err := os.Stat(target); err == nil {
+ if !overwrite {
+ return fmt.Errorf("%w: %s", ErrProjectExists, name)
+ }
+ stale = target + ".replaced-" + time.Now().Format("20060102-150405")
+ if err := rename(target, stale); err != nil {
+ return fmt.Errorf("export: 暂存旧项目 %s: %w", name, err)
+ }
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("export: 检查项目目录 %s: %w", target, err)
+ }
+ if err := rename(src, target); err != nil {
+ if stale != "" {
+ if rbErr := rename(stale, target); rbErr != nil {
+ return fmt.Errorf("export: 落盘赛事项目 %s: %w(旧项目回滚失败,暂存在 %s: %v)", name, err, stale, rbErr)
+ }
+ }
+ return fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err)
+ }
+ if stale != "" {
+ // 新项目已就位,清理旧目录失败不影响本次导入,留待下次人工处理。
+ _ = os.RemoveAll(stale)
+ }
+ return nil
+}
+
// SafeProjectName 校验项目名:它会被当作目录名,必须挡住路径穿越与 Windows 保留名。
func SafeProjectName(name string) (string, error) {
name = strings.TrimSpace(name)
@@ -289,6 +313,10 @@ func SafeProjectName(name string) (string, error) {
if strings.ContainsAny(name, `/\:*?"<>|`) || strings.ContainsRune(name, 0) {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
+ // Windows 会静默剥掉目录名末尾的点与空格,"赛事." 与 "赛事" 会撞成同一个目录。
+ if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
+ return "", fmt.Errorf("%w: %q 不能以点或空格结尾", ErrUnsafeName, name)
+ }
for _, r := range name {
if r < 0x20 {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
@@ -511,7 +539,7 @@ func extractZip(src, dst string) error {
}
defer func() { _ = r.Close() }()
- var total uint64
+ var total int64
for _, f := range r.File {
target, err := secureJoin(dst, f.Name)
if err != nil {
@@ -523,8 +551,8 @@ func extractZip(src, dst string) error {
}
continue
}
- total += f.UncompressedSize64
- if total > maxExtractBytes {
+ // 声明值先卡一道,避免一上来就落盘;注意先判上界再转 int64,防止溢出。
+ if f.UncompressedSize64 > uint64(maxExtractBytes) || total+int64(f.UncompressedSize64) > maxExtractBytes {
return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
@@ -539,12 +567,17 @@ func extractZip(src, dst string) error {
_ = rc.Close()
return fmt.Errorf("export: 写入 %s: %w", target, err)
}
- _, copyErr := io.Copy(out, rc)
+ // header 里的声明值可以伪造,按实际写出的字节数再卡一次上限。
+ n, copyErr := io.Copy(out, io.LimitReader(rc, maxExtractBytes-total+1))
closeOutErr := out.Close()
closeInErr := rc.Close()
if copyErr != nil {
return fmt.Errorf("export: 解压 %s: %w", f.Name, copyErr)
}
+ total += n
+ if total > maxExtractBytes {
+ return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
+ }
if closeOutErr != nil {
return fmt.Errorf("export: 关闭 %s: %w", target, closeOutErr)
}
diff --git a/server/internal/export/project_test.go b/server/internal/export/project_test.go
index eb1218e..152f2f7 100644
--- a/server/internal/export/project_test.go
+++ b/server/internal/export/project_test.go
@@ -147,8 +147,98 @@ func TestImportRejectsBadPackages(t *testing.T) {
})
}
+// 覆盖导入时第二次改名失败,旧项目必须回滚回来,不能出现「旧项目已删、新项目没成」。
+func TestSwapProjectDirRollback(t *testing.T) {
+ root := t.TempDir()
+ target := filepath.Join(root, "赛事")
+ src := filepath.Join(root, ".import-1")
+ writeFile(t, filepath.Join(target, "old.txt"), "旧")
+ writeFile(t, filepath.Join(src, "new.txt"), "新")
+
+ calls := 0
+ rename := func(old, new string) error {
+ calls++
+ if calls == 2 {
+ return errors.New("模拟第二次改名失败")
+ }
+ return os.Rename(old, new)
+ }
+ if err := swapProjectDir(src, target, true, rename); err == nil {
+ t.Fatal("第二次改名失败应返回错误")
+ }
+ if got := readFile(t, filepath.Join(target, "old.txt")); got != "旧" {
+ t.Errorf("回滚后旧项目内容 = %q,期望 旧", got)
+ }
+ if got := readFile(t, filepath.Join(src, "new.txt")); got != "新" {
+ t.Errorf("新项目内容 = %q,期望 新", got)
+ }
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ t.Fatalf("读取项目根目录: %v", err)
+ }
+ for _, e := range entries {
+ if strings.Contains(e.Name(), ".replaced-") {
+ t.Errorf("回滚后残留暂存目录 %s", e.Name())
+ }
+ }
+}
+
+// 不允许覆盖时必须原地报错,不能动旧项目。
+func TestSwapProjectDirNoOverwrite(t *testing.T) {
+ root := t.TempDir()
+ target := filepath.Join(root, "赛事")
+ src := filepath.Join(root, ".import-1")
+ writeFile(t, filepath.Join(target, "old.txt"), "旧")
+ writeFile(t, filepath.Join(src, "new.txt"), "新")
+
+ if err := swapProjectDir(src, target, false, os.Rename); !errors.Is(err, ErrProjectExists) {
+ t.Fatalf("期望 ErrProjectExists,实际 %v", err)
+ }
+ if got := readFile(t, filepath.Join(target, "old.txt")); got != "旧" {
+ t.Errorf("拒绝覆盖后旧项目内容 = %q,期望 旧", got)
+ }
+}
+
+// 覆盖成功后旧目录必须清理干净,不留 .replaced- 残留。
+func TestSwapProjectDirOverwriteCleansStale(t *testing.T) {
+ root := t.TempDir()
+ target := filepath.Join(root, "赛事")
+ src := filepath.Join(root, ".import-1")
+ writeFile(t, filepath.Join(target, "old.txt"), "旧")
+ writeFile(t, filepath.Join(src, "new.txt"), "新")
+
+ if err := swapProjectDir(src, target, true, os.Rename); err != nil {
+ t.Fatalf("覆盖导入: %v", err)
+ }
+ if got := readFile(t, filepath.Join(target, "new.txt")); got != "新" {
+ t.Errorf("覆盖后新项目内容 = %q,期望 新", got)
+ }
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ t.Fatalf("读取项目根目录: %v", err)
+ }
+ for _, e := range entries {
+ if strings.Contains(e.Name(), ".replaced-") {
+ t.Errorf("覆盖成功后残留暂存目录 %s", e.Name())
+ }
+ }
+}
+
+// 解压总体积上限:超过上限的包必须拒绝,不能把磁盘写满。
+func TestExtractZipRejectsOversize(t *testing.T) {
+ old := maxExtractBytes
+ maxExtractBytes = 64
+ t.Cleanup(func() { maxExtractBytes = old })
+
+ p := filepath.Join(t.TempDir(), "big.zip")
+ writeZip(t, p, map[string]string{"event.db": strings.Repeat("x", 200)})
+ if err := extractZip(p, t.TempDir()); !errors.Is(err, ErrInvalidPackage) {
+ t.Fatalf("期望 ErrInvalidPackage,实际 %v", err)
+ }
+}
+
func TestSafeProjectName(t *testing.T) {
- bad := []string{"", "..", "../evil", "a/b", `a\b`, "CON", "lpt1.db", "a:b", "x?y"}
+ bad := []string{"", "..", "../evil", "a/b", `a\b`, "CON", "lpt1.db", "a:b", "x?y", "赛事.", "a.."}
for _, name := range bad {
if _, err := SafeProjectName(name); err == nil {
t.Errorf("SafeProjectName(%q) 应报错", name)
@@ -161,6 +251,25 @@ func TestSafeProjectName(t *testing.T) {
}
}
+func writeFile(t *testing.T, path, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("创建目录 %s: %v", filepath.Dir(path), err)
+ }
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatalf("写入 %s: %v", path, err)
+ }
+}
+
+func readFile(t *testing.T, path string) string {
+ t.Helper()
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("读取 %s: %v", path, err)
+ }
+ return string(raw)
+}
+
func assertTeams(t *testing.T, path string, want int) {
t.Helper()
db := openSQLite(t, path)
diff --git a/server/internal/export/team.go b/server/internal/export/team.go
index 12dc261..5a9d4bb 100644
--- a/server/internal/export/team.go
+++ b/server/internal/export/team.go
@@ -21,6 +21,8 @@ type TeamDetailRow struct {
Source string
Points float64
Remark string
+ // individual 标记该条流水记到个人:合计要按 individual_to_team_weight 折算。
+ individual bool
}
// TeamDetailWorkbook 是「单位积分明细」的内容。
@@ -28,6 +30,8 @@ type TeamDetailWorkbook struct {
Meta Meta
Team domain.Team
Rows []TeamDetailRow
+ // IndividualToTeamWeight 与总积分榜同源,用于把个人分折算进「合计」。
+ IndividualToTeamWeight float64
}
var teamDetailColumns = []column{
@@ -73,22 +77,31 @@ func teamDetailFile(wb TeamDetailWorkbook) (*excelize.File, error) {
return err
}
title := sheetTitle(meta, displayName(wb.Team.Name, wb.Team.ShortName)+" · 积分明细")
- return writeSheet(f, st, name, meta, title, teamDetailColumns, teamDetailRows(wb.Rows))
+ return writeSheet(f, st, name, meta, title, teamDetailColumns, teamDetailRows(wb.Rows, wb.IndividualToTeamWeight))
})
}
-func teamDetailRows(rows []TeamDetailRow) [][]any {
+// teamDetailRows 把明细行排成表格。与 REST 的单位积分明细同口径:
+// 记到个人的流水按 individual_to_team_weight 折算后展示,合计即各行之和。
+func teamDetailRows(rows []TeamDetailRow, weight float64) [][]any {
+ if weight <= 0 {
+ weight = 1
+ }
out := make([][]any, 0, len(rows)+1)
var total float64
for i, r := range rows {
- total += r.Points
+ points := r.Points
+ if r.individual {
+ points *= weight
+ }
+ total += points
rank := ""
if r.Rank > 0 {
rank = strconv.Itoa(r.Rank)
}
out = append(out, []any{
i + 1, r.SportName, r.EventName, r.Category, r.AthleteName,
- rank, r.ResultText, r.Source, r.Points, r.Remark,
+ rank, r.ResultText, r.Source, points, r.Remark,
})
}
if len(rows) > 0 {
diff --git a/server/internal/httpapi/export_handlers.go b/server/internal/httpapi/export_handlers.go
index 7abbd2a..469f401 100644
--- a/server/internal/httpapi/export_handlers.go
+++ b/server/internal/httpapi/export_handlers.go
@@ -2,11 +2,14 @@ package httpapi
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
+ "strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
@@ -16,6 +19,9 @@ import (
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+// maxProjectImportBytes 是赛事包上传的硬上限,避免超大请求把内存和磁盘撑爆。
+const maxProjectImportBytes = 512 << 20
+
// exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。
func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/standings.xlsx", s.read(auth.RoleGuest, s.exportStandingsXLSX))
@@ -115,7 +121,10 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
defer cleanup()
opts.RulesPath = rulesPath
- dst := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip")
+ dst, err := projectExportPath(dir, time.Now())
+ if err != nil {
+ return nil, internal(err)
+ }
if _, err := export.ExportProjectZip(s.ev, dst, opts); err != nil {
return nil, internal(err)
}
@@ -127,6 +136,16 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
return nil, errHandled
}
+// projectExportPath 生成带随机后缀的导出文件名:精度到秒的时间戳在并发导出时会撞名,
+// 后写的会截断先写的文件,导致先返回的响应内容损坏。
+func projectExportPath(dir string, now time.Time) (string, error) {
+ suffix := make([]byte, 4)
+ if _, err := rand.Read(suffix); err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, "project-"+now.Format("20060102-150405")+"-"+hex.EncodeToString(suffix)+".zip"), nil
+}
+
// writeRulesTempFile 把当前规则包导出成临时 YAML,供赛事项目包内嵌 rules.yaml。
// 返回的清理函数由调用方 defer,避免临时文件泄漏。
func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error) {
@@ -155,6 +174,7 @@ func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error)
}
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxProjectImportBytes)
if err := r.ParseMultipartForm(512 << 20); err != nil {
return nil, badRequest("解析上传失败", nil)
}
@@ -183,6 +203,22 @@ func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any,
return nil, internal(err)
}
+ // 覆盖正在运行的赛事项目会让运行中的 event.db 被 unlink(Linux)或 rename 失败(Windows),
+ // 现场继续写进已删除的 inode,重启即丢数据,必须在落盘前直接拒绝。
+ if cur := currentProjectName(s.projectDir); cur != "" {
+ meta, err := export.InspectProjectZip(tmp)
+ if err != nil {
+ return nil, badRequest(err.Error(), nil)
+ }
+ name := meta.Name
+ if v := strings.TrimSpace(r.FormValue("rename")); v != "" {
+ name = v
+ }
+ if safe, err := export.SafeProjectName(name); err == nil && strings.EqualFold(safe, cur) {
+ return nil, badRequest("不能覆盖正在运行的赛事项目 "+cur, nil)
+ }
+ }
+
res, err := export.ImportProjectZip(tmp, filepath.Join(s.dataDir, "event_projs"), export.ImportOptions{
Overwrite: r.FormValue("overwrite") == "1",
Rename: r.FormValue("rename"),
@@ -194,6 +230,15 @@ func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any,
return res, nil
}
+// currentProjectName 从项目目录反推当前运行的赛事项目名;未配置目录时返回空串。
+func currentProjectName(projectDir string) string {
+ cleaned := filepath.Clean(strings.TrimSpace(projectDir))
+ if cleaned == "" || cleaned == "." || cleaned == string(filepath.Separator) {
+ return ""
+ }
+ return filepath.Base(cleaned)
+}
+
func (s *server) listBackups(w http.ResponseWriter, r *http.Request) (any, error) {
items, err := export.ListBackups(filepath.Join(s.dataDir, "backups"), "")
if err != nil {
diff --git a/server/internal/httpapi/export_handlers_internal_test.go b/server/internal/httpapi/export_handlers_internal_test.go
new file mode 100644
index 0000000..92c19cd
--- /dev/null
+++ b/server/internal/httpapi/export_handlers_internal_test.go
@@ -0,0 +1,22 @@
+package httpapi
+
+import (
+ "testing"
+ "time"
+)
+
+// 同一秒内的两次导出必须落到不同文件,否则并发导出会互相截断(P2-6)。
+func TestProjectExportPathUniqueWithinSecond(t *testing.T) {
+ now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
+ first, err := projectExportPath("/tmp/exports", now)
+ if err != nil {
+ t.Fatalf("生成导出路径: %v", err)
+ }
+ second, err := projectExportPath("/tmp/exports", now)
+ if err != nil {
+ t.Fatalf("生成导出路径: %v", err)
+ }
+ if first == second {
+ t.Fatalf("同一秒内两次导出不应撞名,均为 %q", first)
+ }
+}
diff --git a/server/internal/httpapi/export_handlers_test.go b/server/internal/httpapi/export_handlers_test.go
new file mode 100644
index 0000000..4d70b77
--- /dev/null
+++ b/server/internal/httpapi/export_handlers_test.go
@@ -0,0 +1,148 @@
+package httpapi_test
+
+import (
+ "archive/zip"
+ "bytes"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
+)
+
+// exportProjectZip 通过导出接口取一份合法赛事包,供导入测试使用。
+func (e *testEnv) exportProjectZip(t *testing.T, client *http.Client) []byte {
+ t.Helper()
+ resp, err := client.Get(e.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)
+ }
+ return body
+}
+
+// postProjectImport 以 multipart 上传赛事包,附带 overwrite / rename 表单字段。
+func (e *testEnv) postProjectImport(t *testing.T, client *http.Client, pack []byte, fields map[string]string) (int, []byte) {
+ t.Helper()
+ var buf bytes.Buffer
+ w := multipart.NewWriter(&buf)
+ for k, v := range fields {
+ if err := w.WriteField(k, v); err != nil {
+ t.Fatalf("写表单字段: %v", err)
+ }
+ }
+ fw, err := w.CreateFormFile("file", "project.zip")
+ if err != nil {
+ t.Fatalf("创建上传字段: %v", err)
+ }
+ if _, err := fw.Write(pack); err != nil {
+ t.Fatalf("写赛事包: %v", err)
+ }
+ if err := w.Close(); err != nil {
+ t.Fatalf("关闭 multipart: %v", err)
+ }
+
+ req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/export/project/import", &buf)
+ if err != nil {
+ t.Fatalf("构造请求: %v", err)
+ }
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ req.Header.Set("X-CSRF-Token", e.csrfToken(client))
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("上传赛事包: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ raw, _ := io.ReadAll(resp.Body)
+ return resp.StatusCode, raw
+}
+
+// 覆盖导入正在运行的赛事项目会让运行库被 unlink 后继续写入,必须直接拒绝(P0-7)。
+func TestImportProjectZipRejectsRunningProject(t *testing.T) {
+ env := newTestEnvWith(t, func(o *httpapi.Options) {
+ o.ProjectDir = filepath.Join(o.DataDir, "event_projs", "未命名赛事")
+ })
+ env.seed(t)
+ env.createUser("admin", "admin-pass", "admin")
+ client, _ := env.client()
+ env.login(client, "admin", "admin-pass")
+
+ pack := env.exportProjectZip(t, client)
+
+ status, raw := env.postProjectImport(t, client, pack, map[string]string{"overwrite": "1"})
+ if status != http.StatusBadRequest {
+ t.Fatalf("覆盖运行中项目应 400,得到 %d %s", status, raw)
+ }
+ if !strings.Contains(string(raw), "正在运行") {
+ t.Fatalf("错误信息应说明不能覆盖运行中项目,得到 %s", raw)
+ }
+
+ // 用 rename 指定成当前项目名同样要拒绝。
+ status, raw = env.postProjectImport(t, client, pack, map[string]string{"overwrite": "1", "rename": "未命名赛事"})
+ if status != http.StatusBadRequest {
+ t.Fatalf("rename 成运行中项目应 400,得到 %d %s", status, raw)
+ }
+
+ // 换成别的项目名仍应允许导入。
+ status, raw = env.postProjectImport(t, client, pack, map[string]string{"rename": "备份赛事"})
+ if status != http.StatusOK {
+ t.Fatalf("换名导入应 200,得到 %d %s", status, raw)
+ }
+}
+
+// 并发导出不能撞同名文件,否则先返回的响应会被后写的截断(P2-6)。
+func TestExportProjectZipConcurrentUnique(t *testing.T) {
+ env := newTestEnv(t)
+ env.seed(t)
+ env.createUser("admin", "admin-pass", "admin")
+ client, _ := env.client()
+ env.login(client, "admin", "admin-pass")
+
+ const n = 6
+ var wg sync.WaitGroup
+ errs := make(chan string, n)
+ for i := 0; i < n; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ resp, err := client.Get(env.server.URL + "/api/export/project.zip")
+ if err != nil {
+ errs <- "请求失败: " + err.Error()
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil || resp.StatusCode != http.StatusOK {
+ errs <- "导出失败 status=" + resp.Status
+ return
+ }
+ zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
+ if err != nil {
+ errs <- "响应不是完整 zip: " + err.Error()
+ return
+ }
+ found := false
+ for _, f := range zr.File {
+ if f.Name == "meta.yaml" {
+ found = true
+ }
+ }
+ if !found {
+ errs <- "zip 缺少 meta.yaml"
+ }
+ }()
+ }
+ wg.Wait()
+ close(errs)
+ for msg := range errs {
+ t.Errorf("并发导出: %s", msg)
+ }
+}
diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go
index d8db4c3..5e1f685 100644
--- a/server/internal/httpapi/handlers_config.go
+++ b/server/internal/httpapi/handlers_config.go
@@ -43,7 +43,11 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
return nil, internal(err)
}
s.auth.SetCSRFCookie(w, csrf)
- s.audit(r.Context(), "auth.login", "user", u.ID, nil, map[string]any{"username": u.Username})
+ // 登录时请求 ctx 里还是 guest,Auditor 取不到用户,需在审计条目里显式带上 actorID。
+ middleware.Record(r.Context(), middleware.AuditEntry{
+ Action: "auth.login", EntityType: "user", EntityID: u.ID, ActorID: u.ID,
+ After: map[string]any{"username": u.Username},
+ })
return userDTO(u), nil
}
@@ -82,6 +86,9 @@ func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, er
if len(in.NewPassword) < 8 {
return nil, badRequest("新密码至少 8 位", nil)
}
+ if len(in.NewPassword) > auth.MaxPasswordBytes {
+ return nil, badRequest(fmt.Sprintf("新密码不能超过 %d 字节", auth.MaxPasswordBytes), nil)
+ }
if in.NewPassword == in.CurrentPassword {
return nil, badRequest("新密码不能与原密码相同", nil)
}
@@ -525,8 +532,12 @@ type entryImportResult struct {
Errors []string `json:"errors"`
}
+// maxEntryImportBytes 是报名表上传的硬上限(16MB 文件 + multipart 开销)。
+const maxEntryImportBytes = 32 << 20
+
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
+ r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
if err := r.ParseMultipartForm(16 << 20); err != nil {
return nil, badRequest("上传文件解析失败", err.Error())
}
diff --git a/server/internal/httpapi/handlers_config_test.go b/server/internal/httpapi/handlers_config_test.go
new file mode 100644
index 0000000..4070e90
--- /dev/null
+++ b/server/internal/httpapi/handlers_config_test.go
@@ -0,0 +1,87 @@
+package httpapi_test
+
+import (
+ "database/sql"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ "git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
+)
+
+// 登录审计必须落在真实用户上:请求 ctx 里仍是 guest,靠 AuditEntry.ActorID 补(P1-13)。
+func TestLoginAuditRecordsUserID(t *testing.T) {
+ env := newTestEnv(t)
+ userID := env.createUser("recorder", "recorder-pass", "recorder")
+ client, _ := env.client()
+ env.login(client, "recorder", "recorder-pass")
+
+ var auditUser sql.NullInt64
+ if err := env.ev.QueryRow(
+ `SELECT user_id FROM audit_logs WHERE action = 'auth.login' ORDER BY id DESC LIMIT 1`).Scan(&auditUser); err != nil {
+ t.Fatalf("查询登录审计: %v", err)
+ }
+ if !auditUser.Valid || auditUser.Int64 != userID {
+ t.Fatalf("登录审计 user_id 应为 %d,得到 %+v", userID, auditUser)
+ }
+
+ // 按 userId 过滤必须能查到这条登录记录。
+ env.createUser("admin", "admin-pass", "admin")
+ adminClient, _ := env.client()
+ env.login(adminClient, "admin", "admin-pass")
+ _, env2 := env.do(adminClient, http.MethodGet, fmt.Sprintf("/api/audit-logs?userId=%d", userID), nil, false)
+ page := decodeData[map[string]any](t, env2)
+ items, _ := page["items"].([]any)
+ found := false
+ for _, raw := range items {
+ item, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ if item["action"] == "auth.login" && item["userId"] == float64(userID) {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("按 userId 过滤应能查到登录审计,得到 %+v", items)
+ }
+}
+
+// 跨站登录(login CSRF)必须被同源校验挡住(P2-6)。
+func TestLoginRejectsCrossOrigin(t *testing.T) {
+ env := newTestEnv(t)
+ env.createUser("recorder", "recorder-pass", "recorder")
+ client, _ := env.client()
+
+ body := map[string]any{"username": "recorder", "password": "recorder-pass"}
+ status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", body, false,
+ map[string]string{"Origin": "http://evil.example"})
+ if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
+ t.Fatalf("跨站登录应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
+ }
+
+ // 同源(Origin 与 Host 一致)仍可登录。
+ status, env2 = env.doH(client, http.MethodPost, "/api/auth/login", body, false,
+ map[string]string{"Origin": env.server.URL})
+ if status != http.StatusOK || !env2.OK {
+ t.Fatalf("同源登录应 200,得到 %d %+v", status, env2.Error)
+ }
+}
+
+// 超过 bcrypt 72 字节上限的新密码必须 400,而不是被静默截断(P2-6)。
+func TestChangePasswordRejectsTooLong(t *testing.T) {
+ env := newTestEnv(t)
+ env.createUser("recorder", "recorder-pass", "recorder")
+ client, _ := env.client()
+ env.login(client, "recorder", "recorder-pass")
+
+ tooLong := strings.Repeat("a", auth.MaxPasswordBytes+1)
+ status, env2 := env.do(client, http.MethodPost, "/api/auth/password", map[string]any{
+ "currentPassword": "recorder-pass",
+ "newPassword": tooLong,
+ }, true)
+ if status != http.StatusBadRequest {
+ t.Fatalf("超长新密码应 400,得到 %d %+v", status, env2.Error)
+ }
+}
diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go
index 9b06736..ee17dfa 100644
--- a/server/internal/httpapi/handlers_results.go
+++ b/server/internal/httpapi/handlers_results.go
@@ -85,6 +85,11 @@ func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request)
if err != nil {
return nil, err
}
+ // 展示字段以全量单位表为准(含停用单位),否则停用单位的运动员会显示空白单位名。
+ teams, err := s.teamMap(r.Context())
+ if err != nil {
+ return nil, err
+ }
genders := make(map[int64]*string, len(in.Athletes))
for _, a := range in.Athletes {
if a.Gender != "" {
@@ -95,7 +100,7 @@ func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request)
rows := domain.BuildAthleteStandings(in)
out := make([]athleteStandingDTO, 0, len(rows))
for _, row := range rows {
- out = append(out, athleteStandingDTO{
+ item := athleteStandingDTO{
Rank: row.Rank,
AthleteID: row.AthleteID,
AthleteName: row.Name,
@@ -103,12 +108,17 @@ func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request)
TeamID: row.TeamID,
TeamCode: row.TeamCode,
TeamName: row.TeamName,
- ShortName: nilIfEmpty(row.TeamName),
TotalPoints: row.Points,
Gold: row.Gold,
Silver: row.Silver,
Bronze: row.Bronze,
- })
+ }
+ if t, ok := teams[row.TeamID]; ok {
+ item.TeamCode = t.Code
+ item.TeamName = t.Name
+ item.ShortName = t.ShortName
+ }
+ out = append(out, item)
}
return out, nil
}
diff --git a/server/internal/httpapi/handlers_results_test.go b/server/internal/httpapi/handlers_results_test.go
new file mode 100644
index 0000000..398cfd8
--- /dev/null
+++ b/server/internal/httpapi/handlers_results_test.go
@@ -0,0 +1,48 @@
+package httpapi_test
+
+import (
+ "net/http"
+ "testing"
+)
+
+// 个人榜的单位展示字段必须取全量单位表:停用但有积分的单位也要显示名称与简称。
+func TestAthleteStandingsUsesFullTeamTable(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,
+ "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,
+ }, true); status != http.StatusOK {
+ t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
+ }
+ env.exec(t, `UPDATE teams SET active = 0 WHERE id = ?`, ids.teamB)
+
+ _, env2 := env.do(client, http.MethodGet, "/api/standings/athletes", nil, false)
+ rows := decodeData[[]map[string]any](t, env2)
+ seen := map[int64]map[string]any{}
+ for _, row := range rows {
+ seen[int64(row["athleteId"].(float64))] = row
+ }
+
+ active := seen[ids.athleteA]
+ if active == nil || active["teamName"] != "第一分公司" || active["shortName"] != "一司" {
+ t.Fatalf("在职单位的个人行应带单位名与简称: %+v", active)
+ }
+ stopped := seen[ids.athB]
+ if stopped == nil {
+ t.Fatalf("停用单位的运动员仍应在个人榜上: %+v", rows)
+ }
+ if stopped["teamName"] != "第二分公司" || stopped["shortName"] != "二司" {
+ t.Fatalf("停用单位的个人行应补齐单位名与简称,得到 %+v", stopped)
+ }
+}
diff --git a/server/internal/httpapi/handlers_users.go b/server/internal/httpapi/handlers_users.go
index 8191192..b437074 100644
--- a/server/internal/httpapi/handlers_users.go
+++ b/server/internal/httpapi/handlers_users.go
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
+ "fmt"
"net/http"
"strings"
@@ -140,6 +141,9 @@ func (s *server) createUser(ctx context.Context, in createUserInput) (userListIt
if len(in.Password) < 8 {
return userListItemDTO{}, badRequest("password 至少 8 位", nil)
}
+ if len(in.Password) > auth.MaxPasswordBytes {
+ return userListItemDTO{}, badRequest(fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes), nil)
+ }
displayName := strings.TrimSpace(in.DisplayName)
if displayName == "" {
displayName = username
@@ -226,6 +230,8 @@ func (s *server) resetUserPassword(ctx context.Context, id int64, in resetUserPa
generated = true
} else if len(password) < 8 {
return resetPasswordResultDTO{}, badRequest("password 至少 8 位", nil)
+ } else if len(password) > auth.MaxPasswordBytes {
+ return resetPasswordResultDTO{}, badRequest(fmt.Sprintf("password 不能超过 %d 字节", auth.MaxPasswordBytes), nil)
}
// SetPassword 会作废该用户全部会话,被重置的人必须重新登录。
if err := s.auth.SetPassword(ctx, u.Username, password, true); err != nil {
diff --git a/server/internal/httpapi/handlers_users_test.go b/server/internal/httpapi/handlers_users_test.go
new file mode 100644
index 0000000..53ccafc
--- /dev/null
+++ b/server/internal/httpapi/handlers_users_test.go
@@ -0,0 +1,44 @@
+package httpapi_test
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ "git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
+)
+
+// 建号与重置密码都必须拒绝超过 bcrypt 上限的密码,避免静默截断(P2-6)。
+func TestUserPasswordLengthLimits(t *testing.T) {
+ env := newTestEnv(t)
+ env.createUser("admin", "admin-pass", "admin")
+ client, _ := env.client()
+ env.login(client, "admin", "admin-pass")
+
+ tooLong := strings.Repeat("a", auth.MaxPasswordBytes+1)
+ status, env2 := env.do(client, http.MethodPost, "/api/users", map[string]any{
+ "username": "u1", "password": tooLong, "role": "recorder",
+ }, true)
+ if status != http.StatusBadRequest {
+ t.Fatalf("超长密码建号应 400,得到 %d %+v", status, env2.Error)
+ }
+
+ status, env2 = env.do(client, http.MethodPost, "/api/users", map[string]any{
+ "username": "u1", "password": "u1-pass-123", "role": "recorder",
+ }, true)
+ if status != http.StatusOK {
+ t.Fatalf("建号应 200,得到 %d %+v", status, env2.Error)
+ }
+ created := decodeData[map[string]any](t, env2)
+ id, _ := created["id"].(float64)
+ if id == 0 {
+ t.Fatalf("建号响应缺少 id: %+v", created)
+ }
+
+ status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/users/%d/password", int64(id)),
+ map[string]any{"password": tooLong}, true)
+ if status != http.StatusBadRequest {
+ t.Fatalf("超长密码重置应 400,得到 %d %+v", status, env2.Error)
+ }
+}
diff --git a/server/internal/httpapi/idempotency.go b/server/internal/httpapi/idempotency.go
index 20c8460..5fd545f 100644
--- a/server/internal/httpapi/idempotency.go
+++ b/server/internal/httpapi/idempotency.go
@@ -19,9 +19,10 @@ var errHandled = errors.New("httpapi: response already written")
// 迁移里没有幂等表,且不允许改 migrations,所以用进程内 TTL 缓存;
// 进程重启后客户端重试会重新执行,属于可接受的降级。
type idempotencyStore struct {
- mu sync.Mutex
- entries map[string]idempotentResponse
- ttl time.Duration
+ mu sync.Mutex
+ entries map[string]idempotentResponse
+ inflight map[string]chan struct{}
+ ttl time.Duration
}
type idempotentResponse struct {
@@ -33,7 +34,34 @@ func newIdempotencyStore(ttl time.Duration) *idempotencyStore {
if ttl <= 0 {
ttl = 10 * time.Minute
}
- return &idempotencyStore{entries: make(map[string]idempotentResponse), ttl: ttl}
+ return &idempotencyStore{
+ entries: make(map[string]idempotentResponse),
+ inflight: make(map[string]chan struct{}),
+ ttl: ttl,
+ }
+}
+
+// begin 声明某个 key 的执行权:leader=true 表示由本请求执行,
+// 否则返回等待通道——等首次执行结束后回放其结果,避免同一 requestId 并发双执行。
+func (s *idempotencyStore) begin(key string) (leader bool, wait <-chan struct{}) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if ch, ok := s.inflight[key]; ok {
+ return false, ch
+ }
+ ch := make(chan struct{})
+ s.inflight[key] = ch
+ return true, ch
+}
+
+// end 释放执行权并唤醒等待者。
+func (s *idempotencyStore) end(key string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if ch, ok := s.inflight[key]; ok {
+ delete(s.inflight, key)
+ close(ch)
+ }
}
func (s *idempotencyStore) get(key string) ([]byte, bool) {
@@ -106,6 +134,18 @@ func (s *server) cached(inner http.Handler) http.HandlerFunc {
writeCached(w, body)
return
}
+ // 占位锁只放在最外层:内层 idempotent 与这里共用同一把 key,
+ // 若两处都抢占会自我等待。并发同一 requestId 由这里串行化并回放首次结果。
+ leader, wait := s.idem.begin(key)
+ for !leader {
+ <-wait
+ if body, ok := s.idem.get(key); ok {
+ writeCached(w, body)
+ return
+ }
+ leader, wait = s.idem.begin(key)
+ }
+ defer s.idem.end(key)
cw := &captureWriter{ResponseWriter: w, status: http.StatusOK}
inner.ServeHTTP(cw, r)
diff --git a/server/internal/httpapi/idempotency_test.go b/server/internal/httpapi/idempotency_test.go
new file mode 100644
index 0000000..868d9ec
--- /dev/null
+++ b/server/internal/httpapi/idempotency_test.go
@@ -0,0 +1,47 @@
+package httpapi
+
+import (
+ "testing"
+ "time"
+)
+
+// 同一 requestId 并发时只能有一个执行者,其余请求等首次结束后回放其结果。
+func TestIdempotencyStoreSerializesSameKey(t *testing.T) {
+ store := newIdempotencyStore(time.Minute)
+
+ leader, _ := store.begin("k")
+ if !leader {
+ t.Fatal("首个请求应拿到执行权")
+ }
+
+ done := make(chan struct{})
+ go func() {
+ leader, wait := store.begin("k")
+ if leader {
+ t.Error("并发同 key 不应拿到执行权")
+ }
+ <-wait
+ close(done)
+ }()
+
+ // 等 goroutine 进入等待后再检查它不会提前返回。
+ time.Sleep(20 * time.Millisecond)
+ select {
+ case <-done:
+ t.Fatal("未释放执行权前等待者不应返回")
+ default:
+ }
+
+ store.end("k")
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("释放执行权后应唤醒等待者")
+ }
+
+ // 释放后应能重新拿到执行权。
+ if leader, _ := store.begin("k"); !leader {
+ t.Fatal("执行权释放后应可再次获取")
+ }
+ store.end("k")
+}
diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go
index 9fc1366..0f99303 100644
--- a/server/internal/httpapi/repo.go
+++ b/server/internal/httpapi/repo.go
@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"strings"
+
+ "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移,
@@ -176,7 +178,12 @@ func teamMapQ(ctx context.Context, q queryer) (map[int64]teamDTO, error) {
}
func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) {
- t, err := scanTeam(s.ev.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id))
+ return teamByIDQ(ctx, s.ev, id)
+}
+
+// teamByIDQ 在给定连接/事务内读取单位,供写事务内构造 change_log 载荷复用。
+func teamByIDQ(ctx context.Context, q queryer, id int64) (teamDTO, error) {
+ t, err := scanTeam(q.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return teamDTO{}, notFound("单位不存在")
}
@@ -190,7 +197,13 @@ func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return teamDTO{}, badRequest("code 与 name 必填", nil)
}
- res, err := s.ev.ExecContext(ctx,
+ tx, err := s.ev.BeginTx(ctx, nil)
+ if err != nil {
+ return teamDTO{}, internal(err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ res, err := tx.ExecContext(ctx,
`INSERT INTO teams (code, name, short_name, logo_path, color, sort_order, active)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active)
@@ -204,11 +217,28 @@ func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
if err != nil {
return teamDTO{}, internal(err)
}
- return s.getTeam(ctx, id)
+ team, err := teamByIDQ(ctx, tx, id)
+ if err != nil {
+ return teamDTO{}, err
+ }
+ // 单位改名/新建要推给大屏,否则榜单里的单位名不会刷新。
+ if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil {
+ return teamDTO{}, err
+ }
+ if err := tx.Commit(); err != nil {
+ return teamDTO{}, internal(err)
+ }
+ return team, nil
}
func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) {
- res, err := s.ev.ExecContext(ctx,
+ tx, err := s.ev.BeginTx(ctx, nil)
+ if err != nil {
+ return teamDTO{}, internal(err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ res, err := tx.ExecContext(ctx,
`UPDATE teams SET code = ?, name = ?, short_name = ?, logo_path = ?, color = ?, sort_order = ?, active = ?
WHERE id = ?`,
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active, id)
@@ -221,16 +251,38 @@ func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO,
if err := requireChanges(res, "单位不存在"); err != nil {
return teamDTO{}, err
}
- return s.getTeam(ctx, id)
+ team, err := teamByIDQ(ctx, tx, id)
+ if err != nil {
+ return teamDTO{}, err
+ }
+ if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil {
+ return teamDTO{}, err
+ }
+ if err := tx.Commit(); err != nil {
+ return teamDTO{}, internal(err)
+ }
+ return team, nil
}
func (s *server) deleteTeam(ctx context.Context, id int64) error {
// 业务数据软删除:置 active=0,保留历史成绩与流水。
- res, err := s.ev.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id)
+ tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
- return requireChanges(res, "单位不存在")
+ defer func() { _ = tx.Rollback() }()
+
+ res, err := tx.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id)
+ if err != nil {
+ return internal(err)
+ }
+ if err := requireChanges(res, "单位不存在"); err != nil {
+ return err
+ }
+ if err := bumpVersion(ctx, tx, "teams", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
+ return err
+ }
+ return tx.Commit()
}
// ---------- athletes ----------
diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go
index 0027aab..cb5440c 100644
--- a/server/internal/httpapi/repo_events.go
+++ b/server/internal/httpapi/repo_events.go
@@ -5,6 +5,8 @@ import (
"database/sql"
"errors"
"strings"
+
+ "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- events ----------
@@ -53,12 +55,9 @@ func (s *server) listEvents(ctx context.Context, sportID int64) ([]eventDTO, err
}
func (s *server) getEvent(ctx context.Context, id int64) (eventDTO, error) {
- e, err := scanEvent(s.ev.QueryRowContext(ctx, `SELECT `+eventColumns+` FROM events WHERE id = ?`, id))
- if errors.Is(err, sql.ErrNoRows) {
- return eventDTO{}, notFound("小项不存在")
- }
+ e, err := eventByIDQ(ctx, s.ev, id)
if err != nil {
- return eventDTO{}, internal(err)
+ return eventDTO{}, err
}
cats, err := s.listCategories(ctx, id)
if err != nil {
@@ -73,11 +72,29 @@ func (s *server) getEvent(ctx context.Context, id int64) (eventDTO, error) {
return e, nil
}
+// eventByIDQ 在给定连接/事务内读取小项(不含组别/通道),供写事务内构造 change_log 载荷复用。
+func eventByIDQ(ctx context.Context, q queryer, id int64) (eventDTO, error) {
+ e, err := scanEvent(q.QueryRowContext(ctx, `SELECT `+eventColumns+` FROM events WHERE id = ?`, id))
+ if errors.Is(err, sql.ErrNoRows) {
+ return eventDTO{}, notFound("小项不存在")
+ }
+ if err != nil {
+ return eventDTO{}, internal(err)
+ }
+ return e, nil
+}
+
func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) {
if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
- res, err := s.ev.ExecContext(ctx,
+ tx, err := s.ev.BeginTx(ctx, nil)
+ if err != nil {
+ return eventDTO{}, internal(err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ res, err := tx.ExecContext(ctx,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -99,11 +116,24 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
if err != nil {
return eventDTO{}, internal(err)
}
+ // 项目增删改要推给大屏,否则底栏/分项页拿不到新项目。
+ if err := s.bumpEvent(ctx, tx, id); err != nil {
+ return eventDTO{}, err
+ }
+ if err := tx.Commit(); err != nil {
+ return eventDTO{}, internal(err)
+ }
return s.getEvent(ctx, id)
}
func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) {
- res, err := s.ev.ExecContext(ctx,
+ tx, err := s.ev.BeginTx(ctx, nil)
+ if err != nil {
+ return eventDTO{}, internal(err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ res, err := tx.ExecContext(ctx,
`UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?,
result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?,
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?
@@ -125,9 +155,24 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
if err := requireChanges(res, "小项不存在"); err != nil {
return eventDTO{}, err
}
+ if err := s.bumpEvent(ctx, tx, id); err != nil {
+ return eventDTO{}, err
+ }
+ if err := tx.Commit(); err != nil {
+ return eventDTO{}, internal(err)
+ }
return s.getEvent(ctx, id)
}
+// bumpEvent 在写事务内推送小项变更;组别/通道的改动同样以本小项为载体。
+func (s *server) bumpEvent(ctx context.Context, tx *sql.Tx, eventID int64) error {
+ ev, err := eventByIDQ(ctx, tx, eventID)
+ if err != nil {
+ return err
+ }
+ return bumpVersion(ctx, tx, "events", eventID, "upsert", ev, middleware.User(ctx).ID)
+}
+
func (s *server) deleteEvent(ctx context.Context, id int64) error {
has, err := exists(ctx, s.ev, `SELECT 1 FROM entries WHERE event_id = ? LIMIT 1`, id)
if err != nil {
@@ -150,6 +195,9 @@ func (s *server) deleteEvent(ctx context.Context, id int64) error {
return internal(err)
}
}
+ if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
+ return err
+ }
if err := tx.Commit(); err != nil {
return internal(err)
}
@@ -228,6 +276,9 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
return nil, internal(err)
}
}
+ if err := s.bumpEvent(ctx, tx, eventID); err != nil {
+ return nil, err
+ }
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
@@ -291,6 +342,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
return nil, internal(err)
}
}
+ if err := s.bumpEvent(ctx, tx, eventID); err != nil {
+ return nil, err
+ }
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go
index 4d66944..eedf909 100644
--- a/server/internal/httpapi/repo_misc.go
+++ b/server/internal/httpapi/repo_misc.go
@@ -11,6 +11,7 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
+ "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- settings ----------
@@ -34,7 +35,13 @@ func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error)
}
func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) {
- values, err := loadSettingsMap(ctx, s.ev)
+ return s.settingsSnapshotQ(ctx, s.ev)
+}
+
+// settingsSnapshotQ 在给定连接/事务内构造与 GET /api/settings 同构的配置,
+// 供写事务内的 change_log 载荷复用,保证大屏收到的 settings 与 REST 口径一致。
+func (s *server) settingsSnapshotQ(ctx context.Context, q queryer) (dto.EventSettings, error) {
+ values, err := loadSettingsMap(ctx, q)
if err != nil {
return dto.EventSettings{}, err
}
@@ -43,7 +50,7 @@ func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) {
if code := values["scoring.default_profile"]; code != "" {
var id int64
- err := s.ev.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
+ err := q.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
switch {
case errors.Is(err, sql.ErrNoRows):
// 配置引用的模板已被删除:返回 null,由管理端提示重新选择。
@@ -114,6 +121,10 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
return dto.EventSettings{}, err
}
if p.RotateSeconds != nil {
+ // 前端 type=number 的 min/max 只是浏览器提示,服务端必须兜住,否则 1 或 9999 会落库。
+ if *p.RotateSeconds < minRotateSeconds || *p.RotateSeconds > maxRotateSeconds {
+ return dto.EventSettings{}, badRequest("display.rotateSeconds 需在 5~120 之间", *p.RotateSeconds)
+ }
if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil {
return dto.EventSettings{}, err
}
@@ -121,6 +132,10 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
}
if p := patch.Standings; p != nil {
if p.IndividualToTeamWeight != nil {
+ // 0 与负数会被 domain 按 1 处理,等于静默忽略用户配置,直接拒绝更明确。
+ if *p.IndividualToTeamWeight <= 0 {
+ return dto.EventSettings{}, badRequest("standings.individualToTeamWeight 必须大于 0", *p.IndividualToTeamWeight)
+ }
if err := set("standings.individual_to_team_weight",
strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil {
return dto.EventSettings{}, err
@@ -160,12 +175,26 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
}
}
+ // 配置写操作必须留一条 change_log,否则 broadcastChanges 推空、大屏的标题/主题/榜单口径不刷新。
+ snapshot, err := s.settingsSnapshotQ(ctx, tx)
+ if err != nil {
+ return dto.EventSettings{}, err
+ }
+ if err := bumpVersion(ctx, tx, "settings", 0, "upsert", snapshot, middleware.User(ctx).ID); err != nil {
+ return dto.EventSettings{}, err
+ }
if err := tx.Commit(); err != nil {
return dto.EventSettings{}, internal(err)
}
- return s.getSettings(ctx)
+ return snapshot, nil
}
+// 大屏轮播间隔的取值范围,与 EventInfoPanel 的输入约束一致。
+const (
+ minRotateSeconds = 5
+ maxRotateSeconds = 120
+)
+
func atofDefault(v string, def float64) float64 {
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
@@ -216,11 +245,28 @@ func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.Stan
Athletes: athletes,
Ledger: ledger,
Ranked: ranked,
+ TieBreak: splitSettingList(values["standings.tie_break"]),
IndividualToTeamWeight: weight,
ExcludeTeamEventMedals: exclude,
}, nil
}
+// splitSettingList 拆分 settings 里的逗号分隔列表(如 standings.tie_break),
+// 口径与 rules 包对同一键的解析一致:去空白、丢空项,空值返回 nil 由 domain 落回默认序。
+func splitSettingList(raw string) []string {
+ if strings.TrimSpace(raw) == "" {
+ return nil
+ }
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if p = strings.TrimSpace(p); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) {
@@ -363,6 +409,15 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
if err != nil {
return teamBreakdownDTO{}, err
}
+ // 合计必须与总积分榜同口径:仅记单位的流水直加,记到个人的流水乘折算系数。
+ values, err := loadSettingsMap(ctx, s.ev)
+ if err != nil {
+ return teamBreakdownDTO{}, err
+ }
+ weight := atofDefault(values["standings.individual_to_team_weight"], 1)
+ if weight <= 0 {
+ weight = 1
+ }
rows, err := s.ev.QueryContext(ctx,
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name,
l.athlete_id, a.name, r.rank, r.result_text, l.points, COALESCE(l.created_at, '')
@@ -401,6 +456,9 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
item.AthleteName = strPtr(athleteName)
item.Rank = intPtr(rank)
item.ResultText = strPtr(resultText)
+ if item.AthleteID != nil && *item.AthleteID != 0 {
+ item.Points *= weight
+ }
out.Items = append(out.Items, item)
out.TotalPoints += item.Points
}
@@ -455,6 +513,9 @@ func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, use
default:
return recordBreakDTO{}, badRequest("level 非法", in.Level)
}
+ if err := s.validateRecordBreakEntry(ctx, in); err != nil {
+ return recordBreakDTO{}, err
+ }
res, err := s.ev.ExecContext(ctx,
`INSERT INTO record_breaks (event_id, entry_id, level, result_value, result_text, bonus_points, state)
VALUES (?, ?, ?, ?, ?, 0, 'pending')`,
@@ -472,6 +533,45 @@ func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, use
return s.getRecordBreak(ctx, id)
}
+// validateRecordBreakEntry 拦住「凭空加分」:报名项必须属于该小项、小项可破纪录、
+// 且已有已发布且状态正常的成绩,否则确认后会直接写加分流水与纪录库。
+func (s *server) validateRecordBreakEntry(ctx context.Context, in recordBreakInput) error {
+ var (
+ entryEvent int64
+ recordEligible bool
+ )
+ err := s.ev.QueryRowContext(ctx,
+ `SELECT e.event_id, ev.record_eligible FROM entries e JOIN events ev ON ev.id = e.event_id WHERE e.id = ?`,
+ in.EntryID).Scan(&entryEvent, &recordEligible)
+ if errors.Is(err, sql.ErrNoRows) {
+ return badRequest("entryId 不存在", in.EntryID)
+ }
+ if err != nil {
+ return internal(err)
+ }
+ if entryEvent != in.EventID {
+ return badRequest("entryId 不属于该小项", in.EntryID)
+ }
+ if !recordEligible {
+ return badRequest("该小项未开启破纪录登记", in.EventID)
+ }
+ var (
+ state string
+ status string
+ )
+ err = s.ev.QueryRowContext(ctx, `SELECT state, status FROM results WHERE entry_id = ?`, in.EntryID).Scan(&state, &status)
+ if errors.Is(err, sql.ErrNoRows) {
+ return badRequest("该报名项尚无成绩,不能登记破纪录", in.EntryID)
+ }
+ if err != nil {
+ return internal(err)
+ }
+ if state != string(domain.ResultStatePublished) || status != string(domain.ResultStatusOK) {
+ return badRequest("该成绩尚未发布或状态异常,不能登记破纪录", in.EntryID)
+ }
+ return nil
+}
+
func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) {
var (
b recordBreakDTO
diff --git a/server/internal/httpapi/repo_misc_test.go b/server/internal/httpapi/repo_misc_test.go
new file mode 100644
index 0000000..4fd20be
--- /dev/null
+++ b/server/internal/httpapi/repo_misc_test.go
@@ -0,0 +1,208 @@
+package httpapi_test
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+)
+
+// changeLogCount 统计某实体某动作的变更记录数。
+func changeLogCount(t *testing.T, env *testEnv, entity, action string) int {
+ t.Helper()
+ var n int
+ if err := env.ev.QueryRow(`SELECT COUNT(*) FROM change_log WHERE entity_type = ? AND action = ?`,
+ entity, action).Scan(&n); err != nil {
+ t.Fatalf("统计 change_log: %v", err)
+ }
+ return n
+}
+
+// 配置写操作必须写 change_log,否则 broadcastChanges 推空、大屏不刷新;
+// 同时校验数值范围,浏览器 min/max 只是提示,不能当服务端校验。
+func TestSettingsPatchValidationAndChangeLog(t *testing.T) {
+ env := newTestEnv(t)
+ env.createUser("admin", "admin-pass", "admin")
+ client, _ := env.client()
+ env.login(client, "admin", "admin-pass")
+
+ for _, tc := range []struct {
+ name string
+ body map[string]any
+ }{
+ {"轮播间隔过小", map[string]any{"display": map[string]any{"rotateSeconds": 1}}},
+ {"轮播间隔过大", map[string]any{"display": map[string]any{"rotateSeconds": 121}}},
+ {"折算系数为 0", map[string]any{"standings": map[string]any{"individualToTeamWeight": 0}}},
+ {"折算系数为负", map[string]any{"standings": map[string]any{"individualToTeamWeight": -1}}},
+ } {
+ status, env2 := env.do(client, http.MethodPut, "/api/settings", tc.body, true)
+ if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
+ t.Fatalf("%s应 400 VALIDATION_FAILED,得到 %d %+v", tc.name, status, env2.Error)
+ }
+ }
+
+ before := changeLogCount(t, env, "settings", "upsert")
+ status, env2 := env.do(client, http.MethodPut, "/api/settings", map[string]any{
+ "event": map[string]any{"name": "秋季运动会", "subtitle": ""},
+ "display": map[string]any{"rotateSeconds": 30},
+ "standings": map[string]any{"individualToTeamWeight": 0.5},
+ }, true)
+ if status != http.StatusOK || !env2.OK {
+ t.Fatalf("合法配置应 200,得到 %d %+v", status, env2.Error)
+ }
+ if got := changeLogCount(t, env, "settings", "upsert"); got != before+1 {
+ t.Fatalf("配置写操作应新增 1 条 settings change_log,得到 %d → %d", before, got)
+ }
+
+ _, env2 = env.do(client, http.MethodGet, "/api/settings", nil, false)
+ settings := decodeData[map[string]any](t, env2)
+ event := settings["event"].(map[string]any)
+ if event["name"] != "秋季运动会" || event["subtitle"] != nil {
+ t.Fatalf("空串应归一为 null、名称应落库: %+v", event)
+ }
+ display := settings["display"].(map[string]any)
+ if display["rotateSeconds"].(float64) != 30 {
+ t.Fatalf("轮播间隔应落库为 30: %+v", display)
+ }
+}
+
+// 破纪录登记必须基于该项目一条已发布且状态正常的成绩,否则 recorder 可凭空加分。
+func TestRecordBreakRequiresPublishedResult(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")
+
+ body := map[string]any{"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
+ "resultValue": 10.5, "resultText": "10.50"}
+
+ // 没有成绩。
+ status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", body, true)
+ if status != http.StatusBadRequest {
+ t.Fatalf("无成绩时登记破纪录应 400,得到 %d %+v", status, env2.Error)
+ }
+
+ // 只有草稿成绩。
+ env.exec(t, `INSERT INTO results (event_id, entry_id, rank, result_value, status, state)
+ VALUES (?, ?, 1, 10.5, 'ok', 'draft')`, ids.event, ids.entryA)
+ status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
+ if status != http.StatusBadRequest {
+ t.Fatalf("草稿成绩登记破纪录应 400,得到 %d %+v", status, env2.Error)
+ }
+
+ // 已发布但小项不可破纪录。
+ env.exec(t, `UPDATE results SET state = 'published' WHERE entry_id = ?`, ids.entryA)
+ env.exec(t, `UPDATE events SET record_eligible = 0 WHERE id = ?`, ids.event)
+ status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
+ if status != http.StatusBadRequest {
+ t.Fatalf("不可破纪录项目应 400,得到 %d %+v", status, env2.Error)
+ }
+
+ // 报名项不属于该项目。
+ env.exec(t, `UPDATE events SET record_eligible = 1 WHERE id = ?`, ids.event)
+ otherEvent := env.insert(t,
+ `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
+ VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
+ status, env2 = env.do(client, http.MethodPost, "/api/record-breaks",
+ map[string]any{"eventId": otherEvent, "entryId": ids.entryA, "level": "meet",
+ "resultValue": 10.5, "resultText": "10.50"}, true)
+ if status != http.StatusBadRequest {
+ t.Fatalf("跨项目登记破纪录应 400,得到 %d %+v", status, env2.Error)
+ }
+
+ // 合法:已发布成绩 + 可破纪录项目。
+ status, env2 = env.do(client, http.MethodPost, "/api/record-breaks", body, true)
+ if status != http.StatusOK || !env2.OK {
+ t.Fatalf("合法登记应 200,得到 %d %+v", status, env2.Error)
+ }
+}
+
+// 单位积分明细的合计必须与总积分榜同口径:个人流水乘折算系数。
+func TestTeamBreakdownAppliesIndividualWeight(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,
+ "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,
+ }, true); status != http.StatusOK {
+ t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
+ }
+ env.exec(t, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
+
+ status, env2 := env.do(client, http.MethodGet,
+ fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
+ if status != http.StatusOK || !env2.OK {
+ t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error)
+ }
+ breakdown := decodeData[map[string]any](t, env2)
+ if got := breakdown["totalPoints"].(float64); got != 5 {
+ t.Fatalf("合计应按系数折算为 5,得到 %v", got)
+ }
+ items := breakdown["items"].([]any)
+ if len(items) != 1 || items[0].(map[string]any)["points"].(float64) != 5 {
+ t.Fatalf("明细行也应按系数折算: %+v", items)
+ }
+
+ // 与总积分榜一致。
+ _, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
+ for _, row := range decodeData[[]map[string]any](t, env2) {
+ if row["teamCode"] == "A" && row["totalPoints"].(float64) != 5 {
+ t.Fatalf("总积分榜与明细合计应一致: %+v", row)
+ }
+ }
+}
+
+// standings.tie_break 必须真正参与排序,否则换规则包后名次仍走硬编码默认序。
+func TestStandingsTieBreakFromSettings(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.exec(t, `UPDATE teams SET name = 'zzz' WHERE id = ?`, ids.teamA)
+ env.exec(t, `UPDATE teams SET name = 'aaa' WHERE id = ?`, ids.teamB)
+
+ env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
+ "eventId": ids.event,
+ "results": []map[string]any{
+ {"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
+ {"entryId": ids.entryB, "resultValue": 11.58, "unit": "s", "status": "ok"},
+ },
+ }, true)
+ if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
+ "eventId": ids.event,
+ }, true); status != http.StatusOK {
+ t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
+ }
+
+ first := func() string {
+ t.Helper()
+ _, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
+ rows := decodeData[[]map[string]any](t, env2)
+ if len(rows) != 2 {
+ t.Fatalf("应返回 2 个单位,得到 %d", len(rows))
+ }
+ return rows[0]["teamCode"].(string)
+ }
+
+ if got := first(); got != "A" {
+ t.Fatalf("默认按单位编码排序应 A 在前,得到 %s", got)
+ }
+ env.exec(t, `INSERT INTO settings (key, value, updated_at) VALUES ('standings.tie_break', 'points,name', datetime('now'))
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
+ if got := first(); got != "B" {
+ t.Fatalf("配置 standings.tie_break=points,name 后应按名称排序(aaa 在前),得到 %s", got)
+ }
+}
diff --git a/server/internal/httpapi/repo_results.go b/server/internal/httpapi/repo_results.go
index 50c4fbc..94b4e30 100644
--- a/server/internal/httpapi/repo_results.go
+++ b/server/internal/httpapi/repo_results.go
@@ -178,7 +178,8 @@ func (s *server) listResultsPage(ctx context.Context, eventID int64, state strin
return pageResult[resultDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
-// upsertDraft 批量保存草稿;已发布的成绩保留 published 状态,避免静默撤下大屏。
+// upsertDraft 批量保存草稿;已发布的成绩直接拒绝(STATE_INVALID),
+// 必须先撤销发布再改,避免 rank 被就地清空而流水不动。
func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, items []draftResultInput) ([]resultDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
@@ -200,6 +201,18 @@ func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, i
if entryEvent != eventID {
return nil, badRequest("entryId 不属于该小项", it.EntryID)
}
+ // 已发布成绩不能被「存草稿」就地改写:ON CONFLICT 不改 state,
+ // 若放行会把 rank 清空但流水不动,大屏奖牌与积分立刻对不上。
+ var state string
+ err := tx.QueryRowContext(ctx, `SELECT state FROM results WHERE entry_id = ?`, it.EntryID).Scan(&state)
+ switch {
+ case errors.Is(err, sql.ErrNoRows):
+ // 尚无成绩,正常新建草稿。
+ case err != nil:
+ return nil, internal(err)
+ case state == string(domain.ResultStatePublished):
+ return nil, stateInvalid("该成绩已发布,请先撤销发布再修改")
+ }
if _, err := tx.ExecContext(ctx,
`INSERT INTO results (event_id, entry_id, rank, result_text, result_value, unit, is_tie,
points_override, status, state, recorded_by, recorded_at, note, updated_at)
@@ -208,7 +221,7 @@ func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, i
rank = excluded.rank,
result_text = excluded.result_text,
result_value = excluded.result_value,
- unit = excluded.unit,
+ unit = COALESCE(excluded.unit, results.unit),
is_tie = excluded.is_tie,
points_override = excluded.points_override,
status = excluded.status,
diff --git a/server/internal/httpapi/repo_results_test.go b/server/internal/httpapi/repo_results_test.go
new file mode 100644
index 0000000..a5c8d96
--- /dev/null
+++ b/server/internal/httpapi/repo_results_test.go
@@ -0,0 +1,113 @@
+package httpapi_test
+
+import (
+ "database/sql"
+ "net/http"
+ "testing"
+)
+
+// 已发布成绩不能被「存草稿」就地覆盖:ON CONFLICT 不改 state,
+// 放行会把 rank 清空但流水不动,大屏奖牌与积分立刻对不上。
+func TestSaveDraftRejectsPublishedResult(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,
+ "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,
+ }, true); status != http.StatusOK {
+ t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
+ }
+
+ status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
+ "eventId": ids.event,
+ "results": []map[string]any{
+ {"entryId": ids.entryA, "resultValue": 12.00, "status": "ok"},
+ },
+ }, true)
+ if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "STATE_INVALID" {
+ t.Fatalf("已发布成绩存草稿应 409 STATE_INVALID,得到 %d %+v", status, env2.Error)
+ }
+
+ var (
+ rank int
+ value float64
+ state string
+ )
+ if err := env.ev.QueryRow(`SELECT rank, result_value, state FROM results WHERE entry_id = ?`, ids.entryA).
+ Scan(&rank, &value, &state); err != nil {
+ t.Fatalf("读取成绩: %v", err)
+ }
+ if rank != 1 || value != 11.58 || state != "published" {
+ t.Fatalf("被拒的存草稿不得改动已发布行: rank=%d value=%v state=%s", rank, value, state)
+ }
+
+ // 正确流程:先撤销发布,再存草稿。
+ if status, env2 := env.do(client, http.MethodPost, "/api/results/unpublish", map[string]any{
+ "eventId": ids.event,
+ }, true); status != http.StatusOK {
+ t.Fatalf("撤销发布应 200,得到 %d %+v", status, env2.Error)
+ }
+ status, env2 = env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
+ "eventId": ids.event,
+ "results": []map[string]any{
+ {"entryId": ids.entryA, "resultValue": 12.00, "unit": "s", "status": "ok"},
+ },
+ }, true)
+ if status != http.StatusOK || !env2.OK {
+ t.Fatalf("撤销发布后存草稿应 200,得到 %d %+v", status, env2.Error)
+ }
+}
+
+// 草稿未传 unit 时应保留原值,否则 100 米成绩会从 1:06.33 退化成 66.33。
+func TestSaveDraftKeepsUnitWhenOmitted(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,
+ "results": []map[string]any{
+ {"entryId": ids.entryA, "resultValue": 66.33, "unit": "s", "status": "ok"},
+ },
+ }, true)
+ env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
+ "eventId": ids.event,
+ "results": []map[string]any{
+ {"entryId": ids.entryA, "resultValue": 66.33, "resultText": "1:06.33", "status": "ok"},
+ },
+ }, true)
+
+ var unit sql.NullString
+ if err := env.ev.QueryRow(`SELECT unit FROM results WHERE entry_id = ?`, ids.entryA).Scan(&unit); err != nil {
+ t.Fatalf("读取单位: %v", err)
+ }
+ if !unit.Valid || unit.String != "s" {
+ t.Fatalf("未传 unit 时应保留原值 s,得到 %+v", unit)
+ }
+
+ // 显式传 unit 仍可覆盖。
+ env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
+ "eventId": ids.event,
+ "results": []map[string]any{
+ {"entryId": ids.entryA, "resultValue": 1.1, "unit": "min", "status": "ok"},
+ },
+ }, true)
+ if err := env.ev.QueryRow(`SELECT unit FROM results WHERE entry_id = ?`, ids.entryA).Scan(&unit); err != nil {
+ t.Fatalf("读取单位: %v", err)
+ }
+ if !unit.Valid || unit.String != "min" {
+ t.Fatalf("显式传 unit 应覆盖为 min,得到 %+v", unit)
+ }
+}
diff --git a/server/internal/httpapi/repo_test.go b/server/internal/httpapi/repo_test.go
new file mode 100644
index 0000000..019dd31
--- /dev/null
+++ b/server/internal/httpapi/repo_test.go
@@ -0,0 +1,87 @@
+package httpapi_test
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
+)
+
+// 单位增删改必须写 change_log,否则 broadcastChanges 推空、大屏单位名不刷新。
+func TestTeamWritesWriteChangeLogAndBroadcast(t *testing.T) {
+ fake := &fakeRealtime{}
+ env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake })
+ fake.db = env.ev
+ env.createUser("recorder", "recorder-pass", "recorder")
+ client, _ := env.client()
+ env.login(client, "recorder", "recorder-pass")
+
+ status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
+ "code": "C", "name": "第三分公司", "active": true,
+ }, true)
+ if status != http.StatusOK || !env2.OK {
+ t.Fatalf("建单位应 200,得到 %d %+v", status, env2.Error)
+ }
+ if got := changeLogCount(t, env, "teams", "upsert"); got != 1 {
+ t.Fatalf("建单位应写 1 条 teams change_log,得到 %d", got)
+ }
+ created := decodeData[map[string]any](t, env2)
+ teamID := int64(created["id"].(float64))
+
+ found := false
+ for _, ch := range fake.firstBatch(t) {
+ if ch.Entity == "teams" && ch.Action == "upsert" && ch.ID == teamID {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("建单位后应广播 teams 变更")
+ }
+
+ if status, env2 = env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d", teamID), nil, true); status != http.StatusOK {
+ t.Fatalf("删单位应 200,得到 %d %+v", status, env2.Error)
+ }
+ if got := changeLogCount(t, env, "teams", "void"); got != 1 {
+ t.Fatalf("删单位应写 1 条 teams void 变更,得到 %d", got)
+ }
+}
+
+// 项目、组别、通道的写操作同样要推 change_log,大屏才会刷新。
+func TestEventWritesWriteChangeLog(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/events", map[string]any{
+ "sportId": ids.sport, "code": "M400", "name": "400米跑",
+ "eventType": "individual", "resultType": "time", "compare": "asc", "scored": true,
+ }, true)
+ if status != http.StatusOK || !env2.OK {
+ t.Fatalf("建项目应 200,得到 %d %+v", status, env2.Error)
+ }
+ if got := changeLogCount(t, env, "events", "upsert"); got != 1 {
+ t.Fatalf("建项目应写 1 条 events change_log,得到 %d", got)
+ }
+ created := decodeData[map[string]any](t, env2)
+ eventID := int64(created["id"].(float64))
+
+ status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/categories", eventID), map[string]any{
+ "categories": []map[string]any{{"name": "男子", "multiplier": 1}},
+ }, true)
+ if status != http.StatusOK || !env2.OK {
+ t.Fatalf("替换组别应 200,得到 %d %+v", status, env2.Error)
+ }
+ if got := changeLogCount(t, env, "events", "upsert"); got != 2 {
+ t.Fatalf("替换组别应再写 1 条 events change_log,得到 %d", got)
+ }
+
+ if status, env2 = env.do(client, http.MethodDelete, fmt.Sprintf("/api/events/%d", eventID), nil, true); status != http.StatusOK {
+ t.Fatalf("删项目应 200,得到 %d %+v", status, env2.Error)
+ }
+ if got := changeLogCount(t, env, "events", "void"); got != 1 {
+ t.Fatalf("删项目应写 1 条 events void 变更,得到 %d", got)
+ }
+}
diff --git a/server/internal/httpapi/review_fixes_test.go b/server/internal/httpapi/review_fixes_test.go
index 553e0ff..a29d55a 100644
--- a/server/internal/httpapi/review_fixes_test.go
+++ b/server/internal/httpapi/review_fixes_test.go
@@ -402,6 +402,10 @@ func TestApproveRecordBreakUpsertsRecords(t *testing.T) {
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
+ // 破纪录登记要求该报名项已有已发布成绩,先落一条。
+ env.exec(t, `INSERT INTO results (event_id, entry_id, rank, result_value, result_text, unit, status, state)
+ VALUES (?, ?, 1, 10.5, '10.50', 's', 'ok', 'published')`, ids.event, ids.entryA)
+
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",
diff --git a/server/internal/httpapi/rules_handlers.go b/server/internal/httpapi/rules_handlers.go
index 9a99f6c..83face9 100644
--- a/server/internal/httpapi/rules_handlers.go
+++ b/server/internal/httpapi/rules_handlers.go
@@ -52,6 +52,52 @@ func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error
return nil, errHandled
}
+// rulesPackCompatible 判断规则包格式版本是否与当前程序一致。
+func rulesPackCompatible(pack *rules.RulePack) bool {
+ return pack.Format == rules.FormatName && pack.FormatVersion == rules.FormatVersion
+}
+
+// rulesImportPreview 是规则预览响应:在 ChangeSet 之上补齐前端契约里的兼容性与影响面。
+type rulesImportPreview struct {
+ *rules.ChangeSet
+ // Compatible 由 formatVersion 判断;能解析到这里说明版本已兼容。
+ Compatible bool `json:"compatible"`
+ AffectedEventCount int `json:"affectedEventCount"`
+}
+
+// rulesImportResult 是规则落地响应:在 ApplyResult 之上补齐预览字段与重算条数。
+// 同时带上 ChangeSet,前端落地后仍能显示这次导入的逐条差异(否则会被覆盖成「无差异」)。
+type rulesImportResult struct {
+ *rules.ApplyResult
+ *rules.ChangeSet
+ Compatible bool `json:"compatible"`
+ AffectedEventCount int `json:"affectedEventCount"`
+ RecalculatedLedgerRows int `json:"recalculatedLedgerRows"`
+}
+
+// affectedEventCount 统计本次导入影响的小项数:计分通道变化的项目,
+// 加上引用了被改动模板的项目——模板分值一变,这些项目的积分就会跟着变。
+func affectedEventCount(cs *rules.ChangeSet, cur *rules.Snapshot) int {
+ affected := make(map[string]bool, len(cs.Channels))
+ for _, ch := range cs.Channels {
+ affected[ch.EventCode] = true
+ }
+ if len(cs.Profiles) > 0 {
+ changed := make(map[string]bool, len(cs.Profiles))
+ for _, pc := range cs.Profiles {
+ changed[pc.Code] = true
+ }
+ for _, ev := range cur.Events {
+ for _, ch := range ev.Channels {
+ if changed[ch.Profile] {
+ affected[ev.Code] = true
+ }
+ }
+ }
+ }
+ return len(affected)
+}
+
func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if err != nil {
@@ -76,60 +122,88 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
// 先预览再落地:?preview=1 只返回差异。
if r.URL.Query().Get("preview") == "1" {
- return cs, nil
+ return rulesImportPreview{
+ ChangeSet: cs,
+ Compatible: rulesPackCompatible(pack),
+ AffectedEventCount: affectedEventCount(cs, cur),
+ }, nil
}
res, err := rules.Apply(s.ev, cs)
if err != nil {
return nil, internal(err)
}
- // 分值一变,历史成绩的积分必须全量重算。
- if err := s.recomputeAllEvents(r.Context(), middlewareUserID(r)); err != nil {
- return nil, internal(err)
+ // 分值一变,历史成绩的积分与名次必须全量重算。
+ rows, err := s.recomputeAllEvents(r.Context(), middlewareUserID(r))
+ if err != nil {
+ // 规则已经落库、重算整体回滚,现场处于「新规则 + 旧积分」;
+ // 必须显式告知调用方,不能报成功让操作员以为重算已完成。
+ s.logger.Error("规则导入后重算失败", "err", err)
+ return nil, &apiError{
+ status: http.StatusInternalServerError,
+ code: "INTERNAL",
+ message: "规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入",
+ detail: err.Error(),
+ }
}
s.audit(r.Context(), "rules.import", "rules", 0, nil, res)
- return res, nil
+ return rulesImportResult{
+ ApplyResult: res,
+ ChangeSet: cs,
+ Compatible: rulesPackCompatible(pack),
+ AffectedEventCount: affectedEventCount(cs, cur),
+ RecalculatedLedgerRows: rows,
+ }, nil
}
-// recomputeAllEvents 逐项目重算积分流水,用于规则包导入等会改变分值的场景。
-func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) error {
- rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events`)
+// recomputeAllEvents 全量重算所有项目的积分流水与名次,用于规则包导入等会改变分值的场景。
+// 整个重算共用一个事务:任一项目失败就全部回滚,不会留下「只重算了一部分」的中间状态。
+// 返回写入的积分流水条数,供导入结果回报。
+func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, error) {
+ rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events ORDER BY id`)
if err != nil {
- return err
+ return 0, err
}
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
- return err
+ return 0, err
}
ids = append(ids, id)
}
if err := rows.Close(); err != nil {
- return err
+ return 0, err
}
if err := rows.Err(); err != nil {
- return err
+ return 0, err
}
- for _, eventID := range ids {
- tx, err := s.ev.BeginTx(ctx, nil)
- if err != nil {
- return err
- }
- if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import"); err != nil {
- _ = 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
- }
+ tx, err := s.ev.BeginTx(ctx, nil)
+ if err != nil {
+ return 0, err
}
- return nil
+ defer func() { _ = tx.Rollback() }()
+
+ total := 0
+ for _, eventID := range ids {
+ score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import")
+ if err != nil {
+ return 0, err
+ }
+ // 名次随分值一起变化,必须回写 results,否则奖牌榜与积分榜互相矛盾。
+ if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
+ return 0, err
+ }
+ total += len(score.Ledger)
+ }
+ // 重算改动了积分,必须写版本号并推送,否则大屏停在旧比分。
+ if err := s.bumpStandings(ctx, tx, actorID); err != nil {
+ return 0, err
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, err
+ }
+ return total, nil
}
diff --git a/server/internal/httpapi/rules_handlers_test.go b/server/internal/httpapi/rules_handlers_test.go
new file mode 100644
index 0000000..bc4a55f
--- /dev/null
+++ b/server/internal/httpapi/rules_handlers_test.go
@@ -0,0 +1,243 @@
+package httpapi_test
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// decodeRaw 解析 doRaw 返回的完整响应体({ok,data} 信封)。
+func decodeRaw[T any](t *testing.T, raw []byte) T {
+ t.Helper()
+ var env envelope
+ if err := json.Unmarshal(raw, &env); err != nil {
+ t.Fatalf("解析响应: %v (%s)", err, raw)
+ }
+ return decodeData[T](t, env)
+}
+
+// exportRulesYAML 取当前规则包(导出与导入共用同一序列化,保证往返可解析)。
+func (e *testEnv) exportRulesYAML(t *testing.T, client *http.Client) string {
+ t.Helper()
+ resp, err := client.Get(e.server.URL + "/api/rules/export")
+ 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)
+ }
+ return string(body)
+}
+
+// publishTwoResults 录入两条成绩并发布,返回两条流水的分值(按单位)。
+func (e *testEnv) publishTwoResults(t *testing.T, client *http.Client, ids seedIDs) {
+ t.Helper()
+ status, env2 := e.do(client, http.MethodPut, "/api/results/draft", map[string]any{
+ "eventId": ids.event,
+ "requestId": "draft-rules-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 != http.StatusOK || !env2.OK {
+ t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error)
+ }
+ status, env2 = e.do(client, http.MethodPost, "/api/results/publish", map[string]any{
+ "eventId": ids.event,
+ "requestId": "publish-rules-1",
+ }, true)
+ if status != http.StatusOK || !env2.OK {
+ t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
+ }
+}
+
+// ledgerPointsByTeam 读取当前有效流水的「单位 → 分值」。
+func (e *testEnv) ledgerPointsByTeam(t *testing.T) map[int64]float64 {
+ t.Helper()
+ rows, err := e.ev.Query(`SELECT team_id, points FROM score_ledger WHERE voided = 0 AND source_type = 'result'`)
+ if err != nil {
+ t.Fatalf("查询流水: %v", err)
+ }
+ defer func() { _ = rows.Close() }()
+ out := map[int64]float64{}
+ for rows.Next() {
+ var team int64
+ var points float64
+ if err := rows.Scan(&team, &points); err != nil {
+ t.Fatalf("扫描流水: %v", err)
+ }
+ out[team] = points
+ }
+ return out
+}
+
+// 规则导入后必须回写名次:分值一变,results.rank 也要跟着新规则重算(P0-5)。
+func TestRulesImportRewritesRanks(t *testing.T) {
+ env := newTestEnv(t)
+ ids := env.seed(t)
+ env.createUser("admin", "admin-pass", "admin")
+ client, _ := env.client()
+ env.login(client, "admin", "admin-pass")
+ env.publishTwoResults(t, client, ids)
+
+ // 模拟名次过期/写坏,导入规则包后应被重算覆盖。
+ env.exec(t, `UPDATE results SET rank = 99 WHERE event_id = ?`, ids.event)
+
+ pack := env.exportRulesYAML(t, client)
+ status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(pack), "application/yaml", true)
+ if status != http.StatusOK {
+ t.Fatalf("导入规则包应 200,得到 %d %s", status, raw)
+ }
+
+ rows, err := env.ev.Query(`SELECT entry_id, rank FROM results WHERE event_id = ? ORDER BY entry_id`, ids.event)
+ if err != nil {
+ t.Fatalf("查询名次: %v", err)
+ }
+ defer func() { _ = rows.Close() }()
+ ranks := map[int64]int64{}
+ for rows.Next() {
+ var entryID, rank int64
+ if err := rows.Scan(&entryID, &rank); err != nil {
+ t.Fatalf("扫描名次: %v", err)
+ }
+ ranks[entryID] = rank
+ }
+ if ranks[ids.entryA] != 1 || ranks[ids.entryB] != 2 {
+ t.Fatalf("导入后名次应重算为 A=1 B=2,得到 %+v", ranks)
+ }
+}
+
+// 预览与落地响应必须带上 compatible / affectedEventCount / recalculatedLedgerRows(P1-11)。
+func TestRulesImportReturnsContractFields(t *testing.T) {
+ env := newTestEnv(t)
+ ids := env.seed(t)
+ env.createUser("admin", "admin-pass", "admin")
+ client, _ := env.client()
+ env.login(client, "admin", "admin-pass")
+ env.publishTwoResults(t, client, ids)
+
+ // 把 M100 的计分通道换成团队模板:制造一个「影响 1 个小项」的差异。
+ pack := env.exportRulesYAML(t, client)
+ changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1)
+ if changed == pack {
+ t.Fatalf("规则包里没有可替换的计分通道,导出内容:\n%s", pack)
+ }
+
+ // 只改模板分值:引用了该模板的小项同样算「被影响」。
+ pointsChanged := strings.Replace(pack, "points: 10", "points: 11", 1)
+ if pointsChanged == pack {
+ t.Fatalf("规则包里没有可替换的分值,导出内容:\n%s", pack)
+ }
+ status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import?preview=1", []byte(pointsChanged), "application/yaml", true)
+ if status != http.StatusOK {
+ t.Fatalf("预览应 200,得到 %d %s", status, raw)
+ }
+ pointsPreview := decodeRaw[map[string]any](t, raw)
+ if count, _ := pointsPreview["affectedEventCount"].(float64); count != 1 {
+ t.Fatalf("只改模板分值时 affectedEventCount 应为 1,得到 %+v", pointsPreview["affectedEventCount"])
+ }
+
+ status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import?preview=1", []byte(changed), "application/yaml", true)
+ if status != http.StatusOK {
+ t.Fatalf("预览应 200,得到 %d %s", status, raw)
+ }
+ preview := decodeRaw[map[string]any](t, raw)
+ if preview["compatible"] != true {
+ t.Fatalf("预览 compatible 应为 true,得到 %+v", preview["compatible"])
+ }
+ if count, _ := preview["affectedEventCount"].(float64); count != 1 {
+ t.Fatalf("预览 affectedEventCount 应为 1,得到 %+v", preview["affectedEventCount"])
+ }
+ if channels, _ := preview["channels"].([]any); len(channels) != 1 {
+ t.Fatalf("预览 channels 应含 1 个小项,得到 %+v", preview["channels"])
+ }
+
+ status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true)
+ if status != http.StatusOK {
+ t.Fatalf("导入应 200,得到 %d %s", status, raw)
+ }
+ applied := decodeRaw[map[string]any](t, raw)
+ if applied["compatible"] != true {
+ t.Fatalf("导入结果 compatible 应为 true,得到 %+v", applied["compatible"])
+ }
+ if count, _ := applied["affectedEventCount"].(float64); count != 1 {
+ t.Fatalf("导入结果 affectedEventCount 应为 1,得到 %+v", applied["affectedEventCount"])
+ }
+ if rows, _ := applied["recalculatedLedgerRows"].(float64); rows != 2 {
+ t.Fatalf("导入结果 recalculatedLedgerRows 应为 2,得到 %+v", applied["recalculatedLedgerRows"])
+ }
+ // 落地响应也要带差异明细,否则前端会把预览覆盖成「无差异」。
+ if channels, _ := applied["channels"].([]any); len(channels) != 1 {
+ t.Fatalf("导入结果 channels 应含 1 个小项,得到 %+v", applied["channels"])
+ }
+
+ // 换成团队模板后分值应变成 15/10,证明重算确实落库。
+ points := env.ledgerPointsByTeam(t)
+ if points[ids.teamA] != 15 || points[ids.teamB] != 10 {
+ t.Fatalf("导入后流水分值应为 A=15 B=10,得到 %+v", points)
+ }
+}
+
+// 重算失败必须显式报错,且整个重算在同一事务里回滚,不能留下「新规则 + 部分新积分」(P1-12)。
+func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) {
+ env := newTestEnv(t)
+ ids := env.seed(t)
+ env.createUser("admin", "admin-pass", "admin")
+ client, _ := env.client()
+ env.login(client, "admin", "admin-pass")
+ env.publishTwoResults(t, client, ids)
+
+ var profileID int64
+ if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
+ t.Fatalf("读取积分模板: %v", err)
+ }
+ // 第二个项目 compare=none 却配了 basis=result 通道,重算到这里必然报错。
+ // 项目编码排在 M100 之后,保证替换到的第一个通道属于 M100。
+ broken := env.insert(t,
+ `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
+ VALUES (?, 'Z_BROKEN', '损坏项目', 'individual', 'auto', 'time', 'none', 1, 1, 'ongoing')`, ids.sport)
+ brokenCat := env.insert(t,
+ `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, broken)
+ env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
+ VALUES (?, 'athlete', 'result', ?, 0)`, broken, profileID)
+ brokenEntry := env.insert(t,
+ `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
+ VALUES (?, ?, ?, ?, 'registered')`, broken, brokenCat, ids.teamA, ids.athleteA)
+ env.insert(t, `INSERT INTO results (event_id, entry_id, result_value, result_text, unit, status, state, rank)
+ VALUES (?, ?, 11.0, '11.00', 's', 'ok', 'published', 1)`, broken, brokenEntry)
+
+ before := env.ledgerPointsByTeam(t)
+ pack := env.exportRulesYAML(t, client)
+ changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1)
+ if changed == pack {
+ t.Fatalf("规则包里没有可替换的计分通道")
+ }
+
+ status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true)
+ if status != http.StatusInternalServerError {
+ t.Fatalf("重算失败应 500,得到 %d %s", status, raw)
+ }
+ if !strings.Contains(string(raw), "积分重算失败") {
+ t.Fatalf("错误信息应说明重算失败,得到 %s", raw)
+ }
+
+ // 规则本身已经落库(Apply 先提交),但第一个项目的重算必须随第二个项目一起回滚。
+ var code string
+ if err := env.ev.QueryRow(`SELECT p.code FROM event_score_channels c
+ JOIN scoring_profiles p ON p.id = c.profile_id
+ WHERE c.event_id = ?`, ids.event).Scan(&code); err != nil {
+ t.Fatalf("查询导入后的计分通道: %v", err)
+ }
+ if code != "team_top6" {
+ t.Fatalf("规则应已落库(通道改为 team_top6),得到 %q", code)
+ }
+ after := env.ledgerPointsByTeam(t)
+ if after[ids.teamA] != before[ids.teamA] || after[ids.teamB] != before[ids.teamB] {
+ t.Fatalf("重算失败应整体回滚,流水不应变化:before=%+v after=%+v", before, after)
+ }
+}
diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go
index 5549dca..51f0786 100644
--- a/server/internal/httpapi/scoring.go
+++ b/server/internal/httpapi/scoring.go
@@ -328,12 +328,17 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
// recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。
// 作废范围是引擎产出的成绩分与破纪录加分;人工调整由 point_adjustments 管理,绝不在此作废。
+// 聚合通道的流水 entry_id/result_id 都为空(名次主体是单位/运动员,不对应单条报名项),
+// 所以按组别重算时不能只靠 entry_id IN (...) 定位,否则 NULL 永不命中、旧流水存活,
+// 重算再插一份导致该通道分数翻倍。这类流水改按「本小项的聚合通道」定位。
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
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 = ?)`
- args = append(args, categoryID)
+ scope += ` AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?)
+ OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'
+ AND source_id IN (SELECT id FROM event_score_channels WHERE event_id = ?)))`
+ args = append(args, categoryID, eventID)
}
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
@@ -346,7 +351,33 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
if err != nil {
return domain.EventScore{}, err
}
- for _, l := range score.Ledger {
+ ledger := score.Ledger
+ if categoryID > 0 {
+ // 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废;
+ // 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。
+ hasAggregate, err := hasAggregateChannel(ctx, tx, eventID)
+ if err != nil {
+ return domain.EventScore{}, err
+ }
+ if hasAggregate {
+ all, err := computeEventScore(ctx, tx, eventID, 0)
+ if err != nil {
+ return domain.EventScore{}, err
+ }
+ ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
+ for _, l := range score.Ledger {
+ if !isAggregateLedger(l) {
+ ledger = append(ledger, l)
+ }
+ }
+ for _, l := range all.Ledger {
+ if isAggregateLedger(l) {
+ ledger = append(ledger, l)
+ }
+ }
+ }
+ }
+ for _, l := range ledger {
if _, err := tx.ExecContext(ctx,
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
@@ -358,6 +389,22 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
return score, nil
}
+// isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员,
+// 落库时 entry_id 与 result_id 都为空。
+func isAggregateLedger(l domain.LedgerEntry) bool {
+ return l.SourceType == domain.SourceResult && l.EntryID == 0 && l.ResultID == 0
+}
+
+// hasAggregateChannel 判断小项是否配置了聚合通道;没有时按组别重算无需补算整项。
+func hasAggregateChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, error) {
+ var n int
+ if err := tx.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM event_score_channels WHERE event_id = ? AND basis = 'aggregate'`, eventID).Scan(&n); err != nil {
+ return false, internal(err)
+ }
+ return n > 0, nil
+}
+
// applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。
// 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
@@ -575,7 +622,12 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
reason, resultID); err != nil {
return resultDTO{}, internal(err)
}
- if _, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason); err != nil {
+ // 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。
+ score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason)
+ if err != nil {
+ return resultDTO{}, err
+ }
+ if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return resultDTO{}, err
}
diff --git a/server/internal/httpapi/scoring_test.go b/server/internal/httpapi/scoring_test.go
new file mode 100644
index 0000000..8228a55
--- /dev/null
+++ b/server/internal/httpapi/scoring_test.go
@@ -0,0 +1,120 @@
+package httpapi_test
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+)
+
+// resultLedger 统计成绩分流水(未作废)的条数与合计。
+func (e *testEnv) resultLedger(t *testing.T) (int, float64) {
+ t.Helper()
+ var (
+ count int
+ total float64
+ )
+ if err := e.ev.QueryRow(`SELECT COUNT(*), COALESCE(SUM(points), 0) FROM score_ledger
+ WHERE voided = 0 AND source_type = 'result'`).Scan(&count, &total); err != nil {
+ t.Fatalf("统计成绩流水: %v", err)
+ }
+ return count, total
+}
+
+// 聚合通道的流水 entry_id 为空,按组别重算若只靠 entry_id 定位就作废不掉旧流水,
+// 重算再插一份导致分数翻倍。整项发布后反复按组别发布,分数必须保持不变。
+func TestRecomputeAggregateLedgerIsIdempotent(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")
+
+ // 换成聚合通道:按单位求和后排名,流水不对应单条报名项。
+ var teamProfile int64
+ if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil {
+ t.Fatalf("读取团体积分模板: %v", err)
+ }
+ env.exec(t, `DELETE FROM event_score_channels WHERE event_id = ?`, ids.event)
+ env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
+ VALUES (?, 'team', 'aggregate', ?, 0)`, ids.event, teamProfile)
+
+ env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
+ "eventId": ids.event,
+ "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)
+
+ publish := func(name string, categoryID *int64) {
+ t.Helper()
+ body := map[string]any{"eventId": ids.event}
+ if categoryID != nil {
+ body["categoryId"] = *categoryID
+ }
+ status, env2 := env.do(client, http.MethodPost, "/api/results/publish", body, true)
+ if status != http.StatusOK || !env2.OK {
+ t.Fatalf("%s应 200,得到 %d %+v", name, status, env2.Error)
+ }
+ // 聚合通道两单位各一条流水:15 + 10。
+ if count, total := env.resultLedger(t); count != 2 || total != 25 {
+ t.Fatalf("%s后成绩流水应仍为 2 条 25 分(不得翻倍),得到 %d 条 %v 分", name, count, total)
+ }
+ }
+
+ catID := ids.category
+ publish("整项发布", nil)
+ publish("按组别发布", &catID)
+ publish("再次按组别发布", &catID)
+}
+
+// 作废一条成绩会改变同组别名次,重算结果必须回写 results.rank,
+// 否则奖牌榜(读 rank)与积分榜(读流水)互相矛盾。
+func TestVoidResultRewritesRemainingRanks(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,
+ "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,
+ }, true); status != http.StatusOK {
+ t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
+ }
+
+ var winnerID int64
+ if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&winnerID); err != nil {
+ t.Fatalf("读取成绩 id: %v", err)
+ }
+ status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/void", winnerID),
+ map[string]any{"reason": "抢跑"}, true)
+ if status != http.StatusOK || !env2.OK {
+ t.Fatalf("作废成绩应 200,得到 %d %+v", status, env2.Error)
+ }
+
+ var rank int
+ if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&rank); err != nil {
+ t.Fatalf("读取名次: %v", err)
+ }
+ if rank != 1 {
+ t.Fatalf("作废冠军后剩余成绩名次应回写为 1,得到 %d", rank)
+ }
+
+ _, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
+ standings := decodeData[[]map[string]any](t, env2)
+ for _, row := range standings {
+ if row["teamCode"] == "B" {
+ if row["totalPoints"].(float64) != 10 || row["gold"].(float64) != 1 {
+ t.Fatalf("作废后 B 应升为第一(10 分 1 金): %+v", row)
+ }
+ }
+ }
+}
diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go
index cfd03a2..766e7f6 100644
--- a/server/internal/httpapi/server.go
+++ b/server/internal/httpapi/server.go
@@ -126,7 +126,7 @@ func New(opts Options) http.Handler {
h = middleware.RateLimitWrite(svc)(h)
h = middleware.CSRF(h)
h = middleware.MustChangePassword(h)
- h = middleware.Authenticate(svc)(h)
+ h = middleware.Authenticate(svc, logger)(h)
h = middleware.Log(logger)(h)
h = middleware.Recover(logger)(h)
h = middleware.RequestID(h)
diff --git a/server/internal/httpapi/users_revisions_test.go b/server/internal/httpapi/users_revisions_test.go
index bf10b4a..e038d57 100644
--- a/server/internal/httpapi/users_revisions_test.go
+++ b/server/internal/httpapi/users_revisions_test.go
@@ -236,6 +236,9 @@ func TestRevisionsListAndDetail(t *testing.T) {
}
// 第二次发布:A 12.00 变慢,B 反超,名次与分数都变。
+ // 已发布成绩必须先撤销发布才能改(服务端拒绝直接覆盖 published 行),
+ // 这里直接把状态置回 draft,等价于撤销发布但不额外留一条 unpublish 版本。
+ env.exec(t, `UPDATE results SET state = 'draft' WHERE event_id = ?`, ids.event)
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"results": []map[string]any{
diff --git a/server/internal/middleware/audit.go b/server/internal/middleware/audit.go
index 81f85cc..9ea4697 100644
--- a/server/internal/middleware/audit.go
+++ b/server/internal/middleware/audit.go
@@ -16,8 +16,10 @@ type AuditEntry struct {
Action string
EntityType string
EntityID int64
- Before any
- After any
+ // ActorID 非 0 时优先作为审计主体,用于登录等「身份刚建立、请求 ctx 里仍是 guest」的动作。
+ ActorID int64
+ Before any
+ After any
}
type auditCollector struct {
@@ -73,10 +75,15 @@ func (a *Auditor) flush(ctx context.Context, r *http.Request, entries []AuditEnt
if e.EntityID != 0 {
entityID = e.EntityID
}
+ // 登录等动作在请求 ctx 里还没有登录身份,改由条目自带的 ActorID 补上。
+ actor := nullableID(u)
+ if e.ActorID != 0 {
+ actor = e.ActorID
+ }
_, err := a.db.ExecContext(ctx,
`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, before_json, after_json, ip, request_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
- nullableID(u), e.Action, e.EntityType, entityID,
+ actor, e.Action, e.EntityType, entityID,
marshalOrNil(e.Before), marshalOrNil(e.After), ip, requestID)
if err != nil {
// 审计失败不能吞掉业务结果,但必须留下痕迹。
diff --git a/server/internal/middleware/middleware.go b/server/internal/middleware/middleware.go
index 3fee2dc..c348f81 100644
--- a/server/internal/middleware/middleware.go
+++ b/server/internal/middleware/middleware.go
@@ -6,10 +6,13 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
+ "errors"
"log/slog"
"net"
"net/http"
+ "net/url"
"runtime/debug"
+ "strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
@@ -18,10 +21,16 @@ import (
// HeaderRequestID 是客户端可复用的幂等/追踪请求头。
const HeaderRequestID = "X-Request-Id"
-// RequestID 复用客户端传入的 request_id,没有则生成一个并写回响应头。
+// maxRequestIDLen 限制客户端 request_id 长度:它会被下游当作幂等缓存键,超长会撑内存。
+const maxRequestIDLen = 128
+
+// RequestID 复用客户端传入的 request_id,没有或过长则生成一个并写回响应头。
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get(HeaderRequestID)
+ if len(id) > maxRequestIDLen {
+ id = ""
+ }
if id == "" {
buf := make([]byte, 8)
if _, err := rand.Read(buf); err == nil {
@@ -30,6 +39,8 @@ func RequestID(next http.Handler) http.Handler {
id = time.Now().UTC().Format("20060102150405.000000")
}
}
+ // 回写请求头,让下游(幂等缓存)读到同一个受控 id。
+ r.Header.Set(HeaderRequestID, id)
w.Header().Set(HeaderRequestID, id)
next.ServeHTTP(w, r.WithContext(contextWithRequestID(r.Context(), id)))
})
@@ -85,13 +96,24 @@ func Log(logger *slog.Logger) func(http.Handler) http.Handler {
}
// Authenticate 解析会话 Cookie;没有或无效时按 guest 放行(只读默认身份)。
-func Authenticate(svc *auth.Service) func(http.Handler) http.Handler {
+// 会话查询失败必须留痕:否则系统库故障会静默把所有人降级成 guest。
+func Authenticate(svc *auth.Service, logger *slog.Logger) func(http.Handler) http.Handler {
+ if logger == nil {
+ logger = slog.Default()
+ }
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := &auth.User{Role: auth.RoleGuest}
if c, err := r.Cookie(auth.CookieSession); err == nil && c.Value != "" {
- if found, _, err := svc.Lookup(r.Context(), c.Value); err == nil {
+ found, _, err := svc.Lookup(r.Context(), c.Value)
+ switch {
+ case err == nil:
u = found
+ case errors.Is(err, auth.ErrSessionNotFound):
+ // 会话缺失/过期是正常情况,静默按 guest 放行。
+ default:
+ logger.Error("会话查询失败",
+ "err", err, "path", r.URL.Path, "requestId", GetRequestID(r.Context()))
}
}
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), u)))
@@ -162,6 +184,12 @@ func CSRF(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
+ // 登录是 guest 请求,拿不到 CSRF Token;跨站表单仍能把受害者登进攻击者的账号,
+ // 因此对带 Origin/Referer 的登录请求单独做同源校验。
+ if r.Method == http.MethodPost && r.URL.Path == "/api/auth/login" && !sameOrigin(r) {
+ writeError(w, http.StatusForbidden, "FORBIDDEN", "登录请求来源校验失败", nil)
+ return
+ }
if User(r.Context()).Role == auth.RoleGuest {
// 未登录请求由 RequireWrite 处理,无需 CSRF。
next.ServeHTTP(w, r)
@@ -179,6 +207,23 @@ func CSRF(next http.Handler) http.Handler {
})
}
+// sameOrigin 判断请求是否来自本站。浏览器跨站 POST 一定带 Origin(或 Referer),
+// 两个头都没有时(curl、非浏览器客户端)放行,避免误伤脚本调用。
+func sameOrigin(r *http.Request) bool {
+ raw := r.Header.Get("Origin")
+ if raw == "" {
+ raw = r.Header.Get("Referer")
+ }
+ if raw == "" {
+ return true
+ }
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed.Host == "" {
+ return false
+ }
+ return strings.EqualFold(parsed.Host, r.Host)
+}
+
// RateLimitWrite 对已登录用户的写操作限流(默认 30 次/分钟/用户)。
func RateLimitWrite(svc *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
diff --git a/server/internal/middleware/middleware_test.go b/server/internal/middleware/middleware_test.go
new file mode 100644
index 0000000..727ecaf
--- /dev/null
+++ b/server/internal/middleware/middleware_test.go
@@ -0,0 +1,105 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// 客户端超长 request_id 会被下游当作幂等键缓存,必须作废并换成服务端生成的 id(P2-6)。
+func TestRequestIDClampsOversizedHeader(t *testing.T) {
+ var (
+ ctxID string
+ handlerID string
+ )
+ h := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctxID = GetRequestID(r.Context())
+ handlerID = r.Header.Get(HeaderRequestID)
+ }))
+ req := httptest.NewRequest(http.MethodPost, "/api/teams", nil)
+ req.Header.Set(HeaderRequestID, strings.Repeat("x", maxRequestIDLen+1))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+
+ if ctxID == "" || len(ctxID) > maxRequestIDLen {
+ t.Fatalf("超长 id 应换成服务端生成的短 id,得到 %q", ctxID)
+ }
+ if handlerID != ctxID {
+ t.Fatalf("下游读到的 request_id 应与 ctx 一致,ctx=%q header=%q", ctxID, handlerID)
+ }
+ if got := rec.Header().Get(HeaderRequestID); got != ctxID {
+ t.Fatalf("响应头 request_id 应为 %q,得到 %q", ctxID, got)
+ }
+}
+
+// 合理长度的客户端 id 仍应原样复用,保证幂等回放。
+func TestRequestIDKeepsNormalHeader(t *testing.T) {
+ var ctxID string
+ h := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctxID = GetRequestID(r.Context())
+ }))
+ req := httptest.NewRequest(http.MethodPost, "/api/teams", nil)
+ req.Header.Set(HeaderRequestID, "abc-123")
+ h.ServeHTTP(httptest.NewRecorder(), req)
+
+ if ctxID != "abc-123" {
+ t.Fatalf("正常 id 应原样复用,得到 %q", ctxID)
+ }
+}
+
+// 登录是 guest 请求,CSRF Token 校验被跳过,靠同源校验挡住 login CSRF(P2-6)。
+func TestCSRFBlocksCrossOriginLogin(t *testing.T) {
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
+ h := CSRF(next)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+ req.Host = "scoreboard.local"
+ req.Header.Set("Origin", "http://evil.example")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("跨站登录应 403,得到 %d", rec.Code)
+ }
+
+ req = httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+ req.Host = "scoreboard.local"
+ req.Header.Set("Origin", "http://scoreboard.local")
+ rec = httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("同源登录应放行,得到 %d", rec.Code)
+ }
+}
+
+// 同源判断:两个头都没有时放行脚本调用,Origin: null 与解析失败一律拒绝。
+func TestSameOrigin(t *testing.T) {
+ cases := []struct {
+ name string
+ origin string
+ referer string
+ want bool
+ }{
+ {"无来源头", "", "", true},
+ {"同源 Origin", "http://scoreboard.local", "", true},
+ {"跨站 Origin", "http://evil.example", "", false},
+ {"Origin 为 null", "null", "", false},
+ {"同源 Referer", "", "http://scoreboard.local/admin", true},
+ {"跨站 Referer", "", "http://evil.example/x", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
+ req.Host = "scoreboard.local"
+ if tc.origin != "" {
+ req.Header.Set("Origin", tc.origin)
+ }
+ if tc.referer != "" {
+ req.Header.Set("Referer", tc.referer)
+ }
+ if got := sameOrigin(req); got != tc.want {
+ t.Fatalf("sameOrigin = %v,期望 %v", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/server/internal/realtime/client.go b/server/internal/realtime/client.go
index 381f9cb..9ea3a39 100644
--- a/server/internal/realtime/client.go
+++ b/server/internal/realtime/client.go
@@ -33,13 +33,15 @@ type client struct {
sessionRole string
sessionUserID int64
instanceID string
+ // settingsAdmin 表示会话是否为 admin:recorder 也进管理端房间,但读不到系统配置。
+ settingsAdmin bool
ackVersion atomic.Int64 // 客户端已确认应用到的版本
lastSent atomic.Int64 // 服务端已发出的最大版本
slow atomic.Bool
}
-func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) *client {
+func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) *client {
return &client{
hub: h,
conn: conn,
@@ -48,6 +50,7 @@ func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, ses
done: make(chan struct{}),
sessionRole: sessionRole,
sessionUserID: sessionUserID,
+ settingsAdmin: settingsAdmin,
}
}
@@ -63,6 +66,15 @@ func (c *client) stop(code websocket.StatusCode, reason string) {
})
}
+// guardLoop 兜住读写 goroutine 里的 panic:HTTP 中间件的 recover 只覆盖 handler 栈,
+// 这两条 goroutine 不兜底会把整个进程带走。
+func (c *client) guardLoop(name string) {
+ if r := recover(); r != nil {
+ c.hub.log.Error("实时循环 panic,已断开连接", "loop", name, "remote", c.remoteAddr, "panic", r)
+ c.stop(websocket.StatusInternalError, name+"循环异常")
+ }
+}
+
func (c *client) sendEvent(event string, data any) bool {
msg, err := encode(event, data)
if err != nil {
@@ -88,8 +100,10 @@ func (c *client) sendRaw(msg []byte) bool {
}
// readLoop 解析 C→S 消息;首条消息受 helloTimeout 限制,防止空连接占用资源。
+// 读写都在独立 goroutine 里跑,HTTP 中间件的 recover 覆盖不到,必须各自兜底。
func (c *client) readLoop(ctx context.Context) {
defer c.stop(websocket.StatusNormalClosure, "")
+ defer c.guardLoop("读")
for {
c.mu.Lock()
@@ -140,6 +154,14 @@ func (c *client) handleMessage(ctx context.Context, data []byte) {
c.hub.touchInstance(c, time.Now())
case EventResync:
+ // hello 之前的 resync 既没有房间归属,又会提前清掉 hello 超时,直接忽略。
+ c.mu.Lock()
+ greeted := c.greeted
+ c.mu.Unlock()
+ if !greeted {
+ c.hub.log.Debug("忽略 hello 之前的 resync", "remote", c.remoteAddr)
+ return
+ }
c.hub.sendInitial(ctx, c, nil)
default:
@@ -149,6 +171,7 @@ func (c *client) handleMessage(ctx context.Context, data []byte) {
// writeLoop 是唯一的写者,保证同一连接上的帧顺序。
func (c *client) writeLoop(ctx context.Context) {
+ defer c.guardLoop("写")
for {
select {
case <-ctx.Done():
diff --git a/server/internal/realtime/hub.go b/server/internal/realtime/hub.go
index ae955ac..4800ab4 100644
--- a/server/internal/realtime/hub.go
+++ b/server/internal/realtime/hub.go
@@ -21,6 +21,7 @@ const (
defaultSlowLag = 500
defaultHelloTimeout = 15 * time.Second
defaultWriteTimeout = 10 * time.Second
+ defaultCloseTimeout = 2 * time.Second
defaultSendBuffer = 64
)
@@ -221,7 +222,7 @@ func (h *Hub) Handler() http.Handler {
h.mu.Lock()
if h.closed {
h.mu.Unlock()
- _ = conn.CloseNow()
+ _ = conn.Close(websocket.StatusGoingAway, "服务关闭")
return
}
h.connWG.Add(1)
@@ -229,25 +230,26 @@ func (h *Hub) Handler() http.Handler {
defer h.connWG.Done()
// 身份只认会话:/ws 必须被 Authenticate 中间件包住,否则一律按 guest 处理。
- role, userID := wsIdentity(r.Context())
- h.serve(conn, r.RemoteAddr, role, userID)
+ role, userID, settingsAdmin := wsIdentity(r.Context())
+ h.serve(conn, r.RemoteAddr, role, userID, settingsAdmin)
})
}
// wsIdentity 从请求上下文取会话身份并折算成实时层角色。
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
-func wsIdentity(ctx context.Context) (string, int64) {
+// settingsAdmin 单独返回真实的 admin 判定:recorder 能进管理端房间,但读不到系统配置。
+func wsIdentity(ctx context.Context) (string, int64, bool) {
u := middleware.User(ctx)
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
- return RoleDisplay, 0
+ return RoleDisplay, 0, false
}
- return RoleAdmin, u.ID
+ return RoleAdmin, u.ID, u.Role.Allows(auth.RoleAdmin)
}
-func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) {
+func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) {
conn.SetReadLimit(1 << 20)
- c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID)
+ c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID, settingsAdmin)
h.addClient(c)
ctx, cancel := context.WithCancel(h.ctx)
@@ -259,11 +261,29 @@ func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessio
go func() { defer loops.Done(); c.writeLoop(ctx) }()
<-c.done
+ // 先做关闭握手,再取消读上下文:coder/websocket 在读上下文被取消时会直接
+ // 关闭底层连接,那样对端只能看到 EOF,收不到策略性关闭码(如 1008)。
+ // 写循环已因 c.done 退出,读循环会随对端回帧自然结束。
+ c.mu.Lock()
+ code, reason := c.closeCode, c.closeReason
+ c.mu.Unlock()
+ if code == 0 {
+ code = websocket.StatusNormalClosure
+ }
+ closed := make(chan struct{})
+ go func() {
+ defer close(closed)
+ _ = conn.Close(code, reason)
+ }()
+ select {
+ case <-closed:
+ case <-time.After(defaultCloseTimeout):
+ // 对端不回应:cancel 会强制关闭底层连接,读循环随之退出。
+ }
+
cancel()
loops.Wait()
h.removeClient(c)
- // 客户端会自动重连并走 catchup,这里直接断开,避免关闭握手阻塞。
- _ = conn.CloseNow()
}
func (h *Hub) addClient(c *client) {
@@ -429,6 +449,10 @@ func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) {
c.stop(websocket.StatusInternalError, "快照不可用")
return
}
+ // 快照对所有角色下发,但系统配置只有 admin 能看:非 admin 只保留大屏渲染所需字段。
+ if !c.settingsAdmin {
+ snap.Settings = snap.Settings.WithoutAdminFields()
+ }
if !c.sendEvent(EventSnapshot, snap) {
return
}
diff --git a/server/internal/realtime/hub_test.go b/server/internal/realtime/hub_test.go
index f73797f..f893ac0 100644
--- a/server/internal/realtime/hub_test.go
+++ b/server/internal/realtime/hub_test.go
@@ -94,9 +94,10 @@ func (e *testEnv) dial(t *testing.T, hello *helloPayload) *wsClient {
}
type wsMessage struct {
- event string
- data json.RawMessage
- err error
+ event string
+ data json.RawMessage
+ err error
+ closeCode websocket.StatusCode
}
type wsClient struct {
@@ -114,7 +115,7 @@ func (c *wsClient) readLoop() {
for {
_, data, err := c.conn.Read(c.ctx)
if err != nil {
- c.msgs <- wsMessage{err: err}
+ c.msgs <- wsMessage{err: err, closeCode: websocket.CloseStatus(err)}
return
}
var env envelope
@@ -196,6 +197,30 @@ func (c *wsClient) expectClosed(d time.Duration) {
}
}
+// expectClosedWithCode 断言连接被服务端以指定关闭码关闭,且关闭前没有下发任何事件。
+func (c *wsClient) expectClosedWithCode(d time.Duration, want websocket.StatusCode) {
+ c.t.Helper()
+ timer := time.NewTimer(d)
+ defer timer.Stop()
+ for {
+ select {
+ case msg, ok := <-c.msgs:
+ if !ok {
+ c.t.Fatalf("连接已关闭但没有收到关闭帧")
+ }
+ if msg.err != nil {
+ if msg.closeCode != want {
+ c.t.Fatalf("关闭码应为 %d,得到 %d (%v)", want, msg.closeCode, msg.err)
+ }
+ 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
@@ -511,11 +536,12 @@ 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)
+ c.expectClosedWithCode(3*time.Second, websocket.StatusPolicyViolation)
}
// guest 只能订阅 display 房间:即使 hello 里塞了 userId 也不能进入定向房间。
@@ -576,3 +602,124 @@ func TestSlowClientDetected(t *testing.T) {
return len(insts) == 1 && !insts[0].Online
})
}
+
+// 快照对所有角色下发,但系统配置是 admin-only:guest/recorder 只能拿到大屏渲染
+// 所需的 event/display/榜单开关,默认积分模板、审核开关与折算系数必须置空/清零。
+func TestSnapshotSettingsRedactedForNonAdmin(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ user *auth.User
+ }{
+ {"guest", &auth.User{Role: auth.RoleGuest}},
+ {"recorder", &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ env := newTestEnvAs(t, tc.user)
+ mustExec(t, env.db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
+ mustExec(t, env.db, `UPDATE settings SET value = 'true' WHERE key = 'results.review_required'`)
+
+ c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: tc.name + "-1"})
+ snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
+
+ if snap.Settings.DefaultProfileID != nil {
+ t.Fatalf("非 admin 不应看到 defaultProfileId: %+v", snap.Settings.DefaultProfileID)
+ }
+ if snap.Settings.RequireApproval {
+ t.Fatalf("非 admin 不应看到 requireApproval")
+ }
+ if snap.Settings.Standings.IndividualToTeamWeight != 0 {
+ t.Fatalf("非 admin 不应看到 individualToTeamWeight: %v", snap.Settings.Standings.IndividualToTeamWeight)
+ }
+ // 大屏要用的字段必须保留,裁剪不能误伤标题/主题/榜单开关。
+ if snap.Settings.Event.Name == "" || snap.Settings.Display.RotateSecond == 0 {
+ t.Fatalf("event/display 不应被裁剪: %+v", snap.Settings)
+ }
+ if snap.Settings.Standings.RankingMode != "points" ||
+ !snap.Settings.Standings.ShowIndividualBoard || !snap.Settings.Standings.ShowIndividualMedals {
+ t.Fatalf("standings 展示开关不应被裁剪: %+v", snap.Settings.Standings)
+ }
+ })
+ }
+}
+
+// admin 仍拿到完整配置,管理端才能在 WS 首包里读到默认积分模板与审核开关。
+func TestSnapshotSettingsFullForAdmin(t *testing.T) {
+ env := newTestEnvAs(t, &auth.User{ID: 1, Username: "boss", Role: auth.RoleAdmin, Active: true})
+ mustExec(t, env.db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
+ mustExec(t, env.db, `UPDATE settings SET value = 'true' WHERE key = 'results.review_required'`)
+
+ c := env.dial(t, &helloPayload{Role: RoleAdmin})
+ snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
+
+ if snap.Settings.DefaultProfileID == nil {
+ t.Fatalf("admin 应看到 defaultProfileId")
+ }
+ if !snap.Settings.RequireApproval {
+ t.Fatalf("admin 应看到 requireApproval")
+ }
+ if snap.Settings.Standings.IndividualToTeamWeight != 0.5 {
+ t.Fatalf("admin 应看到 individualToTeamWeight=0.5,得到 %v", snap.Settings.Standings.IndividualToTeamWeight)
+ }
+}
+
+// resync 在 hello 之前不可用:否则会绕过房间分配并提前清掉 hello 超时。
+func TestResyncBeforeHelloIgnored(t *testing.T) {
+ env := newTestEnv(t)
+ insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
+
+ c := env.dial(t, nil)
+ c.send(EventResync, struct{}{})
+ if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
+ t.Fatalf("hello 之前的 resync 不应下发快照,收到事件 %s", ev)
+ }
+
+ c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
+ if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
+ t.Fatalf("hello 后应收到版本 1 的快照,得到 %d", snap.Version)
+ }
+ c.send(EventResync, struct{}{})
+ if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
+ t.Fatalf("hello 后 resync 应拿到快照,得到 %d", snap.Version)
+ }
+}
+
+// 离线实例不能无限堆积:超过 TTL 后在下一次读取注册表时被清理。
+func TestInstanceRegistryPrunesStaleOffline(t *testing.T) {
+ env := newTestEnv(t)
+ c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "stale-1"})
+ c.recvEvent(EventSnapshot)
+
+ c.close()
+ waitFor(t, "实例离线", func() bool {
+ insts := env.hub.Instances()
+ return len(insts) == 1 && !insts[0].Online
+ })
+
+ env.hub.mu.Lock()
+ env.hub.instances["stale-1"].lastHeartbeat = time.Now().Add(-instanceTTL - time.Minute)
+ env.hub.mu.Unlock()
+
+ if insts := env.hub.Instances(); len(insts) != 0 {
+ t.Fatalf("过期离线实例应被清理,得到 %+v", insts)
+ }
+}
+
+// 读写 goroutine 的 panic 必须被兜住并断开该连接,而不是带走进程。
+func TestGuardLoopRecoversPanic(t *testing.T) {
+ env := newTestEnv(t)
+ c := newClient(env.hub, nil, "test", RoleDisplay, 0, false)
+
+ func() {
+ defer c.guardLoop("读")
+ panic("boom")
+ }()
+
+ select {
+ case <-c.done:
+ default:
+ t.Fatal("panic 后应关闭该连接")
+ }
+ if c.closeCode != websocket.StatusInternalError {
+ t.Fatalf("panic 后关闭码应为 %d,得到 %d", websocket.StatusInternalError, c.closeCode)
+ }
+}
diff --git a/server/internal/realtime/instances.go b/server/internal/realtime/instances.go
index 871cbad..f1f6786 100644
--- a/server/internal/realtime/instances.go
+++ b/server/internal/realtime/instances.go
@@ -8,6 +8,13 @@ import (
"time"
)
+// 注册表容量控制:离线实例保留一段时间便于排查,过期即清理;
+// 没有 displayKey 的客户端每次重连都会生成新实例,必须有上限兜底。
+const (
+ instanceTTL = 10 * time.Minute
+ maxInstances = 200
+)
+
// Instance 是大屏实例的对外快照,供管理端查询「谁在线、在哪一页」。
type Instance struct {
ID string `json:"id"`
@@ -52,6 +59,7 @@ func (h *Hub) registerDisplay(c *client, id, page string) string {
h.mu.Lock()
defer h.mu.Unlock()
+ h.pruneInstancesLocked(now)
st := h.instances[id]
if st == nil {
st = &instanceState{id: id, connectedAt: now}
@@ -68,7 +76,8 @@ func (h *Hub) registerDisplay(c *client, id, page string) string {
return id
}
-// releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查。
+// releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查;
+// 离线超过 instanceTTL 后由 pruneInstancesLocked 清理。
func (h *Hub) releaseDisplay(c *client) {
c.mu.Lock()
id := c.instanceID
@@ -126,10 +135,37 @@ func (h *Hub) setInstancePage(page string) {
}
}
+// pruneInstancesLocked 清理过期离线实例并把注册表封顶到 maxInstances;
+// 调用方必须持有 h.mu。淘汰只针对离线实例,保证在线屏幕不会被误删。
+func (h *Hub) pruneInstancesLocked(now time.Time) {
+ for id, st := range h.instances {
+ if st.conns == 0 && now.Sub(st.lastHeartbeat) > instanceTTL {
+ delete(h.instances, id)
+ }
+ }
+ if len(h.instances) <= maxInstances {
+ return
+ }
+ offline := make([]*instanceState, 0, len(h.instances))
+ for _, st := range h.instances {
+ if st.conns == 0 {
+ offline = append(offline, st)
+ }
+ }
+ sort.Slice(offline, func(i, j int) bool { return offline[i].lastHeartbeat.Before(offline[j].lastHeartbeat) })
+ for _, st := range offline {
+ if len(h.instances) <= maxInstances {
+ break
+ }
+ delete(h.instances, st.id)
+ }
+}
+
// Instances 返回注册表快照,按 instanceId 排序,便于管理端稳定展示。
func (h *Hub) Instances() []Instance {
- h.mu.RLock()
- defer h.mu.RUnlock()
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.pruneInstancesLocked(time.Now())
out := make([]Instance, 0, len(h.instances))
for _, st := range h.instances {
diff --git a/server/internal/realtime/snapshot.go b/server/internal/realtime/snapshot.go
index 6f5cba8..bbdd336 100644
--- a/server/internal/realtime/snapshot.go
+++ b/server/internal/realtime/snapshot.go
@@ -24,8 +24,8 @@ const latestResultsLimit = 20
type Snapshot struct {
Version int64 `json:"version"`
Settings dto.EventSettings `json:"settings"`
- Teams []domain.Team `json:"teams"`
- Events []domain.Event `json:"events"`
+ Teams []dto.Team `json:"teams"`
+ Events []dto.Event `json:"events"`
Standings []dto.StandingRow `json:"standings"`
Records []Record `json:"records"`
LatestResults []dto.EventResultGroup `json:"latestResults"`
@@ -61,8 +61,8 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
defer func() { _ = tx.Rollback() }()
snap := &Snapshot{
- Teams: []domain.Team{},
- Events: []domain.Event{},
+ Teams: []dto.Team{},
+ Events: []dto.Event{},
Standings: []dto.StandingRow{},
Records: []Record{},
LatestResults: []dto.EventResultGroup{},
@@ -78,13 +78,15 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
if err := fillSettings(ctx, tx, values, snap); err != nil {
return nil, err
}
- if err := loadTeams(ctx, tx, snap); err != nil {
+ teams, err := loadTeams(ctx, tx)
+ if err != nil {
return nil, err
}
+ snap.Teams = dto.TeamsFromDomain(teams)
if err := loadEvents(ctx, tx, snap); err != nil {
return nil, err
}
- if err := loadStandings(ctx, tx, snap, values); err != nil {
+ if err := loadStandings(ctx, tx, snap, teams, values); err != nil {
return nil, err
}
if err := loadRecords(ctx, tx, snap); err != nil {
@@ -142,29 +144,32 @@ func fillSettings(ctx context.Context, tx *sql.Tx, values map[string]string, sna
return nil
}
-func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
+// loadTeams 读取全量单位表(含停用单位):榜单展示字段要与 REST 一致,
+// 停用但仍有流水的单位也必须能补出编码/名称/颜色,否则大屏会出现空白行。
+func loadTeams(ctx context.Context, tx *sql.Tx) ([]domain.Team, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
- FROM teams WHERE active = 1
+ FROM teams
ORDER BY sort_order, code`)
if err != nil {
- return fmt.Errorf("读取单位: %w", err)
+ return nil, fmt.Errorf("读取单位: %w", err)
}
defer func() { _ = rows.Close() }()
+ out := make([]domain.Team, 0)
for rows.Next() {
var (
t domain.Team
active int
)
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil {
- return fmt.Errorf("解析单位: %w", err)
+ return nil, fmt.Errorf("解析单位: %w", err)
}
t.Active = active != 0
- snap.Teams = append(snap.Teams, t)
+ out = append(out, t)
}
- return rows.Err()
+ return out, rows.Err()
}
func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
@@ -204,14 +209,15 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
e.Status = domain.EventStatus(status)
e.RecordEligible = recordEligible != 0
e.Scored = scored != 0
- snap.Events = append(snap.Events, e)
+ snap.Events = append(snap.Events, dto.EventFromDomain(e))
}
return rows.Err()
}
// loadStandings 复用 domain 的排名口径,且输入与 REST /api/standings 完全一致:
-// 个人流水按系数折算、团体奖牌开关生效、只有配了计分通道的项目才产出名次。
-func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, values map[string]string) error {
+// 排名输入只含启用单位,展示字段用全量单位表补齐;个人流水按系数折算、团体奖牌开关生效、
+// 只有配了计分通道的项目才产出名次,排序键取自规则包落库的 standings.tie_break。
+func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, teams []domain.Team, values map[string]string) error {
ledger, err := loadLedger(ctx, tx)
if err != nil {
return err
@@ -220,17 +226,36 @@ func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, values map[s
if err != nil {
return err
}
+ activeTeams := make([]domain.Team, 0, len(teams))
+ for _, t := range teams {
+ if t.Active {
+ activeTeams = append(activeTeams, t)
+ }
+ }
rows := domain.BuildTeamStandings(domain.StandingsInput{
- Teams: snap.Teams,
+ Teams: activeTeams,
Ledger: ledger,
Ranked: ranked,
+ TieBreak: splitTieBreak(values["standings.tie_break"]),
IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true),
})
- snap.Standings = dto.StandingRows(rows, snap.Teams)
+ snap.Standings = dto.StandingRows(rows, teams)
return nil
}
+// splitTieBreak 解析 standings.tie_break(逗号分隔的排序键);空串返回 nil,由 domain 落回默认口径。
+func splitTieBreak(raw string) []string {
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if p = strings.TrimSpace(p); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
@@ -331,6 +356,7 @@ func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
}
// loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。
+// 已作废(status=withdrawn)的成绩必须排除,否则大屏会继续展示被作废的行;
// 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。
func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
@@ -347,7 +373,7 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN teams t ON t.id = e.team_id
LEFT JOIN athletes a ON a.id = e.athlete_id
- WHERE r.state = 'published'
+ WHERE r.state = 'published' AND r.status <> 'withdrawn'
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC
LIMIT ?`, latestResultsLimit)
if err != nil {
diff --git a/server/internal/realtime/snapshot_test.go b/server/internal/realtime/snapshot_test.go
index dcdb078..040564a 100644
--- a/server/internal/realtime/snapshot_test.go
+++ b/server/internal/realtime/snapshot_test.go
@@ -471,3 +471,165 @@ func lastID(t *testing.T, res sql.Result) int64 {
}
return id
}
+
+// 快照榜单排序必须读规则包落库的 standings.tie_break,不能只走硬编码默认序。
+func TestSnapshotTieBreakFromSettings(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", "200米")
+ cat1 := insertCategory(t, db, event1, "男子")
+ cat2 := insertCategory(t, db, event2, "男子")
+ athlete1 := insertAthlete(t, db, "张三", team1)
+ athlete2 := insertAthlete(t, db, "李四", team2)
+
+ // T1:银牌 + 10 分;T2:金牌 + 0 分。默认「总分优先」T1 在前,按金牌优先则 T2 在前。
+ entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
+ insertChannel(t, db, event1, "athlete")
+ result1 := insertResult(t, db, event1, entry1, 2, "11.58", 11.58, "2026-09-09T10:00:00Z")
+ insertLedger(t, db, team1, athlete1, event1, entry1, result1, 10)
+
+ entry2 := insertEntry(t, db, event2, cat2, team2, athlete2)
+ insertChannel(t, db, event2, "athlete")
+ insertResult(t, db, event2, entry2, 1, "22.10", 22.10, "2026-09-09T10:00:01Z")
+
+ snap, err := env.hub.Snapshot(context.Background())
+ if err != nil {
+ t.Fatalf("构建快照: %v", err)
+ }
+ if len(snap.Standings) != 2 || snap.Standings[0].TeamCode != "T1" {
+ t.Fatalf("未配置 tieBreak 时应按总分排序,T1 在前: %+v", snap.Standings)
+ }
+
+ mustExec(t, db, `INSERT INTO settings(key, value) VALUES('standings.tie_break', 'gold')
+ ON CONFLICT(key) DO UPDATE SET value = 'gold'`)
+ snap, err = env.hub.Snapshot(context.Background())
+ if err != nil {
+ t.Fatalf("构建快照: %v", err)
+ }
+ if len(snap.Standings) != 2 || snap.Standings[0].TeamCode != "T2" {
+ t.Fatalf("tieBreak=gold 时应按金牌排序,T2 在前: %+v", snap.Standings)
+ }
+}
+
+// 停用但有积分的单位必须像 REST 一样补出编码/名称;排名输入仍只含启用单位。
+func TestSnapshotStandingsIncludesInactiveTeamDisplayFields(t *testing.T) {
+ env := newTestEnv(t)
+ db := env.db
+
+ insertTeam(t, db, "T1", "第一单位", "")
+ inactive := insertTeam(t, db, "T2", "第二单位", "")
+ mustExec(t, db, `UPDATE teams SET active = 0 WHERE id = ?`, inactive)
+ // 停用且无流水的单位不应出现在榜单里。
+ idle := insertTeam(t, db, "T3", "第三单位", "")
+ mustExec(t, db, `UPDATE teams SET active = 0 WHERE id = ?`, idle)
+
+ event1 := insertEvent(t, db, "E1", "100米")
+ cat1 := insertCategory(t, db, event1, "男子")
+ athlete2 := insertAthlete(t, db, "李四", inactive)
+ entry2 := insertEntry(t, db, event1, cat1, inactive, athlete2)
+ insertChannel(t, db, event1, "athlete")
+ result2 := insertResult(t, db, event1, entry2, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
+ insertLedger(t, db, inactive, athlete2, event1, entry2, result2, 5)
+
+ snap, err := env.hub.Snapshot(context.Background())
+ if err != nil {
+ t.Fatalf("构建快照: %v", err)
+ }
+
+ // teams 是公开单位表,与 GET /api/teams 一样含停用单位。
+ if len(snap.Teams) != 3 {
+ t.Fatalf("快照单位表应含停用单位,得到 %+v", snap.Teams)
+ }
+
+ byCode := make(map[string]dto.StandingRow, len(snap.Standings))
+ for _, row := range snap.Standings {
+ byCode[row.TeamCode] = row
+ }
+ row, ok := byCode["T2"]
+ if !ok {
+ t.Fatalf("停用但有积分的单位应上榜: %+v", snap.Standings)
+ }
+ if row.TeamName != "第二单位" || row.TotalPoints != 5 {
+ t.Fatalf("停用单位应补出名称与积分: %+v", row)
+ }
+ if _, ok := byCode["T3"]; ok {
+ t.Fatalf("停用且无流水的单位不应上榜: %+v", snap.Standings)
+ }
+ if _, ok := byCode["T1"]; !ok {
+ t.Fatalf("启用单位应上榜: %+v", snap.Standings)
+ }
+}
+
+// 已作废(status=withdrawn)的成绩不得出现在大屏「最新成绩」。
+func TestSnapshotLatestResultsExcludesVoided(t *testing.T) {
+ env := newTestEnv(t)
+ db := env.db
+
+ team1 := insertTeam(t, db, "T1", "第一单位", "")
+ event1 := insertEvent(t, db, "E1", "100米")
+ cat1 := insertCategory(t, db, event1, "男子")
+ athlete1 := insertAthlete(t, db, "张三", team1)
+ athlete2 := insertAthlete(t, db, "李四", team1)
+
+ keep := insertEntry(t, db, event1, cat1, team1, athlete1)
+ insertResult(t, db, event1, keep, 1, "11.58", 11.58, "2026-09-09T10:00:01Z")
+ // 同一报名项只能有一条成绩,作废行用另一个报名项。
+ voided := insertEntry(t, db, event1, cat1, team1, athlete2)
+ voidedResult := insertResult(t, db, event1, voided, 2, "11.72", 11.72, "2026-09-09T10:00:02Z")
+ mustExec(t, db, `UPDATE results SET status = 'withdrawn' WHERE id = ?`, voidedResult)
+
+ snap, err := env.hub.Snapshot(context.Background())
+ if err != nil {
+ t.Fatalf("构建快照: %v", err)
+ }
+ for _, group := range snap.LatestResults {
+ for _, row := range group.Rows {
+ if row.EntryID == voided {
+ t.Fatalf("已作废成绩不应出现在最新成绩: %+v", row)
+ }
+ }
+ }
+ if len(snap.LatestResults) != 1 || len(snap.LatestResults[0].Rows) != 1 ||
+ snap.LatestResults[0].Rows[0].EntryID != keep {
+ t.Fatalf("最新成绩应只保留未作废的一条: %+v", snap.LatestResults)
+ }
+}
+
+// 快照的 teams/events 必须用 DTO 序列化:可空字段显式 null、必填字段不因零值消失。
+func TestSnapshotTeamsEventsWireFieldsPresent(t *testing.T) {
+ env := newTestEnv(t)
+ db := env.db
+ insertTeam(t, db, "T1", "第一单位", "")
+ insertEvent(t, db, "E1", "100米")
+
+ snap, err := env.hub.Snapshot(context.Background())
+ if err != nil {
+ t.Fatalf("构建快照: %v", err)
+ }
+ raw, err := json.Marshal(snap)
+ if err != nil {
+ t.Fatalf("序列化快照: %v", err)
+ }
+ var top struct {
+ Teams []map[string]json.RawMessage `json:"teams"`
+ Events []map[string]json.RawMessage `json:"events"`
+ }
+ if err := json.Unmarshal(raw, &top); err != nil {
+ t.Fatalf("解析快照: %v", err)
+ }
+ for _, key := range []string{"id", "code", "name", "shortName", "logoPath", "color", "sortOrder", "active"} {
+ if _, ok := top.Teams[0][key]; !ok {
+ t.Fatalf("teams 缺少契约字段 %s: %s", key, top.Teams[0])
+ }
+ }
+ for _, key := range []string{"id", "sportId", "code", "name", "eventType", "resultMode", "resultType",
+ "compare", "recordEligible", "scored", "multiplier", "status", "scheduledAt", "venue", "sortOrder", "remark"} {
+ if _, ok := top.Events[0][key]; !ok {
+ t.Fatalf("events 缺少契约字段 %s: %s", key, top.Events[0])
+ }
+ }
+}
diff --git a/server/internal/rules/apply_test.go b/server/internal/rules/apply_test.go
index 48b24c3..c836a3c 100644
--- a/server/internal/rules/apply_test.go
+++ b/server/internal/rules/apply_test.go
@@ -278,6 +278,48 @@ func TestExportRoundTripThroughDB(t *testing.T) {
}
}
+// TestApplyClearsChannelsForUnscoredEvent 验证「只排名不计分」的项目导入后固定 0 通道(口径 2):
+// 否则它会继承模板通道,把名次带进奖牌榜。
+func TestApplyClearsChannelsForUnscoredEvent(t *testing.T) {
+ db := openEventDB(t)
+ seedEvents(t, db)
+
+ var indID, profileID int64
+ if err := db.QueryRow(`SELECT id FROM events WHERE code = 'EVT_IND'`).Scan(&indID); err != nil {
+ t.Fatalf("查询个人项目: %v", err)
+ }
+ if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
+ t.Fatalf("查询个人模板: %v", err)
+ }
+ if _, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
+ VALUES (?, 'athlete', 'result', ?, 0)`, indID, profileID); err != nil {
+ t.Fatalf("插入个人通道: %v", err)
+ }
+ if _, err := db.Exec(`UPDATE events SET scored = 0 WHERE id = ?`, indID); err != nil {
+ t.Fatalf("关闭计分开关: %v", err)
+ }
+
+ next, err := Parse([]byte(applyPackYAML))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ cur, err := Load(db)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ cs, err := Diff(cur, next)
+ if err != nil {
+ t.Fatalf("Diff: %v", err)
+ }
+ if _, err := Apply(db, cs); err != nil {
+ t.Fatalf("Apply: %v", err)
+ }
+ if got := queryInt(t, db, `SELECT COUNT(*) FROM event_score_channels c
+ JOIN events e ON e.id = c.event_id WHERE e.code = 'EVT_IND'`); got != 0 {
+ t.Fatalf("不计分项目的通道数 = %d,期望 0", got)
+ }
+}
+
func TestApplyRefusesToDeleteReferencedProfile(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
diff --git a/server/internal/rules/convert.go b/server/internal/rules/convert.go
index ea22948..d04a4d9 100644
--- a/server/internal/rules/convert.go
+++ b/server/internal/rules/convert.go
@@ -31,23 +31,6 @@ func (p Profile) ToDomain(id int64) domain.ScoringProfile {
}
}
-// ProfileFromDomain 把 domain 模板转回规则包类型。
-func ProfileFromDomain(p domain.ScoringProfile) Profile {
- rules := make([]Rule, 0, len(p.Rules))
- for _, r := range p.Rules {
- rules = append(rules, Rule{Rank: r.Rank, Points: r.Points})
- }
- return Profile{
- Code: p.Code,
- Name: p.Name,
- AwardRanks: p.AwardRanks,
- LastPlaceZero: p.LastPlaceZero,
- TieMode: p.TieMode,
- RecordBonusMode: p.RecordBonusMode,
- Rules: rules,
- }
-}
-
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
return domain.ScoreChannel{
@@ -59,15 +42,6 @@ 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,
- }
-}
-
// ProfileIndex 按模板编码建索引,便于通道引用解析。
func (p *RulePack) ProfileIndex() map[string]Profile {
out := make(map[string]Profile, len(p.Profiles))
@@ -77,15 +51,6 @@ func (p *RulePack) ProfileIndex() map[string]Profile {
return out
}
-// ToStandingsInput 把榜单口径映射成 domain 聚合输入。
-func (s Standings) ToStandingsInput() domain.StandingsInput {
- return domain.StandingsInput{
- TieBreak: append([]string(nil), s.TieBreak...),
- IndividualToTeamWeight: s.IndividualToTeamWeight,
- ExcludeTeamEventMedals: !s.MedalCountsTeamEventsEnabled(),
- }
-}
-
// ToDomain 把破纪录配置转成 domain.RecordBonusConfig。
func (r RecordBonus) ToDomain() domain.RecordBonusConfig {
mode := r.Mode
@@ -99,21 +64,7 @@ func (r RecordBonus) ToDomain() domain.RecordBonusConfig {
return domain.RecordBonusConfig{Enabled: r.Enabled, Mode: mode, Levels: levels}
}
-// RecordBonusFromDomain 把 domain 破纪录配置转回规则包类型。
-func RecordBonusFromDomain(c domain.RecordBonusConfig) RecordBonus {
- levels := make([]RecordLevel, 0, len(c.Levels))
- for _, lv := range c.Levels {
- levels = append(levels, RecordLevel{Code: lv.Code, Name: lv.Name, Points: lv.Points})
- }
- return RecordBonus{Enabled: c.Enabled, Mode: c.Mode, Levels: levels}
-}
-
// ToDomain 把人工调整开关转成 domain.ManualAdjustmentConfig。
func (m ManualAdjustment) ToDomain() domain.ManualAdjustmentConfig {
return domain.ManualAdjustmentConfig{Enabled: m.Enabled, ReasonRequired: m.ReasonRequired}
}
-
-// ManualAdjustmentFromDomain 把 domain 人工调整开关转回规则包类型。
-func ManualAdjustmentFromDomain(c domain.ManualAdjustmentConfig) ManualAdjustment {
- return ManualAdjustment{Enabled: c.Enabled, ReasonRequired: c.ReasonRequired}
-}
diff --git a/server/internal/rules/diff.go b/server/internal/rules/diff.go
index 7e58f86..14a6eb2 100644
--- a/server/internal/rules/diff.go
+++ b/server/internal/rules/diff.go
@@ -160,7 +160,7 @@ func rulesEqual(a, b []Rule) bool {
// resolveTargets 把规则包展开成「项目编码 → 目标通道」。
// 优先级:eventOverrides 显式给了 channels > 第一条命中的 channelTemplates。
-// 没被规则包提到的项目不出现在结果里,表示「保持原样」。
+// 「只排名不计分」的项目固定解析成 0 通道;没被规则包提到的计分项目不出现在结果里,表示「保持原样」。
func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []string, error) {
overrideByCode := make(map[string]EventOverride, len(next.EventOverrides))
for _, o := range next.EventOverrides {
@@ -169,6 +169,16 @@ func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []stri
targets := make(map[string][]Channel)
for _, ev := range cur.Events {
+ // 只排名不计分的项目固定 0 通道(devdocs 02 §2.4):否则会继承模板通道,
+ // 把名次带进奖牌榜。override 显式写了 scored:false 时同样清空。
+ scored := ev.Scored
+ if ov, ok := overrideByCode[ev.Code]; ok && ov.Scored != nil {
+ scored = *ov.Scored
+ }
+ if !scored {
+ targets[ev.Code] = nil
+ continue
+ }
if ov, ok := overrideByCode[ev.Code]; ok && ov.Channels != nil {
targets[ev.Code] = append([]Channel(nil), (*ov.Channels)...)
continue
diff --git a/server/internal/rules/diff_test.go b/server/internal/rules/diff_test.go
index 01a39f7..e2cd67f 100644
--- a/server/internal/rules/diff_test.go
+++ b/server/internal/rules/diff_test.go
@@ -201,3 +201,59 @@ func TestResolveTargetsPrecedence(t *testing.T) {
t.Errorf("未知项目识别错误: %v", unknown)
}
}
+
+// TestResolveTargetsClearsChannelsForUnscored 验证「只排名不计分」的项目固定解析成 0 通道(口径 2):
+// 否则它会继承模板通道,把名次带进奖牌榜。
+func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
+ cur := currentSnapshot(t)
+ cur.Events = append(cur.Events, EventInfo{
+ ID: 4, Code: "EVT_NOSCORE", Name: "只排名示例",
+ EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeTime,
+ Scored: false, Multiplier: 1,
+ Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}},
+ })
+
+ t.Run("继承模板的项目也被清空", func(t *testing.T) {
+ next, err := Parse([]byte(nextPackYAML))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ targets, _, err := resolveTargets(cur, next)
+ if err != nil {
+ t.Fatalf("resolveTargets: %v", err)
+ }
+ if got, ok := targets["EVT_NOSCORE"]; !ok || len(got) != 0 {
+ t.Fatalf("不计分项目的目标通道 = %+v,期望空", got)
+ }
+
+ cs, err := Diff(cur, next)
+ if err != nil {
+ t.Fatalf("Diff: %v", err)
+ }
+ removed := false
+ for _, c := range cs.Channels {
+ if c.EventCode == "EVT_NOSCORE" && c.Kind == ChangeRemoved && len(c.After) == 0 {
+ removed = true
+ }
+ }
+ if !removed {
+ t.Errorf("差异预览应显示不计分项目的通道被清空: %+v", cs.Channels)
+ }
+ })
+
+ t.Run("override 写了通道也清空", func(t *testing.T) {
+ pack := *cur.Pack
+ pack.EventOverrides = append(pack.EventOverrides, EventOverride{
+ EventCode: "EVT_NOSCORE",
+ Scored: BoolPtr(false),
+ Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}),
+ })
+ targets, _, err := resolveTargets(cur, &pack)
+ if err != nil {
+ t.Fatalf("resolveTargets: %v", err)
+ }
+ if got, ok := targets["EVT_NOSCORE"]; !ok || len(got) != 0 {
+ t.Fatalf("不计分项目的目标通道 = %+v,期望空", got)
+ }
+ })
+}
diff --git a/server/internal/rules/parse_test.go b/server/internal/rules/parse_test.go
index 11930e4..7cc1eb1 100644
--- a/server/internal/rules/parse_test.go
+++ b/server/internal/rules/parse_test.go
@@ -270,34 +270,15 @@ func TestProfileDomainConversion(t *testing.T) {
t.Errorf("ToDomain 明细错误: %+v", dp.Rules)
}
- back := ProfileFromDomain(dp)
- if !reflect.DeepEqual(back, pack.Profiles[0]) {
- t.Errorf("domain 往返不一致:\n%+v\n%+v", back, pack.Profiles[0])
- }
-
ch := pack.ChannelTemplates[0].Channels[0].ToDomain(3, 7, 0)
if ch.EventID != 3 || ch.ProfileID != 7 || ch.Scope != domain.ScopeAthlete || ch.Basis != domain.BasisResult {
t.Errorf("通道 ToDomain 结果错误: %+v", ch)
}
- if got := ChannelFromDomain(ch, "individual_top6"); got != pack.ChannelTemplates[0].Channels[0] {
- t.Errorf("通道往返不一致: %+v", got)
- }
-
- in := pack.Standings.ToStandingsInput()
- 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 {
t.Errorf("破纪录配置转换错误: %+v", rb)
}
- if got := RecordBonusFromDomain(rb); !reflect.DeepEqual(got, pack.RecordBonus) {
- t.Errorf("破纪录配置往返不一致: %+v", got)
- }
}
// TestRecordBonusFromSettings 验证上层能从 settings 键值对读到破纪录档位:
diff --git a/server/internal/store/store_test.go b/server/internal/store/store_test.go
index 5178ff7..04d2da4 100644
--- a/server/internal/store/store_test.go
+++ b/server/internal/store/store_test.go
@@ -2,8 +2,12 @@ package store
import (
"database/sql"
+ "io/fs"
"path/filepath"
+ "sort"
"testing"
+
+ "git.yeij.top/AskaEth/Scoreboard/server/migrations"
)
// openEventDB 打开一个临时赛事库;DSN 与 store.Open 保持一致,便于验证外键约束。
@@ -127,6 +131,159 @@ func TestEventSchemaAcceptsCoreFlow(t *testing.T) {
}
}
+// 0012 迁移:score_ledger 补 (entry_id, voided) 索引,events 子表外键改为可级联/置空,
+// 删除没有任何报名的小项不再被历史流水与纪录挡住。
+func TestMigrateEventDeleteWithHistory(t *testing.T) {
+ db := openEventDB(t)
+ if err := MigrateEvent(db); err != nil {
+ t.Fatalf("迁移: %v", err)
+ }
+
+ assertIndex(t, db, "score_ledger", "idx_score_ledger_entry_voided")
+
+ mustExec(t, db, `INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`)
+ mustExec(t, db, `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`)
+ mustExec(t, db, `INSERT INTO events(sport_id, code, name) VALUES (1, 'E1', '100米跑')`)
+ mustExec(t, db, `INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points)
+ VALUES (1, 1, 'adjustment', 1, 5)`)
+ mustExec(t, db, `INSERT INTO point_adjustments(team_id, delta, category, reason, event_id)
+ VALUES (1, 5, 'bonus', '测试', 1)`)
+ mustExec(t, db, `INSERT INTO records(event_id, level) VALUES (1, 'meet')`)
+
+ // 与 repo_events.go 的 deleteEvent 同序:先清通道/组别,再删小项。
+ mustExec(t, db, `DELETE FROM event_score_channels WHERE event_id = 1`)
+ mustExec(t, db, `DELETE FROM event_categories WHERE event_id = 1`)
+ mustExec(t, db, `DELETE FROM events WHERE id = 1`)
+
+ // 积分流水与人工调整保留,只把 event_id 置空;纪录随小项删除。
+ if got := countRows(t, db, "score_ledger"); got != 1 {
+ t.Errorf("score_ledger 行数 = %d,期望 1", got)
+ }
+ if got := queryInt(t, db, `SELECT count(*) FROM score_ledger WHERE event_id IS NOT NULL`); got != 0 {
+ t.Errorf("score_ledger.event_id 应置空,实际仍有 %d 行", got)
+ }
+ if got := queryInt(t, db, `SELECT count(*) FROM point_adjustments WHERE event_id IS NOT NULL`); got != 0 {
+ t.Errorf("point_adjustments.event_id 应置空,实际仍有 %d 行", got)
+ }
+ if got := countRows(t, db, "records"); got != 0 {
+ t.Errorf("records 应随小项级联删除,实际 %d 行", got)
+ }
+
+ rows, err := db.Query(`PRAGMA foreign_key_check`)
+ if err != nil {
+ t.Fatalf("外键检查: %v", err)
+ }
+ defer func() { _ = rows.Close() }()
+ if rows.Next() {
+ t.Error("重建后存在外键悬挂")
+ }
+ if _, err := db.Exec(`INSERT INTO score_ledger(team_id, source_type, source_id, points)
+ VALUES (999, 'adjustment', 1, 1)`); err == nil {
+ t.Error("重建后 score_ledger.team_id 外键应仍然生效")
+ }
+}
+
+// 老库升级到 0012 时数据必须原样保留:重建表是「建新表 → 拷数据 → 换名」,不能吞行。
+func TestMigration0012PreservesExistingRows(t *testing.T) {
+ db := openEventDB(t)
+ fsys, err := fs.Sub(migrations.EventFS, "event")
+ if err != nil {
+ t.Fatalf("定位迁移目录: %v", err)
+ }
+ names, err := fs.Glob(fsys, "*.sql")
+ if err != nil {
+ t.Fatalf("列出迁移: %v", err)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ if name >= "0012" {
+ continue
+ }
+ body, err := fs.ReadFile(fsys, name)
+ if err != nil {
+ t.Fatalf("读取迁移 %s: %v", name, err)
+ }
+ if _, err := db.Exec(string(body)); err != nil {
+ t.Fatalf("应用迁移 %s: %v", name, err)
+ }
+ }
+
+ stmts := []string{
+ `INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
+ `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
+ `INSERT INTO events(sport_id, code, name) VALUES (1, 'E1', '100米跑')`,
+ `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
+ `INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
+ `INSERT INTO results(event_id, entry_id, rank, result_text, state) VALUES (1, 1, 1, '11.58', 'published')`,
+ `INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
+ VALUES (1, 1, 1, 1, 'result', 1, 10)`,
+ `INSERT INTO point_adjustments(team_id, delta, category, reason, event_id)
+ VALUES (1, 5, 'bonus', '测试', 1)`,
+ `INSERT INTO records(event_id, level, holder_name) VALUES (1, 'meet', '张三')`,
+ `INSERT INTO record_breaks(event_id, entry_id, level) VALUES (1, 1, 'meet')`,
+ `INSERT INTO result_revisions(id, parent_id, event_id, action, snapshot_json)
+ VALUES (1, NULL, 1, 'publish', '{}'), (2, 1, 1, 'edit', '{}')`,
+ }
+ for _, s := range stmts {
+ if _, err := db.Exec(s); err != nil {
+ t.Fatalf("造数据 %q: %v", s, err)
+ }
+ }
+
+ body, err := fs.ReadFile(fsys, "0012_ledger_entry_index_and_fk.sql")
+ if err != nil {
+ t.Fatalf("读取 0012: %v", err)
+ }
+ tx, err := db.Begin()
+ if err != nil {
+ t.Fatalf("开启事务: %v", err)
+ }
+ if _, err := tx.Exec(string(body)); err != nil {
+ _ = tx.Rollback()
+ t.Fatalf("应用 0012: %v", err)
+ }
+ if err := tx.Commit(); err != nil {
+ t.Fatalf("提交 0012: %v", err)
+ }
+
+ for table, want := range map[string]int{
+ "score_ledger": 1, "point_adjustments": 1, "records": 1, "record_breaks": 1, "result_revisions": 2,
+ } {
+ if got := countRows(t, db, table); got != want {
+ t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
+ }
+ }
+ if got := queryInt(t, db, `SELECT count(*) FROM result_revisions WHERE parent_id = 1`); got != 1 {
+ t.Errorf("result_revisions 的 parent_id 未保留,子行数 = %d", got)
+ }
+ if got := queryInt(t, db, `SELECT CAST(points AS INTEGER) FROM score_ledger WHERE id = 1`); got != 10 {
+ t.Errorf("score_ledger 分值 = %d,期望 10", got)
+ }
+}
+
+func assertIndex(t *testing.T, db *sql.DB, table, index string) {
+ t.Helper()
+ var n int
+ if err := db.QueryRow(
+ `SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = ? AND tbl_name = ?`,
+ index, table,
+ ).Scan(&n); err != nil {
+ t.Fatalf("查询索引 %s: %v", index, err)
+ }
+ if n != 1 {
+ t.Errorf("索引 %s(%s) 不存在", index, table)
+ }
+}
+
+func queryInt(t *testing.T, db *sql.DB, query string, args ...any) int {
+ t.Helper()
+ var n int
+ if err := db.QueryRow(query, args...).Scan(&n); err != nil {
+ t.Fatalf("查询 %q: %v", query, err)
+ }
+ return n
+}
+
func checkProfile(t *testing.T, db *sql.DB, code, name string, want []float64) {
t.Helper()
diff --git a/server/main.go b/server/main.go
index fdd0155..50f77d4 100644
--- a/server/main.go
+++ b/server/main.go
@@ -105,10 +105,11 @@ func main() {
os.Exit(1)
} else if created {
// 初始密码只打印这一次;数据库里只有 bcrypt 哈希。
+ // 结构化日志会被采集/落盘,绝不写入密码,只提示密码已在上方一次性输出。
fmt.Printf("\n========================================\n"+
" 已创建默认管理员\n 用户名: %s\n 初始密码: %s\n 首次登录后请立即修改密码\n"+
"========================================\n\n", username, password)
- logger.Warn("已创建默认管理员", "username", username, "password", password)
+ logger.Warn("已创建默认管理员,初始密码仅在上方一次性输出,不写入日志", "username", username)
}
backupSvc := export.NewService(st.DB(), eventDB, filepath.Join(cfg.DataDir, "backups"), cfg.Backup.Keep, logger)
@@ -202,7 +203,8 @@ func runBackup(args []string) error {
return err
}
defer func() { _ = evDB.Close() }()
- if _, err := export.Backup(evDB, export.BackupOptions{Dir: dir, Prefix: *project, Reason: *reason}); err != nil {
+ // 前缀必须与 Service 一致:PruneBackups 只清理 -*,用项目名会让 CLI 备份只增不减。
+ if _, err := export.Backup(evDB, export.BackupOptions{Dir: dir, Prefix: "event", Reason: *reason}); err != nil {
return err
}
}
diff --git a/server/main_test.go b/server/main_test.go
new file mode 100644
index 0000000..187cb8a
--- /dev/null
+++ b/server/main_test.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// CLI 备份必须用 event 前缀(与备份服务一致),否则 PruneBackups 按 -* 清理不到,
+// 备份目录会只增不减。
+func TestRunBackupUsesEventPrefix(t *testing.T) {
+ root := t.TempDir()
+ dataDir := filepath.Join(root, "data")
+ projDir := filepath.Join(dataDir, "event_projs", "default")
+ if err := os.MkdirAll(projDir, 0o755); err != nil {
+ t.Fatalf("创建赛事目录: %v", err)
+ }
+ // runBackup 只在 event.db 存在时备份赛事库;空库也走同一条 VACUUM INTO 路径。
+ if err := os.WriteFile(filepath.Join(projDir, "event.db"), nil, 0o644); err != nil {
+ t.Fatalf("创建赛事库: %v", err)
+ }
+ cfgPath := filepath.Join(root, "config.yaml")
+ cfg := "dataDir: " + dataDir + "\nserver:\n port: 19540\n"
+ if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
+ t.Fatalf("写配置: %v", err)
+ }
+
+ if err := runBackup([]string{"-config", cfgPath, "-project", "default", "-reason", "manual"}); err != nil {
+ t.Fatalf("runBackup: %v", err)
+ }
+
+ entries, err := os.ReadDir(filepath.Join(dataDir, "backups"))
+ if err != nil {
+ t.Fatalf("读取备份目录: %v", err)
+ }
+ eventBackup := false
+ for _, e := range entries {
+ if strings.HasPrefix(e.Name(), "default-") {
+ t.Errorf("CLI 备份不应使用项目名前缀: %s", e.Name())
+ }
+ if strings.HasPrefix(e.Name(), "event-manual-") {
+ eventBackup = true
+ }
+ }
+ if !eventBackup {
+ t.Errorf("未生成 event- 前缀的赛事库备份,实际文件: %v", entries)
+ }
+}
diff --git a/server/migrations/event/0012_ledger_entry_index_and_fk.sql b/server/migrations/event/0012_ledger_entry_index_and_fk.sql
new file mode 100644
index 0000000..fa578b5
--- /dev/null
+++ b/server/migrations/event/0012_ledger_entry_index_and_fk.sql
@@ -0,0 +1,142 @@
+-- P2-8:score_ledger 缺 (entry_id, voided) 索引;events 的子表外键没有 ON DELETE,
+-- 导致删除小项时被历史流水/纪录挡住(500)。SQLite 改不了外键,只能重建表。
+-- defer_foreign_keys 让重建期间的外键校验推迟到事务提交,避免删旧表时误报。
+PRAGMA defer_foreign_keys = ON;
+
+-- score_ledger:指向 events/entries/results 的引用改为「删除时置空」,历史分值不因删项目丢失。
+CREATE TABLE score_ledger_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ team_id INTEGER NOT NULL REFERENCES teams(id),
+ athlete_id INTEGER REFERENCES athletes(id),
+ event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
+ entry_id INTEGER REFERENCES entries(id) ON DELETE SET NULL,
+ result_id INTEGER REFERENCES results(id) ON DELETE SET NULL,
+ source_type TEXT NOT NULL CHECK (source_type IN ('result', 'record_bonus', 'adjustment')),
+ source_id INTEGER NOT NULL,
+ points REAL NOT NULL,
+ voided INTEGER NOT NULL DEFAULT 0 CHECK (voided IN (0, 1)),
+ voided_by INTEGER,
+ voided_at TEXT,
+ void_reason TEXT,
+ created_by INTEGER,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+INSERT INTO score_ledger_new (id, team_id, athlete_id, event_id, entry_id, result_id,
+ source_type, source_id, points, voided, voided_by, voided_at,
+ void_reason, created_by, created_at)
+SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
+ source_type, source_id, points, voided, voided_by, voided_at,
+ void_reason, created_by, created_at
+FROM score_ledger;
+
+DROP TABLE score_ledger;
+ALTER TABLE score_ledger_new RENAME TO score_ledger;
+
+CREATE INDEX IF NOT EXISTS idx_score_ledger_team_voided ON score_ledger(team_id, voided);
+CREATE INDEX IF NOT EXISTS idx_score_ledger_source ON score_ledger(source_type, source_id);
+CREATE INDEX IF NOT EXISTS idx_score_ledger_athlete ON score_ledger(athlete_id, voided);
+CREATE INDEX IF NOT EXISTS idx_score_ledger_entry_voided ON score_ledger(entry_id, voided);
+
+-- point_adjustments:event_id 可空,删除小项时置空,人工调整记录仍保留。
+CREATE TABLE point_adjustments_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ team_id INTEGER NOT NULL REFERENCES teams(id),
+ delta REAL NOT NULL,
+ category TEXT NOT NULL CHECK (category IN ('penalty', 'bonus', 'correction')),
+ reason TEXT NOT NULL,
+ event_id INTEGER REFERENCES events(id) ON DELETE SET NULL,
+ voided INTEGER NOT NULL DEFAULT 0 CHECK (voided IN (0, 1)),
+ voided_by INTEGER,
+ voided_at TEXT,
+ created_by INTEGER,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+INSERT INTO point_adjustments_new (id, team_id, delta, category, reason, event_id,
+ voided, voided_by, voided_at, created_by, created_at)
+SELECT id, team_id, delta, category, reason, event_id,
+ voided, voided_by, voided_at, created_by, created_at
+FROM point_adjustments;
+
+DROP TABLE point_adjustments;
+ALTER TABLE point_adjustments_new RENAME TO point_adjustments;
+
+CREATE INDEX IF NOT EXISTS idx_point_adjustments_team_voided ON point_adjustments(team_id, voided);
+
+-- records:event_id 非空,小项删除后纪录随之删除。
+CREATE TABLE records_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+ level TEXT NOT NULL CHECK (level IN ('meet', 'city', 'province', 'national')),
+ result_value REAL,
+ result_text TEXT,
+ holder_name TEXT,
+ holder_team_id INTEGER REFERENCES teams(id),
+ achieved_at TEXT,
+ is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0, 1)),
+ source TEXT NOT NULL DEFAULT 'history' CHECK (source IN ('history', 'current')),
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+INSERT INTO records_new (id, event_id, level, result_value, result_text, holder_name,
+ holder_team_id, achieved_at, is_current, source, created_at)
+SELECT id, event_id, level, result_value, result_text, holder_name,
+ holder_team_id, achieved_at, is_current, source, created_at
+FROM records;
+
+DROP TABLE records;
+ALTER TABLE records_new RENAME TO records;
+
+CREATE INDEX IF NOT EXISTS idx_records_event_level ON records(event_id, level, is_current);
+
+-- record_breaks:event_id/entry_id 非空,删除小项时级联删除。
+CREATE TABLE record_breaks_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+ entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
+ level TEXT NOT NULL CHECK (level IN ('meet', 'city', 'province', 'national')),
+ result_value REAL,
+ result_text TEXT,
+ bonus_points REAL NOT NULL DEFAULT 0,
+ state TEXT NOT NULL DEFAULT 'pending'
+ CHECK (state IN ('pending', 'approved', 'rejected')),
+ approved_by INTEGER,
+ approved_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+INSERT INTO record_breaks_new (id, event_id, entry_id, level, result_value, result_text,
+ bonus_points, state, approved_by, approved_at, created_at)
+SELECT id, event_id, entry_id, level, result_value, result_text,
+ bonus_points, state, approved_by, approved_at, created_at
+FROM record_breaks;
+
+DROP TABLE record_breaks;
+ALTER TABLE record_breaks_new RENAME TO record_breaks;
+
+CREATE INDEX IF NOT EXISTS idx_record_breaks_event_id ON record_breaks(event_id, state);
+
+-- result_revisions:event_id 非空,小项删除后版本图谱随之删除。
+CREATE TABLE result_revisions_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ parent_id INTEGER REFERENCES result_revisions_new(id),
+ event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
+ action TEXT NOT NULL CHECK (action IN ('publish', 'unpublish', 'edit')),
+ snapshot_json TEXT NOT NULL,
+ ledger_ids TEXT NOT NULL DEFAULT '[]',
+ actor_id INTEGER,
+ message TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+INSERT INTO result_revisions_new (id, parent_id, event_id, action, snapshot_json,
+ ledger_ids, actor_id, message, created_at)
+SELECT id, parent_id, event_id, action, snapshot_json,
+ ledger_ids, actor_id, message, created_at
+FROM result_revisions;
+
+DROP TABLE result_revisions;
+ALTER TABLE result_revisions_new RENAME TO result_revisions;
+
+CREATE INDEX IF NOT EXISTS idx_result_revisions_event_id ON result_revisions(event_id, created_at);
diff --git a/web/src/admin/api/client.ts b/web/src/admin/api/client.ts
index 74a135a..775a416 100644
--- a/web/src/admin/api/client.ts
+++ b/web/src/admin/api/client.ts
@@ -27,12 +27,14 @@ import { mockDb } from './mock'
/**
* 管理端数据入口。
*
- * 后端未就绪(连接失败 / 超时 / 响应不是约定结构)时自动降级到内存示例数据,
- * 并把 `mockMode` 置为 true,界面据此显示「示例数据」标记。
- * 业务错误(鉴权失败、校验失败、冲突)不降级——那些是真实服务端的回答,必须原样呈现。
+ * 后端未就绪(连接失败 / 超时)时自动降级到内存示例数据,并把 `mockMode` 置为 true,
+ * 界面据此显示「示例数据」标记。
+ * 业务错误(404、鉴权失败、校验失败、冲突)与写操作一律不降级——那些是真实服务端的回答。
*/
-const FALLBACK_CODES = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED']
+// 只有「根本连不上服务端」才降级到示例数据;404 / 解析失败是服务端给出的真实回答,
+// 必须原样抛出,否则「用户不存在」「接口不存在」会被伪装成「后端未就绪」。
+const FALLBACK_CODES = ['NETWORK', 'TIMEOUT']
export const mockMode = ref(false)
export const mockReason = ref(null)
@@ -48,26 +50,38 @@ const client = createApiClient({
})
function isFallback(error: unknown): boolean {
- // 404 表示该接口在当前服务端尚未实现(或整个后端没起来),同样按「未就绪」降级。
- return error instanceof ApiError && (FALLBACK_CODES.includes(error.code) || error.status === 404)
+ return error instanceof ApiError && FALLBACK_CODES.includes(error.code)
}
-async function call(real: () => Promise, mock: () => T | Promise): Promise {
+/** 与 call 同构,但把「本次是否降级」一并返回,供页面显示局部示例数据标记。 */
+async function callWithMock(
+ real: () => Promise,
+ mock: () => T | Promise,
+): Promise<{ data: T; mock: boolean }> {
try {
- const value = await real()
+ const data = await real()
if (mockMode.value) {
mockMode.value = false
mockReason.value = null
}
- return value
+ return { data, mock: false }
} catch (error) {
if (!isFallback(error)) throw error
mockMode.value = true
mockReason.value = error instanceof Error ? error.message : '无法连接服务端'
- return await mock()
+ return { data: await mock(), mock: true }
}
}
+async function call(real: () => Promise, mock: () => T | Promise): Promise {
+ return (await callWithMock(real, mock)).data
+}
+
+/** 写操作一律不降级:示例数据里的「保存成功」会让操作员以为成绩已经落库。 */
+async function callWrite(real: () => Promise): Promise {
+ return real()
+}
+
export interface Bootstrap {
/** settings 是 admin-only;访客/录入员读不到时为 null,其余赛事数据照常返回。 */
settings: EventSettings | null
@@ -125,13 +139,13 @@ export const adminApi = {
call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)),
saveDraft: (payload: SaveDraftPayload): Promise =>
- call(() => client.results.saveDraft(payload), () => mockDb.saveDraft(payload)),
+ callWrite(() => client.results.saveDraft(payload)),
publish: (payload: PublishPayload): Promise =>
- call(() => client.results.publish(payload), () => mockDb.publish(payload)),
+ callWrite(() => client.results.publish(payload)),
unpublish: (payload: PublishPayload): Promise =>
- call(() => client.results.unpublish(payload), () => mockDb.unpublish(payload)),
+ callWrite(() => client.results.unpublish(payload)),
standings: (): Promise =>
call(() => client.standings.teams(), () => mockDb.standings()),
@@ -151,16 +165,17 @@ export const adminApi = {
auditLogs: (query: PageQuery = {}): Promise> =>
call(() => client.auditLogs.list(query), () => mockDb.auditLogs(query.page ?? 1, query.size ?? 20)),
+ /** 审计日志需要知道「这一批是不是示例数据」,用局部标记而不是读全局 mockMode。 */
+ auditLogsWithMock: (query: PageQuery = {}): Promise<{ data: Page; mock: boolean }> =>
+ callWithMock(() => client.auditLogs.list(query), () => mockDb.auditLogs(query.page ?? 1, query.size ?? 20)),
+
displayCommand: (command: DisplayCommand): Promise =>
- call(() => client.display.command(command), () => undefined),
+ callWrite(() => client.display.command(command)),
login: (username: string, password: string): Promise =>
- call(
- () => client.auth.login(username, password),
- () => mockDb.login(username),
- ),
+ callWrite(() => client.auth.login(username, password)),
- logout: (): Promise => call(() => client.auth.logout(), () => undefined),
+ logout: (): Promise => callWrite(() => client.auth.logout()),
me: (): Promise =>
call(
@@ -172,25 +187,13 @@ export const adminApi = {
),
changePassword: (currentPassword: string, newPassword: string): Promise<{ relogin: boolean }> =>
- call(
- () => client.auth.changePassword(currentPassword, newPassword),
- () => {
- throw new ApiError('INTERNAL', '示例数据模式下无法修改密码', 0)
- },
- ),
+ callWrite(() => client.auth.changePassword(currentPassword, newPassword)),
health: (): Promise =>
call(
() => client.health(),
() => mockDb.health(),
),
-
- exportUrl: (kind: 'standings' | 'breakdown' | 'results'): string | null => {
- if (mockMode.value) return null
- if (kind === 'standings') return client.exports.standingsUrl()
- if (kind === 'breakdown') return client.exports.breakdownUrl({})
- return client.exports.resultsUrl()
- },
}
export type AdminApi = typeof adminApi
diff --git a/web/src/admin/api/mock.ts b/web/src/admin/api/mock.ts
index 1c70979..8eb4ac1 100644
--- a/web/src/admin/api/mock.ts
+++ b/web/src/admin/api/mock.ts
@@ -381,6 +381,7 @@ class MockDb {
return {
entryId: entry.id,
teamId: entry.teamId,
+ athleteId: entry.athleteId,
resultValue: result?.resultValue ?? null,
rank: result?.rank ?? null,
status: result?.status ?? 'ok',
@@ -421,6 +422,7 @@ class MockDb {
return {
entryId: input.entryId,
teamId: entry?.teamId ?? 0,
+ athleteId: entry?.athleteId ?? null,
resultValue,
rank: input.rank ?? existing?.rank ?? null,
status: input.status ?? existing?.status ?? 'ok',
diff --git a/web/src/admin/api/scoring.ts b/web/src/admin/api/scoring.ts
index da034ad..d9b6058 100644
--- a/web/src/admin/api/scoring.ts
+++ b/web/src/admin/api/scoring.ts
@@ -1,4 +1,6 @@
import type {
+ ChannelBasis,
+ ChannelScope,
CompareMode,
Event,
EventCategory,
@@ -9,16 +11,19 @@ import type {
} from '@/shared'
/**
- * 名次与积分的纯函数实现。
+ * 名次与积分的纯函数实现,只用于录入页的实时预览与 mock 数据源。
*
- * 录入页的「实时预览」和 mock 数据源的发布结果必须用同一套算法,
- * 否则演示态与真实态算出的分会不一致,预览就失去意义。
- * 服务端就绪后这里只用于预览,真值仍以 /api/standings 为准。
+ * 口径必须与服务端 domain/scoring.go 的 ScoreEvent 一致:按计分通道逐条排名、
+ * 录取名次 awardRanks、末位零分 lastPlaceZero、并列三种口径、多通道叠加,
+ * 个人通道分再按 standings.individual_to_team_weight 折算进单位总分。
+ * 任何一处偏差都会让「预览说加分、发布后不加分」,改动本文件时必须同步核对 scoring.go。
*/
export interface ScorableRow {
entryId: number
teamId: number
+ /** 聚合到运动员的通道需要;缺失时该行不参与个人维度的聚合。 */
+ athleteId?: number | null
resultValue: number | null
rank: number | null
status: ResultStatus
@@ -27,97 +32,245 @@ export interface ScorableRow {
const EPSILON = 1e-9
-function isRanked(row: ScorableRow): boolean {
- return row.status === 'ok' && row.resultValue !== null
+/** 不计分项目的占位模板:名次要算,但不可能算出非零分。 */
+const EMPTY_PROFILE: ScoringProfile = {
+ id: 0,
+ code: '',
+ name: '',
+ tieMode: 'skip',
+ recordBonusMode: 'max',
+ rules: [],
}
-/** 按比较方向排序并赋名次;并列按 tieMode 处理。manual / compare=none 时直接用录入的名次。 */
-export function assignRanks(
- rows: readonly ScorableRow[],
- compare: CompareMode,
- tieMode: TieMode,
- manual: boolean,
-): Map {
- const result = new Map()
- const ranked = rows.filter(isRanked)
+interface Candidate {
+ row: ScorableRow
+ /** 比较值(result / aggregate 通道)。 */
+ value: number
+ /** 录入名次(manual 通道)。 */
+ rankIn: number
+ /** 聚合通道的排名主体(单位 id 或运动员 id)。 */
+ subject: number
+}
- if (manual || compare === 'none') {
- for (const row of rows) result.set(row.entryId, row.status === 'ok' ? row.rank : null)
- return result
- }
+interface RankedRow {
+ /** 聚合通道不对应单条报名项,此时为 0。 */
+ entryId: number
+ teamId: number
+ scope: ChannelScope
+ rank: number
+ points: number
+}
- const direction = compare === 'asc' ? 1 : -1
- const sorted = [...ranked].sort(
- (a, b) => direction * ((a.resultValue ?? 0) - (b.resultValue ?? 0)),
- )
+function effectiveMultiplier(value: number): number {
+ return value > 0 ? value : 1
+}
- let index = 0
- while (index < sorted.length) {
- const group: ScorableRow[] = [sorted[index] as ScorableRow]
- const head = group[0] as ScorableRow
- let cursor = index + 1
- while (
- cursor < sorted.length &&
- Math.abs((sorted[cursor] as ScorableRow).resultValue! - head.resultValue!) < EPSILON
- ) {
- group.push(sorted[cursor] as ScorableRow)
- cursor += 1
+function tieModeOf(profile: ScoringProfile): TieMode {
+ return profile.tieMode === 'average' || profile.tieMode === 'higher' ? profile.tieMode : 'skip'
+}
+
+/** 查表得分并乘项目 / 组别权重;超出录取名次或表中无名次一律 0(scoring.go basePoints)。 */
+function basePoints(
+ profile: ScoringProfile,
+ rank: number,
+ eventMultiplier: number,
+ categoryMultiplier: number,
+): number {
+ if (rank <= 0) return 0
+ if (profile.awardRanks !== undefined && profile.awardRanks > 0 && rank > profile.awardRanks) return 0
+ const rule = profile.rules.find((item) => item.rank === rank)
+ if (rule === undefined) return 0
+ return rule.points * eventMultiplier * categoryMultiplier
+}
+
+/**
+ * 一组并列者拿到的分(scoring.go groupPoints)。
+ * 先确定这组占用的名次区间 startRank .. startRank+size-1,再按并列口径取值。
+ */
+function groupPoints(
+ profile: ScoringProfile,
+ startRank: number,
+ size: number,
+ lastRank: number,
+ eventMultiplier: number,
+ categoryMultiplier: number,
+): number {
+ if (startRank <= 0 || size <= 0) return 0
+ // lastPlaceZero 覆盖「最后一名」;并列在末位的整组都按最后一名处理,保证并列同分。
+ if (profile.lastPlaceZero === true && startRank <= lastRank && lastRank <= startRank + size - 1) return 0
+
+ const mode = tieModeOf(profile)
+ if (mode === 'average') {
+ let sum = 0
+ for (let rank = startRank; rank < startRank + size; rank += 1) {
+ sum += basePoints(profile, rank, eventMultiplier, categoryMultiplier)
}
-
- const position = index + 1
- const tied = group.length > 1
- let rank = position
- if (tied && tieMode === 'average') {
- rank = (position + (position + group.length - 1)) / 2
- }
- // skip / higher:并列者都拿靠前的名次,后续名次顺延(02 第 2.2 节)。
-
- for (const row of group) result.set(row.entryId, rank)
- index = cursor
+ return sum / size
}
+ if (mode === 'higher') {
+ let best = 0
+ for (let rank = startRank; rank < startRank + size; rank += 1) {
+ best = Math.max(best, basePoints(profile, rank, eventMultiplier, categoryMultiplier))
+ }
+ return best
+ }
+ return basePoints(profile, startRank, eventMultiplier, categoryMultiplier)
+}
+/** 收集本通道真正参与排名的行:状态正常,且带有本通道所需数据。 */
+function collectCandidates(rows: readonly ScorableRow[], basis: ChannelBasis): Candidate[] {
+ const out: Candidate[] = []
for (const row of rows) {
- if (!result.has(row.entryId)) result.set(row.entryId, null)
+ if (row.status !== 'ok') continue
+ if (basis === 'manual') {
+ if (row.rank === null || row.rank <= 0) continue
+ out.push({ row, value: 0, rankIn: row.rank, subject: 0 })
+ continue
+ }
+ if (row.resultValue === null) continue
+ out.push({ row, value: row.resultValue, rankIn: 0, subject: 0 })
}
- return result
+ return out
}
-/** 名次 → 分值;并列平均产生小数名次时,取相邻两档的均值。 */
-export function pointsForRank(rank: number | null, profile: ScoringProfile | null): number {
- if (rank === null || profile === null || !Number.isFinite(rank)) return 0
- const exact = profile.rules.find((rule) => rule.rank === rank)
- if (exact !== undefined) return exact.points
-
- const lower = profile.rules.find((rule) => rule.rank === Math.floor(rank))
- const upper = profile.rules.find((rule) => rule.rank === Math.ceil(rank))
- if (lower === undefined && upper === undefined) return 0
- if (lower === undefined) return upper?.points ?? 0
- if (upper === undefined) return lower.points
- return (lower.points + upper.points) / 2
+function sortByValue(cands: Candidate[], compare: CompareMode): void {
+ cands.sort((a, b) => {
+ if (Math.abs(a.value - b.value) <= EPSILON) return a.row.entryId - b.row.entryId
+ return compare === 'desc' ? b.value - a.value : a.value - b.value
+ })
}
-export interface ChannelPlan {
- athleteProfile: ScoringProfile | null
- teamProfile: ScoringProfile | null
+interface TieGroup {
+ startRank: number
+ members: Candidate[]
}
-/** 一个项目可挂多个通道;预览只关心「个人表」和「团体表」两张。 */
-export function planChannels(
- channels: readonly ScoreChannel[],
- profiles: readonly ScoringProfile[],
-): ChannelPlan {
- const pick = (scope: ScoreChannel['scope']): ScoringProfile | null => {
- const channel = channels.find((item) => item.scope === scope)
- if (channel === undefined) return null
- return profiles.find((profile) => profile.id === channel.profileId) ?? null
+/** 值相同即并列,名次按「占位顺推」计算(skip 口径)。 */
+function groupByValue(cands: readonly Candidate[]): TieGroup[] {
+ const groups: TieGroup[] = []
+ let pos = 1
+ for (let i = 0; i < cands.length; ) {
+ let j = i + 1
+ while (j < cands.length && Math.abs((cands[j] as Candidate).value - (cands[i] as Candidate).value) <= EPSILON) {
+ j += 1
+ }
+ groups.push({ startRank: pos, members: cands.slice(i, j) })
+ pos += j - i
+ i = j
+ }
+ return groups
+}
+
+/** 录入名次就是展示名次,不做顺推改写。 */
+function groupByManualRank(cands: readonly Candidate[]): TieGroup[] {
+ const groups: TieGroup[] = []
+ for (let i = 0; i < cands.length; ) {
+ let j = i + 1
+ while (j < cands.length && (cands[j] as Candidate).rankIn === (cands[i] as Candidate).rankIn) j += 1
+ groups.push({ startRank: (cands[i] as Candidate).rankIn, members: cands.slice(i, j) })
+ i = j
+ }
+ return groups
+}
+
+function sortManual(cands: Candidate[]): void {
+ cands.sort((a, b) => (a.rankIn === b.rankIn ? a.row.entryId - b.row.entryId : a.rankIn - b.rankIn))
+}
+
+/**
+ * 聚合通道:先把同一主体的成绩求和,再按值排名(scoring.go scoreByAggregate)。
+ * 运动员维度缺 athleteId 的行按服务端口径直接跳过(subject == 0)。
+ */
+function aggregateCandidates(rows: readonly ScorableRow[], channel: ScoreChannel): Candidate[] {
+ const order: number[] = []
+ const buckets = new Map()
+ for (const candidate of collectCandidates(rows, 'result')) {
+ const subject = channel.scope === 'team' ? candidate.row.teamId : (candidate.row.athleteId ?? 0)
+ if (subject === 0) continue
+ const bucket = buckets.get(subject)
+ if (bucket === undefined) {
+ buckets.set(subject, { row: candidate.row, total: candidate.value })
+ order.push(subject)
+ } else {
+ bucket.total += candidate.value
+ }
+ }
+ return order.map((subject) => {
+ const bucket = buckets.get(subject) as { row: ScorableRow; total: number }
+ return { row: bucket.row, value: bucket.total, rankIn: 0, subject }
+ })
+}
+
+function scoreChannel(
+ rows: readonly ScorableRow[],
+ channel: ScoreChannel,
+ profile: ScoringProfile,
+ event: Event,
+ category: EventCategory | null,
+ scoreEnabled: boolean,
+): RankedRow[] {
+ // compare=none 无法按值排名,退回录入名次;服务端当前会拒绝该组合(见 P1-6)。
+ const basis: ChannelBasis = channel.basis === 'manual' || event.compare === 'none' ? 'manual' : channel.basis
+ const eventMultiplier = effectiveMultiplier(event.multiplier)
+ const categoryMultiplier = effectiveMultiplier(category?.multiplier ?? 1)
+
+ let groups: TieGroup[]
+ if (basis === 'aggregate') {
+ const aggregated = aggregateCandidates(rows, channel)
+ sortByValue(aggregated, event.compare)
+ groups = groupByValue(aggregated)
+ } else if (basis === 'manual') {
+ const cands = collectCandidates(rows, 'manual')
+ sortManual(cands)
+ groups = groupByManualRank(cands)
+ } else {
+ const cands = collectCandidates(rows, 'result')
+ sortByValue(cands, event.compare)
+ groups = groupByValue(cands)
+ }
+
+ let lastRank = 0
+ for (const group of groups) {
+ lastRank = Math.max(lastRank, group.startRank + group.members.length - 1)
+ }
+
+ const out: RankedRow[] = []
+ for (const group of groups) {
+ const points = scoreEnabled
+ ? groupPoints(profile, group.startRank, group.members.length, lastRank, eventMultiplier, categoryMultiplier)
+ : 0
+ for (const member of group.members) {
+ out.push({
+ entryId: basis === 'aggregate' ? 0 : member.row.entryId,
+ teamId: basis === 'aggregate' && channel.scope === 'team' ? member.subject : member.row.teamId,
+ scope: channel.scope,
+ rank: group.startRank,
+ points,
+ })
+ }
+ }
+ return out
+}
+
+/** 没有计分通道时按小项自身的录入方式推导一个排名通道(scoring.go rankOnlyChannel)。 */
+function rankOnlyChannel(event: Event): ScoreChannel {
+ const scope: ChannelScope = event.eventType === 'team' || event.eventType === 'match' ? 'team' : 'athlete'
+ return {
+ id: 0,
+ eventId: event.id,
+ scope,
+ basis: event.resultMode === 'manual' ? 'manual' : 'result',
+ profileId: 0,
+ sortOrder: 0,
}
- return { athleteProfile: pick('athlete'), teamProfile: pick('team') }
}
export interface EventScoreResult {
- /** 每行「直接产生」的积分,用于表格里的积分列。 */
+ /** 每行各通道原始分之和(未乘个人→单位系数),用于表格积分列。 */
rowPoints: Map
- /** 每行名次,用于自动排名展示。 */
+ /** 每行折算到单位的贡献(个人通道分 × 系数 + 团体通道分),用于预览差值。 */
+ rowTeamPoints: Map
+ /** 每行展示名次;多通道时优先个人通道,与 applyComputedRanks 一致。 */
ranks: Map
/** 按单位聚合的得分增量,用于预览面板。 */
teamPoints: Map
@@ -132,45 +285,48 @@ export interface ScoreContext {
weight: number
}
-/**
- * 计算一组成绩的积分。
- *
- * 个人项目:行分记给个人,单位获得 `个人分 × 系数`;
- * 团体 / 对抗项目:行分直接记给单位(02 第 2.4 节)。
- */
-export function computeEventScore(
- rows: readonly ScorableRow[],
- context: ScoreContext,
-): EventScoreResult {
- const manual = context.event.resultMode === 'manual'
- const ranks = assignRanks(rows, context.event.compare, tieModeOf(context), manual)
- const plan = planChannels(context.channels, context.profiles)
- const multiplier = context.event.multiplier * (context.category?.multiplier ?? 1)
- const isIndividual = context.event.eventType === 'individual'
+/** 计分通道处理顺序:sortOrder 升序,其次 id 升序(scoring.go sortedChannels)。 */
+function sortedChannels(channels: readonly ScoreChannel[]): ScoreChannel[] {
+ return [...channels].sort((a, b) => (a.sortOrder === b.sortOrder ? a.id - b.id : a.sortOrder - b.sortOrder))
+}
+
+export function computeEventScore(rows: readonly ScorableRow[], context: ScoreContext): EventScoreResult {
+ const event = context.event
+ const rankOnly = context.channels.length === 0
+ // 只排名不计分:scored=false 或没有计分通道时都算出名次,但一律 0 分。
+ const scoreEnabled = event.scored && !rankOnly
+ const channels = rankOnly ? [rankOnlyChannel(event)] : sortedChannels(context.channels)
+ const weight = context.weight > 0 ? context.weight : 1
const rowPoints = new Map()
+ const rowTeamPoints = new Map()
+ const ranks = new Map()
+ const rankScope = new Map()
const teamPoints = new Map()
- const addTeam = (teamId: number, points: number): void => {
- if (points === 0) return
- teamPoints.set(teamId, (teamPoints.get(teamId) ?? 0) + points)
+
+ for (const channel of channels) {
+ const profile = context.profiles.find((item) => item.id === channel.profileId) ?? null
+ // 不计分时不需要积分表,按空模板处理即可(名次仍要算出来)。
+ const ranked = scoreChannel(rows, channel, profile ?? EMPTY_PROFILE, event, context.category, scoreEnabled && profile !== null)
+ for (const row of ranked) {
+ if (row.entryId > 0) {
+ rowPoints.set(row.entryId, (rowPoints.get(row.entryId) ?? 0) + row.points)
+ const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
+ rowTeamPoints.set(row.entryId, (rowTeamPoints.get(row.entryId) ?? 0) + teamContribution)
+ // 名次优先取个人通道;同一优先级下先处理的通道胜出。
+ const current = rankScope.get(row.entryId)
+ if (current === undefined || (current === 'team' && row.scope === 'athlete')) {
+ ranks.set(row.entryId, row.rank)
+ rankScope.set(row.entryId, row.scope)
+ }
+ }
+ const teamContribution = row.scope === 'athlete' ? row.points * weight : row.points
+ teamPoints.set(row.teamId, (teamPoints.get(row.teamId) ?? 0) + teamContribution)
+ }
}
for (const row of rows) {
- const rank = ranks.get(row.entryId) ?? null
- const profile = isIndividual
- ? (plan.athleteProfile ?? plan.teamProfile)
- : (plan.teamProfile ?? plan.athleteProfile)
- const points = pointsForRank(rank, profile) * multiplier
- rowPoints.set(row.entryId, points)
-
- if (isIndividual) addTeam(row.teamId, points * context.weight)
- else addTeam(row.teamId, points)
+ if (!ranks.has(row.entryId)) ranks.set(row.entryId, null)
}
-
- return { rowPoints, ranks, teamPoints }
-}
-
-function tieModeOf(context: ScoreContext): TieMode {
- const profile = planChannels(context.channels, context.profiles).athleteProfile
- return profile?.tieMode ?? 'skip'
+ return { rowPoints, rowTeamPoints, ranks, teamPoints }
}
diff --git a/web/src/admin/components/history/revisionsSource.ts b/web/src/admin/components/history/revisionsSource.ts
index c9be77c..f0dcbe0 100644
--- a/web/src/admin/components/history/revisionsSource.ts
+++ b/web/src/admin/components/history/revisionsSource.ts
@@ -1,10 +1,12 @@
-import { parseRevisions, type Revision } from './types'
+import { ApiError } from '@/shared'
+import { parseRevisionDetail, parseRevisions, type Revision } from './types'
/**
- * `GET /api/revisions`(05 §7 的变更历史接口,由服务端另一处实现)。
+ * `GET /api/revisions` 与 `GET /api/revisions/{id}`(05 §7 的变更历史接口)。
*
- * 直接 fetch 而不是走 adminApi:client.ts 的接口表里暂时没有这个方法,
- * 而 api/** 不在本页的改动范围内;解析失败 / 404 时由页面降级到示例数据。
+ * 直接 fetch 而不是走 adminApi:client.ts 的接口表里暂时没有这组方法。
+ * 只有网络不可达(NETWORK)才由页面降级到示例数据;403 / 404 / 500 是服务端的真实回答,
+ * 必须原样抛出,否则访客会看到一批冒充历史的假数据。
*/
export interface RevisionQuery {
@@ -24,12 +26,28 @@ function buildUrl(query: RevisionQuery): string {
return search === '' ? REVISIONS_PATH : `${REVISIONS_PATH}?${search}`
}
+async function request(url: string, label: string): Promise {
+ let response: Response
+ try {
+ response = await fetch(url, { headers: { Accept: 'application/json' }, credentials: 'include' })
+ } catch (error) {
+ throw new ApiError('NETWORK', '无法连接服务端', 0, error)
+ }
+ if (!response.ok) {
+ const code = response.status === 403 ? 'FORBIDDEN' : 'INTERNAL'
+ throw new ApiError(code, `${label}返回 HTTP ${response.status}`, response.status)
+ }
+ return response.json()
+}
+
export async function fetchRevisions(query: RevisionQuery = {}): Promise {
- const response = await fetch(buildUrl(query), {
- headers: { Accept: 'application/json' },
- credentials: 'include',
- })
- if (!response.ok) throw new Error(`变更历史接口返回 HTTP ${response.status}`)
- const body: unknown = await response.json()
- return parseRevisions(body)
+ return parseRevisions(await request(buildUrl(query), '变更历史接口'))
+}
+
+/**
+ * 列表接口不含成绩快照,只有 `GET /api/revisions/{id}` 才带 snapshot。
+ * 选中节点后必须补拉一次,否则真机上的 diff、影响单位与积分流水全是 0。
+ */
+export async function fetchRevisionDetail(id: number): Promise {
+ return parseRevisionDetail(await request(`${REVISIONS_PATH}/${id}`, '变更详情接口'))
}
diff --git a/web/src/admin/components/history/types.ts b/web/src/admin/components/history/types.ts
index 3ea1e86..ec7cff5 100644
--- a/web/src/admin/components/history/types.ts
+++ b/web/src/admin/components/history/types.ts
@@ -131,7 +131,7 @@ function parseLedgerIds(value: unknown): number[] {
.filter((item): item is number => item !== null)
}
-function parseRevision(value: unknown): Revision | null {
+export function parseRevision(value: unknown): Revision | null {
if (!isRecord(value)) return null
const id = readNumber(value, 'id')
const eventId = readNumber(value, 'eventId')
@@ -165,3 +165,9 @@ export function parseRevisions(body: unknown): Revision[] {
.map(parseRevision)
.filter((item): item is Revision => item !== null)
}
+
+/** 解析 GET /api/revisions/{id}:单个对象,兼容未拆包的 {ok,data} 统一响应。 */
+export function parseRevisionDetail(body: unknown): Revision | null {
+ const source = isRecord(body) && 'data' in body ? body['data'] : body
+ return parseRevision(source)
+}
diff --git a/web/src/admin/components/system/EventInfoPanel.vue b/web/src/admin/components/system/EventInfoPanel.vue
index e25a04f..6a55f66 100644
--- a/web/src/admin/components/system/EventInfoPanel.vue
+++ b/web/src/admin/components/system/EventInfoPanel.vue
@@ -44,10 +44,12 @@ function clone(value: EventSettings): EventSettings {
return JSON.parse(JSON.stringify(value)) as EventSettings
}
-/** 可选文本:空串在接口里等于「没有」,必须转成 null,否则大屏会显示空白标题。 */
-function optional(value: string | null): string | null {
- const trimmed = (value ?? '').trim()
- return trimmed === '' ? null : trimmed
+/**
+ * 可选文本:服务端对 `*string` 的 nil 是「保持原值」,所以清空必须传空串。
+ * 若这里把空串转成 null,副标题 / 主办方将永远清不掉。
+ */
+function optional(value: string | null): string {
+ return (value ?? '').trim()
}
function reset(): void {
@@ -64,6 +66,11 @@ async function save(): Promise {
return
}
+ // 清空轮播间隔时 v-model.number 会给出空串,直接提交会被服务端按 *float64 解码失败(400)。
+ // 空值按「不改动」处理(服务端对 nil 也是保持原值),回填当前值即可。
+ const rotate = Number(next.display.rotateSeconds)
+ const rotateSeconds = Number.isFinite(rotate) && rotate > 0 ? rotate : current.display.rotateSeconds
+
const patch: EventSettingsPatch = {
event: {
...next.event,
@@ -71,14 +78,19 @@ async function save(): Promise {
subtitle: optional(next.event.subtitle),
host: optional(next.event.host),
},
- display: { ...next.display, title: next.display.title.trim(), subtitle: optional(next.display.subtitle) },
+ display: {
+ ...next.display,
+ title: next.display.title.trim(),
+ subtitle: optional(next.display.subtitle),
+ rotateSeconds,
+ },
standings: { ...next.standings },
}
saving.value = true
saveError.value = null
try {
- const result = await settingsApi.save(patch, current)
+ const result = await settingsApi.save(patch)
usingMock.value = result.mock
data.settings = result.data
form.value = clone(result.data)
diff --git a/web/src/admin/components/system/systemApi.ts b/web/src/admin/components/system/systemApi.ts
index 59ad977..a11aacc 100644
--- a/web/src/admin/components/system/systemApi.ts
+++ b/web/src/admin/components/system/systemApi.ts
@@ -7,8 +7,9 @@ import { mockMode, mockReason } from '../../api/client'
* `api/client.ts` 只封装了 ApiClient 已有的端点,用户管理 / 备份列表 / 赛事项目包 /
* 规则包这几组接口还没进共享客户端(且本任务不允许改 api 层),所以在组件目录里补一层薄封装。
*
- * 降级策略与 client.ts 完全一致:只有「连不上 / 超时 / 响应不是约定结构 / 404 未实现」
- * 才降级示例数据;403、校验失败这类业务回答必须原样抛出,否则会把「没权限」伪装成「示例数据」。
+ * 降级策略与 client.ts 完全一致:只有「连不上 / 超时」才降级示例数据;
+ * 403、404、校验失败这类业务回答必须原样抛出,否则会把「没权限 / 不存在」伪装成「示例数据」。
+ * 写操作(用户、备份、设置、规则落地、项目导入)一律不降级,避免假成功。
*/
const BASE_URL = '/api'
@@ -16,7 +17,8 @@ const DEFAULT_TIMEOUT_MS = 15_000
/** 上传整包可能上百 MB,超时必须放宽,否则现场弱网必然失败。 */
const UPLOAD_TIMEOUT_MS = 120_000
-const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED']
+// 与 client.ts 一致:只有连不上 / 超时才降级;解析失败说明服务端在跑但响应不对,必须暴露。
+const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT']
const API_ERROR_CODES: readonly string[] = [
'AUTH_REQUIRED',
@@ -36,8 +38,9 @@ function isApiErrorCode(value: unknown): value is ApiErrorCode {
return typeof value === 'string' && API_ERROR_CODES.includes(value)
}
-export function isBackendUnavailable(error: unknown): boolean {
- return error instanceof ApiError && (FALLBACK_CODES.includes(error.code) || error.status === 404)
+function isBackendUnavailable(error: unknown): boolean {
+ // 404 / 403 / 校验失败都是服务端的真实回答,只有连不上 / 超时才算「后端未就绪」。
+ return error instanceof ApiError && FALLBACK_CODES.includes(error.code)
}
export interface MockAware {
@@ -51,10 +54,15 @@ function markMock(reason: string): void {
mockReason.value = reason
}
-/** 与 client.ts 的 call() 同构,额外把「是否降级」返回给调用方。 */
-export async function withMockInfo(
+/**
+ * 与 client.ts 的 call() 同构,额外把「是否降级」返回给调用方。
+ *
+ * `mock` 传 null 表示不降级,用于所有写操作:示例数据里的「保存成功」会让操作员
+ * 以为改动已经落库(最典型的是重置密码),因此写操作一律原样抛错。
+ */
+async function withMockInfo(
real: () => Promise,
- mock: () => T | Promise,
+ mock: (() => T | Promise) | null,
): Promise> {
try {
const data = await real()
@@ -64,16 +72,12 @@ export async function withMockInfo(
}
return { data, mock: false }
} catch (error) {
- if (!isBackendUnavailable(error)) throw error
+ if (mock === null || !isBackendUnavailable(error)) throw error
markMock(error instanceof Error ? error.message : '无法连接服务端')
return { data: await mock(), mock: true }
}
}
-export async function withMock(real: () => Promise, mock: () => T | Promise): Promise {
- return (await withMockInfo(real, mock)).data
-}
-
/**
* 服务端只回一句「服务端异常」,真正的线索在 error.detail 里(例如「赛事项目名非法: 名称为空」)。
* 展示时把 detail 一并带上,现场才不用去翻服务端日志。
@@ -166,10 +170,14 @@ async function send(path: string, options: RawOptions = {}): Promise {
if (options.json !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
else if (options.contentType !== undefined) headers['Content-Type'] = options.contentType
+ let json = options.json
if (method !== 'GET') {
const token = readCsrfToken()
if (token !== null && token !== '') headers['X-CSRF-Token'] = token
- headers['X-Request-Id'] = newRequestId()
+ const requestId = newRequestId()
+ headers['X-Request-Id'] = requestId
+ // 服务端幂等缓存同时看 header 与 body.requestId,两边必须一致才可能命中。
+ if (isRecord(json) && !('requestId' in json)) json = { ...json, requestId }
}
const controller = new AbortController()
@@ -180,7 +188,7 @@ async function send(path: string, options: RawOptions = {}): Promise {
method,
headers,
credentials: 'include',
- body: options.json !== undefined ? JSON.stringify(options.json) : options.body,
+ body: json !== undefined ? JSON.stringify(json) : options.body,
signal: controller.signal,
})
} catch (error) {
@@ -402,7 +410,8 @@ export const usersApi = {
if (created === null) throw new ApiError('PARSE_FAILED', '新建用户响应格式不符', 0, body)
return created
},
- () => mockUsers().create(input),
+ // 写操作不降级:假成功会让管理员以为账号已建好。
+ null,
)
},
@@ -414,7 +423,7 @@ export const usersApi = {
if (updated === null) throw new ApiError('PARSE_FAILED', '更新用户响应格式不符', 0, body)
return updated
},
- () => mockUsers().update(id, patch),
+ null,
)
},
@@ -426,7 +435,8 @@ export const usersApi = {
if (password === null) throw new ApiError('PARSE_FAILED', '重置密码响应缺少 password', 0, body)
return { password }
},
- () => mockUsers().resetPassword(id),
+ // 这里绝不能降级:mock 会返回一个假密码,等于谎报「重置成功」。
+ null,
)
},
}
@@ -496,16 +506,8 @@ export const backupsApi = {
if (created === null) throw new ApiError('PARSE_FAILED', '备份响应格式不符', 0, body)
return created
},
- () => {
- const createdAt = new Date().toISOString()
- return {
- name: `event-${createdAt.replace(/[:.]/g, '-')}.db`,
- path: `data/backups/event-${createdAt.replace(/[:.]/g, '-')}.db`,
- size: 1_486_848,
- reason: 'manual',
- createdAt,
- }
- },
+ // 备份是写操作:示例数据里假装备份成功会让现场以为已经落盘。
+ null,
)
},
}
@@ -633,12 +635,8 @@ export const projectApi = {
meta: body.meta,
}
},
- () => ({
- name: options.rename.trim() === '' ? file.name.replace(/\.zip$/i, '') : options.rename.trim(),
- dir: `data/event_projs/${options.rename.trim() === '' ? file.name.replace(/\.zip$/i, '') : options.rename.trim()}`,
- eventDB: 'event.db',
- meta: { note: '示例数据:未真正解压落盘' },
- }),
+ // 导入会覆盖赛事目录,绝不能降级成「示例数据导入成功」。
+ null,
)
},
}
@@ -803,7 +801,8 @@ export const rulesApi = {
contentType: 'application/yaml; charset=utf-8',
timeoutMs: 60_000,
}).then(toRuleApplyResult),
- () => ({ ...mockPreview(), recalculatedLedgerRows: 128 }),
+ // 落地规则包会重算积分,不能降级成假的「已应用」。
+ null,
)
},
}
@@ -815,7 +814,7 @@ export const rulesApi = {
export type EventSettingsPatch = Partial
export const settingsApi = {
- async save(patch: EventSettingsPatch, current: EventSettings): Promise> {
+ async save(patch: EventSettingsPatch): Promise> {
return withMockInfo(
async () => {
const body = await requestJson('/settings', { method: 'PUT', json: patch })
@@ -823,8 +822,8 @@ export const settingsApi = {
// 服务端返回的字段与 shared/types 的 EventSettings 一一对应,这里只做结构校验后透传。
return body as unknown as EventSettings
},
- // 示例模式下把改动合并进当前设置,让表单交互能完整走通。
- () => ({ ...current, ...patch }),
+ // 写操作不降级:示例数据里的「保存成功」会让管理员以为设置已生效。
+ null,
)
},
}
diff --git a/web/src/admin/layouts/AdminLayout.vue b/web/src/admin/layouts/AdminLayout.vue
index 2cf116c..821c5b4 100644
--- a/web/src/admin/layouts/AdminLayout.vue
+++ b/web/src/admin/layouts/AdminLayout.vue
@@ -14,6 +14,8 @@ interface NavItem {
label: string
icon: IconName
badge?: 'pending'
+ /** 系统配置与审计日志只有 admin 可见(devdocs/08 第 2 节)。 */
+ adminOnly?: boolean
}
interface NavGroup {
@@ -45,11 +47,11 @@ const NAV: NavGroup[] = [
{ name: 'base-data', label: '基础数据', icon: 'database' },
{ name: 'export', label: '导出', icon: 'export' },
{ name: 'history', label: '变更历史', icon: 'history' },
- { name: 'logs', label: '日志', icon: 'logs' },
+ { name: 'logs', label: '日志', icon: 'logs', adminOnly: true },
],
},
{ section: '系统', items: [
- { name: 'system', label: '系统设置', icon: 'system' },
+ { name: 'system', label: '系统设置', icon: 'system', adminOnly: true },
{ name: 'password', label: '修改密码', icon: 'lock' },
{ name: 'about', label: '关于', icon: 'info' },
] },
@@ -64,6 +66,15 @@ const toast = useToastStore()
const sidebarOpen = ref(false)
const refreshing = ref(false)
+const isAdmin = computed(() => auth.user?.role === 'admin')
+/** 非 admin 不显示系统设置 / 日志,避免点进去只看到一堆 403。 */
+const navGroups = computed(() =>
+ NAV.map((group) => ({
+ ...group,
+ items: group.items.filter((item) => item.adminOnly !== true || isAdmin.value),
+ })).filter((group) => group.items.length > 0),
+)
+
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' ? '切换到浅色模式' : '切换到深色模式'))
@@ -112,7 +123,7 @@ onMounted(async () => {