fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)

P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
This commit is contained in:
AskaEth committed 2026-09-09 22:16:58 +08:00
1 parent 96cf181ae3
commit 13f1f03cd0
124 files changed
+5619 -1781

No files matched your search

+3 -1
View File
@@ -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`)只跑服务端,大屏投放与进程守护需自行接管。
>
> 完全离线运行,不依赖外网,不需要安装任何运行时。
## 文档
+11 -11
View File
@@ -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 安装包在真机上的安装 / 卸载流程尚未验证。
+18 -2
View File
@@ -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 }
+1 -1
View File
@@ -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 }
+19 -3
View File
@@ -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 }
+14 -14
View File
@@ -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()
// 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠
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()
}
+2
View File
@@ -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('请求超时')))
+52 -11
View File
@@ -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
+57 -8
View File
@@ -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 }
+1 -1
View File
@@ -101,7 +101,7 @@
</div>
</div>
<p class="hint">
单屏时窗口化、按 F11 切换全屏;多屏时除主屏外每块屏一个 kiosk 窗口,各锁一页。
单屏时窗口化、按 F11 切换全屏;多屏时除内置屏外(无内置屏时除主屏外)每块屏一个 kiosk 窗口,各锁一页。
</p>
<ul id="display-list" class="display-list">
<li class="muted">尚未打开大屏窗口</li>
+30 -24
View File
@@ -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\<project>\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 (
+6 -2
View File
@@ -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
+19 -3
View File
@@ -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)
out := make([]byte, 0, n)
for len(out) < 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)]
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
}
+32
View File
@@ -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)
}
}
+1 -1
View File
@@ -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,
}
}
-228
View File
@@ -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
}
+1 -242
View File
@@ -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)
}
}
}
+4 -148
View File
@@ -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)
}
}
+53 -2
View File
@@ -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
}
+117
View File
@@ -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) {
+68 -5
View File
@@ -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
}
+75
View File
@@ -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)}
+48
View File
@@ -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),
}
}
+17
View File
@@ -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 {
+94
View File
@@ -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)
}
}
+41
View File
@@ -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
}
+25 -5
View File
@@ -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 从 <prefix>-<reason>-<yyyyMMdd>-<HHmmss>[-<序号>].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-- {
+83
View File
@@ -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 }{
+116 -82
View File
@@ -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
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 e ON e.id = r.event_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 e.scored = 1 AND en.status = 'registered' AND r.status = 'ok'
ORDER BY r.event_id, r.rank, r.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)
}
@@ -228,31 +297,22 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
for rows.Next() {
var (
rr domain.RankedResult
resultValue sql.NullFloat64
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 {
+193
View File
@@ -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)
}
}
+51 -18
View File
@@ -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)
}
+110 -1
View File
@@ -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)
+17 -4
View File
@@ -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 {
+46 -1
View File
@@ -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 {
@@ -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)
}
}
@@ -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)
}
}
+12 -1
View File
@@ -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())
}
@@ -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)
}
}
+13 -3
View File
@@ -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
}
@@ -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)
}
}
@@ -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 {
@@ -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)
}
}
+41 -1
View File
@@ -21,6 +21,7 @@ var errHandled = errors.New("httpapi: response already written")
type idempotencyStore struct {
mu sync.Mutex
entries map[string]idempotentResponse
inflight map[string]chan struct{}
ttl time.Duration
}
@@ -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)
@@ -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")
}
+59 -7
View File
@@ -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 ----------
+61 -7
View File
@@ -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)
}
+103 -3
View File
@@ -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
+208
View File
@@ -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)
}
}
+15 -2
View File
@@ -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,
@@ -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)
}
}
+87
View File
@@ -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)
}
}
@@ -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",
+96 -22
View File
@@ -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
return 0, err
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import"); err != nil {
_ = tx.Rollback()
return err
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 {
_ = tx.Rollback()
return err
return 0, err
}
if err := tx.Commit(); err != nil {
return err
return 0, err
}
}
return nil
return total, nil
}
@@ -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)
}
}
+55 -3
View File
@@ -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
}
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
}
+120
View File
@@ -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)
}
}
}
}
+1 -1
View File
@@ -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)
@@ -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{
+8 -1
View File
@@ -16,6 +16,8 @@ type AuditEntry struct {
Action string
EntityType string
EntityID int64
// ActorID 非 0 时优先作为审计主体,用于登录等「身份刚建立、请求 ctx 里仍是 guest」的动作。
ActorID int64
Before any
After any
}
@@ -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 {
// 审计失败不能吞掉业务结果,但必须留下痕迹。
+48 -3
View File
@@ -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 {
@@ -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)
}
})
}
}
+24 -1
View File
@@ -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():
+34 -10
View File
@@ -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
}
+149 -2
View File
@@ -97,6 +97,7 @@ type wsMessage struct {
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)
}
}
+39 -3
View File
@@ -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 {
+44 -18
View File
@@ -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 {
+162
View File
@@ -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])
}
}
}
+42
View File
@@ -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)
-49
View File
@@ -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}
}
+11 -1
View File
@@ -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
+56
View File
@@ -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)
}
})
}
-19
View File
@@ -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 键值对读到破纪录档位:
+157
View File
@@ -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()
+4 -2
View File
@@ -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 只清理 <prefix>-*,用项目名会让 CLI 备份只增不减。
if _, err := export.Backup(evDB, export.BackupOptions{Dir: dir, Prefix: "event", Reason: *reason}); err != nil {
return err
}
}
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// CLI 备份必须用 event 前缀(与备份服务一致),否则 PruneBackups 按 <prefix>-* 清理不到,
// 备份目录会只增不减。
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)
}
}
@@ -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);
+35 -32
View File
@@ -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<string | null>(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<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
/** 与 call 同构,但把「本次是否降级」一并返回,供页面显示局部示例数据标记。 */
async function callWithMock<T>(
real: () => Promise<T>,
mock: () => T | Promise<T>,
): 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<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
return (await callWithMock(real, mock)).data
}
/** 写操作一律不降级:示例数据里的「保存成功」会让操作员以为成绩已经落库。 */
async function callWrite<T>(real: () => Promise<T>): Promise<T> {
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<Result[]> =>
call(() => client.results.saveDraft(payload), () => mockDb.saveDraft(payload)),
callWrite(() => client.results.saveDraft(payload)),
publish: (payload: PublishPayload): Promise<Result[]> =>
call(() => client.results.publish(payload), () => mockDb.publish(payload)),
callWrite(() => client.results.publish(payload)),
unpublish: (payload: PublishPayload): Promise<Result[]> =>
call(() => client.results.unpublish(payload), () => mockDb.unpublish(payload)),
callWrite(() => client.results.unpublish(payload)),
standings: (): Promise<StandingRow[]> =>
call(() => client.standings.teams(), () => mockDb.standings()),
@@ -151,16 +165,17 @@ export const adminApi = {
auditLogs: (query: PageQuery = {}): Promise<Page<AuditLog>> =>
call(() => client.auditLogs.list(query), () => mockDb.auditLogs(query.page ?? 1, query.size ?? 20)),
/** 审计日志需要知道「这一批是不是示例数据」,用局部标记而不是读全局 mockMode。 */
auditLogsWithMock: (query: PageQuery = {}): Promise<{ data: Page<AuditLog>; mock: boolean }> =>
callWithMock(() => client.auditLogs.list(query), () => mockDb.auditLogs(query.page ?? 1, query.size ?? 20)),
displayCommand: (command: DisplayCommand): Promise<void> =>
call(() => client.display.command(command), () => undefined),
callWrite(() => client.display.command(command)),
login: (username: string, password: string): Promise<CurrentUser> =>
call(
() => client.auth.login(username, password),
() => mockDb.login(username),
),
callWrite(() => client.auth.login(username, password)),
logout: (): Promise<void> => call(() => client.auth.logout(), () => undefined),
logout: (): Promise<void> => callWrite(() => client.auth.logout()),
me: (): Promise<CurrentUser> =>
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<HealthInfo> =>
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
+2
View File
@@ -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',
+264 -108
View File
@@ -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<number, number | null> {
const result = new Map<number, number | null>()
const ranked = rows.filter(isRanked)
if (manual || compare === 'none') {
for (const row of rows) result.set(row.entryId, row.status === 'ok' ? row.rank : null)
return result
interface Candidate {
row: ScorableRow
/** 比较值(result / aggregate 通道)。 */
value: number
/** 录入名次(manual 通道)。 */
rankIn: number
/** 聚合通道的排名主体(单位 id 或运动员 id)。 */
subject: number
}
const direction = compare === 'asc' ? 1 : -1
const sorted = [...ranked].sort(
(a, b) => direction * ((a.resultValue ?? 0) - (b.resultValue ?? 0)),
)
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
interface RankedRow {
/** 聚合通道不对应单条报名项,此时为 0。 */
entryId: number
teamId: number
scope: ChannelScope
rank: number
points: number
}
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
function effectiveMultiplier(value: number): number {
return value > 0 ? value : 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)
}
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
}
return result
if (row.resultValue === null) continue
out.push({ row, value: row.resultValue, rankIn: 0, subject: 0 })
}
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<number, { row: ScorableRow; total: number }>()
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<number, number>
/** 每行名次,用于自动排名展示。 */
/** 每行折算到单位的贡献(个人通道分 × 系数 + 团体通道分),用于预览差值。 */
rowTeamPoints: Map<number, number>
/** 每行展示名次;多通道时优先个人通道,与 applyComputedRanks 一致。 */
ranks: Map<number, number | null>
/** 按单位聚合的得分增量,用于预览面板。 */
teamPoints: Map<number, number>
@@ -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<number, number>()
const rowTeamPoints = new Map<number, number>()
const ranks = new Map<number, number | null>()
const rankScope = new Map<number, ChannelScope>()
const teamPoints = new Map<number, number>()
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 }
}
@@ -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<unknown> {
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<Revision[]> {
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<Revision | null> {
return parseRevisionDetail(await request(`${REVISIONS_PATH}/${id}`, '变更详情接口'))
}
+7 -1
View File
@@ -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)
}
@@ -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<void> {
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<void> {
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)
+37 -38
View File
@@ -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<T> {
@@ -51,10 +54,15 @@ function markMock(reason: string): void {
mockReason.value = reason
}
/** 与 client.ts 的 call() 同构,额外把「是否降级」返回给调用方。 */
export async function withMockInfo<T>(
/**
* 与 client.ts 的 call() 同构,额外把「是否降级」返回给调用方。
*
* `mock` 传 null 表示不降级,用于所有写操作:示例数据里的「保存成功」会让操作员
* 以为改动已经落库(最典型的是重置密码),因此写操作一律原样抛错。
*/
async function withMockInfo<T>(
real: () => Promise<T>,
mock: () => T | Promise<T>,
mock: (() => T | Promise<T>) | null,
): Promise<MockAware<T>> {
try {
const data = await real()
@@ -64,16 +72,12 @@ export async function withMockInfo<T>(
}
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<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
return (await withMockInfo(real, mock)).data
}
/**
* 服务端只回一句「服务端异常」,真正的线索在 error.detail 里(例如「赛事项目名非法: 名称为空」)。
* 展示时把 detail 一并带上,现场才不用去翻服务端日志。
@@ -166,10 +170,14 @@ async function send(path: string, options: RawOptions = {}): Promise<Response> {
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<Response> {
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<EventSettings>
export const settingsApi = {
async save(patch: EventSettingsPatch, current: EventSettings): Promise<MockAware<EventSettings>> {
async save(patch: EventSettingsPatch): Promise<MockAware<EventSettings>> {
return withMockInfo(
async () => {
const body = await requestJson<unknown>('/settings', { method: 'PUT', json: patch })
@@ -823,8 +822,8 @@ export const settingsApi = {
// 服务端返回的字段与 shared/types 的 EventSettings 一一对应,这里只做结构校验后透传。
return body as unknown as EventSettings
},
// 示例模式下把改动合并进当前设置,让表单交互能完整走通。
() => ({ ...current, ...patch }),
// 写操作不降级:示例数据里的「保存成功」会让管理员以为设置已生效。
null,
)
},
}
+14 -3
View File
@@ -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<NavGroup[]>(() =>
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 () => {
</div>
<nav class="nav">
<div v-for="group in NAV" :key="group.section" class="nav-group">
<div v-for="group in navGroups" :key="group.section" class="nav-group">
<div class="mx-sidebar-section">{{ group.section }}</div>
<RouterLink
v-for="item in group.items"
+3 -2
View File
@@ -8,8 +8,9 @@ const data = useEventStore()
const SYSTEM_NAME = '运动会积分榜系统'
const SYSTEM_EN = 'Scoreboard'
// /api/health 只回 version,没有构建时间字段;构建期常量要改 vite.config,本页按约定显示 dev
const BUILD_TIME = 'dev'
// /api/health 只回 version,/api/system/about 也不带构建时间;前端构建期未注入该常量,
// 因此这里如实显示「未注入」,不再用假的 'dev' 冒充构建时间。
const BUILD_TIME = '未注入'
const version = ref<string | null>(null)
const versionChecked = ref(false)
+24 -7
View File
@@ -56,6 +56,7 @@ const localItems = ref<PointAdjustment[]>([])
let nextLocalId = -1
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const isAdmin = computed(() => auth.user?.role === 'admin')
/**
* 人工调整开关:true / false / null(未知)。
*
@@ -77,12 +78,15 @@ const filteredItems = computed(() => {
})
})
// 分页只作用于服务端记录;本地降级条目固定显示在当前页,不能混进 total 的页码计算。
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
const rangeText = computed(() => {
if (total.value === 0) return '共 0 条'
const local = localItems.value.length
const suffix = local > 0 ? `,另有 ${local} 条本地示例记录` : ''
if (total.value === 0) return local > 0 ? `共 ${local} 条本地示例记录` : '共 0 条'
const from = (page.value - 1) * size + 1
const to = Math.min(page.value * size, total.value)
return `第 ${from}–${to} 条,共 ${total.value} 条`
return `第 ${from}–${to} 条,共 ${total.value} 条${suffix}`
})
function teamLabel(teamId: number): string {
@@ -109,20 +113,33 @@ function isLocal(item: PointAdjustment): boolean {
return item.id < 0
}
/** 只有真的连不上服务端才做本地降级;业务 404 / 解析失败必须原样呈现。 */
function isFallback(error: unknown): boolean {
return (
error instanceof ApiError &&
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
)
return error instanceof ApiError && ['NETWORK', 'TIMEOUT'].includes(error.code)
}
function isForbidden(error: unknown): boolean {
return error instanceof ApiError && (error.status === 403 || error.code === 'FORBIDDEN')
}
async function loadRules(): Promise<void> {
try {
// /api/rules/export 是 admin-only:录入员跳过它,改用下面的只读开关端点,
// 否则页面会常驻一条红色「读取规则包失败:无权访问该资源」。
if (isAdmin.value) {
rulePack.value = await adminApi.client.rules.get()
ruleError.value = null
} else {
rulePack.value = null
ruleError.value = null
}
} catch (caught) {
rulePack.value = null
ruleError.value = caught instanceof Error ? caught.message : '无法读取规则包'
ruleError.value = isForbidden(caught)
? null
: caught instanceof Error
? caught.message
: '无法读取规则包'
}
// 规则包只有 admin 能读;录入员改用只读开关端点,避免把已启用功能误报成关闭。
try {
+91 -22
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
formatPoints,
@@ -25,6 +25,7 @@ interface EditableRow {
categoryId: number
bib: string | null
teamId: number
athleteId: number | null
teamName: string
teamShort: string
athleteName: string
@@ -78,6 +79,8 @@ const unpublishDialog = ref(false)
const focusedEntryId = ref<number | null>(null)
const tableRef = ref<HTMLElement | null>(null)
const recordForEvent = ref<{ resultText: string; resultValue: number } | null>(null)
/** 项目切换的请求序号:只有最新一次请求的响应才允许写入页面状态。 */
let requestSeq = 0
const canWrite = computed(() => !auth.readOnly && data.canWrite)
@@ -93,7 +96,6 @@ const selectedEvent = computed(() => detail.value ?? (selectedEventId.value ===
const categories = computed(() => detail.value?.categories ?? [])
const channels = computed(() => detail.value?.channels ?? [])
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
const isIndividual = computed(() => selectedEvent.value?.eventType === 'individual')
const manualRank = computed(() => selectedEvent.value?.resultMode === 'manual' || selectedEvent.value?.compare === 'none')
function unitOf(type: Event['resultType']): string {
@@ -114,6 +116,7 @@ const scorable = computed<ScorableRow[]>(() =>
visibleRows.value.map((row) => ({
entryId: row.entryId,
teamId: row.teamId,
athleteId: row.athleteId,
resultValue: parseResultValue(row.resultText, unit.value),
rank: row.rank,
status: row.status,
@@ -136,16 +139,15 @@ const score = computed(() => {
/**
* 已发布行产生的分已经计入当前总分,预览时必须先扣掉,差值才是「本次发布」的影响。
*
* 不能用 result.points:服务端从不返回该字段(恒为 0),否则已发布分会被重复计算。
* 这里改用与预览同一套算法算出的 rowPoints,口径与服务端计分一致。
* 用 rowTeamPoints(各通道原始分,个人通道已按权重折算)而不是服务端 result.points:
* 后者只覆盖已发布行,且编辑中的行服务端还没算分,两边口径无法混用。
*/
const publishedContribution = computed(() => {
const map = new Map<number, number>()
const rowPoints = score.value?.rowPoints
const rowTeamPoints = score.value?.rowTeamPoints
for (const row of visibleRows.value) {
if (row.resultState !== 'published') continue
const points = rowPoints?.get(row.entryId) ?? 0
map.set(row.teamId, (map.get(row.teamId) ?? 0) + (isIndividual.value ? points * weight.value : points))
map.set(row.teamId, (map.get(row.teamId) ?? 0) + (rowTeamPoints?.get(row.entryId) ?? 0))
}
return map
})
@@ -165,7 +167,13 @@ const preview = computed<PreviewRow[]>(() => {
const maxDelta = computed(() => preview.value.reduce((max, row) => Math.max(max, Math.abs(row.delta)), 0))
const affectedTeams = computed(() => preview.value.filter((row) => row.delta !== 0).length)
const isPublished = computed(() => visibleRows.value.some((row) => row.resultState === 'published'))
const dirty = computed(() => visibleRows.value.some((row) => rowDirty(row)))
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
/** 已发布的行由服务端锁定:只能先撤销发布,不能就地改草稿。 */
function rowLocked(row: EditableRow): boolean {
return row.resultState === 'published'
}
const issues = computed<Issue[]>(() => {
const list: Issue[] = []
@@ -233,9 +241,10 @@ function round(value: number): number {
return Number(value.toFixed(2))
}
/** 空缺成绩的快照必须与 rowDirty 拼出的默认行一致,否则一进页面就亮「已修改」。 */
function snapshotOf(entry: EntryWithResult): string {
const result = entry.result
if (result === null) return '||||'
if (result === null) return ['', '', 'ok', '0', ''].join('|')
return [result.resultText, result.rank ?? '', result.status, result.isTie ? '1' : '0', result.note ?? ''].join('|')
}
@@ -246,6 +255,7 @@ function toRow(entry: EntryWithResult): EditableRow {
categoryId: entry.categoryId,
bib: entry.bib,
teamId: entry.teamId,
athleteId: entry.athleteId,
teamName: entry.teamName ?? data.teamById(entry.teamId)?.name ?? '未知单位',
teamShort: entry.shortName ?? data.teamById(entry.teamId)?.shortName ?? '',
athleteName: entry.athleteName ?? '',
@@ -266,6 +276,14 @@ function rowDirty(row: EditableRow): boolean {
return row.original !== [row.resultText, row.rank ?? '', row.status, row.isTie ? '1' : '0', row.note].join('|')
}
/** 名次输入框清空时 v-model.number 会给出空串,必须在入口就归一为 null。 */
function rankInputValue(event: InputEvent): number | null {
const raw = (event.target as HTMLInputElement).value.trim()
if (raw === '') return null
const value = Number(raw)
return Number.isFinite(value) ? Math.trunc(value) : null
}
function rankOf(row: EditableRow): number | null {
if (manualRank.value) return row.rank
return score.value?.ranks.get(row.entryId) ?? null
@@ -279,9 +297,17 @@ function issueFor(entryId: number): Issue | undefined {
return issues.value.find((issue) => issue.entryId === entryId)
}
/** 切换项目会丢弃当前编辑,先让操作员确认。 */
function confirmDiscard(): boolean {
if (!dirty.value) return true
return window.confirm('当前有未保存的成绩修改,切换项目会丢弃这些修改。确定继续吗?')
}
async function selectEvent(id: number): Promise<void> {
if (selectedEventId.value === id && detail.value !== null) return
if (!confirmDiscard()) return
selectedEventId.value = id
const seq = (requestSeq += 1)
loading.value = true
try {
const [event, entries, records] = await Promise.all([
@@ -289,28 +315,35 @@ async function selectEvent(id: number): Promise<void> {
adminApi.entries(id),
adminApi.records(),
])
// 切换过快时旧响应可能后到,只有最新一次请求的结果才允许覆盖页面。
if (seq !== requestSeq) return
detail.value = event
selectedCategoryId.value = event.categories?.[0]?.id ?? null
allRows.value = entries.map(toRow)
const record = records.find((item) => item.eventId === id && item.isCurrent) ?? null
recordForEvent.value = record === null ? null : { resultText: record.resultText, resultValue: record.resultValue }
} catch (caught) {
if (seq !== requestSeq) return
toast.error(caught instanceof Error ? caught.message : '加载项目失败')
} finally {
loading.value = false
if (seq === requestSeq) loading.value = false
}
}
async function reload(): Promise<void> {
if (selectedEventId.value === null) return
const id = selectedEventId.value
const seq = requestSeq
loading.value = true
try {
allRows.value = (await adminApi.entries(id)).map(toRow)
const entries = await adminApi.entries(id)
if (seq !== requestSeq) return
allRows.value = entries.map(toRow)
} catch (caught) {
if (seq !== requestSeq) return
toast.error(caught instanceof Error ? caught.message : '重新加载失败')
} finally {
loading.value = false
if (seq === requestSeq) loading.value = false
}
}
@@ -320,6 +353,8 @@ function buildDraftPayload(): DraftResultInput[] {
rank: manualRank.value ? row.rank : null,
resultText: row.resultText.trim(),
resultValue: parseResultValue(row.resultText, unit.value),
// 不传 unit 时服务端会把已有单位覆盖成 NULL,大屏 100 米就会显示成 66.33。
unit: unit.value,
status: row.status,
isTie: row.isTie,
note: row.note.trim() === '' ? null : row.note.trim(),
@@ -328,6 +363,12 @@ function buildDraftPayload(): DraftResultInput[] {
async function saveDraft(silent = false): Promise<boolean> {
if (selectedEventId.value === null) return false
if (saving.value) return false
if (isPublished.value) {
// 已发布行由服务端锁定:就地存草稿会清掉名次却不重算积分,必须先撤销发布。
toast.error('该组已发布', '修改前请先撤销发布')
return false
}
if (!canWrite.value) {
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
return false
@@ -361,6 +402,10 @@ function requestPublish(): void {
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
if (isPublished.value) {
toast.error('该组已发布', '如需修改请先撤销发布')
return
}
if (errors.value.length > 0) {
toast.error(`有 ${errors.value.length} 处校验未通过`, errors.value[0]?.message ?? null)
return
@@ -384,7 +429,7 @@ async function confirmPublish(): Promise<void> {
})
publishDialog.value = false
await reload()
await data.refreshStandings()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
toast.success('发布成功,积分榜已更新')
} catch (caught) {
toast.error('发布失败', caught instanceof Error ? caught.message : null)
@@ -403,7 +448,7 @@ async function confirmUnpublish(): Promise<void> {
})
unpublishDialog.value = false
await reload()
await data.refreshStandings()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
toast.warning('已撤销发布,大屏将回退该组成绩')
} catch (caught) {
toast.error('撤销发布失败', caught instanceof Error ? caught.message : null)
@@ -442,10 +487,18 @@ function onPageKeydown(event: KeyboardEvent): void {
if (!event.ctrlKey && !event.altKey) return
if (event.key !== 'Enter') return
event.preventDefault()
if (saving.value || publishing.value || unpublishing.value) return
if (event.altKey) requestPublish()
else void saveDraft()
}
/** 关闭/刷新页面前拦住未保存的编辑,避免一整组成绩静默丢失。 */
function onBeforeUnload(event: BeforeUnloadEvent): void {
if (!dirty.value) return
event.preventDefault()
event.returnValue = ''
}
/**
* Excel 粘贴:两列及以上时,第一列按单位名匹配行,最后一列作为成绩;
* 单列时从当前行往下顺序填充(07 第 2 节第 4 条)。
@@ -502,9 +555,14 @@ async function pickInitialEvent(): Promise<void> {
}
onMounted(() => {
window.addEventListener('beforeunload', onBeforeUnload)
void pickInitialEvent()
})
onUnmounted(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
})
// 项目列表由布局层异步加载,加载完成后补一次默认选中。
watch(
() => data.loaded,
@@ -519,13 +577,19 @@ watch(
<PageHeader title="成绩录入" :subtitle="selectedEvent?.name ?? '选择左侧项目开始录入'">
<template #actions>
<span v-if="dirty" class="mx-badge mx-badge-warning">有未保存修改</span>
<button class="mx-btn mx-btn-secondary" type="button" :disabled="!canWrite || saving" @click="saveDraft()">
<button
class="mx-btn mx-btn-secondary"
type="button"
:disabled="!canWrite || saving || isPublished"
:title="isPublished ? '该组已发布,请先撤销发布' : ''"
@click="saveDraft()"
>
<MxIcon name="check" :size="15" /> 存草稿
</button>
<button
class="mx-btn mx-btn-primary"
type="button"
:disabled="!canWrite || publishing || data.isMock"
:disabled="!canWrite || publishing || data.isMock || isPublished"
@click="requestPublish"
>
<MxIcon name="push" :size="15" /> 发布
@@ -612,7 +676,7 @@ watch(
<div v-if="isPublished" class="published-note">
<MxIcon name="lock" :size="15" />
该组已发布,修改前需先「撤销发布」;草稿态不会上屏。
该组已发布,成绩与名次已锁定上屏:如需修改,请先「撤销发布」。草稿态不会上屏。
</div>
<div class="entry-body">
@@ -648,7 +712,8 @@ watch(
data-cell
class="mx-cell-input mono"
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error' }"
:disabled="!canWrite"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
:placeholder="unit === 's' ? '1:06.33' : unit"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@@ -659,12 +724,14 @@ watch(
<td class="center">
<input
v-if="manualRank"
v-model.number="row.rank"
:value="row.rank ?? ''"
data-cell
type="number"
min="1"
class="mx-cell-input rank-input"
:disabled="!canWrite"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
@input="row.rank = rankInputValue($event)"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
@@ -676,7 +743,8 @@ watch(
v-model="row.status"
data-cell
class="mx-cell-input status-select"
:disabled="!canWrite"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
@@ -696,7 +764,8 @@ watch(
v-model="row.note"
data-cell
class="mx-cell-input"
:disabled="!canWrite"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
placeholder="—"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
+89 -5
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
formatDateTime,
type EntryWithResult,
type Event,
@@ -13,7 +14,7 @@ import PageHeader from '../components/PageHeader.vue'
import RevisionGraph from '../components/history/RevisionGraph.vue'
import RevisionDetail from '../components/history/RevisionDetail.vue'
import { buildGraph, describeRow, diffSnapshots, laneColor } from '../components/history/graph'
import { fetchRevisions } from '../components/history/revisionsSource'
import { fetchRevisionDetail, fetchRevisions } from '../components/history/revisionsSource'
import { createMockRevisions } from '../components/history/mockRevisions'
import { ACTION_LABELS, type Revision, type RevisionDiffRow, type TeamImpact } from '../components/history/types'
import { adminApi } from '../api/client'
@@ -36,16 +37,21 @@ const revisions = ref<Revision[]>([])
const loading = ref(false)
const usingMock = ref(false)
const mockReason = ref<string | null>(null)
const loadError = ref<string | null>(null)
const eventFilter = ref<number | 'all'>('all')
const timeFilter = ref<TimeFilter>('all')
const selectedId = ref<number | null>(null)
const contexts = ref<Record<number, EventContext>>({})
/** 已成功拉到快照的修订 id,避免 watch 反复请求。 */
const detailLoadedIds = ref<number[]>([])
const detailLoading = ref(false)
const detailError = ref<string | null>(null)
const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1)
const visibleRevisions = computed(() => {
const threshold = timeThreshold(timeFilter.value)
const list = threshold === null ? revisions.value : revisions.value.filter((item) => item.createdAt >= threshold)
const list = threshold === null ? revisions.value : revisions.value.filter((item) => toTime(item.createdAt) >= threshold)
return [...list].sort((a, b) => (a.createdAt === b.createdAt ? b.id - a.id : a.createdAt < b.createdAt ? 1 : -1))
})
@@ -129,10 +135,21 @@ function round(value: number): number {
return Number(value.toFixed(2))
}
function timeThreshold(filter: TimeFilter): string | null {
function timeThreshold(filter: TimeFilter): number | null {
if (filter === 'all') return null
const hours = filter === '24h' ? 24 : 24 * 7
return new Date(Date.now() - hours * 3_600_000).toISOString()
return Date.now() - hours * 3_600_000
}
/**
* 服务端存的是 SQLite `datetime('now')`,即无时区后缀的 UTC 串。
* 直接做字符串比较会因空格(0x20)小于 'T'(0x54)而整体误判,必须先转时间戳。
*/
function toTime(value: string): number {
if (value === '') return 0
const normalized = /[zZ]|[+-]\d{2}:?\d{2}$/.test(value) ? value : `${value.replace(' ', 'T')}Z`
const parsed = Date.parse(normalized)
return Number.isNaN(parsed) ? 0 : parsed
}
function teamLabel(teamId: number): string {
@@ -171,6 +188,7 @@ function teamTotals(revision: Revision, context: EventContext | null): Map<numbe
scorable.push({
entryId: row.entryId,
teamId,
athleteId: context.entryById.get(row.entryId)?.athleteId ?? null,
resultValue: row.resultValue,
rank: row.rank,
status: normalizeStatus(row.status),
@@ -205,14 +223,48 @@ async function ensureContext(eventId: number): Promise<void> {
}
}
/**
* 列表接口不含成绩快照,只有详情接口才带;选中节点后补拉一次,
* 否则真机上 diff 恒 0、影响单位恒空、积分流水恒 0。
*/
async function loadDetail(id: number): Promise<void> {
if (usingMock.value || detailLoadedIds.value.includes(id)) return
detailLoading.value = true
detailError.value = null
try {
const detail = await fetchRevisionDetail(id)
if (detail === null) return
revisions.value = revisions.value.map((item) => (item.id === id ? { ...item, ...detail } : item))
detailLoadedIds.value = [...detailLoadedIds.value, id]
} catch (caught) {
detailError.value = caught instanceof Error ? caught.message : '加载变更详情失败'
} finally {
detailLoading.value = false
}
}
/** 只有网络不可达才降级示例数据;403 / 404 / 500 必须原样呈现。 */
function isUnavailable(error: unknown): boolean {
return error instanceof ApiError && ['NETWORK', 'TIMEOUT'].includes(error.code)
}
async function load(): Promise<void> {
loading.value = true
const eventId = eventFilter.value === 'all' ? undefined : eventFilter.value
detailLoadedIds.value = []
detailError.value = null
loadError.value = null
try {
revisions.value = await fetchRevisions(eventId === undefined ? {} : { eventId })
usingMock.value = false
mockReason.value = null
} catch (caught) {
if (!isUnavailable(caught)) {
revisions.value = []
usingMock.value = false
loadError.value = caught instanceof Error ? caught.message : '变更历史接口不可用'
return
}
const all = createMockRevisions()
revisions.value = eventId === undefined ? all : all.filter((item) => item.eventId === eventId)
usingMock.value = true
@@ -245,7 +297,9 @@ watch(
watch(
selectedRevision,
(revision) => {
if (revision !== null) void ensureContext(revision.eventId)
if (revision === null) return
void ensureContext(revision.eventId)
if (revision.snapshot.length === 0) void loadDetail(revision.id)
},
{ immediate: true },
)
@@ -268,6 +322,12 @@ watch(
<span class="mock-hint">接口就绪后自动切换为真实数据</span>
</div>
<div v-if="loadError !== null" class="error-banner">
<MxIcon name="warning" :size="15" />
<span>{{ loadError }}</span>
<button class="mx-btn mx-btn-secondary mx-btn-sm" type="button" @click="load">重试</button>
</div>
<div class="filter-bar">
<label class="filter">
<span>小项</span>
@@ -341,6 +401,8 @@ watch(
<span v-if="selectedRevision" class="mx-badge mx-badge-neutral mono">#{{ selectedRevision.id }}</span>
</div>
<div class="mx-card-body">
<p v-if="detailLoading" class="detail-hint">正在加载节点详情…</p>
<p v-else-if="detailError !== null" class="detail-hint error">加载节点详情失败:{{ detailError }}</p>
<RevisionDetail
v-if="selectedRevision"
:revision="selectedRevision"
@@ -381,6 +443,20 @@ watch(
color: var(--mx-t3);
font-size: 12px;
}
.error-banner {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
margin-top: 14px;
border-radius: var(--mx-r-sm);
background: rgba(248, 113, 113, 0.08);
color: var(--mx-red);
font-size: 13px;
}
.error-banner .mx-btn {
margin-left: auto;
}
.filter-bar {
display: flex;
align-items: center;
@@ -464,6 +540,14 @@ watch(
background: rgba(251, 191, 36, 0.12);
color: var(--mx-amber);
}
.detail-hint {
font-size: 12px;
color: var(--mx-t3);
margin-bottom: 10px;
}
.detail-hint.error {
color: var(--mx-error);
}
.foot-hint {
display: flex;
align-items: center;
+9 -5
View File
@@ -6,7 +6,7 @@ import PageHeader from '../components/PageHeader.vue'
import MockBadge from '../components/system/MockBadge.vue'
import JsonDiff from '../components/system/JsonDiff.vue'
import { describeError } from '../components/system/systemApi'
import { adminApi, mockMode } from '../api/client'
import { adminApi } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
@@ -81,14 +81,18 @@ function onFilterChange(): void {
page.value = 1
}
const isAdmin = computed(() => auth.user?.role === 'admin')
async function load(): Promise<void> {
// /api/audit-logs 是 admin-only;非 admin 直接不请求,避免常驻 403 报错。
if (!isAdmin.value) return
loading.value = true
loadError.value = null
try {
const result = await adminApi.auditLogs({ page: 1, size: WINDOW_SIZE })
rows.value = result.items
// adminApi 内部静默降级,只能从全局标记反推这批数据是不是示例。
usingMock.value = mockMode.value
// 用局部 mock 标记判断本批数据是不是示例,不读会被并发请求清掉的全局状态。
const result = await adminApi.auditLogsWithMock({ page: 1, size: WINDOW_SIZE })
rows.value = result.data.items
usingMock.value = result.mock
if (page.value > pageCount.value) page.value = 1
} catch (caught) {
loadError.value = describeError(caught)
-159
View File
@@ -1,159 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import type { IconName } from '../components/icons'
/**
* 未实现页面的统一骨架。
* M4 只要求占位路由,这里把「这页将来做什么」写清楚,避免点进来是一片空白。
*/
const route = useRoute()
const TITLE_ICONS: Record<string, IconName> = {
review: 'review',
records: 'record',
adjustments: 'adjust',
'base-data': 'database',
export: 'export',
history: 'history',
logs: 'logs',
system: 'system',
}
const title = computed(() => (typeof route.meta.title === 'string' ? route.meta.title : '页面'))
const note = computed(() => (typeof route.meta.note === 'string' ? route.meta.note : ''))
const icon = computed<IconName>(() => TITLE_ICONS[String(route.name)] ?? 'system')
const PLANNED: Record<string, string[]> = {
review: ['草稿列表(按项目 / 时间筛选)', '并排对比历史版本', '批量发布 / 退回,留审计'],
records: ['疑似破纪录自动提示', '裁判长确认后生成加分流水', '本期破纪录加分关闭,功能保留'],
adjustments: ['人工加减分(分值可正可负)', '类别:处罚 / 补分 / 更正', '理由必填、可作废、全程审计'],
'base-data': ['单位 / 大项 / 小项 / 组别', '积分模板与计分通道', '运动员与报名表 xlsx 导入'],
export: ['总表 / 分项成绩表 / 单位明细', '按小项、组别单独导出', '赛事项目包 zip 导入导出'],
history: ['仿 Git Graph 的时间轴 + 分支线', '节点详情:操作人 / 动作 / 分数增减', '只读,不做一键回滚'],
logs: ['审计日志:谁在什么时候改了什么', '按实体类型 / 用户 / 时间筛选', '可关联 requestId 追查重试'],
system: ['用户与角色管理', '备份与恢复', '赛事信息与项目切换'],
}
</script>
<template>
<div>
<PageHeader :title="title" :subtitle="note">
<template #actions>
<span class="mx-badge mx-badge-neutral">规划中</span>
</template>
</PageHeader>
<div class="mx-card">
<div class="mx-card-body">
<div class="skeleton-head">
<span class="skeleton-icon"><MxIcon :name="icon" :size="22" /></span>
<div>
<h2>{{ title }}页面将在后续里程碑实现</h2>
<p>M4 只交付占位路由与骨架,核心链路(成绩录入 → 积分榜 → 大屏)已可用。</p>
</div>
</div>
<div class="planned">
<div class="planned-title">计划能力</div>
<ul>
<li v-for="item in PLANNED[String(route.name)] ?? []" :key="item">
<MxIcon name="check" :size="14" />
<span>{{ item }}</span>
</li>
</ul>
</div>
<div class="mx-stat-grid skeleton-grid">
<div v-for="index in 3" :key="index" class="mx-stat skeleton-stat">
<div class="mx-skeleton line short"></div>
<div class="mx-skeleton line tall"></div>
<div class="mx-skeleton line"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.skeleton-head {
display: flex;
gap: 14px;
align-items: flex-start;
margin-bottom: 20px;
}
.skeleton-icon {
width: 44px;
height: 44px;
border-radius: var(--mx-r-sm);
background: var(--mx-primary-glow);
color: var(--mx-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.skeleton-head h2 {
font-size: 16px;
font-weight: 700;
margin-bottom: 6px;
}
.skeleton-head p {
font-size: 13px;
color: var(--mx-t3);
line-height: 1.6;
}
.planned {
padding: 14px 16px;
border-radius: var(--mx-r-sm);
background: var(--mx-s3);
margin-bottom: 20px;
}
.planned-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--mx-t3);
margin-bottom: 10px;
}
.planned ul {
display: flex;
flex-direction: column;
gap: 8px;
}
.planned li {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--mx-t2);
}
.planned li svg {
color: var(--mx-green);
flex-shrink: 0;
}
.skeleton-grid {
opacity: 0.6;
}
.skeleton-stat {
cursor: default;
display: flex;
flex-direction: column;
gap: 10px;
}
.line {
height: 12px;
border-radius: 6px;
}
.line.short {
width: 40%;
}
.line.tall {
height: 24px;
width: 60%;
}
</style>
+27 -6
View File
@@ -78,6 +78,7 @@ const rejectedKeys = ref<string[]>([])
const approvedKeys = ref<string[]>([])
const canWrite = computed(() => !auth.readOnly && data.canWrite)
const isAdmin = computed(() => auth.user?.role === 'admin')
/**
* 破纪录加分开关:true / false / null(未知)。
*
@@ -139,11 +140,13 @@ function levelRank(level: RecordLevel): number {
return index < 0 ? 0 : index
}
/** 只有真的连不上服务端才做本地降级;业务 404 / 解析失败必须原样呈现。 */
function isFallback(error: unknown): boolean {
return (
error instanceof ApiError &&
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
)
return error instanceof ApiError && ['NETWORK', 'TIMEOUT'].includes(error.code)
}
function isForbidden(error: unknown): boolean {
return error instanceof ApiError && (error.status === 403 || error.code === 'FORBIDDEN')
}
function teamLabel(teamId: number): string {
@@ -161,11 +164,22 @@ function beats(compare: Event['compare'], value: number, recordValue: number): b
async function loadRules(): Promise<void> {
rulesLoading.value = true
try {
// /api/rules/export 是 admin-only:录入员跳过它,改用下面的只读开关端点,
// 否则页面会常驻一条红色「读取规则包失败:无权访问该资源」。
if (isAdmin.value) {
rulePack.value = await adminApi.client.rules.get()
ruleError.value = null
} else {
rulePack.value = null
ruleError.value = null
}
} catch (caught) {
rulePack.value = null
ruleError.value = caught instanceof Error ? caught.message : '无法读取规则包'
ruleError.value = isForbidden(caught)
? null
: caught instanceof Error
? caught.message
: '无法读取规则包'
} finally {
rulesLoading.value = false
}
@@ -459,7 +473,14 @@ watch(
>
确认加分
</button>
<button class="mx-btn mx-btn-ghost mx-btn-sm" type="button" @click="rejectTarget = item">拒绝</button>
<button
class="mx-btn mx-btn-ghost mx-btn-sm"
type="button"
:disabled="!canWrite"
@click="rejectTarget = item"
>
拒绝
</button>
</td>
</tr>
</tbody>
+41 -9
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
formatPoints,
parseResultValue,
type EntryWithResult,
type Event,
type EventCategory,
@@ -128,11 +129,35 @@ function teamLabel(teamId: number, fallback: string): string {
return team?.shortName ?? team?.name ?? fallback
}
/** 只有真的连不上服务端才做本地降级;业务 404 / 解析失败必须原样呈现。 */
function isFallback(error: unknown): boolean {
return (
error instanceof ApiError &&
(['NETWORK', 'TIMEOUT', 'PARSE_FAILED'].includes(error.code) || error.status === 404)
)
return error instanceof ApiError && ['NETWORK', 'TIMEOUT'].includes(error.code)
}
function unitOf(type: Event['resultType']): string {
if (type === 'time') return 's'
if (type === 'distance') return 'm'
if (type === 'count') return '次'
if (type === 'score') return 'pts'
return ''
}
/** 发布前防呆:与录入页同一套校验,避免整组发布缺成绩 / 无法解析的行。 */
function publishBlockers(group: DraftGroup): string[] {
const unit = unitOf(group.event.resultType)
const manual = group.event.resultMode === 'manual' || group.event.compare === 'none'
const blockers: string[] = []
for (const row of group.rows) {
if (row.status !== 'ok') continue
const label = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
if (row.resultText.trim() === '') {
if (!(manual && row.rank !== null)) blockers.push(`${label}:缺少成绩`)
} else if (parseResultValue(row.resultText, unit) === null) {
blockers.push(`${label}:成绩格式无法解析`)
}
if (manual && row.rank === null) blockers.push(`${label}:缺少名次`)
}
return blockers
}
function sportNameOf(event: Event): string {
@@ -147,6 +172,7 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
const scorable: ScorableRow[] = scoped.map((entry) => ({
entryId: entry.id,
teamId: entry.teamId,
athleteId: entry.athleteId,
resultValue: entry.result?.resultValue ?? null,
rank: entry.result?.rank ?? null,
status: entry.result?.status ?? 'ok',
@@ -160,15 +186,16 @@ function buildGroup(event: Event, category: EventCategory | null, entries: Entry
weight: weight.value,
}
const score = computeEventScore(scorable, context)
const individual = event.eventType === 'individual'
// 已发布行的分已经计入当前总分,对比时必须先扣掉,差值才是「本次发布」的影响。
// result.points 服务端从不返回(恒为 0),改用与预览同一套算法算出的 rowPoints。
// rowTeamPoints 已按通道 scope 折算好个人→单位系数,与服务端流水口径一致。
const published = new Map<number, number>()
for (const entry of scoped) {
if (entry.result?.state !== 'published') continue
const points = round(score.rowPoints.get(entry.id) ?? 0) * (individual ? weight.value : 1)
published.set(entry.teamId, (published.get(entry.teamId) ?? 0) + points)
published.set(
entry.teamId,
(published.get(entry.teamId) ?? 0) + round(score.rowTeamPoints.get(entry.id) ?? 0),
)
}
const teamIds = new Set<number>([...score.teamPoints.keys(), ...published.keys()])
@@ -277,6 +304,11 @@ function requestPublish(group: DraftGroup): void {
toast.error('当前不可发布', auth.readOnly ? '请先登录' : '服务端未连接')
return
}
const blockers = publishBlockers(group)
if (blockers.length > 0) {
toast.error(`有 ${blockers.length} 处校验未通过`, blockers[0] ?? null)
return
}
publishTarget.value = group
}
@@ -288,7 +320,7 @@ async function confirmPublish(): Promise<void> {
await adminApi.publish({ eventId: group.event.id, categoryId: group.category?.id })
publishTarget.value = null
await load()
await data.refreshStandings()
await Promise.all([data.refreshStandings(), data.refreshEvents()])
toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`)
} catch (caught) {
toast.error('发布失败', caught instanceof Error ? caught.message : null)
+15 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import PageHeader from '../components/PageHeader.vue'
import UsersPanel from '../components/system/UsersPanel.vue'
import BackupPanel from '../components/system/BackupPanel.vue'
@@ -28,6 +28,16 @@ const data = useEventStore()
const activeTab = ref<TabKey>('users')
const isAdmin = computed(() => auth.user?.role === 'admin')
/** 用户管理 / 备份的后端接口是 admin-only,非 admin 不挂载这两个面板。 */
const visibleTabs = computed(() => (isAdmin.value ? TABS : TABS.filter((tab) => tab.key === 'event')))
watch(
isAdmin,
(admin) => {
if (!admin && activeTab.value !== 'event') activeTab.value = 'event'
},
{ immediate: true },
)
</script>
<template>
@@ -37,7 +47,7 @@ const isAdmin = computed(() => auth.user?.role === 'admin')
<div class="system-toolbar">
<div class="mx-tabs">
<button
v-for="tab in TABS"
v-for="tab in visibleTabs"
:key="tab.key"
class="mx-tab"
:class="{ active: activeTab === tab.key }"
@@ -48,7 +58,7 @@ const isAdmin = computed(() => auth.user?.role === 'admin')
</button>
</div>
<span class="mx-badge" :class="isAdmin ? 'mx-badge-success' : 'mx-badge-neutral'">
{{ isAdmin ? `当前身份:${auth.displayName}` : `只读访客 · ${auth.displayName}` }}
{{ isAdmin ? `当前身份:${auth.displayName}` : `${auth.roleLabel} · ${auth.displayName}` }}
</span>
</div>
@@ -59,8 +69,8 @@ const isAdmin = computed(() => auth.user?.role === 'admin')
<p v-if="data.error !== null" class="role-note error">数据加载失败:{{ data.error }}</p>
<UsersPanel v-if="activeTab === 'users'" />
<BackupPanel v-else-if="activeTab === 'backup'" />
<UsersPanel v-if="isAdmin && activeTab === 'users'" />
<BackupPanel v-else-if="isAdmin && activeTab === 'backup'" />
<EventInfoPanel v-else />
</div>
</template>
+16 -3
View File
@@ -1,8 +1,12 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'
import AdminLayout from './layouts/AdminLayout.vue'
import { useAuthStore } from './stores/auth'
/**
* 路由表。页面已全部落地,不再使用 PlaceholderPage。
* 路由表。
*
* 系统设置与日志的接口是 admin-only,这里用 meta.requiresAdmin 兜底:
* 直接改 hash 深链也进不去,避免 recorder/guest 触发一串 403 请求。
*/
const routes: RouteRecordRaw[] = [
{
@@ -80,13 +84,13 @@ const routes: RouteRecordRaw[] = [
path: 'logs',
name: 'logs',
component: () => import('./pages/LogsPage.vue'),
meta: { title: '日志' },
meta: { title: '日志', requiresAdmin: true },
},
{
path: 'system',
name: 'system',
component: () => import('./pages/SystemPage.vue'),
meta: { title: '系统设置' },
meta: { title: '系统设置', requiresAdmin: true },
},
{
path: 'password',
@@ -112,6 +116,15 @@ export const router = createRouter({
routes,
})
router.beforeEach(async (to) => {
if (to.meta.requiresAdmin !== true) return true
const auth = useAuthStore()
// 刷新页面时深链先于布局层挂载,这里补一次会话探测,避免把管理员误判成访客。
if (!auth.initialized) await auth.load()
if (auth.user?.role === 'admin') return true
return { name: 'dashboard' }
})
router.afterEach((to) => {
const title = typeof to.meta.title === 'string' ? to.meta.title : ''
document.title = title === '' ? '管理面板' : `${title} · 管理面板`
+1
View File
@@ -73,6 +73,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
user,
loading,
error,
initialized,
isGuest,
readOnly,
displayName,
-9
View File
@@ -34,7 +34,6 @@ export const useEventStore = defineStore('admin-event', () => {
const realtimeStatus = ref<RealtimeStatus>('idle')
const serverVersion = ref<number | null>(null)
const serverTime = ref<string | null>(null)
const serverHealth = ref<string | null>(null)
let realtime: RealtimeClient | null = null
@@ -111,12 +110,6 @@ export const useEventStore = defineStore('admin-event', () => {
realtime.connect()
}
function stopRealtime(): void {
realtime?.close()
realtime = null
realtimeStatus.value = 'idle'
}
async function refreshStandings(): Promise<void> {
try {
const [next, athletes] = await Promise.all([
@@ -161,7 +154,6 @@ export const useEventStore = defineStore('admin-event', () => {
lastRefreshAt,
realtimeStatus,
serverVersion,
serverTime,
serverHealth,
isMock,
mockLabel,
@@ -173,6 +165,5 @@ export const useEventStore = defineStore('admin-event', () => {
refreshEvents,
eventById,
teamById,
stopRealtime,
}
})
-7
View File
@@ -96,10 +96,3 @@ body {
.mx-table-entry tbody tr.is-focus {
background: var(--mx-primary-glow);
}
/* 只读访客态:禁用写操作控件。 */
.is-readonly .mx-btn-primary,
.is-readonly .mx-btn-danger {
opacity: 0.45;
pointer-events: none;
}
+230
View File
@@ -0,0 +1,230 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import type { Event, EventCategory, ScoreChannel, ScoringProfile } from '@/shared'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
// 逐条对齐服务端 internal/domain/scoring.go 的 ScoreEvent 口径。
// 运行:node --test --import ./src/shared/tests/register.mjs src/admin/tests/
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 1,
sportId: 1,
code: 'M100',
name: '100 米',
eventType: 'individual',
resultMode: 'auto',
resultType: 'time',
compare: 'asc',
recordEligible: false,
scored: true,
multiplier: 1,
status: 'pending',
scheduledAt: null,
venue: null,
sortOrder: 1,
remark: null,
...overrides,
}
}
function makeProfile(overrides: Partial<ScoringProfile> = {}): ScoringProfile {
return { id: 1, code: 'p', name: 'p', tieMode: 'skip', recordBonusMode: 'max', rules: [], ...overrides }
}
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
return { id: 1, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, ...overrides }
}
function makeRow(entryId: number, resultValue: number | null, teamId: number, rank: number | null = null): ScorableRow {
return { entryId, teamId, athleteId: entryId, resultValue, rank, status: 'ok', isTie: false }
}
function makeContext(overrides: Partial<ScoreContext> = {}): ScoreContext {
return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides }
}
function close(actual: number, expected: number): void {
assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`)
}
test('awardRanks 之外的名次一律 0 分', () => {
const profile = makeProfile({
awardRanks: 2,
rules: [
{ rank: 1, points: 9 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 12, 102)],
makeContext({ channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, 9)
close(result.rowPoints.get(2) ?? 0, 6)
close(result.rowPoints.get(3) ?? 0, 0)
close(result.teamPoints.get(102) ?? 0, 0)
})
test('lastPlaceZero 让末位整组 0 分', () => {
const profile = makeProfile({
lastPlaceZero: true,
rules: [
{ rank: 1, points: 9 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 12, 102)],
makeContext({ channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, 9)
close(result.rowPoints.get(2) ?? 0, 6)
close(result.rowPoints.get(3) ?? 0, 0)
})
test('末位并列整组都按最后一名处理', () => {
const profile = makeProfile({
lastPlaceZero: true,
rules: [
{ rank: 1, points: 9 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 11, 102)],
makeContext({ channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, 9)
close(result.rowPoints.get(2) ?? 0, 0)
close(result.rowPoints.get(3) ?? 0, 0)
})
test('scored=false 只排名不计分', () => {
const profile = makeProfile({
rules: [
{ rank: 1, points: 9 },
{ rank: 2, points: 6 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 11, 101)],
makeContext({ event: makeEvent({ scored: false }), channels: [makeChannel()], profiles: [profile] }),
)
assert.equal(result.ranks.get(1), 1)
assert.equal(result.ranks.get(2), 2)
close(result.rowPoints.get(1) ?? 0, 0)
close(result.teamPoints.get(100) ?? 0, 0)
})
test('多通道叠加,个人通道分按权重折算进单位总分', () => {
const athleteProfile = makeProfile({ id: 1, rules: [{ rank: 1, points: 10 }] })
const teamProfile = makeProfile({ id: 2, rules: [{ rank: 1, points: 4 }] })
const result = computeEventScore(
[makeRow(1, 10, 100)],
makeContext({
channels: [
makeChannel({ id: 1, scope: 'athlete', profileId: 1 }),
makeChannel({ id: 2, scope: 'team', profileId: 2 }),
],
profiles: [athleteProfile, teamProfile],
weight: 2,
}),
)
// 行分是两条通道的原始分之和;折算到单位时只有个人通道乘系数。
close(result.rowPoints.get(1) ?? 0, 14)
close(result.rowTeamPoints.get(1) ?? 0, 24)
close(result.teamPoints.get(100) ?? 0, 24)
})
test('并列 average 取占用名次区间的均值', () => {
const profile = makeProfile({
tieMode: 'average',
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
{ rank: 4, points: 1 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 10, 101), makeRow(3, 10, 102)],
makeContext({ channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, (10 + 6 + 3) / 3)
close(result.rowPoints.get(2) ?? 0, (10 + 6 + 3) / 3)
close(result.rowPoints.get(3) ?? 0, (10 + 6 + 3) / 3)
assert.equal(result.ranks.get(1), 1)
})
test('并列 higher 取区间最高档', () => {
const profile = makeProfile({
tieMode: 'higher',
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 10, 101), makeRow(3, 10, 102)],
makeContext({ channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, 10)
close(result.rowPoints.get(2) ?? 0, 10)
close(result.rowPoints.get(3) ?? 0, 10)
})
test('没有计分通道时算出名次但一律 0 分', () => {
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 11, 101)],
makeContext({ channels: [], profiles: [] }),
)
assert.equal(result.ranks.get(1), 1)
assert.equal(result.ranks.get(2), 2)
close(result.rowPoints.get(1) ?? 0, 0)
close(result.teamPoints.get(100) ?? 0, 0)
})
test('manual 通道使用录入名次,不做顺推改写', () => {
const profile = makeProfile({
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
],
})
const result = computeEventScore(
[makeRow(1, null, 100, 2), makeRow(2, null, 101, 1)],
makeContext({
event: makeEvent({ resultMode: 'manual', compare: 'none' }),
channels: [makeChannel({ basis: 'manual' })],
profiles: [profile],
}),
)
close(result.rowPoints.get(1) ?? 0, 6)
close(result.rowPoints.get(2) ?? 0, 10)
assert.equal(result.ranks.get(1), 2)
assert.equal(result.ranks.get(2), 1)
})
test('项目与组别权重相乘', () => {
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }] })
const category: EventCategory = {
id: 7,
eventId: 1,
name: '甲组',
multiplier: 3,
gender: null,
ageMin: null,
ageMax: null,
sortOrder: 1,
}
const result = computeEventScore(
[makeRow(1, 10, 100)],
makeContext({ event: makeEvent({ multiplier: 2 }), category, channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, 60)
})
+16 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Component } from 'vue'
import { mix, parseHexColor, readableForeground, toCss } from './color'
import type { DisplayPageKey } from './types'
@@ -26,10 +26,13 @@ const PAGES: Record<DisplayPageKey, Component> = {
const { bootstrap, dispose, page, settings } = useScoreboard()
// 单屏窗口化才有标题栏;kiosk 下 windowed 为 false,下面所有相关分支都不生效
// 单屏窗口化才有标题栏;kiosk 下 windowed 为 false,下面所有相关分支都不生效。
const windowed = isWindowed()
const { scale, offsetX, offsetY } = useStageScale({
topInset: () => (windowed ? WINDOWED_TITLEBAR_HEIGHT : 0),
// Console 的 F11 由主进程切换全屏:全屏时自绘标题栏必须让位,否则舞台顶部被多扣 50px。
const fullscreen = ref(document.fullscreenElement !== null)
const showTitleBar = computed(() => windowed && !fullscreen.value)
const { scale, offsetX, offsetY, fit } = useStageScale({
topInset: () => (showTitleBar.value ? WINDOWED_TITLEBAR_HEIGHT : 0),
})
const currentPage = computed<Component>(() => PAGES[page.value])
@@ -71,12 +74,20 @@ const themeVars = computed<Record<string, string>>(() => {
const RELOAD_MS = 24 * 60 * 60 * 1000
let reloadTimer = 0
function onFullscreenChange(): void {
fullscreen.value = document.fullscreenElement !== null
// 标题栏出现/消失会改变可用高度,必须重新算缩放(fullscreenchange 不保证带 resize)。
fit()
}
onMounted(() => {
document.addEventListener('fullscreenchange', onFullscreenChange)
void bootstrap()
reloadTimer = window.setTimeout(() => window.location.reload(), RELOAD_MS)
})
onUnmounted(() => {
document.removeEventListener('fullscreenchange', onFullscreenChange)
window.clearTimeout(reloadTimer)
dispose()
})
@@ -84,7 +95,7 @@ onUnmounted(() => {
<template>
<div class="root" :style="rootStyle">
<WindowedTitleBar v-if="windowed" :title="barTitle" :logo="barLogo" />
<WindowedTitleBar v-if="showTitleBar" :title="barTitle" :logo="barLogo" />
<div class="screen">
<div class="stage" :style="[stageStyle, themeVars]">
+9 -2
View File
@@ -2,15 +2,17 @@
import { computed } from 'vue'
import { useScoreboard } from '../useScoreboard'
const { booted, status, source, version } = useScoreboard()
const { booted, status, source, version, authRequired } = useScoreboard()
const offline = computed(() => booted.value && status.value !== 'online')
// connecting 是启动瞬间的正常状态,闪「连接中断」会误报;只有真正断开才提示。
const offline = computed(() => booted.value && status.value === 'offline')
const mock = computed(() => source.value === 'mock')
</script>
<template>
<div class="badges">
<span v-if="mock" class="badge mock">示例数据</span>
<span v-if="authRequired" class="badge auth">登录已失效 · 请重新登录</span>
<span v-if="offline" class="badge offline">⚠ 连接中断 · 数据可能不是最新</span>
<span v-if="version !== null" class="badge version">v{{ version }}</span>
</div>
@@ -43,6 +45,11 @@ const mock = computed(() => source.value === 'mock')
border-color: rgba(255, 215, 94, 0.5);
color: var(--gold);
}
.auth {
background: rgba(255, 160, 64, 0.16);
border-color: rgba(255, 160, 64, 0.55);
color: #ffd0a0;
}
.version {
background: rgba(255, 255, 255, 0.06);
color: var(--text-dim);
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more