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:
1 parent
96cf181ae3
commit
13f1f03cd0
124 files changed
+5658
-1820
No files matched your search
@@ -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
@@ -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
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
+16
-16
@@ -13,7 +13,7 @@ const {
|
||||
|
||||
const { loadConfig, resolveExecutable } = require('./config')
|
||||
const { LogBus } = require('./log-bus')
|
||||
const { ServiceSupervisor } = require('./service')
|
||||
const { ServiceSupervisor, healthHost } = require('./service')
|
||||
const { DisplayManager } = require('./displays')
|
||||
const { fetchDisplayInstances } = require('./instances')
|
||||
const { createTrayIcon } = require('./tray-icon')
|
||||
@@ -36,7 +36,6 @@ function main() {
|
||||
let tray = null
|
||||
let powerBlockerId = null
|
||||
let displayFallbackTimer = null
|
||||
let displaysEnabled = false
|
||||
let quitting = false
|
||||
let shutdownDone = false
|
||||
let exePath = null
|
||||
@@ -133,16 +132,14 @@ function main() {
|
||||
}
|
||||
|
||||
function adminPanelUrl() {
|
||||
// 复用服务端的地址推导:host 配成 0.0.0.0 时本机也要走回环
|
||||
// 复用服务端的地址推导:host 配成 0.0.0.0/:: 时本机也要走回环
|
||||
if (service) return service.status.adminUrl
|
||||
const host = (cfg.values.server.host || '127.0.0.1') === '0.0.0.0' ? '127.0.0.1' : cfg.values.server.host
|
||||
return `http://${host || '127.0.0.1'}:${cfg.values.server.port}/admin`
|
||||
return `http://${healthHost(cfg.values.server)}:${cfg.values.server.port}/admin`
|
||||
}
|
||||
|
||||
function serverHost() {
|
||||
if (service) return service.healthHost()
|
||||
const host = cfg.values.server.host || '127.0.0.1'
|
||||
return host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host
|
||||
return healthHost(cfg.values.server)
|
||||
}
|
||||
|
||||
// 大屏实例看板:读服务端 GET /api/display/instances 并推给管理窗口。
|
||||
@@ -248,8 +245,7 @@ function main() {
|
||||
}
|
||||
|
||||
function ensureDisplays() {
|
||||
if (displaysEnabled || !displays) return
|
||||
displaysEnabled = true
|
||||
if (!displays || displays.enabled) return
|
||||
if (displayFallbackTimer) {
|
||||
clearTimeout(displayFallbackTimer)
|
||||
displayFallbackTimer = null
|
||||
@@ -392,7 +388,7 @@ function main() {
|
||||
return true
|
||||
})
|
||||
ipcMain.handle('display:relayout', () => {
|
||||
if (displays && displaysEnabled) displays.relayout()
|
||||
if (displays && displays.enabled) displays.relayout()
|
||||
return statusSnapshot()
|
||||
})
|
||||
ipcMain.handle('app:open-admin-window', () => {
|
||||
@@ -471,17 +467,16 @@ function main() {
|
||||
clearTimeout(displayFallbackTimer)
|
||||
displayFallbackTimer = null
|
||||
}
|
||||
if (displays && displaysEnabled) {
|
||||
if (displays && displays.enabled) {
|
||||
displays.closeAll()
|
||||
log.push('console', '服务已停止,大屏窗口已关闭')
|
||||
}
|
||||
displaysEnabled = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!status.healthy) return
|
||||
// 服务端恢复后必须让大屏重新加载,否则页面还停在断线兜底画面上
|
||||
if (displaysEnabled) displays.relayout()
|
||||
if (displays.enabled) displays.relayout()
|
||||
else ensureDisplays()
|
||||
})
|
||||
|
||||
@@ -497,8 +492,13 @@ function main() {
|
||||
applyOpenAtLogin()
|
||||
|
||||
// 防熄屏要在服务端起来之前就生效,否则开机后第一段时间屏幕仍会休眠
|
||||
powerBlockerId = powerSaveBlocker.start('prevent-display-sleep')
|
||||
log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`)
|
||||
try {
|
||||
powerBlockerId = powerSaveBlocker.start('prevent-display-sleep')
|
||||
log.push('console', `防熄屏已启用(powerSaveBlocker=${powerBlockerId},生效=${powerSaveBlocker.isStarted(powerBlockerId)})`)
|
||||
} catch (err) {
|
||||
powerBlockerId = null
|
||||
log.push('console', `防熄屏启用失败:${err.message}`)
|
||||
}
|
||||
|
||||
if (cfg.values.server.autoStart) {
|
||||
service.start()
|
||||
@@ -511,7 +511,7 @@ function main() {
|
||||
if (wait > 0) {
|
||||
displayFallbackTimer = setTimeout(() => {
|
||||
displayFallbackTimer = null
|
||||
if (!displaysEnabled) {
|
||||
if (displays && !displays.enabled) {
|
||||
log.push('console', `等待服务端就绪超过 ${Math.round(wait / 1000)} 秒,先打开大屏窗口`)
|
||||
ensureDisplays()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -31,6 +31,8 @@ const (
|
||||
DefaultLoginPerMinute = 5
|
||||
// DefaultWritePerMinute 写操作限流:30 次/分钟/用户。
|
||||
DefaultWritePerMinute = 30
|
||||
// MaxPasswordBytes 是 bcrypt 的输入上限;超过会被静默截断,必须显式拒绝。
|
||||
MaxPasswordBytes = 72
|
||||
)
|
||||
|
||||
// dummyBcryptHash 是固定 cost=12 的哈希,用于「用户不存在」时也做一次同价比较,
|
||||
@@ -177,6 +179,9 @@ func (s *Service) SetPassword(ctx context.Context, username, password string, mu
|
||||
if password == "" {
|
||||
return fmt.Errorf("密码不能为空")
|
||||
}
|
||||
if len(password) > MaxPasswordBytes {
|
||||
return fmt.Errorf("密码不能超过 %d 字节", MaxPasswordBytes)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成密码哈希: %w", err)
|
||||
@@ -445,15 +450,26 @@ func randomToken(n int) (string, error) {
|
||||
}
|
||||
|
||||
// randomPassword 生成去掉易混淆字符的随机密码。
|
||||
// 用拒绝采样消除取模偏差,避免某些字符出现概率更高。
|
||||
func randomPassword(n int) (string, error) {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789"
|
||||
// 只接受小于 256-(256%len) 的字节,落在尾部的字节直接丢弃重取。
|
||||
const limit = 256 - (256 % len(alphabet))
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("生成随机密码: %w", err)
|
||||
}
|
||||
out := make([]byte, n)
|
||||
for i, b := range buf {
|
||||
out[i] = alphabet[int(b)%len(alphabet)]
|
||||
out := make([]byte, 0, n)
|
||||
for len(out) < n {
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("生成随机密码: %w", err)
|
||||
}
|
||||
for _, b := range buf {
|
||||
if int(b) >= limit {
|
||||
continue
|
||||
}
|
||||
out = append(out, alphabet[int(b)%len(alphabet)])
|
||||
if len(out) == n {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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-- {
|
||||
|
||||
@@ -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 }{
|
||||
|
||||
+120
-86
@@ -85,7 +85,13 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
|
||||
return StandingsWorkbook{}, err
|
||||
}
|
||||
|
||||
teams, err := loadTeams(db)
|
||||
// 排名输入只含启用单位,与 REST 一致。
|
||||
teams, err := loadTeams(db, true)
|
||||
if err != nil {
|
||||
return StandingsWorkbook{}, err
|
||||
}
|
||||
// 展示字段用全量单位表:停用但有流水的单位由 domain 兜底出行,名称不能是空的。
|
||||
allTeams, err := loadTeams(db, false)
|
||||
if err != nil {
|
||||
return StandingsWorkbook{}, err
|
||||
}
|
||||
@@ -107,24 +113,29 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
|
||||
Athletes: athletes,
|
||||
Ledger: ledger,
|
||||
Ranked: ranked,
|
||||
TieBreak: splitSettingList(settings["standings.tie_break"]),
|
||||
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
|
||||
ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"),
|
||||
}
|
||||
return StandingsWorkbook{
|
||||
Meta: meta,
|
||||
Team: domain.BuildTeamStandings(in),
|
||||
Medal: domain.BuildMedalStandings(in),
|
||||
Athlete: domain.BuildAthleteStandings(in),
|
||||
}, nil
|
||||
team := domain.BuildTeamStandings(in)
|
||||
medal := domain.BuildMedalStandings(in)
|
||||
athlete := domain.BuildAthleteStandings(in)
|
||||
fillTeamDisplay(team, medal, athlete, allTeams)
|
||||
return StandingsWorkbook{Meta: meta, Team: team, Medal: medal, Athlete: athlete}, nil
|
||||
}
|
||||
|
||||
func loadTeams(db *sql.DB) ([]domain.Team, error) {
|
||||
// 只取启用中的单位;已软删除但仍有流水的单位由 domain 兜底补一行,分数不会被吞掉。
|
||||
rows, err := db.Query(`
|
||||
// loadTeams 读取单位表;activeOnly 为 true 时只取启用中的单位(排名输入口径)。
|
||||
func loadTeams(db *sql.DB, activeOnly bool) ([]domain.Team, error) {
|
||||
query := `
|
||||
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
|
||||
COALESCE(color, ''), sort_order, active
|
||||
FROM teams WHERE active = 1
|
||||
ORDER BY sort_order, code`)
|
||||
FROM teams`
|
||||
if activeOnly {
|
||||
query += ` WHERE active = 1`
|
||||
}
|
||||
query += ` ORDER BY sort_order, code`
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取单位: %w", err)
|
||||
}
|
||||
@@ -146,6 +157,66 @@ func loadTeams(db *sql.DB) ([]domain.Team, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fillTeamDisplay 用全量单位表补齐榜单行的展示字段。
|
||||
// 排名输入只含启用单位,停用但有流水的单位由 domain 兜底出行,编码/名称原本是空的。
|
||||
func fillTeamDisplay(team []domain.StandingsRow, medal []domain.MedalRow, athlete []domain.AthleteStandingsRow, teams []domain.Team) {
|
||||
byID := make(map[int64]domain.Team, len(teams))
|
||||
for _, t := range teams {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
for i := range team {
|
||||
if t, ok := byID[team[i].TeamID]; ok {
|
||||
if team[i].Code == "" {
|
||||
team[i].Code = t.Code
|
||||
}
|
||||
if team[i].Name == "" {
|
||||
team[i].Name = t.Name
|
||||
}
|
||||
if team[i].ShortName == "" {
|
||||
team[i].ShortName = t.ShortName
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range medal {
|
||||
if t, ok := byID[medal[i].TeamID]; ok {
|
||||
if medal[i].Code == "" {
|
||||
medal[i].Code = t.Code
|
||||
}
|
||||
if medal[i].Name == "" {
|
||||
medal[i].Name = t.Name
|
||||
}
|
||||
if medal[i].ShortName == "" {
|
||||
medal[i].ShortName = t.ShortName
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range athlete {
|
||||
if t, ok := byID[athlete[i].TeamID]; ok {
|
||||
if athlete[i].TeamCode == "" {
|
||||
athlete[i].TeamCode = t.Code
|
||||
}
|
||||
if athlete[i].TeamName == "" {
|
||||
athlete[i].TeamName = t.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitSettingList 解析逗号分隔的设置值(如 standings.tie_break),空值返回 nil。
|
||||
func splitSettingList(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadAthletes(db *sql.DB) ([]domain.Athlete, error) {
|
||||
// 停用的运动员也可能有历史积分,名字要能显示,所以这里不过滤 active。
|
||||
rows, err := db.Query(`
|
||||
@@ -201,24 +272,22 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
|
||||
}
|
||||
|
||||
// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。
|
||||
// 奖牌只看「名次 + 通道 scope」,积分以 score_ledger 为准,所以这里不需要
|
||||
// 重算分值,也就能保证与正式计分结果完全一致。
|
||||
// 与 REST 的 buildRanked、快照的 loadRanked 同源:一条成绩在每个计分通道下各产出一条明细,
|
||||
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌。
|
||||
// 积分以 score_ledger 为准,这里只取名次,不需要重算分值。
|
||||
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
scopes, err := loadChannelScopes(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT r.id, r.entry_id, r.event_id, en.category_id, en.team_id,
|
||||
COALESCE(en.athlete_id, 0), COALESCE(a.name, ''), r.rank, r.result_value
|
||||
FROM results r
|
||||
JOIN entries en ON en.id = r.entry_id
|
||||
JOIN events e ON e.id = r.event_id
|
||||
LEFT JOIN athletes a ON a.id = en.athlete_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0
|
||||
AND e.scored = 1 AND en.status = 'registered' AND r.status = 'ok'
|
||||
ORDER BY r.event_id, r.rank, r.id`)
|
||||
SELECT r.id, r.event_id, en.category_id, r.rank, r.result_value, r.status,
|
||||
en.id, en.team_id, en.athlete_id, COALESCE(a.name, ''), c.scope, c.id
|
||||
FROM results r
|
||||
JOIN entries en ON en.id = r.entry_id
|
||||
JOIN events ev ON ev.id = r.event_id
|
||||
JOIN event_score_channels c ON c.event_id = r.event_id
|
||||
LEFT JOIN athletes a ON a.id = en.athlete_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
|
||||
AND ev.scored = 1
|
||||
AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)
|
||||
ORDER BY r.id, c.sort_order, c.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取名次: %w", err)
|
||||
}
|
||||
@@ -227,32 +296,23 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
var out []domain.RankedResult
|
||||
for rows.Next() {
|
||||
var (
|
||||
rr domain.RankedResult
|
||||
resultValue sql.NullFloat64
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
rank int
|
||||
)
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EntryID, &rr.EventID, &rr.CategoryID, &rr.TeamID,
|
||||
&rr.AthleteID, &rr.AthleteName, &rr.Rank, &resultValue); err != nil {
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
|
||||
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描名次: %w", err)
|
||||
}
|
||||
if resultValue.Valid {
|
||||
v := resultValue.Float64
|
||||
rr.Rank = rank
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
rr.ResultValue = &v
|
||||
}
|
||||
if rr.TeamID == 0 {
|
||||
continue
|
||||
}
|
||||
// 一个项目可能同时配个人通道与团体通道,同一成绩要在两个榜各记一枚,
|
||||
// 因此按 scope 展开成多条明细。
|
||||
for _, sc := range scopes[rr.EventID] {
|
||||
if sc == domain.ScopeAthlete && rr.AthleteID == 0 {
|
||||
continue
|
||||
}
|
||||
item := rr
|
||||
item.Scope = sc
|
||||
item.Basis = domain.BasisResult
|
||||
item.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
out = append(out, item)
|
||||
}
|
||||
rr.AthleteID = athleteID.Int64
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
out = append(out, rr)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("export: 遍历名次: %w", err)
|
||||
@@ -260,39 +320,6 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadChannelScopes(db *sql.DB) (map[int64][]domain.Scope, error) {
|
||||
rows, err := db.Query(`SELECT event_id, scope FROM event_score_channels ORDER BY event_id, sort_order, id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取计分通道: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64][]domain.Scope)
|
||||
for rows.Next() {
|
||||
var eventID int64
|
||||
var scope domain.Scope
|
||||
if err := rows.Scan(&eventID, &scope); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描计分通道: %w", err)
|
||||
}
|
||||
if !containsScope(out[eventID], scope) {
|
||||
out[eventID] = append(out[eventID], scope)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("export: 遍历计分通道: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func containsScope(list []domain.Scope, scope domain.Scope) bool {
|
||||
for _, s := range list {
|
||||
if s == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// eventInfo 是导出分项成绩表需要的项目信息。
|
||||
type eventInfo struct {
|
||||
id int64
|
||||
@@ -471,7 +498,7 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
|
||||
if db == nil {
|
||||
return TeamDetailWorkbook{}, ErrNoDatabase
|
||||
}
|
||||
meta, _, err := resolveMeta(db, opts.Meta)
|
||||
meta, settings, err := resolveMeta(db, opts.Meta)
|
||||
if err != nil {
|
||||
return TeamDetailWorkbook{}, err
|
||||
}
|
||||
@@ -483,7 +510,12 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
|
||||
if err != nil {
|
||||
return TeamDetailWorkbook{}, err
|
||||
}
|
||||
return TeamDetailWorkbook{Meta: meta, Team: team, Rows: rows}, nil
|
||||
return TeamDetailWorkbook{
|
||||
Meta: meta,
|
||||
Team: team,
|
||||
Rows: rows,
|
||||
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) {
|
||||
@@ -508,7 +540,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''),
|
||||
COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''),
|
||||
l.source_type, l.points, COALESCE(l.void_reason, '')
|
||||
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0)
|
||||
FROM score_ledger l
|
||||
LEFT JOIN events e ON e.id = l.event_id
|
||||
LEFT JOIN sports s ON s.id = e.sport_id
|
||||
@@ -527,11 +559,13 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
for rows.Next() {
|
||||
var r TeamDetailRow
|
||||
var source string
|
||||
var athleteID int64
|
||||
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
|
||||
&r.ResultText, &source, &r.Points, &r.Remark); err != nil {
|
||||
&r.ResultText, &source, &r.Points, &r.Remark, &athleteID); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
|
||||
}
|
||||
r.Source = sourceLabel(source)
|
||||
r.individual = athleteID != 0
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,10 @@ var errHandled = errors.New("httpapi: response already written")
|
||||
// 迁移里没有幂等表,且不允许改 migrations,所以用进程内 TTL 缓存;
|
||||
// 进程重启后客户端重试会重新执行,属于可接受的降级。
|
||||
type idempotencyStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]idempotentResponse
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
entries map[string]idempotentResponse
|
||||
inflight map[string]chan struct{}
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
type idempotentResponse struct {
|
||||
@@ -33,7 +34,34 @@ func newIdempotencyStore(ttl time.Duration) *idempotencyStore {
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
return &idempotencyStore{entries: make(map[string]idempotentResponse), ttl: ttl}
|
||||
return &idempotencyStore{
|
||||
entries: make(map[string]idempotentResponse),
|
||||
inflight: make(map[string]chan struct{}),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
// begin 声明某个 key 的执行权:leader=true 表示由本请求执行,
|
||||
// 否则返回等待通道——等首次执行结束后回放其结果,避免同一 requestId 并发双执行。
|
||||
func (s *idempotencyStore) begin(key string) (leader bool, wait <-chan struct{}) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if ch, ok := s.inflight[key]; ok {
|
||||
return false, ch
|
||||
}
|
||||
ch := make(chan struct{})
|
||||
s.inflight[key] = ch
|
||||
return true, ch
|
||||
}
|
||||
|
||||
// end 释放执行权并唤醒等待者。
|
||||
func (s *idempotencyStore) end(key string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if ch, ok := s.inflight[key]; ok {
|
||||
delete(s.inflight, key)
|
||||
close(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *idempotencyStore) get(key string) ([]byte, bool) {
|
||||
@@ -106,6 +134,18 @@ func (s *server) cached(inner http.Handler) http.HandlerFunc {
|
||||
writeCached(w, body)
|
||||
return
|
||||
}
|
||||
// 占位锁只放在最外层:内层 idempotent 与这里共用同一把 key,
|
||||
// 若两处都抢占会自我等待。并发同一 requestId 由这里串行化并回放首次结果。
|
||||
leader, wait := s.idem.begin(key)
|
||||
for !leader {
|
||||
<-wait
|
||||
if body, ok := s.idem.get(key); ok {
|
||||
writeCached(w, body)
|
||||
return
|
||||
}
|
||||
leader, wait = s.idem.begin(key)
|
||||
}
|
||||
defer s.idem.end(key)
|
||||
|
||||
cw := &captureWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
inner.ServeHTTP(cw, r)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -52,6 +52,52 @@ func (s *server) exportRules(w http.ResponseWriter, r *http.Request) (any, error
|
||||
return nil, errHandled
|
||||
}
|
||||
|
||||
// rulesPackCompatible 判断规则包格式版本是否与当前程序一致。
|
||||
func rulesPackCompatible(pack *rules.RulePack) bool {
|
||||
return pack.Format == rules.FormatName && pack.FormatVersion == rules.FormatVersion
|
||||
}
|
||||
|
||||
// rulesImportPreview 是规则预览响应:在 ChangeSet 之上补齐前端契约里的兼容性与影响面。
|
||||
type rulesImportPreview struct {
|
||||
*rules.ChangeSet
|
||||
// Compatible 由 formatVersion 判断;能解析到这里说明版本已兼容。
|
||||
Compatible bool `json:"compatible"`
|
||||
AffectedEventCount int `json:"affectedEventCount"`
|
||||
}
|
||||
|
||||
// rulesImportResult 是规则落地响应:在 ApplyResult 之上补齐预览字段与重算条数。
|
||||
// 同时带上 ChangeSet,前端落地后仍能显示这次导入的逐条差异(否则会被覆盖成「无差异」)。
|
||||
type rulesImportResult struct {
|
||||
*rules.ApplyResult
|
||||
*rules.ChangeSet
|
||||
Compatible bool `json:"compatible"`
|
||||
AffectedEventCount int `json:"affectedEventCount"`
|
||||
RecalculatedLedgerRows int `json:"recalculatedLedgerRows"`
|
||||
}
|
||||
|
||||
// affectedEventCount 统计本次导入影响的小项数:计分通道变化的项目,
|
||||
// 加上引用了被改动模板的项目——模板分值一变,这些项目的积分就会跟着变。
|
||||
func affectedEventCount(cs *rules.ChangeSet, cur *rules.Snapshot) int {
|
||||
affected := make(map[string]bool, len(cs.Channels))
|
||||
for _, ch := range cs.Channels {
|
||||
affected[ch.EventCode] = true
|
||||
}
|
||||
if len(cs.Profiles) > 0 {
|
||||
changed := make(map[string]bool, len(cs.Profiles))
|
||||
for _, pc := range cs.Profiles {
|
||||
changed[pc.Code] = true
|
||||
}
|
||||
for _, ev := range cur.Events {
|
||||
for _, ch := range ev.Channels {
|
||||
if changed[ch.Profile] {
|
||||
affected[ev.Code] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(affected)
|
||||
}
|
||||
|
||||
func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
|
||||
if err != nil {
|
||||
@@ -76,60 +122,88 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
|
||||
|
||||
// 先预览再落地:?preview=1 只返回差异。
|
||||
if r.URL.Query().Get("preview") == "1" {
|
||||
return cs, nil
|
||||
return rulesImportPreview{
|
||||
ChangeSet: cs,
|
||||
Compatible: rulesPackCompatible(pack),
|
||||
AffectedEventCount: affectedEventCount(cs, cur),
|
||||
}, nil
|
||||
}
|
||||
|
||||
res, err := rules.Apply(s.ev, cs)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
// 分值一变,历史成绩的积分必须全量重算。
|
||||
if err := s.recomputeAllEvents(r.Context(), middlewareUserID(r)); err != nil {
|
||||
return nil, internal(err)
|
||||
// 分值一变,历史成绩的积分与名次必须全量重算。
|
||||
rows, err := s.recomputeAllEvents(r.Context(), middlewareUserID(r))
|
||||
if err != nil {
|
||||
// 规则已经落库、重算整体回滚,现场处于「新规则 + 旧积分」;
|
||||
// 必须显式告知调用方,不能报成功让操作员以为重算已完成。
|
||||
s.logger.Error("规则导入后重算失败", "err", err)
|
||||
return nil, &apiError{
|
||||
status: http.StatusInternalServerError,
|
||||
code: "INTERNAL",
|
||||
message: "规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入",
|
||||
detail: err.Error(),
|
||||
}
|
||||
}
|
||||
s.audit(r.Context(), "rules.import", "rules", 0, nil, res)
|
||||
return res, nil
|
||||
return rulesImportResult{
|
||||
ApplyResult: res,
|
||||
ChangeSet: cs,
|
||||
Compatible: rulesPackCompatible(pack),
|
||||
AffectedEventCount: affectedEventCount(cs, cur),
|
||||
RecalculatedLedgerRows: rows,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// recomputeAllEvents 逐项目重算积分流水,用于规则包导入等会改变分值的场景。
|
||||
func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) error {
|
||||
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events`)
|
||||
// recomputeAllEvents 全量重算所有项目的积分流水与名次,用于规则包导入等会改变分值的场景。
|
||||
// 整个重算共用一个事务:任一项目失败就全部回滚,不会留下「只重算了一部分」的中间状态。
|
||||
// 返回写入的积分流水条数,供导入结果回报。
|
||||
func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, error) {
|
||||
rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events ORDER BY id`)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
var ids []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
_ = rows.Close()
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, eventID := range ids {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import"); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
// 重算改动了积分,必须写版本号并推送,否则大屏停在旧比分。
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return nil
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
total := 0
|
||||
for _, eventID := range ids {
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 名次随分值一起变化,必须回写 results,否则奖牌榜与积分榜互相矛盾。
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total += len(score.Ledger)
|
||||
}
|
||||
// 重算改动了积分,必须写版本号并推送,否则大屏停在旧比分。
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -328,12 +328,17 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
|
||||
|
||||
// recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。
|
||||
// 作废范围是引擎产出的成绩分与破纪录加分;人工调整由 point_adjustments 管理,绝不在此作废。
|
||||
// 聚合通道的流水 entry_id/result_id 都为空(名次主体是单位/运动员,不对应单条报名项),
|
||||
// 所以按组别重算时不能只靠 entry_id IN (...) 定位,否则 NULL 永不命中、旧流水存活,
|
||||
// 重算再插一份导致该通道分数翻倍。这类流水改按「本小项的聚合通道」定位。
|
||||
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
|
||||
scope := `source_type IN ('result', 'record_bonus') AND event_id = ?`
|
||||
args := []any{eventID}
|
||||
if categoryID > 0 {
|
||||
scope += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
|
||||
args = append(args, categoryID)
|
||||
scope += ` AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?)
|
||||
OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'
|
||||
AND source_id IN (SELECT id FROM event_score_channels WHERE event_id = ?)))`
|
||||
args = append(args, categoryID, eventID)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
|
||||
@@ -346,7 +351,33 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
for _, l := range score.Ledger {
|
||||
ledger := score.Ledger
|
||||
if categoryID > 0 {
|
||||
// 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废;
|
||||
// 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。
|
||||
hasAggregate, err := hasAggregateChannel(ctx, tx, eventID)
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
if hasAggregate {
|
||||
all, err := computeEventScore(ctx, tx, eventID, 0)
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
|
||||
for _, l := range score.Ledger {
|
||||
if !isAggregateLedger(l) {
|
||||
ledger = append(ledger, l)
|
||||
}
|
||||
}
|
||||
for _, l := range all.Ledger {
|
||||
if isAggregateLedger(l) {
|
||||
ledger = append(ledger, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, l := range ledger {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
@@ -358,6 +389,22 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
|
||||
return score, nil
|
||||
}
|
||||
|
||||
// isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员,
|
||||
// 落库时 entry_id 与 result_id 都为空。
|
||||
func isAggregateLedger(l domain.LedgerEntry) bool {
|
||||
return l.SourceType == domain.SourceResult && l.EntryID == 0 && l.ResultID == 0
|
||||
}
|
||||
|
||||
// hasAggregateChannel 判断小项是否配置了聚合通道;没有时按组别重算无需补算整项。
|
||||
func hasAggregateChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, error) {
|
||||
var n int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM event_score_channels WHERE event_id = ? AND basis = 'aggregate'`, eventID).Scan(&n); err != nil {
|
||||
return false, internal(err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。
|
||||
// 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。
|
||||
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
|
||||
@@ -575,7 +622,12 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
reason, resultID); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
if _, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason); err != nil {
|
||||
// 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -16,8 +16,10 @@ type AuditEntry struct {
|
||||
Action string
|
||||
EntityType string
|
||||
EntityID int64
|
||||
Before any
|
||||
After any
|
||||
// ActorID 非 0 时优先作为审计主体,用于登录等「身份刚建立、请求 ctx 里仍是 guest」的动作。
|
||||
ActorID int64
|
||||
Before any
|
||||
After any
|
||||
}
|
||||
|
||||
type auditCollector struct {
|
||||
@@ -73,10 +75,15 @@ func (a *Auditor) flush(ctx context.Context, r *http.Request, entries []AuditEnt
|
||||
if e.EntityID != 0 {
|
||||
entityID = e.EntityID
|
||||
}
|
||||
// 登录等动作在请求 ctx 里还没有登录身份,改由条目自带的 ActorID 补上。
|
||||
actor := nullableID(u)
|
||||
if e.ActorID != 0 {
|
||||
actor = e.ActorID
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`INSERT INTO audit_logs (user_id, action, entity_type, entity_id, before_json, after_json, ip, request_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
nullableID(u), e.Action, e.EntityType, entityID,
|
||||
actor, e.Action, e.EntityType, entityID,
|
||||
marshalOrNil(e.Before), marshalOrNil(e.After), ip, requestID)
|
||||
if err != nil {
|
||||
// 审计失败不能吞掉业务结果,但必须留下痕迹。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -94,9 +94,10 @@ func (e *testEnv) dial(t *testing.T, hello *helloPayload) *wsClient {
|
||||
}
|
||||
|
||||
type wsMessage struct {
|
||||
event string
|
||||
data json.RawMessage
|
||||
err error
|
||||
event string
|
||||
data json.RawMessage
|
||||
err error
|
||||
closeCode websocket.StatusCode
|
||||
}
|
||||
|
||||
type wsClient struct {
|
||||
@@ -114,7 +115,7 @@ func (c *wsClient) readLoop() {
|
||||
for {
|
||||
_, data, err := c.conn.Read(c.ctx)
|
||||
if err != nil {
|
||||
c.msgs <- wsMessage{err: err}
|
||||
c.msgs <- wsMessage{err: err, closeCode: websocket.CloseStatus(err)}
|
||||
return
|
||||
}
|
||||
var env envelope
|
||||
@@ -196,6 +197,30 @@ func (c *wsClient) expectClosed(d time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// expectClosedWithCode 断言连接被服务端以指定关闭码关闭,且关闭前没有下发任何事件。
|
||||
func (c *wsClient) expectClosedWithCode(d time.Duration, want websocket.StatusCode) {
|
||||
c.t.Helper()
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.msgs:
|
||||
if !ok {
|
||||
c.t.Fatalf("连接已关闭但没有收到关闭帧")
|
||||
}
|
||||
if msg.err != nil {
|
||||
if msg.closeCode != want {
|
||||
c.t.Fatalf("关闭码应为 %d,得到 %d (%v)", want, msg.closeCode, msg.err)
|
||||
}
|
||||
return
|
||||
}
|
||||
c.t.Fatalf("连接关闭前仍收到事件 %s", msg.event)
|
||||
case <-timer.C:
|
||||
c.t.Fatalf("等待服务端关闭连接超时")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decode[T any](t *testing.T, raw json.RawMessage) T {
|
||||
t.Helper()
|
||||
var v T
|
||||
@@ -511,11 +536,12 @@ func TestAdminRoomTargeting(t *testing.T) {
|
||||
}
|
||||
|
||||
// guest 自报 role=admin 必须被拒绝:管理端房间只能由会话授予。
|
||||
// 断开必须带上策略性关闭码,否则客户端会当成网络异常无限重连。
|
||||
func TestGuestCannotSubscribeAdminRoom(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||||
|
||||
c.expectClosed(3 * time.Second)
|
||||
c.expectClosedWithCode(3*time.Second, websocket.StatusPolicyViolation)
|
||||
}
|
||||
|
||||
// guest 只能订阅 display 房间:即使 hello 里塞了 userId 也不能进入定向房间。
|
||||
@@ -576,3 +602,124 @@ func TestSlowClientDetected(t *testing.T) {
|
||||
return len(insts) == 1 && !insts[0].Online
|
||||
})
|
||||
}
|
||||
|
||||
// 快照对所有角色下发,但系统配置是 admin-only:guest/recorder 只能拿到大屏渲染
|
||||
// 所需的 event/display/榜单开关,默认积分模板、审核开关与折算系数必须置空/清零。
|
||||
func TestSnapshotSettingsRedactedForNonAdmin(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
user *auth.User
|
||||
}{
|
||||
{"guest", &auth.User{Role: auth.RoleGuest}},
|
||||
{"recorder", &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env := newTestEnvAs(t, tc.user)
|
||||
mustExec(t, env.db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
|
||||
mustExec(t, env.db, `UPDATE settings SET value = 'true' WHERE key = 'results.review_required'`)
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: tc.name + "-1"})
|
||||
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||||
|
||||
if snap.Settings.DefaultProfileID != nil {
|
||||
t.Fatalf("非 admin 不应看到 defaultProfileId: %+v", snap.Settings.DefaultProfileID)
|
||||
}
|
||||
if snap.Settings.RequireApproval {
|
||||
t.Fatalf("非 admin 不应看到 requireApproval")
|
||||
}
|
||||
if snap.Settings.Standings.IndividualToTeamWeight != 0 {
|
||||
t.Fatalf("非 admin 不应看到 individualToTeamWeight: %v", snap.Settings.Standings.IndividualToTeamWeight)
|
||||
}
|
||||
// 大屏要用的字段必须保留,裁剪不能误伤标题/主题/榜单开关。
|
||||
if snap.Settings.Event.Name == "" || snap.Settings.Display.RotateSecond == 0 {
|
||||
t.Fatalf("event/display 不应被裁剪: %+v", snap.Settings)
|
||||
}
|
||||
if snap.Settings.Standings.RankingMode != "points" ||
|
||||
!snap.Settings.Standings.ShowIndividualBoard || !snap.Settings.Standings.ShowIndividualMedals {
|
||||
t.Fatalf("standings 展示开关不应被裁剪: %+v", snap.Settings.Standings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// admin 仍拿到完整配置,管理端才能在 WS 首包里读到默认积分模板与审核开关。
|
||||
func TestSnapshotSettingsFullForAdmin(t *testing.T) {
|
||||
env := newTestEnvAs(t, &auth.User{ID: 1, Username: "boss", Role: auth.RoleAdmin, Active: true})
|
||||
mustExec(t, env.db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
|
||||
mustExec(t, env.db, `UPDATE settings SET value = 'true' WHERE key = 'results.review_required'`)
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||||
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||||
|
||||
if snap.Settings.DefaultProfileID == nil {
|
||||
t.Fatalf("admin 应看到 defaultProfileId")
|
||||
}
|
||||
if !snap.Settings.RequireApproval {
|
||||
t.Fatalf("admin 应看到 requireApproval")
|
||||
}
|
||||
if snap.Settings.Standings.IndividualToTeamWeight != 0.5 {
|
||||
t.Fatalf("admin 应看到 individualToTeamWeight=0.5,得到 %v", snap.Settings.Standings.IndividualToTeamWeight)
|
||||
}
|
||||
}
|
||||
|
||||
// resync 在 hello 之前不可用:否则会绕过房间分配并提前清掉 hello 超时。
|
||||
func TestResyncBeforeHelloIgnored(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
c := env.dial(t, nil)
|
||||
c.send(EventResync, struct{}{})
|
||||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
|
||||
t.Fatalf("hello 之前的 resync 不应下发快照,收到事件 %s", ev)
|
||||
}
|
||||
|
||||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||||
t.Fatalf("hello 后应收到版本 1 的快照,得到 %d", snap.Version)
|
||||
}
|
||||
c.send(EventResync, struct{}{})
|
||||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||||
t.Fatalf("hello 后 resync 应拿到快照,得到 %d", snap.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// 离线实例不能无限堆积:超过 TTL 后在下一次读取注册表时被清理。
|
||||
func TestInstanceRegistryPrunesStaleOffline(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "stale-1"})
|
||||
c.recvEvent(EventSnapshot)
|
||||
|
||||
c.close()
|
||||
waitFor(t, "实例离线", func() bool {
|
||||
insts := env.hub.Instances()
|
||||
return len(insts) == 1 && !insts[0].Online
|
||||
})
|
||||
|
||||
env.hub.mu.Lock()
|
||||
env.hub.instances["stale-1"].lastHeartbeat = time.Now().Add(-instanceTTL - time.Minute)
|
||||
env.hub.mu.Unlock()
|
||||
|
||||
if insts := env.hub.Instances(); len(insts) != 0 {
|
||||
t.Fatalf("过期离线实例应被清理,得到 %+v", insts)
|
||||
}
|
||||
}
|
||||
|
||||
// 读写 goroutine 的 panic 必须被兜住并断开该连接,而不是带走进程。
|
||||
func TestGuardLoopRecoversPanic(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
c := newClient(env.hub, nil, "test", RoleDisplay, 0, false)
|
||||
|
||||
func() {
|
||||
defer c.guardLoop("读")
|
||||
panic("boom")
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-c.done:
|
||||
default:
|
||||
t.Fatal("panic 后应关闭该连接")
|
||||
}
|
||||
if c.closeCode != websocket.StatusInternalError {
|
||||
t.Fatalf("panic 后关闭码应为 %d,得到 %d", websocket.StatusInternalError, c.closeCode)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 键值对读到破纪录档位:
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
interface Candidate {
|
||||
row: ScorableRow
|
||||
/** 比较值(result / aggregate 通道)。 */
|
||||
value: number
|
||||
/** 录入名次(manual 通道)。 */
|
||||
rankIn: number
|
||||
/** 聚合通道的排名主体(单位 id 或运动员 id)。 */
|
||||
subject: number
|
||||
}
|
||||
|
||||
if (manual || compare === 'none') {
|
||||
for (const row of rows) result.set(row.entryId, row.status === 'ok' ? row.rank : null)
|
||||
return result
|
||||
}
|
||||
interface RankedRow {
|
||||
/** 聚合通道不对应单条报名项,此时为 0。 */
|
||||
entryId: number
|
||||
teamId: number
|
||||
scope: ChannelScope
|
||||
rank: number
|
||||
points: number
|
||||
}
|
||||
|
||||
const direction = compare === 'asc' ? 1 : -1
|
||||
const sorted = [...ranked].sort(
|
||||
(a, b) => direction * ((a.resultValue ?? 0) - (b.resultValue ?? 0)),
|
||||
)
|
||||
function effectiveMultiplier(value: number): number {
|
||||
return value > 0 ? value : 1
|
||||
}
|
||||
|
||||
let index = 0
|
||||
while (index < sorted.length) {
|
||||
const group: ScorableRow[] = [sorted[index] as ScorableRow]
|
||||
const head = group[0] as ScorableRow
|
||||
let cursor = index + 1
|
||||
while (
|
||||
cursor < sorted.length &&
|
||||
Math.abs((sorted[cursor] as ScorableRow).resultValue! - head.resultValue!) < EPSILON
|
||||
) {
|
||||
group.push(sorted[cursor] as ScorableRow)
|
||||
cursor += 1
|
||||
function tieModeOf(profile: ScoringProfile): TieMode {
|
||||
return profile.tieMode === 'average' || profile.tieMode === 'higher' ? profile.tieMode : 'skip'
|
||||
}
|
||||
|
||||
/** 查表得分并乘项目 / 组别权重;超出录取名次或表中无名次一律 0(scoring.go basePoints)。 */
|
||||
function basePoints(
|
||||
profile: ScoringProfile,
|
||||
rank: number,
|
||||
eventMultiplier: number,
|
||||
categoryMultiplier: number,
|
||||
): number {
|
||||
if (rank <= 0) return 0
|
||||
if (profile.awardRanks !== undefined && profile.awardRanks > 0 && rank > profile.awardRanks) return 0
|
||||
const rule = profile.rules.find((item) => item.rank === rank)
|
||||
if (rule === undefined) return 0
|
||||
return rule.points * eventMultiplier * categoryMultiplier
|
||||
}
|
||||
|
||||
/**
|
||||
* 一组并列者拿到的分(scoring.go groupPoints)。
|
||||
* 先确定这组占用的名次区间 startRank .. startRank+size-1,再按并列口径取值。
|
||||
*/
|
||||
function groupPoints(
|
||||
profile: ScoringProfile,
|
||||
startRank: number,
|
||||
size: number,
|
||||
lastRank: number,
|
||||
eventMultiplier: number,
|
||||
categoryMultiplier: number,
|
||||
): number {
|
||||
if (startRank <= 0 || size <= 0) return 0
|
||||
// lastPlaceZero 覆盖「最后一名」;并列在末位的整组都按最后一名处理,保证并列同分。
|
||||
if (profile.lastPlaceZero === true && startRank <= lastRank && lastRank <= startRank + size - 1) return 0
|
||||
|
||||
const mode = tieModeOf(profile)
|
||||
if (mode === 'average') {
|
||||
let sum = 0
|
||||
for (let rank = startRank; rank < startRank + size; rank += 1) {
|
||||
sum += basePoints(profile, rank, eventMultiplier, categoryMultiplier)
|
||||
}
|
||||
|
||||
const position = index + 1
|
||||
const tied = group.length > 1
|
||||
let rank = position
|
||||
if (tied && tieMode === 'average') {
|
||||
rank = (position + (position + group.length - 1)) / 2
|
||||
}
|
||||
// skip / higher:并列者都拿靠前的名次,后续名次顺延(02 第 2.2 节)。
|
||||
|
||||
for (const row of group) result.set(row.entryId, rank)
|
||||
index = cursor
|
||||
return sum / size
|
||||
}
|
||||
if (mode === 'higher') {
|
||||
let best = 0
|
||||
for (let rank = startRank; rank < startRank + size; rank += 1) {
|
||||
best = Math.max(best, basePoints(profile, rank, eventMultiplier, categoryMultiplier))
|
||||
}
|
||||
return best
|
||||
}
|
||||
return basePoints(profile, startRank, eventMultiplier, categoryMultiplier)
|
||||
}
|
||||
|
||||
/** 收集本通道真正参与排名的行:状态正常,且带有本通道所需数据。 */
|
||||
function collectCandidates(rows: readonly ScorableRow[], basis: ChannelBasis): Candidate[] {
|
||||
const out: Candidate[] = []
|
||||
for (const row of rows) {
|
||||
if (!result.has(row.entryId)) result.set(row.entryId, null)
|
||||
if (row.status !== 'ok') continue
|
||||
if (basis === 'manual') {
|
||||
if (row.rank === null || row.rank <= 0) continue
|
||||
out.push({ row, value: 0, rankIn: row.rank, subject: 0 })
|
||||
continue
|
||||
}
|
||||
if (row.resultValue === null) continue
|
||||
out.push({ row, value: row.resultValue, rankIn: 0, subject: 0 })
|
||||
}
|
||||
return result
|
||||
return out
|
||||
}
|
||||
|
||||
/** 名次 → 分值;并列平均产生小数名次时,取相邻两档的均值。 */
|
||||
export function pointsForRank(rank: number | null, profile: ScoringProfile | null): number {
|
||||
if (rank === null || profile === null || !Number.isFinite(rank)) return 0
|
||||
const exact = profile.rules.find((rule) => rule.rank === rank)
|
||||
if (exact !== undefined) return exact.points
|
||||
|
||||
const lower = profile.rules.find((rule) => rule.rank === Math.floor(rank))
|
||||
const upper = profile.rules.find((rule) => rule.rank === Math.ceil(rank))
|
||||
if (lower === undefined && upper === undefined) return 0
|
||||
if (lower === undefined) return upper?.points ?? 0
|
||||
if (upper === undefined) return lower.points
|
||||
return (lower.points + upper.points) / 2
|
||||
function sortByValue(cands: Candidate[], compare: CompareMode): void {
|
||||
cands.sort((a, b) => {
|
||||
if (Math.abs(a.value - b.value) <= EPSILON) return a.row.entryId - b.row.entryId
|
||||
return compare === 'desc' ? b.value - a.value : a.value - b.value
|
||||
})
|
||||
}
|
||||
|
||||
export interface ChannelPlan {
|
||||
athleteProfile: ScoringProfile | null
|
||||
teamProfile: ScoringProfile | null
|
||||
interface TieGroup {
|
||||
startRank: number
|
||||
members: Candidate[]
|
||||
}
|
||||
|
||||
/** 一个项目可挂多个通道;预览只关心「个人表」和「团体表」两张。 */
|
||||
export function planChannels(
|
||||
channels: readonly ScoreChannel[],
|
||||
profiles: readonly ScoringProfile[],
|
||||
): ChannelPlan {
|
||||
const pick = (scope: ScoreChannel['scope']): ScoringProfile | null => {
|
||||
const channel = channels.find((item) => item.scope === scope)
|
||||
if (channel === undefined) return null
|
||||
return profiles.find((profile) => profile.id === channel.profileId) ?? null
|
||||
/** 值相同即并列,名次按「占位顺推」计算(skip 口径)。 */
|
||||
function groupByValue(cands: readonly Candidate[]): TieGroup[] {
|
||||
const groups: TieGroup[] = []
|
||||
let pos = 1
|
||||
for (let i = 0; i < cands.length; ) {
|
||||
let j = i + 1
|
||||
while (j < cands.length && Math.abs((cands[j] as Candidate).value - (cands[i] as Candidate).value) <= EPSILON) {
|
||||
j += 1
|
||||
}
|
||||
groups.push({ startRank: pos, members: cands.slice(i, j) })
|
||||
pos += j - i
|
||||
i = j
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/** 录入名次就是展示名次,不做顺推改写。 */
|
||||
function groupByManualRank(cands: readonly Candidate[]): TieGroup[] {
|
||||
const groups: TieGroup[] = []
|
||||
for (let i = 0; i < cands.length; ) {
|
||||
let j = i + 1
|
||||
while (j < cands.length && (cands[j] as Candidate).rankIn === (cands[i] as Candidate).rankIn) j += 1
|
||||
groups.push({ startRank: (cands[i] as Candidate).rankIn, members: cands.slice(i, j) })
|
||||
i = j
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function sortManual(cands: Candidate[]): void {
|
||||
cands.sort((a, b) => (a.rankIn === b.rankIn ? a.row.entryId - b.row.entryId : a.rankIn - b.rankIn))
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合通道:先把同一主体的成绩求和,再按值排名(scoring.go scoreByAggregate)。
|
||||
* 运动员维度缺 athleteId 的行按服务端口径直接跳过(subject == 0)。
|
||||
*/
|
||||
function aggregateCandidates(rows: readonly ScorableRow[], channel: ScoreChannel): Candidate[] {
|
||||
const order: number[] = []
|
||||
const buckets = new Map<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}`, '变更详情接口'))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
rulePack.value = await adminApi.client.rules.get()
|
||||
ruleError.value = null
|
||||
// /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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
rulePack.value = await adminApi.client.rules.get()
|
||||
ruleError.value = null
|
||||
// /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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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} · 管理面板`
|
||||
|
||||
@@ -73,6 +73,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
initialized,
|
||||
isGuest,
|
||||
readOnly,
|
||||
displayName,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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]">
|
||||
|
||||
@@ -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
Reference in new issue
Block a user