fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛) results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」, 团体手工通道把个人名次当团体名次又按团体表发一遍分。 - 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建) 与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键, 避免规则包重导时级联删掉录入数据) - results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回; 团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底, 个人报名项的名次绝不进团体通道 - writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked / realtime.loadRanked 三份同源 SQL 一起改读新表 - 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证) ## 奖牌结算开关与奖牌通道(P1(5),新功能) - events.award_medals + 通道 count_to_medals / count_to_team_medals (迁移默认 1,升级不改变既有榜单) - 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位) - 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算 - 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、 录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks) ## 身份口径改回证件号(P1(15),推翻第三十六轮) - 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard - 同表重复证件号 = 同一人报多个组别(notices 提示,不报错); 只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate) - 补齐后端缺失但前端/文档已在依赖的三处契约 ## 其余 P1 - 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径, 被限流的请求挂起并由心跳兜底补发,不丢刷新) - 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 / 积分模板 awardRanks → 400 或 404,不再 500 - 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、 报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价) - 配置改动触发重算:通道 / 小项参数 / 积分模板 - 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算 (服务对外监听之前完成,避免升级后奖牌榜全 0) - 示例数据模式下破坏性操作被阻断,不再假成功 - Console:修复「只剩一块屏」后屏幕监听器被永久摘掉 - 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常 ## 文档 - docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、 常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、 部署指南、上线检查清单 - 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步) 自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build; Console 35 例 + 语法检查。
This commit is contained in:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
+1
-1
@@ -119,7 +119,7 @@ electron-builder 生成的卸载器结尾是 `RMDir /r $INSTDIR`,而**覆盖
|
||||
|
||||
| 场景 | 行为 |
|
||||
| --- | --- |
|
||||
| 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不挂** `display-metrics-changed` 等监听(行为冻结);`F11` 手动切换全屏,窗口级全屏状态经 `display-preload.js` 下发给页面,页面据此收起自绘标题栏 |
|
||||
| 只有一块屏 | 窗口化(屏幕 80% 大小、居中),不做多屏编排,**不跟随** `display-metrics-changed`(分辨率/缩放变化不重排,行为冻结);`display-added` / `display-removed` 一直听着,电视插回来会自动排布回 kiosk。监听常驻、只有 `closeAll()`(服务停止 / 应用退出)才摘(回归测试见 `main/displays.test.js`);`F11` 手动切换全屏,窗口级全屏状态经 `display-preload.js` 下发给页面,页面据此收起自绘标题栏 |
|
||||
| 模式与 URL | **必须永远一致**:模式(窗口化 / kiosk)记在窗口的 entry 上,谁改模式谁按新模式重载 URL。`layout()` 只对排布自己开的窗口套用全局 `single`,管理面板手动开的窗口保持自己的模式——否则会出现「窗口化窗口被改成 kiosk 全屏、URL 里还留着 `windowed=1`」这种页面认知与窗口真实模式脱钩、标题栏画反的问题(回归测试见 `main/displays.test.js`)|
|
||||
| 多块屏 | 除内置屏外的每块屏一个 kiosk 窗口,按 `display.bounds`(DIP)定位,窗口 `setIgnoreMouseEvents(true)`,光标由页面 CSS 隐藏 |
|
||||
| 选屏 | 运行时枚举,不写死坐标;`selection` 可选 `all-secondary`(默认,用 `display.internal` 排除内置屏)/ `all` / `specified` |
|
||||
|
||||
+18
-10
@@ -21,7 +21,6 @@ class DisplayManager {
|
||||
|
||||
this.enabled = false
|
||||
this.single = true
|
||||
this.boundSingle = null
|
||||
// entry.single 记这个窗口是以哪种模式建的(true=窗口化,false=kiosk),
|
||||
// entry.manual 记它是管理面板手动开的(不跟随自动排布改模式)
|
||||
this.windows = new Map() // displayId -> { win, page, single, manual, reloadTimer }
|
||||
@@ -89,7 +88,7 @@ class DisplayManager {
|
||||
if (!this.enabled) return
|
||||
const { single, displays } = this.pickDisplays()
|
||||
this.single = single
|
||||
this.bindScreenListeners(single)
|
||||
this.bindScreenListeners()
|
||||
|
||||
const wanted = new Set(displays.map((item) => item.id))
|
||||
for (const [displayId, entry] of [...this.windows]) {
|
||||
@@ -268,12 +267,12 @@ class DisplayManager {
|
||||
return `http://${healthHost(this.server)}:${this.server.port}/display?${params.toString()}`
|
||||
}
|
||||
|
||||
// 单屏模式要「行为冻结」:不跟随屏幕变化重排,所以连监听都不挂
|
||||
bindScreenListeners(single) {
|
||||
if (this.screenListeners.length && this.boundSingle === single) return
|
||||
this.unbindScreenListeners()
|
||||
this.boundSingle = single
|
||||
if (single) return
|
||||
// 屏幕监听常驻,只有 closeAll()(服务停止 / 应用退出)才摘:
|
||||
// 单屏态的「行为冻结」只冻结分辨率与缩放,display-added / display-removed 必须一直听着。
|
||||
// 否则「只剩一块屏」那次排布会把监听整个摘掉,扩展屏插回来再没人重排——
|
||||
// 大屏永远停在窗口化小窗,电视黑屏,只能重启 Console(审查日志 P1(18))。
|
||||
bindScreenListeners() {
|
||||
if (this.screenListeners.length) return
|
||||
|
||||
const schedule = () => {
|
||||
if (this.layoutTimer) clearTimeout(this.layoutTimer)
|
||||
@@ -284,11 +283,18 @@ class DisplayManager {
|
||||
}, 600)
|
||||
}
|
||||
|
||||
screen.on('display-metrics-changed', schedule)
|
||||
// 单屏窗口化是现场调试态,分辨率/缩放变化不跟着重排(保留原有的冻结语义);
|
||||
// 屏幕数量变化不能冻结,那是「电视被拔/被插」的唯一信号
|
||||
const metricsChanged = () => {
|
||||
if (this.single) return
|
||||
schedule()
|
||||
}
|
||||
|
||||
screen.on('display-metrics-changed', metricsChanged)
|
||||
screen.on('display-added', schedule)
|
||||
screen.on('display-removed', schedule)
|
||||
this.screenListeners = [
|
||||
['display-metrics-changed', schedule],
|
||||
['display-metrics-changed', metricsChanged],
|
||||
['display-added', schedule],
|
||||
['display-removed', schedule]
|
||||
]
|
||||
@@ -344,6 +350,8 @@ class DisplayManager {
|
||||
// 手动开的窗口也计入 enabled:服务停止时 closeAll 才会把它一起收走。
|
||||
// manual=true:自动排布不许改它的模式,否则面板开的小窗会被下一次排布顶成 kiosk
|
||||
this.enabled = true
|
||||
// 面板开窗同样是「管理已激活」,监听一并挂上:否则在这个状态下拔插电视没人听见
|
||||
this.bindScreenListeners()
|
||||
const entry = this.createWindow(display, 0, !kiosk, wantedPage, true)
|
||||
this.windows.set(display.id, entry)
|
||||
return { created: true, mode: this.modeOf(entry) }
|
||||
|
||||
@@ -109,19 +109,26 @@ class FakeBrowserWindow extends EventEmitter {
|
||||
restore() {}
|
||||
}
|
||||
|
||||
const fakeScreen = {
|
||||
displays: [],
|
||||
primaryId: 1,
|
||||
// screen 桩要让「挂监听 / 摘监听」可观察,否则 P1(18) 那类
|
||||
// 「监听被永久摘掉」的回归测不出来
|
||||
class FakeScreen extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
this.displays = []
|
||||
this.primaryId = 1
|
||||
}
|
||||
|
||||
getAllDisplays() {
|
||||
return this.displays
|
||||
},
|
||||
}
|
||||
|
||||
getPrimaryDisplay() {
|
||||
return this.displays.find((item) => item.id === this.primaryId) || this.displays[0]
|
||||
},
|
||||
on() {},
|
||||
removeListener() {}
|
||||
}
|
||||
}
|
||||
|
||||
const fakeScreen = new FakeScreen()
|
||||
|
||||
const electronStub = { BrowserWindow: FakeBrowserWindow, screen: fakeScreen }
|
||||
const originalLoad = Module._load
|
||||
Module._load = function (request, parent, isMain) {
|
||||
@@ -141,6 +148,8 @@ function fakeDisplay(id, { internal = false } = {}) {
|
||||
function makeManager(t, { selection = 'all', kiosk = true } = {}) {
|
||||
fakeScreen.displays = []
|
||||
fakeScreen.primaryId = 1
|
||||
// 上一个用例若忘了收摊,残留的监听会串到这一个用例里
|
||||
fakeScreen.removeAllListeners()
|
||||
created = []
|
||||
const log = { lines: [], push(_scope, text) { this.lines.push(text) } }
|
||||
const manager = new DisplayManager({
|
||||
@@ -333,3 +342,177 @@ test('kiosk 窗口不挂 F11 处理', (t) => {
|
||||
|
||||
assert.equal(manager.windows.get(2).win.webContents.listeners('before-input-event').length, 0)
|
||||
})
|
||||
|
||||
// 屏幕监听的三条不变式,对应审查日志模块13 [1] P1:
|
||||
// ① 任何一次「屏幕数量变化」都不能把监听永久摘掉(只有 closeAll 摘)
|
||||
// ② 事件到达后 600ms 防抖重排,从 1 块恢复 2 块要自动回到 kiosk,从 2 块变 1 块要收回窗口化
|
||||
// ③ 单屏态只冻结「分辨率/缩放」,不冻结「插拔」
|
||||
|
||||
const SCREEN_EVENTS = ['display-metrics-changed', 'display-added', 'display-removed']
|
||||
|
||||
function screenListenerCounts() {
|
||||
return SCREEN_EVENTS.map((event) => fakeScreen.listenerCount(event))
|
||||
}
|
||||
|
||||
// 触发屏幕事件并跑完防抖定时器
|
||||
function emitScreen(t, event, display) {
|
||||
fakeScreen.emit(event, {}, display)
|
||||
t.mock.timers.tick(600)
|
||||
}
|
||||
|
||||
test('回归 P1(18):出现过一次「只剩一块屏」之后,插回扩展屏仍会自动重排成 kiosk', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const manager = makeManager(t)
|
||||
const primary = fakeDisplay(1, { internal: true })
|
||||
const external = fakeDisplay(2)
|
||||
fakeScreen.displays = [primary, external]
|
||||
manager.enable()
|
||||
assertModeConsistent(manager, 2, false)
|
||||
|
||||
// 电视待机 / HDMI 松动:系统报 display-removed,整机退化成单屏
|
||||
fakeScreen.displays = [primary]
|
||||
emitScreen(t, 'display-removed', external)
|
||||
|
||||
assert.equal(manager.windows.has(2), false, '只剩一块屏时扩展屏窗口要收掉,不能留在已消失的坐标上')
|
||||
assertModeConsistent(manager, 1, true)
|
||||
assert.deepEqual(screenListenerCounts(), [1, 1, 1], '退化成单屏也不许摘监听:这是唯一能听见电视插回来的通道')
|
||||
|
||||
// 电视再开机:没有任何人工干预,必须自己排布回去
|
||||
fakeScreen.displays = [primary, external]
|
||||
emitScreen(t, 'display-added', external)
|
||||
|
||||
assertModeConsistent(manager, 1, false)
|
||||
assertModeConsistent(manager, 2, false)
|
||||
assert.deepEqual(screenListenerCounts(), [1, 1, 1], '重排之后监听仍然只挂一份')
|
||||
})
|
||||
|
||||
test('反复插拔:监听既不被摘掉也不叠加', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const manager = makeManager(t)
|
||||
const primary = fakeDisplay(1, { internal: true })
|
||||
const external = fakeDisplay(2)
|
||||
fakeScreen.displays = [primary, external]
|
||||
manager.enable()
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
fakeScreen.displays = [primary]
|
||||
emitScreen(t, 'display-removed', external)
|
||||
fakeScreen.displays = [primary, external]
|
||||
emitScreen(t, 'display-added', external)
|
||||
}
|
||||
|
||||
assert.deepEqual(screenListenerCounts(), [1, 1, 1])
|
||||
assertModeConsistent(manager, 1, false)
|
||||
assertModeConsistent(manager, 2, false)
|
||||
})
|
||||
|
||||
test('单屏态冻结分辨率/缩放变化,但不冻结屏幕数量变化', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const manager = makeManager(t)
|
||||
const primary = fakeDisplay(1, { internal: true })
|
||||
fakeScreen.displays = [primary]
|
||||
manager.enable()
|
||||
|
||||
const entry = assertModeConsistent(manager, 1, true)
|
||||
const loadsBefore = entry.win.webContents.loadCount
|
||||
|
||||
// 单屏调试态下调分辨率/缩放:不跟着重排,现场调窗口时不会被打断
|
||||
const resized = { ...primary, bounds: { x: 0, y: 0, width: 1280, height: 720 } }
|
||||
emitScreen(t, 'display-metrics-changed', resized)
|
||||
assert.equal(entry.win.webContents.loadCount, loadsBefore, '单屏态下分辨率变化不应触发重排')
|
||||
assert.deepEqual(screenListenerCounts(), [1, 1, 1])
|
||||
|
||||
// 但插上第二块屏必须重排——冻结分辨率不等于冻结插拔
|
||||
fakeScreen.displays = [primary, fakeDisplay(2)]
|
||||
emitScreen(t, 'display-added', fakeDisplay(2))
|
||||
assertModeConsistent(manager, 1, false)
|
||||
assertModeConsistent(manager, 2, false)
|
||||
})
|
||||
|
||||
test('从 2 块变 1 块:主屏窗口收回窗口化,不卡在 kiosk 全屏', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const manager = makeManager(t)
|
||||
const primary = fakeDisplay(1, { internal: true })
|
||||
const external = fakeDisplay(2)
|
||||
fakeScreen.displays = [primary, external]
|
||||
manager.enable()
|
||||
|
||||
const entry = assertModeConsistent(manager, 1, false)
|
||||
// 真实 Electron 里 kiosk 窗口 isFullScreen() 也是 true
|
||||
entry.win.fullScreen = true
|
||||
|
||||
fakeScreen.displays = [primary]
|
||||
emitScreen(t, 'display-removed', external)
|
||||
|
||||
const after = assertModeConsistent(manager, 1, true)
|
||||
assert.equal(after.win, entry.win, '同一块屏上的窗口就地改模式,不重建')
|
||||
assert.equal(after.win.isFullScreen(), false, '必须退出全屏,否则窗口化模式会卡在黑屏全屏里')
|
||||
assert.deepEqual(after.win.getBounds(), { x: 192, y: 108, width: WIDTH - 2 * 192, height: HEIGHT - 2 * 108 })
|
||||
})
|
||||
|
||||
test('只有 closeAll(服务停止 / 应用退出)才摘监听,重新启用后自动挂回', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const manager = makeManager(t)
|
||||
const primary = fakeDisplay(1, { internal: true })
|
||||
fakeScreen.displays = [primary, fakeDisplay(2)]
|
||||
manager.enable()
|
||||
assert.deepEqual(screenListenerCounts(), [1, 1, 1])
|
||||
|
||||
// 服务停止:窗口全收、监听全摘(此时没有大屏要维护了)
|
||||
manager.closeAll()
|
||||
assert.deepEqual(screenListenerCounts(), [0, 0, 0])
|
||||
assert.equal(manager.windows.size, 0)
|
||||
|
||||
// 服务再起来:enable() 必须把监听挂回来,否则后续拔插又没人听
|
||||
fakeScreen.displays = [primary, fakeDisplay(2)]
|
||||
manager.enable()
|
||||
assert.deepEqual(screenListenerCounts(), [1, 1, 1])
|
||||
assertModeConsistent(manager, 2, false)
|
||||
|
||||
// 重复 enable() 不能叠加监听
|
||||
manager.enable()
|
||||
assert.deepEqual(screenListenerCounts(), [1, 1, 1])
|
||||
})
|
||||
|
||||
test('面板手动开窗同样挂上监听:之后拔插屏幕不会失联', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const manager = makeManager(t)
|
||||
const primary = fakeDisplay(1, { internal: true })
|
||||
fakeScreen.displays = [primary]
|
||||
|
||||
manager.openOn(primary, { single: true })
|
||||
assert.deepEqual(screenListenerCounts(), [1, 1, 1], '手动开窗也是「管理已激活」,必须能听见屏幕变化')
|
||||
|
||||
// 服务端一直没起来的情况下插上电视,也要自动排布;
|
||||
// 手动开的那块屏保持自己的窗口化模式,新插进来的屏按 kiosk 开
|
||||
fakeScreen.displays = [primary, fakeDisplay(2)]
|
||||
emitScreen(t, 'display-added', fakeDisplay(2))
|
||||
assertModeConsistent(manager, 1, true)
|
||||
assertModeConsistent(manager, 2, false)
|
||||
})
|
||||
|
||||
test('电视回来后 kiosk 仍开在 display.selection=specified 指定的那块屏上', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const manager = makeManager(t, { selection: 'specified' })
|
||||
manager.display.displays = [2]
|
||||
const primary = fakeDisplay(1, { internal: true })
|
||||
const external = fakeDisplay(2)
|
||||
fakeScreen.displays = [primary, external]
|
||||
manager.enable()
|
||||
|
||||
assertModeConsistent(manager, 2, false)
|
||||
assert.equal(manager.windows.has(1), false, 'specified 模式下只该在指定的那块屏上开窗')
|
||||
|
||||
// 电视待机:只剩主屏,先退化成窗口化
|
||||
fakeScreen.displays = [primary]
|
||||
emitScreen(t, 'display-removed', external)
|
||||
assert.equal(manager.windows.has(2), false)
|
||||
assertModeConsistent(manager, 1, true)
|
||||
|
||||
// 电视回来:必须重新只开在 display=2 上,而不是把主屏也铺成 kiosk
|
||||
fakeScreen.displays = [primary, external]
|
||||
emitScreen(t, 'display-added', external)
|
||||
assertModeConsistent(manager, 2, false)
|
||||
assert.equal(manager.windows.has(1), false)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scoreboard-console",
|
||||
"productName": "Scoreboard Console",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"author": "AskaEth",
|
||||
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
|
||||
|
||||
+3
-1
@@ -37,11 +37,13 @@
|
||||
- [ ] 同页的**大屏配置**:上传大屏会标、选大屏背景图,填大屏标题 / 副标题、轮播间隔与主题色(上传的图片随赛事项目包一起导出/导入)
|
||||
- [ ] 同页的**字体**:确认**标题字体**与**正文字体**已选好(默认、四款内置字体,或自己上传的字体);选完点「保存」,并核对大屏上标题与正文显示正常。上传的字体存在赛事项目的 `uploads\fonts\` 里,**导出赛事项目包会一并带走、导入后不用重新上传**(见使用手册 14.3、部署指南 8.5)
|
||||
- [ ] **登录有效期按赛程设好**:到「系统设置 → 登录有效期」把有效期设成覆盖本次比赛全部时段的时长(默认 **24 小时**、可调 **1–720** 小时,跨天比赛建议 **72 小时**),并核对页面顶部「当前会话将于 … 过期」;比赛期间操作员长时间不操作也不会掉线,但它不是「永不掉线」(见使用手册 14.4、常见问题 Q89)
|
||||
- [ ] **收表口径已交代**:提醒各单位报名名单里的**号码布**是系统认人的唯一标识、不能重号(重号行会被跳过);证件号可选、但也不能与别人重号(见报名名单导入规范)
|
||||
- [ ] **收表口径已交代**:提醒各单位报名名单里的**证件号**是系统认人的第一依据(有就优先填、填准,一人一号);没有身份证的人用**号码布**顶替,同一个号码布不能给两个人(重号行会被跳过);同一个人报多个组别时号码布可以重复(见报名名单导入规范)
|
||||
- [ ] **赛事项目确认 / 导入**:到「导出 → 赛事项目包」核对**赛事项目列表**里带「当前」徽标的是本次要用的赛事;需要换赛事时先「导入 .zip」,再点「切换为当前赛事」(服务端会重启一次,页面显示「正在重启服务端…」,恢复后自动刷新)。账号与会话在数据目录的 `system.db` 里,**不受切换影响**,一般不用重新登录。这一步做完再往下走,否则后面看到的是别的赛事的数据
|
||||
- [ ] **导入规则包**:`%LOCALAPPDATA%\ScoreboardConsole\rules\2026-驭电利刃杯军事运动会.yaml`,走「导出 → 规则包 → 选择 YAML → 预览差异 → 确认导入并全量重算」(规则目录在用户数据目录里,见部署指南 3.5)
|
||||
—— 服务端不会自动加载规则包,必须导入一次
|
||||
- [ ] 检查计分模板:个人前六名 `10/8/6/3/2/1`、团体 `15/10/8/4/2/1`、最后一名不得分
|
||||
- [ ] **按赛事规则逐项核对过「奖牌结算开关」与「奖牌通道」**:到「基础数据 → 大项 / 小项」逐个确认要发牌的小项都勾着 **「该项启用奖牌结算」**(不该发牌的项目取消勾选);再到「基础数据 → 计分通道」逐项核对每条通道的 **「发奖牌」** 与(个人名次通道的)**「个人奖牌并入单位金牌榜」** 是否符合本场赛会规则。关掉的通道在「大项 / 小项」页的通道摘要里会标出 `不发奖牌` / `个人奖牌不并入单位榜`(见使用手册 10.2、10.4;查不出原因时按常见问题 Q94 逐条排除)
|
||||
- [ ] **升级 / 首次启动后确认奖牌榜数字与上一场一致(不是全 0)**:本版新增迁移 0020,迁移只建表不回填,服务端会在下一次启动时自动全量重算补齐名次——「运行日志」里出现 `检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算` 与 `一次性全量重算完成,通道名次与奖牌榜已补齐` 是正常的,不是报错;等它跑完后刷新「积分榜 → 团体奖牌榜」,与升级前的数字(或备份里的导出件)对一遍,**不能是全 0**(见部署指南 3.4、常见问题 Q96)
|
||||
- [ ] 「只排名不计分」的项目(飞行人员战斗体能)导入后应显示为不计分
|
||||
- [ ] 「基础数据 → 参赛单位」里各单位的 logo 都已上传(会显示在总积分榜、团体奖牌榜、分项成绩表与个人积分榜上)
|
||||
- [ ] 同页核对**单位名单齐全、没有误删**:被「彻底删除」的单位不会出现在这个列表里(它的报名、成绩与积分仍在库里,积分榜照常显示它);发现少了就用它的编码导入一次报名表,它会重新出现(详见使用手册 10.1、常见问题 Q61–Q64)
|
||||
|
||||
+81
-20
@@ -196,10 +196,33 @@
|
||||
| **批量恢复** | 把选中的退赛者改回「正常参赛」 | —— |
|
||||
| **批量移出报名** | 把选中的报名项**物理删除**,连同他的成绩与积分流水一起删掉 | **不可以**,需二次确认 |
|
||||
|
||||
- **批量退赛 / 恢复**会按新名次重算积分并推到大屏(若开了延迟结算,同样等该组录完才结算,见 4.6、10.5)。已经是「取消资格(dq)」「未出场(dns)」的行会被**跳过、不覆盖原判定**(跳过的人会在确认框里逐条列出原因,例如「已被判罚取消资格(dq),跳过」)。
|
||||
- **批量移出报名**的确认框会列出**将被删除的人**(姓名|号码布|单位,超过 20 人只列前 20 并报剩余数量)以及**将重算的小项与组别**——看完再确认,删了不能恢复。
|
||||
- **批量退赛 / 恢复**会**立即**按新名次重算积分并推到大屏——**延迟结算(见 10.5)也照算**,因为退赛 / 恢复改的是「谁还算数」,不是「录了多少」,名次和分数必须当场跟着改。已经是「取消资格(dq)」「未出场(dns)」的行会被**跳过、不覆盖原判定**(跳过的人会在确认框里逐条列出原因,例如「已被判罚取消资格(dq),跳过」)。
|
||||
- **批量移出报名**的确认框会列出**将被删除的人**(姓名|号码布|单位,超过 20 人只列前 20 并报剩余数量)以及**将重算的小项与组别**——看完再确认,删了不能恢复;删除后同样**立即重算并推榜**,不受延迟结算影响。
|
||||
- 一次最多处理 **500 条**报名项,超出请分批提交;有未保存的成绩修改时,请先「存草稿」再批量操作;示例数据模式下不能批量操作。
|
||||
|
||||
### 4.9 团体名次(按单位录入)
|
||||
|
||||
有些项目要按**单位**排团体名次(例如「团体引体向上」),但场上并没有一条"单位自己的成绩"可填——名次是裁判直接给定的。这类项目在录入页会多出一块 **「团体名次」** 卡片。
|
||||
|
||||
**什么时候会出现**:只有该组别配了「**团体名次 + 手动名次**」这两项都符合的计分通道时才会出现(通道在「基础数据 → 计分通道」配,见 10.4)。没配这样通道的小项,录入页**不显示**这块卡片——这是正常的,说明它的团体名次另有来源(例如篮球这类纯团体项目,走的是团体报名行上的名次,仍在成绩列表里录)。
|
||||
|
||||
**怎么填**:
|
||||
|
||||
1. 卡片里按**单位**一行一个输入框(单位清单取「基础数据 → 参赛单位」里启用中的单位,已「彻底删除」的单位不出现);
|
||||
2. 在单位后面填名次(从 1 开始的正整数);**留空 = 清空该单位的名次**(不需要先填一个 0);
|
||||
3. 填完点 **「保存团体名次」**;没有改动时按钮是灰的,改了才亮,卡片右上角会显示「未保存」。
|
||||
|
||||
**保存后会发生什么**:系统按新名次重算**该组别名次与积分**,并刷新积分榜与大屏(这一步与《个人名次》无关——**个人名次仍然在成绩列表里录**,团体名次只喂给团体通道)。保存完成后会提示「团体名次已保存 · 该组名次与积分榜会按新名次重算」。
|
||||
|
||||
**要注意的**:
|
||||
|
||||
- **名次重复允许并列**(服务端按积分模板的并列口径处理),但输入框会描红并提示 `名次 N 重复 X 次,允许并列,请确认是否为想要的结果`——确认无误可以照常保存;
|
||||
- **名次超出参赛单位数**同样会提示(多半是笔误),但**不挡保存**:改不改由你决定;
|
||||
- **个人名次不受影响**:这块卡片改的是单位,成绩表里每个人的名次、分数一行都不动;
|
||||
- **中途离开会提醒**:有未保存的团体名次时,切走会有确认框(和未保存的成绩一样);
|
||||
- **权限**:读得到成绩的人都能看到这块卡片(访客只读,输入框置灰并写明原因);**能保存的是录入员及以上**(与成绩录入同权限)。示例数据模式下不能保存;
|
||||
- 团体名次是**独立保存**的:点「保存团体名次」只提交这块卡片,不会顺手发布成绩;成绩仍然要另外「发布」。
|
||||
|
||||
---
|
||||
|
||||
## 5. 成绩审核
|
||||
@@ -395,10 +418,24 @@
|
||||
### 10.2 大项 / 小项
|
||||
|
||||
1. 左侧选择或新增大项(编码、名称、简称、默认积分模板、排序);
|
||||
2. 右侧维护该大项下的小项:点「新增小项」,填写编码、名称、类型(个人 / 团体 / 对抗 / 比赛 / 个人+团体)、录入方式(自动排名 / 手动名次)、成绩类型(时间 / 距离 / 计数 / 得分 / 无成绩)、比较方式(越小越好 / 越大越好 / 不比较)、状态、是否计分等;
|
||||
2. 右侧维护该大项下的小项:点「新增小项」,填写编码、名称、类型(个人 / 团体 / 对抗 / 比赛 / 个人+团体)、录入方式(自动排名 / 手动名次)、成绩类型(时间 / 距离 / 计数 / 得分 / 无成绩)、比较方式(越小越好 / 越大越好 / 不比较)、状态、是否计分、**是否结算奖牌**等;
|
||||
3. 小项行上的「配置组别」「配置计分通道」可直接跳到对应分区;
|
||||
4. 删除大项 / 小项会二次确认。
|
||||
|
||||
**「该项启用奖牌结算」开关**(新增)
|
||||
|
||||
小项表单下方的「选项」里,"是否计分"旁边有一个 **「该项启用奖牌结算」** 勾选框,**默认勾上**。
|
||||
|
||||
- **勾上(默认)**:这个小项按名次发奖牌——第 1 名金、第 2 名银、第 3 名铜,和以前一样;
|
||||
- **取消勾选**:这个小项的**名次与积分照常计算、照常上屏**,但**一块奖牌都不发**——金牌榜(团体奖牌榜)与个人积分榜的奖牌列里都不再出现它。
|
||||
|
||||
什么时候该关掉它?**"算分但不发牌"的项目**——例如赛会规则只记名次分、不设金银铜的项目,或者现场临时决定不发牌、但分数仍要计入单位总分的项目。
|
||||
|
||||
> 注意别和「计分」搞混:**取消勾选「计分」**的项目(只排名不计分)本来就不发奖牌,那种情况不用动这里;**「该项启用奖牌结算」管的是"计分的项目里哪些要发牌"**。
|
||||
>
|
||||
> 它保存后立即生效:系统会马上重算该小项,金牌榜随之更新。想恢复发牌,把勾重新勾上保存即可。
|
||||
> 这是"小项级"的总闸;某个小项里**单独某条计分通道**不发牌,另有开关,见 10.4。
|
||||
|
||||
**「个人+团体」是什么**
|
||||
|
||||
用于团体与个人赛事合并的项目:同一条成绩会同时按个人通道与团体通道记分(具体通道见 10.4「计分通道」)。
|
||||
@@ -438,7 +475,24 @@
|
||||
3. 点「保存全部」整体替换该小项的通道配置;
|
||||
4. 标记为「不计分」的小项,通道可以保存但不参与积分计算;
|
||||
5. 「名次来源」选「按队内平均」时,把同一个单位(或个人)在该组别的成绩**取平均**后排名,并列按积分模板的并列口径处理(默认顺延);平均值分母取录入页选的「平均值口径」——`只算有效成绩`时是本单位有有效成绩的条数(一个人都没有效成绩的单位不参与团体排名、不得分),`全部报名人员都计入`时是本单位在该组别的报名条数(没成绩的按 0 计入)。报名表里 `status` 不是 `registered` 的报名项(弃赛 / 取消)既不算参与,也不进分母;
|
||||
6. 每条通道有一个「计入单位总分」开关,默认打开。关掉后该通道的分只记在个人头上,不进单位总分——「个人+团体」项目的个人通道就靠它做到只记个人。
|
||||
6. 每条通道有一个「计入单位总分」开关,默认打开。关掉后该通道的分只记在个人头上,不进单位总分——「个人+团体」项目的个人通道就靠它做到只记个人;
|
||||
7. 每条通道还有一个**「奖牌通道」**列,里面是两个开关(都默认打开):
|
||||
|
||||
| 开关 | 默认 | 关掉之后 |
|
||||
| --- | --- | --- |
|
||||
| **发奖牌** | 打开(该通道的名次发奖牌) | 这条通道算出来的名次**只出积分、不发奖牌**:名次与分数照旧,金牌榜与个人榜里不再为它记牌 |
|
||||
| **个人奖牌并入单位金牌榜** | 打开 | 只对「名次范围 = 个人名次」的行显示。关掉后这条通道的个人奖牌**仍然进个人积分榜的奖牌列**,但**不计入单位的金牌榜**(团体奖牌榜 / 单位总分榜的金银铜) |
|
||||
|
||||
怎么用:
|
||||
|
||||
- **什么该关「发奖牌」**:一条通道只用来给分、名次不该算奖牌时——例如个人通道的分要记、但这批名次不是赛会正式名次;
|
||||
- **什么该关「个人奖牌并入单位金牌榜」**:某个个人项目**不按单位计牌**时(单位金牌榜只统计团体项目的牌子、或该项目被判定为个人项目不计入单位成绩);
|
||||
- 「名次范围 = 团体名次」的行**不显示**第二个开关:团体名次本来就是记在单位头上的,不存在「个人奖牌并入单位」这回事;
|
||||
- 两个开关**都在「保存全部」之后生效**,保存后系统会重算该小项并更新榜单;
|
||||
- 这些开关只决定**发不发牌、算不算单位榜**,**不改变积分**(积分由「计入单位总分」与积分模板决定,见第 6 条)。
|
||||
|
||||
> 三个层次一起决定一枚奖牌:**小项的「该项启用奖牌结算」(见 10.2)→ 通道的「发奖牌」→ 名次在前三**。三者都满足才有奖牌;「计入单位金牌榜」再决定这枚牌算不算到单位头上。
|
||||
> 想知道某个小项到底发不发牌:在「大项 / 小项」页选中它,页面下方「当前小项的计分通道」摘要里,关掉的开关会直接标出 **`不发奖牌`** 与 **`个人奖牌不并入单位榜`**。
|
||||
|
||||
### 10.5 积分模板
|
||||
|
||||
@@ -457,7 +511,7 @@
|
||||
### 10.6 运动员
|
||||
|
||||
1. 点「新增运动员」,填写姓名 *、性别、年龄、所属单位 *、工号 / 学号、证件号、号码布等(年龄可留空,填了必须是 0–120 的整数);
|
||||
2. **号码布是单人的唯一标识**,同名同姓的人靠它区分,两个人的号码布不能重复;**证件号(如身份证号)是可选的档案信息**,报名导入**不按证件号认人**,但系统里**一个证件号也只能属于一个人**,与别人重复同样会被拒绝。没有身份证信息的人只填号码布就行;两项都不填也能保存档案,但这样的人员**不能在「报名导入 → 个人报名」里勾选**(系统认不出是谁)。已有历史数据两项可以都为空;
|
||||
2. **证件号(如身份证号)是单人的唯一标识**,同名同姓的人靠它区分,两个人的证件号不能重复;**没有身份证信息的人只填号码布**,此时号码布顶替证件号认人(报名导入会先按证件号认人,没有证件号或库里那条证件号为空时再按号码布认人),两个人的号码布同样不能重复。两项都不填也能保存档案,但这样的人员**不能在「报名导入 → 个人报名」里勾选**(系统认不出是谁)。已有历史数据两项可以都为空;
|
||||
3. 每行有三个动作:**编辑**、**停用**(停用后按钮变成「启用」)、**彻底删除**;
|
||||
4. 个人项目的报名项会关联到运动员,团体项目可以留空;
|
||||
5. **多选与批量操作**:列表每行有勾选框,表头是「全选当前筛选结果」(只勾当前筛出来的人)。选中后顶部出现「已选 N 人」与 **批量停用** / **批量彻底删除** 两个按钮,整批只提交一个请求,不用一个个点;**批量彻底删除**会先预览将删掉谁、重算哪些小项,确认后才执行,规则与单个「彻底删除」完全一致(见下)。
|
||||
@@ -515,41 +569,47 @@
|
||||
|
||||
1. 点「下载 CSV 模板」获取表头样例;
|
||||
2. 表头共 12 列:`event_code`、`category`、`team_code`、`athlete_name`、`id_card`、`gender`、`age`、`bib`、`seed`、`heat`、`status`、`member_names`(大小写不敏感)。其中 `id_card`(证件号)、`gender`(性别)、`age`(年龄)、`heat`(批次)为可选列,缺失不影响导入,其余列必须包含;
|
||||
3. **只要有姓名的行,`id_card` 与 `bib` 必须至少填一个**,否则该行报错并跳过;`gender` 只认「男」「女」,其他值忽略;`age` 可留空,填了必须是 0–120 的整数;**同一份文件里同一个号码布重复出现会报错并跳过**(号码布是唯一标识);同一个证件号重复出现**不再报错**,但证件号撞上系统里**另一个人**时会报「证件号 X 已被另一个人占用(姓名 Y,号码布 Z)」并跳过该行;
|
||||
4. **`status` 列很宽容**:大小写不敏感(`DNS`、`Registered`、`registered` 都认),也认中文别名——
|
||||
3. **只要有姓名的行,`id_card` 与 `bib` 必须至少填一个**,否则该行报错并跳过;`gender` 只认「男」「女」,其他值忽略;`age` 可留空,填了必须是 0–120 的整数;**证件号是系统认人的第一依据**,同一个证件号在同一份文件里重复出现**不报错**(同一个人报多个组别时会重复,导入结果里会给一条「证件号 XXXX 在本表中出现 N 次(同一人报名多个组别)」提示);同一小项同一个组别里出现两次同样的证件号才按**真重复**进冲突列表;**同一个号码布给了两个不同的人会报错并跳过**(同一个人报多个组别时号码布可以重复);
|
||||
4. 人员匹配顺序(**证件号是单人的唯一标识**):
|
||||
1. 先按 `id_card`(证件号)匹配:命中即认作同一人;姓名 / 性别 / 年龄 / 单位 / 号码布与他的档案对不上时进「冲突」列表,等你逐条决定(见下);
|
||||
2. 证件号为空、或库里那条还没登记证件号,再按 `bib`(号码布)匹配:命中即认作同一人,差异同样进「冲突」列表;
|
||||
3. 证件号与号码布都没找到,再按「姓名 + 单位」找:
|
||||
- 恰好一人,两边给的证件号(或号码布)对得上或有一边没填 → 认作同一人,表里给的证件号 / 号码布 / 年龄会**回填**进他的档案(不算冲突),重传不带证件号的名单不会把每个人复制一份;
|
||||
- 恰好一人,但他已有**另一个**证件号(或号码布)且与表里不同 → 按「**同名的另一个人**」新建档案,并在导入结果里单独列出(姓名 / 单位 / 原证件号或号码布 / 新证件号或号码布)提醒核对,**不算错误、不拦截**;
|
||||
- 多人 → 分不清是谁,进「冲突」列表;
|
||||
4. 都没匹配到则新建人员(姓名、性别、年龄、单位、证件号、号码布)——被「彻底删除」的人(见 10.6)档案已经不在库里,匹配不到他,再次导入会当作**新人员**重新建档,他以前的报名与成绩不会回来;
|
||||
5. **一个证件号只能属于一个人**:表里给的证件号若已是别人的,这一行会按证件号认到**那个人**,姓名 / 单位等对不上就进冲突列表让你决定;确实无法认到(例如同时又给了别人的号码布)时会报「证件号 X 已被另一个人占用(姓名 Y,号码布 Z)」并跳过,不会把证件号抢过来;
|
||||
5. **`status` 列很宽容**:大小写不敏感(`DNS`、`Registered`、`registered` 都认),也认中文别名——
|
||||
- `正常` / `正常参赛` / `参赛` → 正常参赛;
|
||||
- `退赛` / `弃赛` / `请假` → 退赛;
|
||||
- `取消资格` / `犯规` → 取消资格;
|
||||
- `未出场` / `未到` / `缺席` → 未出场。
|
||||
留空即「正常参赛」。真正认不出的写法会给出中文行级错误、该行跳过,例如「第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空)」;
|
||||
5. 人员匹配顺序(**号码布是单人的唯一标识,证件号不参与认人**,只作为档案信息写入 / 更新):
|
||||
1. 先按 `bib`(号码布)匹配:命中即认作同一人;姓名 / 性别 / 年龄 / 单位 / 号码布与他的档案对不上时进「冲突」列表,等你逐条决定(见下);
|
||||
2. 号码布为空、或按号码布没找到,再按「姓名 + 单位」找:
|
||||
- 恰好一人、且他还没有号码布 → 认作同一人,表里给的号码布 / 证件号 / 年龄会**回填**进他的档案(不算冲突);
|
||||
- 恰好一人、且他已有号码布 → 分两种:表里**没给号码布**(或与他相同)时**仍判同一人**(重传不带号码布的名单不会把每个人复制一份);表里**给了号码布且与他不同**时才按「**同名的另一个人**」新建档案,并在导入结果里单独列出(姓名 / 单位 / 原号码布 / 新号码布)提醒核对,**不算错误、不拦截**;
|
||||
- 多人 → 分不清是谁,进「冲突」列表;
|
||||
3. 都没匹配到则新建人员(姓名、性别、年龄、单位、证件号、号码布)——被「彻底删除」的人(见 10.6)档案已经不在库里,匹配不到他,再次导入会当作**新人员**重新建档,他以前的报名与成绩不会回来;
|
||||
4. 证件号不参与认人,但**一个证件号只能属于一个人**:表里给的证件号若已被别人占用,该行会报「证件号 X 已被另一个人占用(姓名 Y,号码布 Z)」并跳过,不会把证件号抢过来;
|
||||
6. `bib` 列会同时写入人员档案与报名记录;从已有名单勾选生成的报名也会带上人员的证件号与号码布;
|
||||
7. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表);
|
||||
- **CSV 编码不用管**:UTF-8、带 BOM 的 UTF-8(Excel 的「CSV UTF-8」)、GBK / GB18030(中文 Windows 上 Excel「另存为 CSV」的默认编码)、UTF-16 都能直接导入,中文不会乱码,服务端会自动识别;
|
||||
8. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。
|
||||
|
||||
> **导入会同步重算受影响的小项与组别**:报名名单改的是「谁还算数」(新建报名、或把正常参赛改成退赛 / 取消资格 / 未出场),所以导入完成后系统会按新名单重算这些组别的名次与积分,并刷新榜单与大屏——**不受「分批发布时实时更新榜单」这个延迟结算开关影响**(同 4.8 的批量退赛)。「只改了姓名 / 号码布 / 批次」这类不影响算分的改动不会白算一遍。
|
||||
|
||||
**人员信息冲突的处理**
|
||||
|
||||
导入时如果按号码布(或「姓名 + 单位」唯一命中)找到的人在系统里已有记录,但表里的姓名、性别、年龄、单位或号码布与他的档案不一致,这个人会被列为**冲突**,本次不写入(该人的报名也不会创建)。处理办法:
|
||||
导入时如果按证件号(或号码布、「姓名 + 单位」唯一命中)找到的人在系统里已有记录,但表里的姓名、证件号、性别、年龄、单位或号码布与他的档案不一致,这个人会被列为**冲突**,本次不写入(该人的报名也不会创建)。处理办法:
|
||||
|
||||
1. 导入结果里会单独列出冲突人员,每条显示已有数据、新数据,以及不一致的字段(含年龄与号码布);
|
||||
1. 导入结果里会单独列出冲突人员,每条显示已有数据、新数据,以及不一致的字段(含证件号、年龄与号码布);
|
||||
2. 用左侧复选框选中一条或多条(也可以全选),再点「保留已有数据」或「使用新数据」:
|
||||
- **保留已有数据**:人员字段沿用系统里已有的,按这个人的信息建 / 更新报名;
|
||||
- **使用新数据**:用表里的姓名、性别、年龄、单位、号码布等更新该人员,再建 / 更新报名;
|
||||
- **使用新数据**:用表里的姓名、证件号、性别、年龄、单位、号码布等更新该人员,再建 / 更新报名;
|
||||
3. **没有做决定的冲突不会被写入**,可以稍后再处理;全部处理完后冲突列表清空。
|
||||
- 有一种冲突卡片底部写着**「本表内重复行」**:同一证件号 + 同一个小项同一个组别在表里出现了两次。这种卡片上可能一个字段都没高亮,是正常的——两条本来就可能一模一样。**保留已有** = 已经导入的那条报名保持不动,**使用新数据** = 用这一行的值覆盖它。
|
||||
|
||||
> 「从已有名单勾选」走的是同一个导入通道,人员匹配与冲突处理规则完全一致。
|
||||
|
||||
**「同名的另一个人」提醒(不是冲突)**
|
||||
|
||||
如果表里某人的号码布与库里「同名 + 同单位」的人不同,系统按**同名的另一个人**给他**新建档案**,并在导入结果里单独列出一块:`以下 N 人按同名的另一个人新建了档案(号码布不同)`,逐条显示 `姓名(单位):原有号码布 XXX → 新号码布 XXX`。这些行**已经导入成功、不会拦截**,只是提醒你核对号码布有没有写错;若确认是本人写错了号,到「基础数据 → 运动员」把档案里的号码布改回来、并删掉多出来的那条档案即可。
|
||||
如果表里某人的证件号(或号码布)与库里「同名 + 同单位」的人不同,系统按**同名的另一个人**给他**新建档案**,并在导入结果里单独列出一块:`以下 N 人按同名的另一个人新建了档案(证件号 / 号码布不同)`,逐条显示 `姓名(单位):证件号 XXX → XXX;号码布 XXX → XXX`。这些行**已经导入成功、不会拦截**,只是提醒你核对证件号与号码布有没有写错;若确认是本人写错了号,到「基础数据 → 运动员」把档案里的号改回来、并删掉多出来的那条档案即可。
|
||||
|
||||
同一证件号在本表里出现多次时,导入结果里还会给一条 `证件号 XXXX 在本表中出现 N 次(同一人报名多个组别)` 的提示——这是**正常情况**,只提醒你别把不同的人写成同一个证件号。
|
||||
|
||||
种子等字段无法在勾选界面填写,仍需通过表格导入;号码布取自人员档案,勾选时会自动写入报名。团体报名登记的队员名单只存姓名,不会带动运动员档案。
|
||||
|
||||
@@ -631,8 +691,9 @@
|
||||
- **按编码匹配**:单位 / 大项按编码,小项按编码,组别按「小项编码 + 组别名」;**存在就更新、不存在就新建**;
|
||||
- **绝不删除**:文件里没有的行(表里没有、或整张工作表没放)**不会被删掉**,也不会被停用;
|
||||
- **列不在表里 = 不动该字段**(**布尔列也一样**——把整列「启用」删掉再导回,**不会**把所有停用的单位/大项改成启用);**列在表里但格子留空 = 按该列的默认口径处理**:简称 / 颜色 / 场地 / 备注等文本列与年龄上下限会**清空**,权重留空按 1、启用 / 计分 / 计破纪录等布尔列留空按「是」,**排序留空则保持原来的值**(**四张表口径一致**,单位 / 大项留空同样不会被改写)。改数据前先想清楚这一点;
|
||||
- **枚举列是唯一的例外:列在表里但格子留空 = 不改动,不是"取默认值"**。小项的**类型 / 结果模式 / 结果类型 / 比较方式 / 状态 / 平均口径**、组别的**性别**都属于这一类:留空就沿用这一行在系统里原来的值(**只有新建的行**才用默认值,例如新建组别时性别留空 = 不限)。这么定是为了防手滑——表格里误清一格,不会把一个已发布的小项打回「待录入」、也不会把「团体」改成「个人」;
|
||||
- **表头行可以不在第一行**:从系统导出的包表头在第 4 行(上面是标题与副标题),手工整理的表表头在第 1 行,两种都能识别(识别范围是前 20 行);
|
||||
- **枚举列要填系统里的英文规范值**(表头是中文,容易踩):类型 `individual` / `team` / `match` / `individual_team`,录入方式 `auto` / `manual`,成绩类型 `time` / `distance` / `count` / `score` / `none`,比较方式 `asc` / `desc` / `none`,小项状态 `pending` / `ongoing` / `finished` / `published`,平均口径 `valid` / `all`;布尔列(启用 / 计分 / 计破纪录)填 **是 / 否**;组别表的「性别」填 **男 / 女**(留空 = 不限);
|
||||
- **枚举列要填系统里的英文规范值**(表头是中文,容易踩):类型 `individual` / `team` / `match` / `individual_team`,录入方式 `auto` / `manual`,成绩类型 `time` / `distance` / `count` / `score` / `none`,比较方式 `asc` / `desc` / `none`,小项状态 `pending` / `ongoing` / `finished` / `published`,平均口径 `valid` / `all`;布尔列(启用 / 计分 / 计破纪录)填 **是 / 否**;组别表的「性别」填 **男 / 女**(列在表里但格子留空 = 不改动;新建的组别留空 = 不限);
|
||||
- **最稳的用法**:先「导出基础数据包」,在导出件上直接改,再导回来——表头与取值天然对得上;手工整理的表格请照导出件的表头写;
|
||||
- 被「彻底删除」的单位不在导出件里;导入**不会**把它翻出来(已有的隐藏记录只更新字段,仍是隐藏状态)。
|
||||
|
||||
|
||||
+59
-11
@@ -240,24 +240,25 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。
|
||||
|
||||
**Q48:报名表里的证件号重复了怎么办?**
|
||||
先分清两种「重复」,处理方式完全不同:
|
||||
先分清三种「重复」,处理方式完全不同:
|
||||
|
||||
- **同一份表里同一个证件号出现多次**:**不再报错**,照常导入(同一个人报多项时,证件号本来就会重复)。证件号只是**档案信息,不参与认人**。
|
||||
- **证件号已经登记在系统里另一个人名下**(姓名、号码布都对不上):这一行会报 `第 N 行:证件号 X 已被另一个人占用(姓名 Y,号码布 Z)` 并**跳过该行**。系统里一个证件号只能属于一个人(避免把两个人的身份弄混)。处理办法:先核对证件号是不是抄错了——不是这个人的,把这一格留空即可(只填号码布也能导入);确实是他的,先到「基础数据 → 运动员」把另一条档案里的证件号清掉或改对,再重新上传。
|
||||
- **同一份表里同一个证件号出现多次**:**不报错**,照常导入——证件号是**人员的唯一标识**,同一个证件号就是同一个人(同一个人报多个组别时本来就会重复)。导入结果里会给一条提示:`证件号 XXXX 在本表中出现 N 次(同一人报名多个组别)`,只是在提醒你别把不同的人写成同一个证件号。
|
||||
- **同一证件号 + 同一个小项同一个组别**:这才是**真重复**(同一个人在同一组里被写了两遍)。这一行会进「冲突」列表,由你选**保留已有**(已导入的那条报名不动)或**使用新数据**(用这一行覆盖它)。
|
||||
- **证件号已经登记在系统里另一个人名下**:这一行会**按证件号认到那个人**,姓名 / 单位等对不上就进「冲突」列表让你决定;只有实在认不到(例如同时又给了别人的号码布)时才会报 `第 N 行:证件号 X 已被另一个人占用(姓名 Y,号码布 Z)` 并**跳过该行**。处理办法:先核对证件号是不是抄错了——不是这个人的,把这一格留空即可(只填号码布也能导入);确实是他的,先到「基础数据 → 运动员」把另一条档案里的证件号清掉或改对,再重新上传。
|
||||
- **在「运动员」里新增 / 编辑时提示证件号重复**:同上,说明该证件号已属于另一个人。是同一个人的话直接用已有记录;证件号填错就改对。已有历史数据可以没有证件号。
|
||||
|
||||
详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。
|
||||
|
||||
**Q49:人员没有身份证,只有号码布,能报名吗?**
|
||||
可以,这正是**号码布作为唯一标识**的用法:在「运动员」里只填号码布也能保存;报名表里有姓名的行,`id_card` 与 `bib` 至少填一个就能导入;从已有名单勾选报名时,两项里有一项就能勾选。号码布会同时写入人员档案与报名记录。反过来,证件号只是**可选的档案信息**——只填证件号、没有号码布也能导入,但系统是**按号码布认人**的(号码布为空时按「姓名 + 单位」认),所以能编号码布就编上,重名重姓时才分得清。
|
||||
可以。证件号是系统认人的**第一依据**,但没有身份证的人只填号码布也能用:号码布此时**顶替证件号认人**。在「运动员」里只填号码布也能保存;报名表里有姓名的行,`id_card` 与 `bib` 至少填一个就能导入;从已有名单勾选报名时,两项里有一项就能勾选。号码布会同时写入人员档案与报名记录。因为没有证件号,认人就全看号码布了——能编上号码布就编上,重名重姓、号码布还必须写成同一个人每次都用的那个号。
|
||||
|
||||
**Q50:号码布重复了怎么办?**
|
||||
号码布是**单人的唯一标识**,系统不允许两个人员共用同一个号码布。
|
||||
两个人**不能**共用同一个号码布(同一个人报多个组别时可以重复)。
|
||||
|
||||
- **在「运动员」里新增 / 编辑时提示重复**:说明该号码布已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查号码布是否填错。
|
||||
- **同一份报名表里号码布重复出现**:重复的行会报错并跳过,改好后重新上传。
|
||||
- **号码布在系统里已有、但表里这个人跟他对不上**(姓名 / 性别 / 年龄 / 单位不同):会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后决定「保留已有数据」还是「使用新数据」。
|
||||
- **表里某人的号码布与库里「同名 + 同单位」的人不同**:系统按「**同名的另一个人**」给他**新建档案**(不报错),并在导入结果里单独列出 `姓名(单位):原有号码布 XXX → 新号码布 XXX` 提醒核对。多半是号码布写错了:到「基础数据 → 运动员」把档案里的号改回来、删掉多出来的那条档案即可。
|
||||
- **同一份报名表里,同一个号码布给了两个不同的人**:重复的那一行会报错并跳过,改好后重新上传。(同一个人报多个组别、号码布写成同一个,不报错。)
|
||||
- **号码布在系统里已有、但表里这个人跟他对不上**(姓名 / 性别 / 年龄 / 单位不同):会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后决定「保留已有数据」还是「使用新数据」。表里同时给了**别人的证件号**时,会先按证件号认人,同样是进「冲突」列表。
|
||||
- **表里某人的证件号(或号码布)与库里「同名 + 同单位」的人不同**:系统按「**同名的另一个人**」给他**新建档案**(不报错),并在导入结果里单独列出 `姓名(单位):证件号 XXX → XXX;号码布 XXX → XXX` 提醒核对。多半是证件号或号码布写错了:到「基础数据 → 运动员」把档案里的号改回来、删掉多出来的那条档案即可。
|
||||
|
||||
详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。
|
||||
|
||||
@@ -308,7 +309,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
|
||||
**Q58:删掉的人怎么又出现在名单里了?**
|
||||
|
||||
彻底删除之后,如果又在报名表里导入同一个人(同一个号码布,或同一「单位 + 姓名」),他会以**全新的档案**重新出现在运动员名单里——因为旧档案已经连同他的报名与成绩一起删掉了,系统按「号码布 → 姓名 + 单位」找不到他(证件号不参与认人),就当作新人员建档。所以名单里突然多出一个「以前删过的人」,通常就是某次导入带回来的。
|
||||
彻底删除之后,如果又在报名表里导入同一个人(同一个证件号、或同一个号码布、或同一「单位 + 姓名」),他会以**全新的档案**重新出现在运动员名单里——因为旧档案已经连同他的报名与成绩一起删掉了,系统按「证件号 → 号码布 → 姓名 + 单位」都找不到他,就当作新人员建档。所以名单里突然多出一个「以前删过的人」,通常就是某次导入带回来的。
|
||||
|
||||
- 新出现的这条是**全新的记录**:以前的报名、成绩与积分不会回来,需要重新录;
|
||||
- 不想他再出现,就让各单位不要把这个人填进报名表;确实不该有的,再「彻底删除」一次即可。
|
||||
@@ -530,7 +531,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
录入、发布、看榜这些日常操作手机上都能做;赛事配置这类条目较多的操作仍建议在电脑上完成。如果某台手机上仍显示不全,请把**机型、浏览器与屏幕宽度**(或截图)告诉维护人员——目前适配覆盖到 390px 宽的窄屏,桌面端布局不受影响。
|
||||
|
||||
**Q86:号码布和证件号,哪个才是系统认人的依据?**
|
||||
**号码布。** 报名导入时的匹配顺序是「号码布 → 姓名 + 单位」;**证件号不参与认人**,只作为可选的档案信息写入 / 更新。不过系统里**一个证件号仍然只能属于一个人**,撞上别人的证件号会明确报错并指出占用者(见 Q48)。完整匹配规则见[报名名单导入规范](./报名名单导入规范.md)。
|
||||
**证件号。** 报名导入时的匹配顺序是「证件号 → 号码布 → 姓名 + 单位」:先按证件号认人;证件号为空、或库里的档案还没登记证件号时按号码布认;两者都没命中才按「姓名 + 单位」找,并把表里给的证件号 / 号码布**回填**进档案。系统里**一个证件号只能属于一个人**,撞上别人的证件号会按证件号认到那个人并进「冲突」列表(见 Q48)。号码布不再单独充当唯一标识,但仍然**不能给两个人共用**(同一个人报多个组别可以重复)。完整匹配规则见[报名名单导入规范](./报名名单导入规范.md)。
|
||||
|
||||
**Q87:怎么把报名名单导出来?**
|
||||
到「导出 → 结果导出」页,往下找**「报名名单」**卡片:先按**小项 / 组别 / 单位 / 批次**筛选(都可以留空 = 不筛,即导全部),再点「导出报名名单」,得到 `报名名单.xlsx`。表里有 13 列:小项编码、小项名、组别、单位编码、单位名、姓名、证件号、号码布、批次、性别、年龄、状态(中文:正常参赛 / 已退赛 / 取消资格 / 未出场)、备注;号码布按文本导出,前导零不会丢。访客也能导出(只读)。
|
||||
@@ -541,7 +542,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
- **导出**:一个工作簿四张工作表(单位 / 大项 / 小项 / 组别,组别表带所属小项编码);
|
||||
- **导入**(**仅管理员**,只认 `.xlsx`):按编码匹配,**存在就更新、不存在就新建,绝不删除**(表里没有的行不会被删、也不会被停用)。先点「预览差异」看「新增 / 更新 / 无变化 / 错误」与逐行变化,确认后再点「**确认写入**」;**有错的行会跳过、合法的行照常写入**,行级错误是中文并带真实 Excel 行号(如 `单位 第 3 行:单位编码必填`)。
|
||||
|
||||
要点:**列不在表里 = 不动该字段**(**布尔列也一样**,缺整列 ≠ 取默认值);**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列(启用 / 计分等)按「是」,**排序留空保持原值**(**四张表口径一致**,单位 / 大项留空同样不会被改写)。表头行在第 4 行(导出件)或第 1 行(手工表)都能识别;枚举列要填英文规范值、布尔列填「是 / 否」。**最稳的做法是从系统导出、改完再导回**。详见使用手册 11.4。
|
||||
要点:**列不在表里 = 不动该字段**(**布尔列也一样**,缺整列 ≠ 取默认值);**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列(启用 / 计分等)按「是」,**排序留空保持原值**(**四张表口径一致**,单位 / 大项留空同样不会被改写)。**只有枚举列例外:列在表里但格子空 = 不改动**(小项的类型 / 结果模式 / 结果类型 / 比较 / 状态 / 平均口径、组别的性别),留空就沿用原来的值,只有**新建的行**才用默认值(新建组别时性别留空 = 不限)——这样误清一格不会把已发布小项打回「待录入」、也不会把「团体」改成「个人」。表头行在第 4 行(导出件)或第 1 行(手工表)都能识别;枚举列要填英文规范值、布尔列填「是 / 否」。**最稳的做法是从系统导出、改完再导回**。详见使用手册 11.4。
|
||||
|
||||
**Q89:登录会不会用着用着被踢下线?「登录有效期」怎么设?**
|
||||
默认有效期 **24 小时**,管理员可在「系统设置 → 登录有效期」里改成 **1–720 小时**(最长 30 天);比赛期间建议设成 **72 小时**。会话是**滑动续期**的:只要在有效期内继续操作(页面开着也会定期自动续期),到期时间就往后顺延——**不会因为「登录满 N 小时」被踢**。当前到期时间在右上角用户菜单与系统设置页都写着「当前会话将于 X 过期」。
|
||||
@@ -559,6 +560,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
- **批量退赛**:标记弃权(0 分、不占名次),**随时可以批量恢复**;已经是「取消资格 / 未出场」的行会被跳过、不覆盖原判定;
|
||||
- **批量移出报名**:**物理删除**报名项连同他的成绩与积分流水,**不可恢复**,确认框会列出将被删的人与将重算的小项 / 组别;
|
||||
- 一次最多 500 条;有未保存的成绩修改时请先「存草稿」再操作。
|
||||
- 这三个动作(以及**撤销发布**、**作废单条成绩**、**改积分模板 / 组别权重 / 计分通道**)都**立即重算并推榜**,**不受「分批发布时实时更新榜单」这个延迟结算开关影响**——它们改的是「谁还算数」「按什么规则算」,不是「录了多少」,留着旧分就会变成「榜上还有分、成绩却撤了」。延迟结算只作用于**发布成绩**这一步(见 Q90)。
|
||||
|
||||
运动员列表也有类似的多选与**批量停用 / 批量彻底删除**(见使用手册 10.6)。
|
||||
|
||||
@@ -580,3 +582,49 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
3. 核对预览后删除。若某张图在你打开页面之后刚被配到别处,服务端会**拒删并说明被谁引用**(宁可漏删、不误删)。
|
||||
|
||||
注意:这个页签**只管图片**,上传的字体在「赛事信息 → 字体库」里各自删除(被标题 / 正文字体引用的不能删);这些图片和字体都是文件,**备份的 `.db` 快照和「一键恢复」都不含它们**(见 Q37),要清点或搬迁请到 `data\event_projs\<项目名>\uploads\`。
|
||||
|
||||
**Q94:奖牌榜上一个牌都没有,或者金牌数不对,怎么查?**
|
||||
按下面顺序一项项排除(都不需要改数据,改完保存会自动重算榜单):
|
||||
|
||||
1. **是不是刚升级完、第一次启动**?→ 见 Q96,那是升级后的正常现象,服务端会自己补齐;
|
||||
2. **该小项关了「该项启用奖牌结算」吗**?到「基础数据 → 大项 / 小项」编辑这个小项,看「选项」里的**「该项启用奖牌结算」**有没有被取消勾选——取消后这个小项**只出积分、不发奖牌**(见使用手册 10.2);
|
||||
3. **该小项的通道关了「发奖牌」吗**?到「基础数据 → 计分通道」选中该小项,看「奖牌通道」列里这条通道的**「发奖牌」**有没有被取消——取消后这条通道的名次只出积分、不发牌(见使用手册 10.4);关掉的通道在「大项 / 小项」页的通道摘要里会标出 `不发奖牌`;
|
||||
4. **个人项目的牌子是不是被排除在单位金牌榜之外**?同一列的**「个人奖牌并入单位金牌榜」**被取消时,这些个人奖牌仍进**个人积分榜**的奖牌列,但**不进单位金牌榜**——所以「某单位的金牌数比现场数的少」多半是这一条;
|
||||
5. **这个小项本身计分吗**?标记为「不计分」(只排名不计分)的小项**从来不发奖牌**,这是它本来的口径,不用去动上面那些开关;
|
||||
6. **名次是不是还没结算**?如果管理员开了**延迟结算**(Q90),未结算的组名次显示 `—`,自然也没有奖牌;等该组录完,或在录入页点「立即结算」;
|
||||
7. **奖牌只发给前三名**,而且**并列第 1 各记 1 金、不发银牌**(名次顺延口径,见 Q10)——「牌数比名次人数少」通常就是这个原因;
|
||||
8. 还有一条**全局开关**在规则包里(`standings.medalCountsTeamEvents`,默认**计入**团体项目的奖牌),界面上没有勾选框;本赛会的规则包是「计入」,一般不需要动。只有导入过别的规则包时才会把它改掉,检查办法见 Q47。
|
||||
|
||||
**Q95:团体名次在哪里录?为什么有时候录入页没有那一块?**
|
||||
团体名次在**「成绩录入」页**录:选中项目与组别后,成绩表格下面会有一块 **「团体名次」** 卡片,按**单位**一行一个输入框,填完点「保存团体名次」。**留空就清空该单位的名次**;名次重复允许并列(会提示确认,不挡保存);**个人名次不受影响**——它是另一套数据,每个人的名次仍然在成绩列表里录。
|
||||
|
||||
**录入页没有这一块是正常的**,说明这个项目**没有配「团体名次 + 手动名次」的计分通道**。常见情况:
|
||||
|
||||
- 纯团体项目(篮球、拔河等):团体名次就是那条**团体报名行上的名次**,仍在成绩列表里照常录,不需要这块卡片;
|
||||
- 「个人+团体」项目:团体名次是**按队内成绩平均**算出来的,也不在这里录;
|
||||
- 真的需要按单位手填名次(例如「团体引体向上」):到「基础数据 → 计分通道」给这个小项加一条**名次范围 = 团体名次、名次来源 = 手动名次**的通道并「保存全部」,回录入页就会出现这块卡片。
|
||||
|
||||
完整说明见使用手册 4.9。
|
||||
|
||||
**Q96:升级到新版本后,奖牌榜是空的(全是 0),怎么办?**
|
||||
这是升级后的**正常现象,会自己好**。原因:本版把奖牌统计的数据来源换成了一张新的「通道名次」表,而升级脚本只**建表、不回填**历史名次——所以升级完成到第一次重算之间,奖牌榜会短暂是 0。
|
||||
|
||||
服务端**下一次启动时**会自动做一次全量重算把名次补齐(重算在对外提供服务之前跑完,所以大屏不会先亮出一帧全 0)。在 Console「运行日志」或 `logs\operations\` 当天文件里能看到这两行,**是正常的,不是报错**:
|
||||
|
||||
```
|
||||
检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算
|
||||
一次性全量重算完成,通道名次与奖牌榜已补齐(events=… ledgerRows=… elapsed=…)
|
||||
```
|
||||
|
||||
- 通常**几秒内**跑完(数据量大时稍久,日志里会写耗时);跑完后刷新管理端/大屏即可看到奖牌;
|
||||
- 它**只在需要时跑一次**:表里已经有名次(说明重算过)时不会再触发;
|
||||
- 如果看到的是 `通道名次自愈失败,奖牌榜可能仍为空`,那不是"坏了"而是重算没做完:**随便发布一次成绩**、或**重新导入一次规则包**都会触发重算,重算完奖牌就回来了;
|
||||
- 想立刻确认数据没问题,也可以手动**重新导入一次规则包**(会全量重算)。
|
||||
|
||||
**Q97(已知限制):双计项目回看历史版本时,积分跟当时现场对不上?**
|
||||
有这种可能,属于**已知限制,不影响当前榜单**。
|
||||
|
||||
- **哪些项目**:配了「团体名次 + 手动名次」通道的项目(也就是录入页会出现「团体名次」卡片的那类,例如「团体引体向上」);
|
||||
- **什么现象**:在「变更历史」里展开某个历史版本时,页面会按**那一版保存下来的成绩**重新算一遍各单位分数。而**按单位录入的「团体名次」不在历史版本快照里**,所以回看时这些组别的团体分算不出来,各单位的分数增减可能与当时现场看到的略有差异;
|
||||
- **不影响什么**:**当前榜单、大屏、导出全部是对的**(它们读的是库里现存的团体名次,与历史快照无关);只有「历史版本详情」里那一列**分数增减**(以及它影响的单位数)会因此偏小或对不上;
|
||||
- 想核对当时的真实分数,以**当时的导出件**(成绩总表 / 单位积分明细)为准。
|
||||
+25
-21
@@ -25,10 +25,10 @@
|
||||
| `category` | **必须** | 组别**名称**(中文,要跟系统里一字不差) | `男子30岁以下` |
|
||||
| `team_code` | **必须** | 参赛单位编码,抄系统里配好的 | `F1` |
|
||||
| `athlete_name` | **必须** | 运动员姓名。**团体项目这一列留空** | `张伟` |
|
||||
| `id_card` | 可选 | 证件号(身份证号)。**只是档案信息,不参与认人**;一个证件号只能属于一个人 | `110101199001011234` |
|
||||
| `id_card` | 可选 | 证件号(身份证号)。**系统的第一认人依据**;一个证件号只能属于一个人 | `110101199001011234` |
|
||||
| `gender` | 可选 | 性别,只写「男」或「女」 | `男` |
|
||||
| `age` | 可选 | 年龄,0–120 的整数 | `26` |
|
||||
| `bib` | 可选 | 号码布,**单人的唯一标识**(同名同姓的人靠它区分) | `101` |
|
||||
| `bib` | 可选 | 号码布;证件号为空时按它认人(同一个号码布不能给两个人) | `101` |
|
||||
| `seed` | 可选 | 道次 / 种子,随便写或留空 | `种子1` |
|
||||
| `heat` | 可选 | 批次号:1–999 的整数;**留空 = 未分批**(不参与算分,见下) | `1` |
|
||||
| `status` | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` |
|
||||
@@ -63,11 +63,11 @@
|
||||
### `id_card` 证件号 / `bib` 号码布 —— 有姓名就至少填一个
|
||||
只要有姓名,**证件号与号码布至少要填一个**,否则无法确认是哪一个人,这一行会被跳过,提示「填写了 athlete_name 就必须填 id_card 或 bib」。
|
||||
|
||||
- **号码布是单人的唯一标识,也是系统认人的依据**:先按号码布找人,号码布为空或没找到时,再按「姓名 + 单位」找(完整顺序见本节的「系统怎么认人」)。
|
||||
- **证件号是可选的档案信息,不参与认人**:它只写进 / 更新人员档案,用来登记身份;没有身份证、外聘人员只填号码布就行。
|
||||
- **但一个证件号在系统里只能属于一个人**:如果表里给的证件号已经登记在**另一个人**名下(姓名、号码布都对不上),这一行会报 `证件号 X 已被另一个人占用(姓名 Y,号码布 Z)` 并跳过——证件号不会被抢过来,那个人也不会被改成表里的人。
|
||||
- **同一个证件号在同一份表里重复出现不再报错**(同一个人报多项时本来就会重复),照常导入。
|
||||
- **号码布不能在一份表里重复出现**:重号说明要么是填错、要么是把号码布给了两个人,重复的行会被跳过并报错。
|
||||
- **证件号是系统认人的第一依据**:先按证件号找人;证件号为空、或库里的档案还没登记证件号时,再按号码布找;两者都没找到,才按「姓名 + 单位」找(完整顺序见本节的「系统怎么认人」)。
|
||||
- **一个证件号在系统里只能属于一个人**。表里给的证件号如果已经登记在别人名下,系统会按这个证件号去认那个人,姓名 / 单位对不上就进「冲突」列表让你逐条决定——不会把证件号抢过来,也不会静默改档。**证件号填错是最容易认错人的地方,请照抄身份证,不要凭记忆写。**
|
||||
- **没有身份证的人只填号码布也行**(外聘人员、临时工常见):此时系统按号码布认人,下次导入也要带同样的号码布。
|
||||
- **同一个证件号在同一份表里重复出现不报错**(同一个人报多个组别时本来就会重复):系统当作同一个人,每个组别各落一条报名,并在导入结果里给一条提示「证件号 XXXX 在本表中出现 N 次(同一人报名多个组别)」。**但同一小项同一个组别里出现两次就是真重复**,会进「冲突」列表,由你决定保留已有还是改用表里这条。
|
||||
- **号码布不是唯一标识**,但两个人不能共用一个号码布:把同一个号码布给了两个不同的人,这一行会报错并跳过。(同一个人报多个组别时号码布可以重复。)
|
||||
- 号码布会同时写进人员档案和报名记录。
|
||||
|
||||
### `gender` 性别
|
||||
@@ -117,15 +117,16 @@
|
||||
- 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。
|
||||
- 系统界面里会把这条名单显示成「张三、李四、王五」。
|
||||
|
||||
### 系统怎么认人(号码布 → 姓名 + 单位)
|
||||
有姓名的行,系统按下面的顺序认人。**号码布是唯一标识,证件号只当档案信息,不参与这一步**:
|
||||
### 系统怎么认人(证件号 → 号码布 → 姓名 + 单位)
|
||||
有姓名的行,系统按下面的顺序认人。**证件号是唯一标识,号码布是它的替补,最后才靠「姓名 + 单位」兜底回填**:
|
||||
|
||||
| 情况 | 系统怎么处理 |
|
||||
|---|---|
|
||||
| 表里的号码布在系统里已有 | 认作**同一个人**;若表里的姓名 / 性别 / 年龄 / 单位 / 号码布与他的档案对不上,会进「冲突」列表等你逐条决定(保留哪一侧),该行本轮不写入 |
|
||||
| 号码布为空,或按号码布没找到 | 按「**姓名 + 单位**」找:<br>· 找不到 → 新建人员档案;<br>· 恰好一人、且他**还没有号码布** → 认定是同一人,此时若表里给了号码布,会**回填**到他的档案;<br>· 恰好一人、且他**已有号码布**,但表里**号码布为空或与他相同** → 仍认定是同一人(重传不带号码布的名单不会把每个人复制一份);<br>· 恰好一人、他已有号码布、**且表里给的号码布与他不同** → 按「**同名的另一个人**」**新建档案**,并在导入结果里单独列出(姓名 / 单位 / 原号码布 / 新号码布)提醒你核对,**不拦截、不报错**;<br>· 多人 → 分不清是谁,进「冲突」列表等逐条决定 |
|
||||
| 表里的**证件号**在系统里已有 | 认作**同一个人**;若表里的姓名 / 性别 / 年龄 / 单位 / 号码布与他的档案对不上,会进「冲突」列表等你逐条决定(保留哪一侧),该行本轮不写入 |
|
||||
| 证件号为空,或系统里还没有人有这个证件号 | 再看**号码布**:号码布在系统里已有 → 认作同一个人(差异同样进「冲突」列表) |
|
||||
| 证件号与号码布都没找到 | 按「**姓名 + 单位**」找:<br>· 找不到 → 新建人员档案;<br>· 恰好一人、两边给的证件号(或号码布)对得上或有一边没填 → 认定是同一人,表里给的证件号 / 号码布 / 年龄会**回填**到他的档案(不算冲突);<br>· 恰好一人、但他已有**另一个**证件号(或号码布)且与表里不同 → 按「**同名的另一个人**」**新建档案**,并在导入结果里单独列出(姓名 / 单位 / 原证件号或号码布 / 新证件号或号码布)提醒你核对,**不拦截、不报错**;<br>· 多人 → 分不清是谁,进「冲突」列表等逐条决定 |
|
||||
|
||||
> 「同名的另一个人」多半是号码布写错了。收到这条提醒时,先核对新号码布是不是本人:是别人(真的同名),确认无误即可;是本人写错了号,到「基础数据 → 运动员」把档案里的号码布改回来,并把多出来的那条新档案删掉。
|
||||
> 「同名的另一个人」多半是证件号(或号码布)写错了。收到这条提醒时,先核对表里那个号是不是本人:是别人(真的同名),确认无误即可;是本人写错了,到「基础数据 → 运动员」把档案里的号改回来,并把多出来的那条新档案删掉。
|
||||
|
||||
---
|
||||
|
||||
@@ -170,8 +171,10 @@
|
||||
- [ ] `event_code`、`team_code` 都是从系统里抄的编码,没有空格
|
||||
- [ ] `category` 与系统里的组别名一字不差
|
||||
- [ ] 个人行都有姓名,且都有证件号或号码布
|
||||
- [ ] 一份表里**没有重复的号码布**(号码布是唯一标识,重号会报错)
|
||||
- [ ] 证件号可以重复(同一个人报多项很正常),但每个证件号在系统里只属于一个人,别和别人的档案重号
|
||||
- [ ] 证件号优先填、填准(**它是系统认人的第一依据**,填错最容易认错人)
|
||||
- [ ] 一份表里**没有把同一个号码布给两个不同的人**(同一个人报多个组别时可以重复)
|
||||
- [ ] 同一小项同一个组别里没有把同一个人写两遍(真重复会进「冲突」列表等决策)
|
||||
- [ ] 每个证件号在系统里只属于一个人,别和别人的档案重号
|
||||
- [ ] `status` 只用那 4 个英文词(大小写都行)或第三节列的中文别名,不写别的词;拿不准就留空
|
||||
- [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组)
|
||||
- [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的)
|
||||
@@ -192,12 +195,12 @@
|
||||
| 第 N 行:单位 XX 不存在 | `team_code` 写错,或该单位还没配 | 到「基础数据 → 参赛单位」核对 |
|
||||
| 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) |
|
||||
| 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 |
|
||||
| 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项唯一标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 |
|
||||
| 第 N 行:号码布 XX 在报名表内重复出现 | 同一份表里同一个号码布出现了两次 | 删掉重复行(同一个人报多个项目时,`event_code` 必须不同;同一个号码布也不能给两个人) |
|
||||
| 第 N 行:证件号 X 已被另一个人占用(姓名 Y,号码布 Z) | 表里给的证件号已经登记在**另一个人**名下。证件号不参与认人,但系统里一个证件号只能属于一个人 | 先核对证件号是不是抄错了:**不是这个人**的,把这一格留空即可(只填号码布也能导入);**是他的**,先到「基础数据 → 运动员」把另一条档案里的证件号清掉或改对,再重新上传这一步列出的行 |
|
||||
| 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 |
|
||||
| 第 N 行:号码布 XX 在报名表内重复出现 | 同一份表里,**两个不同的人**用了同一个号码布 | 删掉重复行或改成各自的号码布。(同一个人报多个项目时号码布可以重复;不同的人不能共用。) |
|
||||
| 第 N 行:证件号 X 已被另一个人占用(姓名 Y,号码布 Z) | 表里给的证件号已经登记在**另一个人**名下,而这一行又没法按证件号认到那个人(例如同时又给了别人的号码布) | 先核对证件号是不是抄错了:**不是这个人**的,把这一格留空即可(只填号码布也能导入);**是他的**,先到「基础数据 → 运动员」把另一条档案里的证件号清掉或改对,再重新上传这一步列出的行 |
|
||||
| 第 N 行:status「xxx」无法识别 | `status` 填了系统不认的词(4 个标准词以外的英文,或第三节没列的中文) | 改成 `registered` / `scratched` / `dq` / `dns`(大小写都行)或第三节列的中文别名,也可以留空(= 正常参赛);改完重新上传 |
|
||||
| 第 N 行:heat 需要是 1-999 的整数(留空表示未分批) | `heat` 填了非整数、0、负数或超过 999 的值(如 `第1批`、`一组`、`1000`) | 改成 1–999 的整数,或者整格留空(= 未分批);批次只影响录入分组,不参与算分 |
|
||||
| 提示「冲突」,人员没有写入 | 按号码布(或「姓名 + 单位」唯一命中)的人在系统里已有记录,但姓名 / 性别 / 年龄 / 单位 / 号码布跟表里**对不上** | 在导入结果里逐条选「保留已有数据」或「使用新数据」,选完再提交 |
|
||||
| 提示「冲突」,人员没有写入 | 按证件号(或号码布、「姓名 + 单位」唯一命中)的人在系统里已有记录,但姓名 / 证件号 / 性别 / 年龄 / 单位 / 号码布跟表里**对不上** | 在导入结果里逐条选「保留已有数据」或「使用新数据」,选完再提交 |
|
||||
| 文件传不上去 | 超过 16MB | 拆分上传,或改用 xlsx |
|
||||
| 只导入了第一张表 | xlsx 只读第一个工作表 | 把要导入的数据放在第一个工作表里 |
|
||||
|
||||
@@ -209,11 +212,12 @@
|
||||
- **新增**:这次新建的报名;
|
||||
- **更新**:这个人本来就有这个项目的报名,用表里的信息覆盖了(**不会产生重复报名**);
|
||||
- **跳过**:没能导入的行,会带着**行号**列出来,改好那几行重新上传即可。
|
||||
- 另有一份**「按同名的另一个人新建了档案(号码布不同)」**清单,单独列出姓名 / 单位 / 原号码布 / 新号码布。它不是错误:这些行**已经导入成功**,只是提醒你核对号码布有没有写错(见第三节的「系统怎么认人」)。
|
||||
- 另有一份**「按同名的另一个人新建了档案(证件号 / 号码布不同)」**清单,单独列出姓名 / 单位 / 原证件号或号码布 / 新证件号或号码布。它不是错误:这些行**已经导入成功**,只是提醒你核对证件号与号码布有没有写错(见第三节的「系统怎么认人」)。
|
||||
- 同一证件号在本表里出现多次时,还会给一条**「证件号 XXXX 在本表中出现 N 次(同一人报名多个组别)」**的提示:这是正常的,只提醒你别把不同的人写成同一个证件号。
|
||||
2. **重复上传同一份表是安全的**,不会把名单变成两份。改完再传一遍就行。
|
||||
- ⚠️ 但要注意「更新」是**整条覆盖**:表里空着的格会把系统里原来的值清掉(队员名单、道次、号码布、**批次**都会被清空),`status` 空着会被重置成「正常参赛」。如果之前手工调整过某些人的状态或批次,**重传前请在表里把 `status`、`heat` 写清楚**。
|
||||
3. 名单导入后,到「成绩录入」里录成绩、发布,积分榜就会自动更新。
|
||||
4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个号码布(或同一「姓名 + 单位」),系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。
|
||||
4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个证件号(或同一个号码布、同一「姓名 + 单位」),系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。
|
||||
5. **被「彻底删除」的单位则相反,导入时会「复活」**:管理员彻底删除某个单位,只是把它从单位列表里移除,它的报名与成绩一条都没删;表里再出现这个 `team_code`,系统按编码复用原来那条单位记录,清掉删除标记并让它重新出现在列表里,**原有的报名、成绩与积分都还在**,也不会新建第二个同名单位。
|
||||
6. **别一口气连传几十次**:写操作限 **120 次/分钟/用户**,超了会返回「操作过于频繁,请稍后再试」。正常一次导入只算一次;真被限了就等一分钟再传,不要连点。
|
||||
|
||||
@@ -224,7 +228,7 @@
|
||||
> 1. 用系统里下载的模板,**第一行不要动**;
|
||||
> 2. **一个人一行**;一个人报几项就写几行;
|
||||
> 3. `event_code`(小项)、`team_code`(单位)、`category`(组别)照抄系统里配好的,不自己编;
|
||||
> 4. 每人要有**证件号或号码布**,至少一个;**号码布是系统认人的唯一标识**(优先填、别重号),证件号只是可选档案信息、也不能和别人重号;
|
||||
> 4. 每人要有**证件号或号码布**,至少一个;**证件号是系统认人的第一依据**(有就优先填、填准、别和人重号);没有证件号时号码布顶替它认人,**号码布也不能给两个人**;
|
||||
> 5. 团体项目:姓名列留空,队员写进 `member_names`,多人用 `/` 隔开;
|
||||
> 6. 拿不准的列**留空**,不要写「无」「/」「—」这类符号;
|
||||
> 7. 表里**不要合并单元格**、不要加标题行、不要插图;
|
||||
|
||||
@@ -119,6 +119,22 @@ deploy\build.bat
|
||||
|
||||
> 升级到本版本后第一次启动,请打开 Console 的「运行日志」确认是否有迁移记录,并核对管理端里的赛事/账号数据是否完整。
|
||||
|
||||
**升级后奖牌榜可能短暂是空的(正常,会自己补齐)**
|
||||
|
||||
本版给赛事库加了一条新迁移(**0020**),并新增了一张存"通道名次"的表与三个奖牌开关列。迁移**只建表、不回填**历史名次,所以**老库升级后、服务端做第一次重算之前,奖牌榜(金 / 银 / 铜)会是 0**——这不是数据丢了。
|
||||
|
||||
服务端**下一次启动时会自动做一次全量重算**把名次补齐(这次重算在开始对外提供服务之前跑完,所以大屏不会先亮出一帧全 0)。在 Console「运行日志」或 `<数据目录>\logs\operations\` 的当天日志里能看到这两行,**是正常的,不是报错**:
|
||||
|
||||
```
|
||||
检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算
|
||||
一次性全量重算完成,通道名次与奖牌榜已补齐
|
||||
```
|
||||
|
||||
- 通常**几秒内**跑完(成绩多时稍久,日志里会写处理了几个小项与耗时);跑完后刷新管理端 / 大屏,奖牌就回来了;
|
||||
- 这是**一次性**的:表里已经有名次(说明重算过)之后不会再触发;
|
||||
- 若日志里出现 `通道名次自愈失败,奖牌榜可能仍为空;可重新发布成绩或导入规则包触发重算`,说明这次重算没做完(不影响服务启动):**随便发布一次成绩**,或**重新导入一次规则包**(会全量重算),奖牌同样会回来。升级前如果做过备份,先确认榜单数字与升级前一致再开赛(见《上线检查清单》)。
|
||||
- 升级命令与首次启动的完整流程见 3.4 开头;这条迁移是**加列 / 建表**,可重复执行,不会改动任何既有数据行。
|
||||
|
||||
### 3.5 配置与规则放在哪里(升级/卸载也不会丢)
|
||||
|
||||
`config.json`、`config.yaml`、`rules\` 以前跟着安装包落在安装目录里,而**安装目录会被卸载器整个删掉**(覆盖安装会先静默跑一遍旧卸载器),所以用户改过的配置和规则包会一起没。本版本起它们也搬到**用户数据目录**(与数据目录同一处,安装目录里那三份只当模板):
|
||||
@@ -519,6 +535,7 @@ http://<主机IP>:19540/display
|
||||
- **按编码匹配,存在就更新、不存在就新建;绝不删除**:文件里没有的行不会被删,也不会被停用;
|
||||
- **两步走**:先「预览差异」(新增 / 更新 / 无变化 / 错误 + 逐行变化,这一步不写库),确认后再点「**确认写入**」;**有错的行会跳过,合法的行照常写入**,行级错误带**真实 Excel 行号**(如 `单位 第 3 行:单位编码必填`);
|
||||
- **列不在表里 = 不动该字段**(**布尔列也一样**:缺整列不等于「取默认值」,否则把「启用」列删掉再导回,会把所有停用的单位/大项静默改成启用);**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列按「是」,**排序留空保持原值**(**四张表口径一致**,单位 / 大项留空同样不会被改写);
|
||||
- **枚举列例外:列在表里但格子空 = 不改动**(不是「取默认值」)。小项的类型 / 结果模式 / 结果类型 / 比较 / 状态 / 平均口径,以及组别的性别,留空都沿用原值;只有**新建的行**才用默认值(新建组别时性别留空 = 不限)。这是为了防手滑——误清一格不会把已发布小项打回「待录入」、也不会把「团体」改成「个人」;
|
||||
- 表头行在第 4 行(系统导出件,上面是标题与副标题)或第 1 行(手工整理的表)都能识别;枚举列要填英文规范值(如 `individual` / `time` / `published`),布尔列填「是 / 否」;
|
||||
- 被「彻底删除」的单位不在导出件里,导入也**不会**把它翻出来(仍是隐藏状态);
|
||||
- **这个包只含基础数据**,不含成绩、报名与规则;整场赛事的搬迁仍用赛事项目包(见 8.5)。赛前导出一份留底,现场改乱了可以导回对照。
|
||||
|
||||
@@ -477,6 +477,32 @@ func (s *Service) RevokeUserSessions(ctx context.Context, userID int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionActive 判定一条会话现在是否仍然可用:会话行还在、未过期、用户未停用。
|
||||
//
|
||||
// 判定口径与 Lookup 完全一致(同样是 JOIN users 并比对 expires_at),但不做滑动续期、
|
||||
// 也不回写 last_seen_at:它只回答「这条会话还在不在」,供 /ws 长连接周期性复查。
|
||||
// 撤销类操作(登出、改密、管理员重置密码、停用用户)都会删掉会话行或停用用户,
|
||||
// 因此这里查不到即为失效。查询失败按错误返回,由调用方决定断开还是保留连接。
|
||||
func (s *Service) SessionActive(ctx context.Context, sessionID string) (bool, error) {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
var one int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT 1 FROM sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.id = ? AND s.expires_at > ? AND u.active = 1`,
|
||||
sessionID, s.now().UTC().Format(time.DateTime)).Scan(&one)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return false, nil
|
||||
case err != nil:
|
||||
return false, fmt.Errorf("复查会话: %w", err)
|
||||
default:
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) createSession(ctx context.Context, userID int64, ip, userAgent string) (string, *Session, error) {
|
||||
token, err := randomToken(32)
|
||||
if err != nil {
|
||||
|
||||
@@ -158,6 +158,61 @@ func TestLookupTouchThrottleKeepsLongLivedSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 会话复查:/ws 长连接靠它判断背后的会话是否还有效。撤销(登出 / 改密 / 管理员重置密码 /
|
||||
// 停用用户)与到期都必须判为失效,否则被停用的人只要不关页面就能一直收推送。
|
||||
func TestSessionActiveTracksRevocation(t *testing.T) {
|
||||
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
sessionID string
|
||||
prepare func(t *testing.T, db *sql.DB)
|
||||
want bool
|
||||
}{
|
||||
{"有效会话", "sid", nil, true},
|
||||
{"会话被撤销(登出 / 改密 / 重置密码都删行)", "sid", func(t *testing.T, db *sql.DB) {
|
||||
execTest(t, db, `DELETE FROM sessions`)
|
||||
}, false},
|
||||
{"会话已过期", "sid", func(t *testing.T, db *sql.DB) {
|
||||
setExpiry(t, db, now.Add(-time.Second))
|
||||
}, false},
|
||||
{"用户已停用", "sid", func(t *testing.T, db *sql.DB) {
|
||||
execTest(t, db, `UPDATE users SET active = 0`)
|
||||
}, false},
|
||||
{"会话 id 未知", "other", nil, false},
|
||||
{"会话 id 为空", "", nil, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := newSettingsDB(t, nil)
|
||||
svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour,
|
||||
Now: func() time.Time { return now }})
|
||||
userID := insertUser(t, db, "admin")
|
||||
insertSession(t, db, userID, now.Add(time.Hour))
|
||||
if tc.prepare != nil {
|
||||
tc.prepare(t, db)
|
||||
}
|
||||
|
||||
got, err := svc.SessionActive(context.Background(), tc.sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("复查会话: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("SessionActive = %v,期望 %v", got, tc.want)
|
||||
}
|
||||
// 复查是只读判定:不能顺延到期时间,也不能回写活动时间。
|
||||
if tc.want && dbExpiry(t, db) != now.Add(time.Hour).Format(time.DateTime) {
|
||||
t.Fatalf("复查不该写库,库里到期时间是 %s", dbExpiry(t, db))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func execTest(t *testing.T, db *sql.DB, query string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(query, args...); err != nil {
|
||||
t.Fatalf("执行 %s: %v", query, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 越过节流窗口后再次顺延:单独跑一遍,避免上面的用例把它和活动时间混在一起。
|
||||
func TestLookupRenewsAfterThrottleWindow(t *testing.T) {
|
||||
db := newSettingsDB(t, nil)
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件守住 devdocs/17 的两条底线:
|
||||
// 1. P0:同一份数据连续发布两次,流水与名次必须完全一致——团体通道绝不读
|
||||
// 「athlete_id != 0 的报名项」的 results.rank(那是个人名次,读了会让同一份成绩发两次团体分);
|
||||
// 2. 团体名次的来源只有 in.TeamRanks 与团体报名项(athlete_id == 0)的 results.rank 两处,
|
||||
// 同一单位两边都有时以 TeamRanks 为准。
|
||||
|
||||
// teamPullupInput 构造 TEAM_PULLUP 场景:1 个小项、1 个组别、6 名运动员同属 A 队(1001),
|
||||
// 成绩 30/29/28/27/26/25(引体向上,次数多者名次靠前),两条通道 =
|
||||
// 个人通道(athlete + result + 个人表)+ 团体通道(team + manual + 团体表)。
|
||||
// teamRanks 是录入员填的团体名次(可为空)。
|
||||
func teamPullupInput(teamRanks []TeamManualRank) EventScoreInput {
|
||||
values := []float64{30, 29, 28, 27, 26, 25}
|
||||
ps := make([]participant, 0, len(values))
|
||||
for i, v := range values {
|
||||
ps = append(ps, participant{
|
||||
entryID: int64(i + 1), athleteID: int64(101 + i), teamID: 1001,
|
||||
name: "选手" + string(rune('A'+i)), value: v,
|
||||
})
|
||||
}
|
||||
entries, results := fixture(ps)
|
||||
ev := individualEvent(1)
|
||||
ev.EventType = EventTypeIndividualTeam
|
||||
ev.Compare = CompareDesc
|
||||
|
||||
return EventScoreInput{
|
||||
Event: ev, Category: testCategory(),
|
||||
Channels: []ScoreChannel{
|
||||
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
|
||||
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisManual, ProfileID: 2},
|
||||
},
|
||||
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
|
||||
Entries: entries,
|
||||
Results: results,
|
||||
TeamRanks: teamRanks,
|
||||
}
|
||||
}
|
||||
|
||||
// writeBackComputedRanks 模拟 httpapi 的 applyComputedRanks:
|
||||
// 只把个人通道(scope=athlete)算出的名次写回对应成绩的 results.rank。
|
||||
func writeBackComputedRanks(in EventScoreInput, ranked []RankedResult) {
|
||||
for _, rr := range ranked {
|
||||
if rr.Scope != ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
|
||||
continue
|
||||
}
|
||||
for i := range in.Results {
|
||||
if in.Results[i].ID == rr.ResultID {
|
||||
in.Results[i].Rank = rr.Rank
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTeamPullupPublishTwiceIsIdempotent 是 P0 回归:
|
||||
// 「发布 → 把个人名次写回 results.rank → 再发布一次」两次的 Ledger 与 Ranked 必须逐字段一致。
|
||||
func TestTeamPullupPublishTwiceIsIdempotent(t *testing.T) {
|
||||
t.Run("团体名次已录:两次发布一致,A 队总分不跳变", func(t *testing.T) {
|
||||
in := teamPullupInput([]TeamManualRank{{TeamID: 1001, Rank: 1}})
|
||||
|
||||
first := mustScore(t, in)
|
||||
writeBackComputedRanks(in, first.Ranked)
|
||||
second := mustScore(t, in)
|
||||
|
||||
if !reflect.DeepEqual(first.Ledger, second.Ledger) {
|
||||
t.Fatalf("两次发布的流水不一致:\n第一次 %+v\n第二次 %+v", first.Ledger, second.Ledger)
|
||||
}
|
||||
if !reflect.DeepEqual(first.Ranked, second.Ranked) {
|
||||
t.Fatalf("两次发布的名次不一致:\n第一次 %+v\n第二次 %+v", first.Ranked, second.Ranked)
|
||||
}
|
||||
|
||||
// 团体通道的分数只来自团体名次:唯一一条单位流水,没有报名项 / 成绩(EntryID=0、ResultID=0)。
|
||||
var teamLedger []LedgerEntry
|
||||
for _, l := range second.Ledger {
|
||||
if l.AthleteID == 0 {
|
||||
teamLedger = append(teamLedger, l)
|
||||
}
|
||||
}
|
||||
if len(teamLedger) != 1 {
|
||||
t.Fatalf("团体流水 = %d 条,期望 1 条:%+v", len(teamLedger), teamLedger)
|
||||
}
|
||||
l := teamLedger[0]
|
||||
if l.TeamID != 1001 || l.EntryID != 0 || l.ResultID != 0 {
|
||||
t.Fatalf("团体流水 = %+v,期望只记单位 1001 且无报名项 / 成绩", l)
|
||||
}
|
||||
if !nearlyEqual(l.Points, 15) {
|
||||
t.Fatalf("团体名次第 1 名的分 = %v,期望 15(团体表)", l.Points)
|
||||
}
|
||||
if l.SourceID != 2 {
|
||||
t.Fatalf("sourceID 兜底应取通道 id=2,得到 %d", l.SourceID)
|
||||
}
|
||||
if l.SourceType != SourceResult {
|
||||
t.Fatalf("SourceType = %q,期望 %q", l.SourceType, SourceResult)
|
||||
}
|
||||
|
||||
// 团体通道只有一条名次明细,主体是单位,绝不消费那 6 条个人名次。
|
||||
var teamRanked []RankedResult
|
||||
for _, rr := range second.Ranked {
|
||||
if rr.ChannelID == 2 {
|
||||
teamRanked = append(teamRanked, rr)
|
||||
}
|
||||
}
|
||||
if len(teamRanked) != 1 {
|
||||
t.Fatalf("团体通道名次 = %d 条,期望 1 条(只认团体名次):%+v", len(teamRanked), teamRanked)
|
||||
}
|
||||
if teamRanked[0].EntryID != 0 || teamRanked[0].AthleteID != 0 || teamRanked[0].SubjectKey != "t:1001" {
|
||||
t.Fatalf("团体名次 = %+v,期望合成主体 t:1001", teamRanked[0])
|
||||
}
|
||||
if teamRanked[0].Rank != 1 {
|
||||
t.Fatalf("团体名次 Rank = %d,期望 1", teamRanked[0].Rank)
|
||||
}
|
||||
|
||||
// 个人通道照旧按成绩排名 1..6。
|
||||
ranks := rankByEntry(second.Ranked)
|
||||
for i := 1; i <= 6; i++ {
|
||||
if ranks[int64(i)] != i {
|
||||
t.Fatalf("个人名次 = %v,期望 entry%d → %d", ranks, i, i)
|
||||
}
|
||||
}
|
||||
|
||||
// A 队总分 = 个人表 10+8+6+3+2+1 = 30,加团体名次第 1 名的 15,共 45;
|
||||
// 第二次发布如果团体通道又读了一遍个人名次,这里会变成 85(P0 的真机表现)。
|
||||
for _, es := range []EventScore{first, second} {
|
||||
if got := SumByTeam(es.Ledger, 1)[1001].Total; !nearlyEqual(got, 45) {
|
||||
t.Fatalf("A 队总分 = %v,期望 45(30 个人 + 15 团体)", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("团体名次未录:个人名次绝不进团体通道", func(t *testing.T) {
|
||||
in := teamPullupInput(nil)
|
||||
|
||||
first := mustScore(t, in)
|
||||
writeBackComputedRanks(in, first.Ranked)
|
||||
second := mustScore(t, in)
|
||||
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second)
|
||||
}
|
||||
for _, rr := range second.Ranked {
|
||||
if rr.ChannelID == 2 {
|
||||
t.Fatalf("没录团体名次时团体通道不应产出名次:%+v", rr)
|
||||
}
|
||||
}
|
||||
if got := SumByTeam(second.Ledger, 1)[1001].Total; !nearlyEqual(got, 30) {
|
||||
t.Fatalf("A 队总分 = %v,期望只算个人通道的 30(不跳变)", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTeamManualChannelCandidateSources 守住团体手工通道的候选来源。
|
||||
func TestTeamManualChannelCandidateSources(t *testing.T) {
|
||||
score := func(t *testing.T, in EventScoreInput) EventScore {
|
||||
t.Helper()
|
||||
return mustScore(t, in)
|
||||
}
|
||||
|
||||
t.Run("只认团体报名项兜底,个人报名项的名次不进团体通道", func(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
// 个人报名项:录入名次第 1,是个人名次,团体通道不许读。
|
||||
{entryID: 1, athleteID: 101, teamID: 1001, name: "甲", rank: 1, noValue: true},
|
||||
// 团体报名项(athlete_id = 0):团体名次第 2。
|
||||
{entryID: 2, teamID: 1002, rank: 2, noValue: true},
|
||||
})
|
||||
es := score(t, EventScoreInput{
|
||||
Event: teamEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamManualChannel(2)},
|
||||
Profiles: map[int64]ScoringProfile{2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
|
||||
got := pointsByTeam(es.Ledger)
|
||||
if !nearlyEqual(got[1002], 10) {
|
||||
t.Fatalf("单位 1002 = %v,期望团体名次第 2 名的 10 分:%v", got[1002], got)
|
||||
}
|
||||
if _, ok := got[1001]; ok {
|
||||
t.Fatalf("个人报名项的名次进了团体通道:%v", got)
|
||||
}
|
||||
if len(es.Ranked) != 1 || es.Ranked[0].TeamID != 1002 || es.Ranked[0].EntryID != 2 {
|
||||
t.Fatalf("团体名次明细 = %+v,期望只有单位 1002 的团体报名项", es.Ranked)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TeamRanks 覆盖团体报名项的兜底名次", func(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, teamID: 1001, rank: 3, noValue: true},
|
||||
})
|
||||
es := score(t, EventScoreInput{
|
||||
Event: teamEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamManualChannel(2)},
|
||||
Profiles: map[int64]ScoringProfile{2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}},
|
||||
})
|
||||
|
||||
if got := pointsByTeam(es.Ledger); !nearlyEqual(got[1001], 15) {
|
||||
t.Fatalf("单位 1001 = %v,期望以 TeamRanks 的第 1 名计 15 分:%v", got[1001], got)
|
||||
}
|
||||
if len(es.Ranked) != 1 {
|
||||
t.Fatalf("同一单位应只产出一条名次,得到 %d 条:%+v", len(es.Ranked), es.Ranked)
|
||||
}
|
||||
if es.Ranked[0].Rank != 1 || es.Ranked[0].EntryID != 1 || es.Ranked[0].SubjectKey != "e:1" {
|
||||
t.Fatalf("覆盖后的名次 = %+v,期望报名项 1 记为第 1 名", es.Ranked[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rank<=0 的团体名次视为未填,退回团体报名项", func(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, teamID: 1001, rank: 2, noValue: true},
|
||||
})
|
||||
es := score(t, EventScoreInput{
|
||||
Event: teamEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamManualChannel(2)},
|
||||
Profiles: map[int64]ScoringProfile{2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 0}, {TeamID: 1002, Rank: -1}},
|
||||
})
|
||||
|
||||
got := pointsByTeam(es.Ledger)
|
||||
if !nearlyEqual(got[1001], 10) {
|
||||
t.Fatalf("单位 1001 = %v,期望退回团体报名项的第 2 名(10 分):%v", got[1001], got)
|
||||
}
|
||||
if _, ok := got[1002]; ok {
|
||||
t.Fatalf("rank<=0 的团体名次不应产出名次:%v", got)
|
||||
}
|
||||
if len(es.Ranked) != 1 {
|
||||
t.Fatalf("名次明细 = %d 条,期望 1 条:%+v", len(es.Ranked), es.Ranked)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("并列团体名次走模板的 tieMode", func(t *testing.T) {
|
||||
ranks := []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 1}}
|
||||
cases := []struct {
|
||||
name string
|
||||
tie TieMode
|
||||
points float64
|
||||
}{
|
||||
{"顺延(默认)", TieSkip, 15},
|
||||
{"平均", TieAverage, 12.5},
|
||||
{"就高", TieHigher, 15},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
profile := teamTop6()
|
||||
profile.TieMode = tc.tie
|
||||
es := score(t, EventScoreInput{
|
||||
Event: teamEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamManualChannel(2)},
|
||||
Profiles: map[int64]ScoringProfile{2: profile},
|
||||
TeamRanks: ranks,
|
||||
})
|
||||
|
||||
if len(es.Ranked) != 2 {
|
||||
t.Fatalf("并列名次 = %d 条,期望 2 条:%+v", len(es.Ranked), es.Ranked)
|
||||
}
|
||||
got := pointsByTeam(es.Ledger)
|
||||
for _, rr := range es.Ranked {
|
||||
if rr.Rank != 1 {
|
||||
t.Fatalf("并列名次 = %+v,期望同为第 1 名", rr)
|
||||
}
|
||||
}
|
||||
if !nearlyEqual(got[1001], tc.points) || !nearlyEqual(got[1002], tc.points) {
|
||||
t.Fatalf("并列得分 = %v,期望各 %v", got, tc.points)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRankedSubjectKeys 覆盖计分引擎各条产出路径的 SubjectKey 口径。
|
||||
func TestRankedSubjectKeys(t *testing.T) {
|
||||
t.Run("result 通道按报名项", func(t *testing.T) {
|
||||
entries, results := fixture(lineupValues(11, 12))
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: individualEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{athleteResultChannel(1)},
|
||||
Profiles: map[int64]ScoringProfile{1: individualTop6()},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
for _, rr := range es.Ranked {
|
||||
if want := "e:" + strconv.FormatInt(rr.EntryID, 10); rr.SubjectKey != want {
|
||||
t.Fatalf("SubjectKey = %q,期望 %q(%+v)", rr.SubjectKey, want, rr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("团体手工名次按单位", func(t *testing.T) {
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: teamEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamManualChannel(2)},
|
||||
Profiles: map[int64]ScoringProfile{2: teamTop6()},
|
||||
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 2}},
|
||||
})
|
||||
want := []string{"t:1001", "t:1002"}
|
||||
if len(es.Ranked) != len(want) {
|
||||
t.Fatalf("名次条数 = %d,期望 %d", len(es.Ranked), len(want))
|
||||
}
|
||||
for i, key := range want {
|
||||
if es.Ranked[i].SubjectKey != key {
|
||||
t.Fatalf("第 %d 条 SubjectKey = %q,期望 %q", i, es.Ranked[i].SubjectKey, key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("聚合通道按主体", func(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 11},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 12},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 13},
|
||||
})
|
||||
ev := teamEvent(1)
|
||||
ev.Compare = CompareAsc
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: ev, Category: testCategory(),
|
||||
Channels: []ScoreChannel{
|
||||
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisAggregate, ProfileID: 1},
|
||||
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: 2},
|
||||
},
|
||||
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
for _, rr := range es.Ranked {
|
||||
var want string
|
||||
if rr.Scope == ScopeAthlete {
|
||||
want = "a:" + strconv.FormatInt(rr.AthleteID, 10)
|
||||
} else {
|
||||
want = "t:" + strconv.FormatInt(rr.TeamID, 10)
|
||||
}
|
||||
if rr.SubjectKey != want || rr.EntryID != 0 {
|
||||
t.Fatalf("聚合名次 = %+v,期望 SubjectKey %q 且 EntryID=0", rr, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSubjectKeyOfFallback 直接覆盖 subjectKeyOf 的四层口径(含退化兜底)。
|
||||
func TestSubjectKeyOfFallback(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rr RankedResult
|
||||
want string
|
||||
}{
|
||||
{"有报名项按报名项", RankedResult{EntryID: 7, AthleteID: 101, TeamID: 1001, ChannelID: 3, Scope: ScopeAthlete}, "e:7"},
|
||||
{"个人主体", RankedResult{AthleteID: 101, TeamID: 1001, ChannelID: 3, Scope: ScopeAthlete}, "a:101"},
|
||||
{"单位主体", RankedResult{TeamID: 1001, ChannelID: 3, Scope: ScopeTeam}, "t:1001"},
|
||||
{"团体名次但没有单位", RankedResult{AthleteID: 101, ChannelID: 3, Scope: ScopeTeam}, "ch:3"},
|
||||
{"全空退化通道", RankedResult{ChannelID: 5}, "ch:5"},
|
||||
{"连通道都没有", RankedResult{}, "ch:0"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := subjectKeyOf(tc.rr); got != tc.want {
|
||||
t.Fatalf("subjectKeyOf = %q,期望 %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import "testing"
|
||||
|
||||
func ptr(f float64) *float64 { return &f }
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
// individualTop6 是规则包里的个人项目前六名:10/8/6/3/2/1。
|
||||
func individualTop6() ScoringProfile {
|
||||
return ScoringProfile{
|
||||
@@ -40,7 +42,8 @@ func testCategory() EventCategory {
|
||||
func individualEvent(multiplier float64) Event {
|
||||
return Event{
|
||||
ID: 1, Code: "E1", Name: "100米跑", EventType: EventTypeIndividual,
|
||||
Compare: CompareAsc, RecordEligible: true, Scored: true, Multiplier: multiplier, Status: EventStatusPublished,
|
||||
Compare: CompareAsc, RecordEligible: true, Scored: true, AwardMedals: true,
|
||||
Multiplier: multiplier, Status: EventStatusPublished,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ func TestMatrix04_ThreeWayTieAtThird(t *testing.T) {
|
||||
func TestMatrix05_TeamEventDoubled(t *testing.T) {
|
||||
ps := lineup(8)
|
||||
for i := range ps {
|
||||
// 纯团体项目的报名项是团体报名项(无运动员):团体名次由 results.rank 兜底。
|
||||
ps[i].athleteID = 0
|
||||
ps[i].rank = i + 1
|
||||
}
|
||||
entries, results := fixture(ps)
|
||||
@@ -193,24 +195,24 @@ func TestMatrix13_TieBreakOrder(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "金牌优先",
|
||||
ranked: []RankedResult{{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 1, Scope: ScopeAthlete}},
|
||||
ranked: []RankedResult{mkRanked(2, ScopeAthlete, 1)},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "银牌优先",
|
||||
ranked: []RankedResult{
|
||||
{TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 1, Scope: ScopeAthlete},
|
||||
{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 1, Scope: ScopeAthlete},
|
||||
{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 2, Scope: ScopeAthlete},
|
||||
mkRanked(1, ScopeAthlete, 1),
|
||||
mkRanked(2, ScopeAthlete, 1),
|
||||
mkRanked(2, ScopeAthlete, 2),
|
||||
},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "铜牌优先",
|
||||
ranked: []RankedResult{
|
||||
{TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 3, Scope: ScopeAthlete},
|
||||
{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 3, Scope: ScopeAthlete},
|
||||
{TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 3, Scope: ScopeAthlete},
|
||||
mkRanked(1, ScopeAthlete, 3),
|
||||
mkRanked(2, ScopeAthlete, 3),
|
||||
mkRanked(1, ScopeAthlete, 3),
|
||||
},
|
||||
want: 1,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件守住 devdocs/17 §4.5 的奖牌口径:
|
||||
// 入选 = 小项结算奖牌 && 通道是奖牌通道 && 名次前三;
|
||||
// 单位榜再加 scope == team || count_to_team_medals;
|
||||
// 个人榜只取 scope == athlete && AthleteID != 0。
|
||||
// 奖牌开关只影响奖牌,不影响积分流水。
|
||||
|
||||
// medalInput 是奖牌开关的测试底座:3 名运动员各代表一个单位,个人通道按成绩排出 1/2/3 名。
|
||||
func medalInput(ch ScoreChannel) EventScoreInput {
|
||||
entries, results := fixture(lineupValues(11, 12, 13))
|
||||
return EventScoreInput{
|
||||
Event: individualEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{ch},
|
||||
Profiles: map[int64]ScoringProfile{1: individualTop6()},
|
||||
Entries: entries, Results: results,
|
||||
}
|
||||
}
|
||||
|
||||
// medalTotals 汇总单位榜 / 个人榜的金银铜。
|
||||
func medalTotals(t *testing.T, in StandingsInput) (team, athlete [3]int) {
|
||||
t.Helper()
|
||||
for _, r := range BuildMedalStandings(in) {
|
||||
team[0] += r.Gold
|
||||
team[1] += r.Silver
|
||||
team[2] += r.Bronze
|
||||
}
|
||||
for _, r := range BuildAthleteStandings(in) {
|
||||
athlete[0] += r.Gold
|
||||
athlete[1] += r.Silver
|
||||
athlete[2] += r.Bronze
|
||||
}
|
||||
return team, athlete
|
||||
}
|
||||
|
||||
func TestAwardMedalsSwitch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
award bool
|
||||
wantTeam [3]int
|
||||
wantAthlet [3]int
|
||||
}{
|
||||
{"开启奖牌结算", true, [3]int{1, 1, 1}, [3]int{1, 1, 1}},
|
||||
{"关闭奖牌结算", false, [3]int{0, 0, 0}, [3]int{0, 0, 0}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
in := medalInput(athleteResultChannel(1))
|
||||
in.Event.AwardMedals = tc.award
|
||||
es := mustScore(t, in)
|
||||
|
||||
if len(es.Ledger) != 3 {
|
||||
t.Fatalf("流水条数 = %d,期望 3(奖牌开关不影响积分)", len(es.Ledger))
|
||||
}
|
||||
if got := pointsByEntry(es.Ledger); !nearlyEqual(got[1], 10) || !nearlyEqual(got[3], 6) {
|
||||
t.Fatalf("积分 = %v,期望 10/8/6(奖牌开关不影响积分)", got)
|
||||
}
|
||||
|
||||
team, athlete := medalTotals(t, StandingsInput{
|
||||
Teams: testTeams(1001, 1002, 1003), Ranked: es.Ranked,
|
||||
})
|
||||
if team != tc.wantTeam || athlete != tc.wantAthlet {
|
||||
t.Fatalf("奖牌 = 单位 %v / 个人 %v,期望 %v / %v", team, athlete, tc.wantTeam, tc.wantAthlet)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelCountToMedalsSwitch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
countTo *bool
|
||||
want [3]int
|
||||
}{
|
||||
{"未配置按发牌", nil, [3]int{1, 1, 1}},
|
||||
{"显式开启", boolPtr(true), [3]int{1, 1, 1}},
|
||||
{"显式关闭", boolPtr(false), [3]int{0, 0, 0}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ch := athleteResultChannel(1)
|
||||
ch.CountToMedals = tc.countTo
|
||||
in := medalInput(ch)
|
||||
es := mustScore(t, in)
|
||||
|
||||
if len(es.Ledger) != 3 {
|
||||
t.Fatalf("流水条数 = %d,期望 3(奖牌通道开关不影响积分)", len(es.Ledger))
|
||||
}
|
||||
team, athlete := medalTotals(t, StandingsInput{
|
||||
Teams: testTeams(1001, 1002, 1003), Ranked: es.Ranked,
|
||||
})
|
||||
if team != tc.want || athlete != tc.want {
|
||||
t.Fatalf("奖牌 = 单位 %v / 个人 %v,期望 %v", team, athlete, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 个人奖牌不并入单位榜:个人榜照常记牌,单位榜一枚都没有。
|
||||
func TestCountToTeamMedalsSwitch(t *testing.T) {
|
||||
ch := athleteResultChannel(1)
|
||||
ch.CountToTeamMedals = boolPtr(false)
|
||||
in := medalInput(ch)
|
||||
es := mustScore(t, in)
|
||||
|
||||
team, athlete := medalTotals(t, StandingsInput{
|
||||
Teams: testTeams(1001, 1002, 1003), Ranked: es.Ranked,
|
||||
})
|
||||
if team != [3]int{0, 0, 0} {
|
||||
t.Fatalf("单位奖牌榜 = %v,期望 0(个人奖牌不并入单位榜)", team)
|
||||
}
|
||||
if athlete != [3]int{1, 1, 1} {
|
||||
t.Fatalf("个人奖牌榜 = %v,期望 1/1/1", athlete)
|
||||
}
|
||||
|
||||
// 单位总分不受奖牌通道开关影响。
|
||||
if got := SumByTeam(es.Ledger, 1); !nearlyEqual(got[1001].Total, 10) || !nearlyEqual(got[1003].Total, 6) {
|
||||
t.Fatalf("单位总分 = %v,期望 10/8/6(奖牌通道开关不影响积分)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// scope=team 的奖牌不受 count_to_team_medals 影响:它本来就记在单位头上。
|
||||
func TestTeamScopeMedalIgnoresCountToTeamMedals(t *testing.T) {
|
||||
ch := teamManualChannel(2)
|
||||
ch.CountToTeamMedals = boolPtr(false)
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: teamEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{ch},
|
||||
Profiles: map[int64]ScoringProfile{2: teamTop6()},
|
||||
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 2}},
|
||||
})
|
||||
|
||||
team, athlete := medalTotals(t, StandingsInput{Teams: testTeams(1001, 1002), Ranked: es.Ranked})
|
||||
if team != [3]int{1, 1, 0} {
|
||||
t.Fatalf("单位奖牌榜 = %v,期望 1 金 1 银", team)
|
||||
}
|
||||
if athlete != [3]int{0, 0, 0} {
|
||||
t.Fatalf("个人奖牌榜 = %v,期望 0(团体名次不发个人牌)", athlete)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧全局开关与 per-通道开关是「与」的关系:总闸关掉时团体项目的奖牌一律不进单位榜。
|
||||
func TestExcludeTeamEventMedalsStillApplies(t *testing.T) {
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: teamEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamManualChannel(2)},
|
||||
Profiles: map[int64]ScoringProfile{2: teamTop6()},
|
||||
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}},
|
||||
})
|
||||
teams := testTeams(1001)
|
||||
|
||||
team, _ := medalTotals(t, StandingsInput{Teams: teams, Ranked: es.Ranked})
|
||||
if team != [3]int{1, 0, 0} {
|
||||
t.Fatalf("默认口径单位榜 = %v,期望 1 金", team)
|
||||
}
|
||||
|
||||
team, _ = medalTotals(t, StandingsInput{Teams: teams, Ranked: es.Ranked, ExcludeTeamEventMedals: true})
|
||||
if team != [3]int{0, 0, 0} {
|
||||
t.Fatalf("全局排除团体奖牌后单位榜 = %v,期望 0", team)
|
||||
}
|
||||
}
|
||||
|
||||
// P1(5) 回归:individual_team 项目的团体聚合名次现在能进 Ranked 并产出单位金牌。
|
||||
func TestAggregateTeamRankProducesTeamMedal(t *testing.T) {
|
||||
entries, results := fixture([]participant{
|
||||
{entryID: 1, athleteID: 1, teamID: 1001, value: 11},
|
||||
{entryID: 2, athleteID: 2, teamID: 1001, value: 12},
|
||||
{entryID: 3, athleteID: 3, teamID: 1002, value: 13},
|
||||
})
|
||||
ev := individualEvent(1)
|
||||
ev.EventType = EventTypeIndividualTeam
|
||||
ev.Compare = CompareAsc // 团体平均成绩越小越好
|
||||
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: ev, Category: testCategory(),
|
||||
Channels: []ScoreChannel{
|
||||
athleteResultChannel(1),
|
||||
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: 2},
|
||||
},
|
||||
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
|
||||
Entries: entries, Results: results,
|
||||
})
|
||||
|
||||
var teamRanked []RankedResult
|
||||
for _, rr := range es.Ranked {
|
||||
if rr.ChannelID != 2 {
|
||||
continue
|
||||
}
|
||||
teamRanked = append(teamRanked, rr)
|
||||
if rr.EntryID != 0 || rr.ResultID != 0 || rr.AthleteID != 0 {
|
||||
t.Fatalf("团体聚合名次 = %+v,期望不对应单条报名项 / 成绩", rr)
|
||||
}
|
||||
if rr.SubjectKey != "t:"+strconv.FormatInt(rr.TeamID, 10) {
|
||||
t.Fatalf("团体聚合名次 SubjectKey = %q,期望 t:%d", rr.SubjectKey, rr.TeamID)
|
||||
}
|
||||
}
|
||||
if len(teamRanked) != 2 {
|
||||
t.Fatalf("团体聚合名次 = %d 条,期望 2 条:%+v", len(teamRanked), teamRanked)
|
||||
}
|
||||
|
||||
// 只拿团体通道的名次也能看出:1001 第 1 名(金),1002 第 2 名(银)。
|
||||
aggOnly := BuildTeamStandings(StandingsInput{Teams: testTeams(1001, 1002), Ranked: teamRanked})
|
||||
if aggOnly[0].TeamID != 1001 || aggOnly[0].Gold != 1 {
|
||||
t.Fatalf("团体名次单独统计 = %+v,期望单位 1001 记 1 金", aggOnly)
|
||||
}
|
||||
if aggOnly[1].TeamID != 1002 || aggOnly[1].Silver != 1 {
|
||||
t.Fatalf("团体名次单独统计 = %+v,期望单位 1002 记 1 银", aggOnly)
|
||||
}
|
||||
|
||||
// 全量榜单:个人通道(1 金 1 银 1 铜)与团体通道(1 金 1 银)各记一枚,互不去重。
|
||||
team, athlete := medalTotals(t, StandingsInput{Teams: testTeams(1001, 1002), Ranked: es.Ranked})
|
||||
if team != [3]int{2, 2, 1} {
|
||||
t.Fatalf("单位奖牌合计 = %v,期望 2 金 2 银 1 铜", team)
|
||||
}
|
||||
if athlete != [3]int{1, 1, 1} {
|
||||
t.Fatalf("个人奖牌合计 = %v,期望 1/1/1(团体名次不进个人榜)", athlete)
|
||||
}
|
||||
}
|
||||
@@ -111,11 +111,18 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
|
||||
}
|
||||
breaks = append(breaks, b)
|
||||
}
|
||||
byEntry := make(map[int64]Result, len(in.Results))
|
||||
for _, r := range in.Results {
|
||||
byEntry[r.EntryID] = r
|
||||
}
|
||||
modes := make(map[int64]RecordBonusMode, len(in.Entries))
|
||||
byID := make(map[int64]Entry, len(in.Entries))
|
||||
for _, e := range in.Entries {
|
||||
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
|
||||
// 状态不正常的报名项 / 成绩不给加分:与名次分同一口径,理由见 recordBonusEntryRankable。
|
||||
if !recordBonusEntryRankable(in, e, byEntry) {
|
||||
continue
|
||||
}
|
||||
byID[e.ID] = e
|
||||
if m, ok := recordBonusModeFor(in, e); ok {
|
||||
modes[e.ID] = m
|
||||
}
|
||||
@@ -125,14 +132,6 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
byID := make(map[int64]Entry, len(in.Entries))
|
||||
for _, e := range in.Entries {
|
||||
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
|
||||
continue
|
||||
}
|
||||
byID[e.ID] = e
|
||||
}
|
||||
|
||||
var out []LedgerEntry
|
||||
for _, b := range bonuses {
|
||||
if b.Points == 0 {
|
||||
@@ -165,6 +164,29 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// recordBonusEntryRankable 判断一条报名项现在还够不够拿破纪录加分。
|
||||
//
|
||||
// 判据与 collectCandidates 的可排名判据同源(报名项与成绩都走 rankable(),草稿不算),
|
||||
// 不另写一套:名次分与破纪录加分是同一条成绩的两种产出,一边认「这条成绩还算数」、
|
||||
// 另一边不认,就会出现退赛 / 弃权 / 失格的人拿不到名次分、却继续在个人榜与单位榜上拿加分,
|
||||
// 已作废成绩重算时也会把加分再写一遍(P1-6)。
|
||||
//
|
||||
// 这里只判「还算不算数」,不动 record_breaks 本身:登记是现场发生过的事实,
|
||||
// 状态改回来(批量恢复 / 成绩改回 ok)时同一个登记还要能把加分重新算出来。
|
||||
func recordBonusEntryRankable(in EventScoreInput, e Entry, byEntry map[int64]Result) bool {
|
||||
if !entryMatches(in, e) || !e.Status.rankable() {
|
||||
return false
|
||||
}
|
||||
r, ok := byEntry[e.ID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if r.State == ResultStateDraft && !in.Options.IncludeDrafts {
|
||||
return false
|
||||
}
|
||||
return r.Status.rankable()
|
||||
}
|
||||
|
||||
// recordBonusModeFor 取报名项「命中通道」所属模板的破纪录口径。
|
||||
// 命中通道按 channelBefore 的优先级挑选(athlete → team → 通道 id 升序),
|
||||
// 与奖牌去重同一口径;没有可命中通道或模板缺失时返回 false,由调用方退回全局口径。
|
||||
|
||||
@@ -318,3 +318,112 @@ func TestRecordBonusLedgerFiltersByCategory(t *testing.T) {
|
||||
t.Fatalf("别的组别的加分不应记入本组别,实际 %d 条", otherCategory)
|
||||
}
|
||||
}
|
||||
|
||||
// bonusLedgerOfEntry 取某报名项名下两种流水的分值:成绩分与破纪录加分。
|
||||
func bonusLedgerOfEntry(es EventScore, entryID int64) (rankPoints, bonusPoints float64) {
|
||||
for _, l := range es.Ledger {
|
||||
if l.EntryID != entryID {
|
||||
continue
|
||||
}
|
||||
switch l.SourceType {
|
||||
case SourceResult:
|
||||
rankPoints += l.Points
|
||||
case SourceRecordBonus:
|
||||
bonusPoints += l.Points
|
||||
}
|
||||
}
|
||||
return rankPoints, bonusPoints
|
||||
}
|
||||
|
||||
// TestRecordBonusFollowsRankableStatus 验证破纪录加分与名次分同一口径(P1-6):
|
||||
// 报名项退赛 / 弃权 / 失格,或成绩被作废 / 未发布时都不该再有加分流水。
|
||||
//
|
||||
// 除了逐条钉期望值,还断言「名次分有没有,破纪录加分就有没有」——两条通道是同一条成绩的
|
||||
// 两种产出,判据必须同源(EntryStatus.rankable / ResultStatus.rankable),
|
||||
// 各写一套就会出现「退赛的人拿不到名次分、却继续拿加分」。
|
||||
func TestRecordBonusFollowsRankableStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(entries *[]Entry, results *[]Result)
|
||||
want bool
|
||||
}{
|
||||
{"正常参赛且成绩有效", func(*[]Entry, *[]Result) {}, true},
|
||||
{"报名项退赛", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusScratched }, false},
|
||||
{"报名项失格", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusDQ }, false},
|
||||
{"报名项未出场", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusDNS }, false},
|
||||
{"成绩作废", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusWithdrawn }, false},
|
||||
{"成绩失格", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDQ }, false},
|
||||
{"成绩未出场", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDNS }, false},
|
||||
{"成绩未完赛", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDNF }, false},
|
||||
{"成绩仍是草稿", func(_ *[]Entry, results *[]Result) { (*results)[0].State = ResultStateDraft }, false},
|
||||
{"没有成绩行", func(_ *[]Entry, results *[]Result) { *results = nil }, false},
|
||||
{"状态留空按正常处理", func(entries *[]Entry, results *[]Result) {
|
||||
(*entries)[0].Status = ""
|
||||
(*results)[0].Status = ""
|
||||
}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
entries, results := fixture(lineup(1))
|
||||
tc.mutate(&entries, &results)
|
||||
cfg := bonusConfig(RecordBonusMax)
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: individualEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{athleteResultChannel(1)},
|
||||
Profiles: map[int64]ScoringProfile{1: individualTop6()},
|
||||
Entries: entries, Results: results,
|
||||
RecordBonus: &cfg,
|
||||
RecordBreaks: []RecordBreak{{
|
||||
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
|
||||
Level: "meet", State: RecordBreakApproved,
|
||||
}},
|
||||
})
|
||||
rankPoints, bonusPoints := bonusLedgerOfEntry(es, 1)
|
||||
if tc.want && !nearlyEqual(bonusPoints, 2) {
|
||||
t.Fatalf("加分 = %v,期望 2", bonusPoints)
|
||||
}
|
||||
if !tc.want && bonusPoints != 0 {
|
||||
t.Fatalf("不该产生破纪录加分,得到 %v", bonusPoints)
|
||||
}
|
||||
if got := rankPoints > 0; got != (bonusPoints > 0) {
|
||||
t.Fatalf("成绩分与破纪录加分必须同生同灭,得到 成绩分=%v 加分=%v", rankPoints, bonusPoints)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordBonusComesBackAfterRestore 验证状态判据是「看当前状态」而不是「一次性作废」:
|
||||
// 退赛 / 作废后再改回正常,同一个登记还要能把加分还回来(现场纠正误判就靠这条)。
|
||||
func TestRecordBonusComesBackAfterRestore(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,
|
||||
}},
|
||||
}
|
||||
|
||||
entries[0].Status = EntryStatusScratched
|
||||
if got := recordBonusPointsOf(t, in); got != 0 {
|
||||
t.Fatalf("退赛后的加分 = %v,期望 0", got)
|
||||
}
|
||||
entries[0].Status = EntryStatusRegistered
|
||||
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
|
||||
t.Fatalf("恢复参赛后的加分 = %v,期望 2", got)
|
||||
}
|
||||
|
||||
results[0].Status = ResultStatusWithdrawn
|
||||
if got := recordBonusPointsOf(t, in); got != 0 {
|
||||
t.Fatalf("成绩作废后的加分 = %v,期望 0", got)
|
||||
}
|
||||
results[0].Status = ResultStatusOK
|
||||
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
|
||||
t.Fatalf("成绩改回有效后的加分 = %v,期望 2", got)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,10 @@ type EventScoreInput struct {
|
||||
Results []Result
|
||||
RecordBonus *RecordBonusConfig
|
||||
RecordBreaks []RecordBreak
|
||||
Options ScoreOptions
|
||||
// TeamRanks 是按单位录入的团体名次,只有 scope=team + basis=manual 的通道读它。
|
||||
// 与「团体报名项(athlete_id=0)的 results.rank」共同构成团体名次的全部来源。
|
||||
TeamRanks []TeamManualRank
|
||||
Options ScoreOptions
|
||||
}
|
||||
|
||||
// EventScore 是一次小项计分的结果。
|
||||
@@ -54,6 +57,15 @@ type RankedResult struct {
|
||||
Points float64
|
||||
ResultValue *float64
|
||||
Status ResultStatus
|
||||
// SubjectKey 是这条名次的主体标识:名次按 (通道, 组别, 主体) 唯一,
|
||||
// 取值 'e:<entryId>' / 'a:<athleteId>' / 't:<teamId>',三者都是 0 时退化为 'ch:<channelId>'。
|
||||
// 聚合与团体手工名次没有单条报名项,靠它才能唯一落库。
|
||||
SubjectKey string
|
||||
// 下面三个开关随名次一起携带,榜单侧(standings.go)不必回查库就能算出奖牌:
|
||||
// 小项是否结算奖牌、通道是否发牌、个人奖牌是否并入单位奖牌榜。
|
||||
AwardMedals bool
|
||||
CountToMedals bool
|
||||
CountToTeamMedals bool
|
||||
}
|
||||
|
||||
// ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。
|
||||
@@ -175,10 +187,14 @@ func entryMatches(in EventScoreInput, e Entry) bool {
|
||||
|
||||
// collectCandidates 过滤出本通道真正参与排名的报名项:
|
||||
// 只取已发布、状态正常、且带有本通道所需数据的成绩。聚合通道不用本函数,见 scoreByAggregate。
|
||||
// scope=team + basis=manual 的候选来源与其它通道不同(P0 的修法),转给 collectTeamManualCandidates。
|
||||
func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error) {
|
||||
if ch.Basis == BasisResult && in.Event.Compare == CompareNone {
|
||||
return nil, ErrCompareRequired
|
||||
}
|
||||
if ch.Basis == BasisManual && ch.Scope == ScopeTeam {
|
||||
return collectTeamManualCandidates(in, ch), nil
|
||||
}
|
||||
|
||||
byEntry := make(map[int64]Result, len(in.Results))
|
||||
for _, r := range in.Results {
|
||||
@@ -222,6 +238,72 @@ func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// collectTeamManualCandidates 是 scope=team + basis=manual 的候选来源,只有两处:
|
||||
// 1. in.TeamRanks(按单位录入的团体名次):没有对应的报名项,用合成 Entry{TeamID: id} 承载;
|
||||
// 2. 兜底:团体报名项(athlete_id == 0)的 results.rank,纯团体项目(篮球等)沿用原录入流程。
|
||||
//
|
||||
// 同一单位两个来源都有时以 TeamRanks 为准;rank <= 0 的团体名次视为未填 / 清空,直接忽略。
|
||||
// 个人报名项(athlete_id != 0)的名次是个人名次,绝不进团体通道——否则双通道项目
|
||||
// 第 2 次发布时会把刚写回 results.rank 的个人名次当成团体名次再发一次分(P0)。
|
||||
func collectTeamManualCandidates(in EventScoreInput, ch ScoreChannel) []candidate {
|
||||
type manualRank struct {
|
||||
teamID int64
|
||||
rank int
|
||||
}
|
||||
overrides := make([]manualRank, 0, len(in.TeamRanks))
|
||||
overrideOf := make(map[int64]int, len(in.TeamRanks))
|
||||
for _, tr := range in.TeamRanks {
|
||||
if tr.TeamID == 0 || tr.Rank <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, dup := overrideOf[tr.TeamID]; dup {
|
||||
continue
|
||||
}
|
||||
overrideOf[tr.TeamID] = tr.Rank
|
||||
overrides = append(overrides, manualRank{teamID: tr.TeamID, rank: tr.Rank})
|
||||
}
|
||||
|
||||
byEntry := make(map[int64]Result, len(in.Results))
|
||||
for _, r := range in.Results {
|
||||
byEntry[r.EntryID] = r
|
||||
}
|
||||
|
||||
out := make([]candidate, 0, len(in.Entries)+len(overrides))
|
||||
taken := make(map[int64]bool, len(overrides))
|
||||
for _, e := range in.Entries {
|
||||
if !entryMatches(in, e) || e.AthleteID != 0 || !e.Status.rankable() {
|
||||
continue
|
||||
}
|
||||
r, ok := byEntry[e.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if r.State == ResultStateDraft && !in.Options.IncludeDrafts {
|
||||
continue
|
||||
}
|
||||
if !r.Status.rankable() || r.Rank <= 0 {
|
||||
continue
|
||||
}
|
||||
rankIn := r.Rank
|
||||
if rank, ok := overrideOf[e.TeamID]; ok {
|
||||
rankIn = rank
|
||||
}
|
||||
taken[e.TeamID] = true
|
||||
out = append(out, candidate{entry: e, result: r, rankIn: rankIn})
|
||||
}
|
||||
for _, or := range overrides {
|
||||
if taken[or.teamID] {
|
||||
continue
|
||||
}
|
||||
// 合成报名项:ID=0 表示「不对应任何一条报名项」,团体名次的主体是单位。
|
||||
out = append(out, candidate{
|
||||
entry: Entry{TeamID: or.teamID},
|
||||
rankIn: or.rank,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scoreByResult(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
|
||||
cands, err := collectCandidates(in, ch)
|
||||
if err != nil {
|
||||
@@ -366,6 +448,10 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
|
||||
Points: points,
|
||||
Status: c.result.Status,
|
||||
}
|
||||
rr.SubjectKey = subjectKeyOf(rr)
|
||||
rr.AwardMedals = in.Event.AwardMedals
|
||||
rr.CountToMedals = ch.CountsToMedals()
|
||||
rr.CountToTeamMedals = ch.CountsToTeamMedals()
|
||||
if c.result.ResultValue != nil {
|
||||
v := *c.result.ResultValue
|
||||
rr.ResultValue = &v
|
||||
@@ -474,6 +560,10 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
|
||||
rr.AthleteName = c.entry.AthleteName
|
||||
rr.Shares = wholeShare(c.entry.primaryTeamID())
|
||||
}
|
||||
rr.SubjectKey = subjectKeyOf(rr)
|
||||
rr.AwardMedals = in.Event.AwardMedals
|
||||
rr.CountToMedals = ch.CountsToMedals()
|
||||
rr.CountToTeamMedals = ch.CountsToTeamMedals()
|
||||
v := c.value
|
||||
rr.ResultValue = &v
|
||||
out = append(out, rr)
|
||||
@@ -516,6 +606,11 @@ func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) {
|
||||
// ledgerFromRanked 把名次明细转成积分流水:按通道 scope 决定记给个人还是单位;
|
||||
// 0 分不产生流水,避免流水表被零分噪声撑大。
|
||||
// 流水是否计入单位总分由通道的 countToTeam 决定(个人通道可以只记个人)。
|
||||
//
|
||||
// 团体手工名次与聚合名次没有对应的报名项与成绩(EntryID=0、ResultID=0),
|
||||
// 仍要能落库:SourceType 与聚合流水一样是 result,sourceID 退化为通道 id,
|
||||
// 由真实来源(通道 + 主体)唯一确定。写入侧按「EntryID=0 && ResultID=0 && source_type=result」
|
||||
// 识别这类流水(httpapi 的 isAggregateLedger),按组别重算时会整项重算回填,条数不增不减。
|
||||
func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult) []LedgerEntry {
|
||||
var out []LedgerEntry
|
||||
for _, rr := range ranked {
|
||||
@@ -556,6 +651,22 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
|
||||
return out
|
||||
}
|
||||
|
||||
// subjectKeyOf 计算一条名次明细的主体标识(channel_ranks.subject_key 同口径):
|
||||
// 有报名项就用报名项(个人名次按报名项去重才不会被并列拆开),否则按主体(个人 / 单位),
|
||||
// 全为空时退化为通道 id,保证 (通道, 组别, 主体) 的唯一索引不会撞键。
|
||||
func subjectKeyOf(rr RankedResult) string {
|
||||
switch {
|
||||
case rr.EntryID != 0:
|
||||
return fmt.Sprintf("e:%d", rr.EntryID)
|
||||
case rr.Scope == ScopeAthlete && rr.AthleteID != 0:
|
||||
return fmt.Sprintf("a:%d", rr.AthleteID)
|
||||
case rr.TeamID != 0:
|
||||
return fmt.Sprintf("t:%d", rr.TeamID)
|
||||
default:
|
||||
return fmt.Sprintf("ch:%d", rr.ChannelID)
|
||||
}
|
||||
}
|
||||
|
||||
// wholeShare 构造「主单位独占 100%」的归属份额。联队拆分未实现,这里只产出这一种口径。
|
||||
func wholeShare(teamID int64) []TeamShare {
|
||||
if teamID == 0 {
|
||||
|
||||
@@ -160,9 +160,8 @@ func TestScopeAndLedgerAttribution(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("team 通道仅记单位", func(t *testing.T) {
|
||||
ps := lineup(1)
|
||||
ps[0].rank = 1
|
||||
entries, results := fixture(ps)
|
||||
// 团体手工通道的候选只能来自 TeamRanks 或团体报名项(athlete_id = 0)。
|
||||
entries, results := fixture([]participant{{entryID: 1, teamID: 1001, rank: 1, noValue: true}})
|
||||
es := mustScore(t, EventScoreInput{
|
||||
Event: teamEvent(1), Category: testCategory(),
|
||||
Channels: []ScoreChannel{teamManualChannel(2)},
|
||||
|
||||
@@ -52,6 +52,12 @@ func rankMedal(rank int) int {
|
||||
}
|
||||
}
|
||||
|
||||
// medalEligible 是奖牌入选判据(devdocs 17 §4.5):小项结算奖牌、通道是奖牌通道、
|
||||
// 名次在前三,三者同时成立才计入。三个开关随 RankedResult 携带,榜单不必回查库。
|
||||
func medalEligible(rr RankedResult) bool {
|
||||
return rr.AwardMedals && rr.CountToMedals && rankMedal(rr.Rank) != 0
|
||||
}
|
||||
|
||||
// BuildTeamStandings 生成总积分榜。
|
||||
// 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 单位编码。
|
||||
func BuildTeamStandings(in StandingsInput) []StandingsRow {
|
||||
@@ -243,12 +249,15 @@ func medalKeyOf(rr RankedResult, index int) medalKey {
|
||||
return k
|
||||
}
|
||||
|
||||
// pickMedalChannels 把按通道展开的名次明细去重成「每个报名项一条」:
|
||||
// 只保留优先级最高的通道名次,否则双通道项目一条成绩会拿两枚奖牌。
|
||||
// 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 !medalEligible(rr) {
|
||||
continue
|
||||
}
|
||||
if excludeTeam && rr.Scope == ScopeTeam {
|
||||
continue
|
||||
}
|
||||
@@ -270,11 +279,20 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
|
||||
return out
|
||||
}
|
||||
|
||||
// teamMedals 统计团体奖牌榜。按 Shares 逐份额记牌:当前只有「主单位独占 100%」一种口径,
|
||||
// teamMedals 统计单位奖牌榜。按 Shares 逐份额记牌:当前只有「主单位独占 100%」一种口径,
|
||||
// 将来若支持联队拆分,每个成员单位都会各记一枚。
|
||||
//
|
||||
// 两层开关(devdocs 17 §4.5):
|
||||
// - 粗粒度总闸 StandingsInput.ExcludeTeamEventMedals:为 true 时团体项目的奖牌一律不进单位榜;
|
||||
// - 通道开关 count_to_team_medals:scope=athlete 的通道置 false 时,它的个人奖牌不进单位榜。
|
||||
//
|
||||
// 两者是「与」的关系;scope=team 的奖牌本来就记在单位头上,不受通道开关影响。
|
||||
func teamMedals(in StandingsInput) map[int64]medalTally {
|
||||
out := make(map[int64]medalTally)
|
||||
for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals) {
|
||||
if rr.Scope != ScopeTeam && !rr.CountToTeamMedals {
|
||||
continue
|
||||
}
|
||||
medal := rankMedal(rr.Rank)
|
||||
if medal == 0 {
|
||||
continue
|
||||
@@ -295,7 +313,8 @@ func teamMedals(in StandingsInput) map[int64]medalTally {
|
||||
return out
|
||||
}
|
||||
|
||||
// athleteMedals 只统计个人项目(scope=athlete)的奖牌。
|
||||
// athleteMedals 只统计个人项目(scope=athlete)的奖牌;
|
||||
// 通道的 count_to_team_medals 与个人榜无关,只决定这枚奖牌算不算进单位榜。
|
||||
func athleteMedals(in StandingsInput) map[int64]medalTally {
|
||||
out := make(map[int64]medalTally)
|
||||
for _, rr := range pickMedalChannels(in.Ranked, false) {
|
||||
|
||||
@@ -4,10 +4,13 @@ import "testing"
|
||||
|
||||
func mkRanked(teamID int64, scope Scope, rank int) RankedResult {
|
||||
return RankedResult{
|
||||
TeamID: teamID,
|
||||
Shares: []TeamShare{{TeamID: teamID, Weight: 1}},
|
||||
Scope: scope,
|
||||
Rank: rank,
|
||||
TeamID: teamID,
|
||||
Shares: []TeamShare{{TeamID: teamID, Weight: 1}},
|
||||
Scope: scope,
|
||||
Rank: rank,
|
||||
AwardMedals: true,
|
||||
CountToMedals: true,
|
||||
CountToTeamMedals: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +190,10 @@ func TestAthleteStandings(t *testing.T) {
|
||||
Teams: teams, Athletes: athletes, Ledger: ledger,
|
||||
Ranked: []RankedResult{{
|
||||
TeamID: 1001, AthleteID: 1, Scope: ScopeTeam, Rank: 1,
|
||||
Shares: []TeamShare{{TeamID: 1001, Weight: 1}},
|
||||
Shares: []TeamShare{{TeamID: 1001, Weight: 1}},
|
||||
AwardMedals: true,
|
||||
CountToMedals: true,
|
||||
CountToTeamMedals: true,
|
||||
}},
|
||||
})
|
||||
for _, r := range rows {
|
||||
@@ -201,7 +207,10 @@ func TestAthleteStandings(t *testing.T) {
|
||||
func TestUnionMedals(t *testing.T) {
|
||||
rr := RankedResult{
|
||||
TeamID: 1, Rank: 1, Scope: ScopeAthlete,
|
||||
Shares: []TeamShare{{TeamID: 1, Weight: 0.6}, {TeamID: 2, Weight: 0.4}},
|
||||
Shares: []TeamShare{{TeamID: 1, Weight: 0.6}, {TeamID: 2, Weight: 0.4}},
|
||||
AwardMedals: true,
|
||||
CountToMedals: true,
|
||||
CountToTeamMedals: true,
|
||||
}
|
||||
t.Run("拆分时两单位各记一金", func(t *testing.T) {
|
||||
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: []RankedResult{rr}})
|
||||
@@ -274,7 +283,10 @@ func TestMedalDedupAcrossChannels(t *testing.T) {
|
||||
return RankedResult{
|
||||
EventID: 1, CategoryID: 1, EntryID: 7, ChannelID: channelID, Scope: scope,
|
||||
TeamID: 1, AthleteID: 101, Rank: rank,
|
||||
Shares: []TeamShare{{TeamID: 1, Weight: 1}},
|
||||
Shares: []TeamShare{{TeamID: 1, Weight: 1}},
|
||||
AwardMedals: true,
|
||||
CountToMedals: true,
|
||||
CountToTeamMedals: true,
|
||||
}
|
||||
}
|
||||
teams := testTeams(1)
|
||||
@@ -332,6 +344,7 @@ func TestMedalDedupAcrossChannels(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}},
|
||||
AwardMedals: true, CountToMedals: true, CountToTeamMedals: true,
|
||||
}
|
||||
b := a
|
||||
b.ChannelID = 2
|
||||
|
||||
@@ -208,6 +208,9 @@ type Event struct {
|
||||
Venue string `json:"venue,omitempty"`
|
||||
SortOrder int `json:"sortOrder,omitempty"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
// AwardMedals 决定该小项是否结算奖牌;库中默认 1(开启),关闭后它的名次不产生任何奖牌。
|
||||
// 积分不受影响:奖牌与积分是两套口径。
|
||||
AwardMedals bool `json:"awardMedals"`
|
||||
// AvgMode 团体成绩(basis=aggregate 通道)取平均时的分母口径;空值按 valid 处理。
|
||||
AvgMode AvgMode `json:"avgMode"`
|
||||
}
|
||||
@@ -237,6 +240,19 @@ type ScoreChannel struct {
|
||||
// CountToTeam 决定该通道产出的流水是否计入单位总分;nil 表示按缺省(计入)处理。
|
||||
// 「个人+团体」项目的个人通道置 false:分只记在个人头上,不进单位总分。
|
||||
CountToTeam *bool `json:"countToTeam,omitempty"`
|
||||
// CountToMedals 决定该通道产出的名次是否发奖牌;nil 表示按缺省(发牌)处理。
|
||||
CountToMedals *bool `json:"countToMedals,omitempty"`
|
||||
// CountToTeamMedals 决定该通道的个人奖牌是否并入单位奖牌榜;nil 表示按缺省(并入)处理。
|
||||
// 只对 scope=athlete 的通道有意义:scope=team 的名次本来就记在单位头上。
|
||||
CountToTeamMedals *bool `json:"countToTeamMedals,omitempty"`
|
||||
}
|
||||
|
||||
// TeamManualRank 是「按单位录入的团体名次」,只被 scope=team + basis=manual 的通道读取。
|
||||
// 团体名次的来源只有两个:这里,以及团体报名项(athlete_id=0)的 results.rank;
|
||||
// 同一单位两边都有时以这里为准。个人报名项的名次属于个人名次,绝不进团体通道。
|
||||
type TeamManualRank struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Rank int `json:"rank"`
|
||||
}
|
||||
|
||||
// ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。
|
||||
@@ -446,6 +462,16 @@ func (c ScoreChannel) countToTeam() bool {
|
||||
return c.CountToTeam == nil || *c.CountToTeam
|
||||
}
|
||||
|
||||
// CountsToMedals 未配置时按 true 处理:只有显式关闭的通道才不发奖牌。
|
||||
func (c ScoreChannel) CountsToMedals() bool {
|
||||
return c.CountToMedals == nil || *c.CountToMedals
|
||||
}
|
||||
|
||||
// CountsToTeamMedals 未配置时按 true 处理:个人通道的奖牌默认也计入单位奖牌榜。
|
||||
func (c ScoreChannel) CountsToTeamMedals() bool {
|
||||
return c.CountToTeamMedals == nil || *c.CountToTeamMedals
|
||||
}
|
||||
|
||||
func (c EventCategory) multiplier() float64 {
|
||||
if c.Multiplier <= 0 {
|
||||
return 1
|
||||
|
||||
@@ -186,20 +186,52 @@ func LoadAthleteDetailWorkbook(db *sql.DB, opts Options) (AthleteDetailWorkbook,
|
||||
return AthleteDetailWorkbook{Meta: meta, Sheets: sheets}, nil
|
||||
}
|
||||
|
||||
// athleteDetailRankExpr 取一条个人流水的名次(devdocs 17 §11.3):按流水对应的通道与主体关联 channel_ranks。
|
||||
//
|
||||
// score_ledger 上没有通道列(域模型 LedgerEntry.ChannelID 没有落库),所以只能按流水自身能确定的主体来找:
|
||||
// - 有 entry_id 的流水(成绩流水):按报名项关联,通道用「主通道」口径(scope=athlete 优先,其次 sort_order);
|
||||
// - entry_id / result_id 都为空的流水(个人聚合名次):写入侧把 source_id 退化为通道 id,就拿它 + 运动员关联。
|
||||
//
|
||||
// 组别用该行能唯一确定的那一个(c = 报名项的组别,ca = 回推出来的组别);回推不出来时不猜,名次留空。
|
||||
// channel_ranks 是发布 / 重算才重建的派生数据,没有对应行时回落到 results.rank,不把明细的名次列变成空白。
|
||||
const athleteDetailRankExpr = `COALESCE((
|
||||
SELECT cr.rank
|
||||
FROM channel_ranks cr
|
||||
JOIN event_score_channels ch ON ch.id = cr.channel_id
|
||||
WHERE cr.event_id = l.event_id
|
||||
AND cr.category_id = COALESCE(c.id, ca.id)
|
||||
AND ((l.entry_id IS NOT NULL AND cr.entry_id = l.entry_id)
|
||||
OR (l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result'
|
||||
AND cr.channel_id = l.source_id
|
||||
AND cr.scope = 'athlete' AND cr.athlete_id = l.athlete_id))
|
||||
ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id
|
||||
LIMIT 1), COALESCE(r.rank, 0))`
|
||||
|
||||
func loadAthleteDetailRows(db *sql.DB) ([]AthleteDetailRow, error) {
|
||||
// 聚合流水的组别回推与单位明细同一条思路:某个运动员在本小项只参加了一个组别时,那个组别就是答案;
|
||||
// 多个组别时回推不出来,宁可留空也不猜(回推只对 entry_id / result_id 都为空的聚合流水生效)。
|
||||
rows, err := db.Query(`
|
||||
SELECT l.team_id, COALESCE(a.name, ''),
|
||||
COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''),
|
||||
COALESCE(r.rank, 0), COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
|
||||
COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''),
|
||||
` + athleteDetailRankExpr + `, COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
|
||||
l.points, l.source_type,
|
||||
COALESCE(s.sort_order, 0), COALESCE(e.sort_order, 0), COALESCE(c.sort_order, 0),
|
||||
COALESCE(l.event_id, 0), COALESCE(c.id, 0), l.id
|
||||
COALESCE(s.sort_order, 0), COALESCE(e.sort_order, 0), COALESCE(c.sort_order, ca.sort_order, 0),
|
||||
COALESCE(l.event_id, 0), COALESCE(c.id, ca.id, 0), l.id
|
||||
FROM score_ledger l
|
||||
LEFT JOIN athletes a ON a.id = l.athlete_id
|
||||
LEFT JOIN events e ON e.id = l.event_id
|
||||
LEFT JOIN sports s ON s.id = e.sport_id
|
||||
LEFT JOIN entries en ON en.id = l.entry_id
|
||||
LEFT JOIN event_categories c ON c.id = en.category_id
|
||||
LEFT JOIN (
|
||||
SELECT en2.athlete_id, en2.event_id, MIN(en2.category_id) AS category_id
|
||||
FROM entries en2
|
||||
WHERE en2.athlete_id IS NOT NULL
|
||||
GROUP BY en2.athlete_id, en2.event_id
|
||||
HAVING COUNT(DISTINCT en2.category_id) = 1
|
||||
) aac ON aac.event_id = l.event_id AND aac.athlete_id = l.athlete_id
|
||||
AND l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result'
|
||||
LEFT JOIN event_categories ca ON ca.id = aac.category_id
|
||||
LEFT JOIN results r ON r.id = l.result_id
|
||||
WHERE l.athlete_id IS NOT NULL AND l.athlete_id <> 0 AND l.voided = 0`)
|
||||
if err != nil {
|
||||
|
||||
@@ -371,7 +371,13 @@ type BaseDataRow struct {
|
||||
// Line 是该行在工作表里的真实行号(1 起,含表头上方的标题行),行级报错直接用它。
|
||||
Line int
|
||||
// Cells 是「字段名 → 单元格原文(已去空白)」;Has 标记该字段的列有没有出现在表头里。
|
||||
// 两者分开:列不存在 = 不改动(更新时沿用库里的值),列存在但为空 = 明确写空。
|
||||
//
|
||||
// 这里的 Has / 空格之分是留给上层的原料,具体怎么解释按列的性质定(解析只负责如实报告):
|
||||
// - 文本列 / 年龄 / 布尔 / 权重 / 排序:列缺席 = 不动,列在而格子空 = 按该列口径处理
|
||||
// (文本与年龄清空、布尔取「是」、权重取 1、排序保持原值)——与三处用户文档一致;
|
||||
// - 枚举列(小项的 类型/结果模式/结果类型/比较/状态/平均口径、组别的 性别):
|
||||
// 列缺席与格子空**都** = 不动,只有显式填值才覆盖。留空曾按「取系统默认」处理,
|
||||
// 一次误清格就会把已发布小项打回待录入、把团体项目类型改成个人 —— 见 planEvents。
|
||||
Cells map[string]string
|
||||
Has map[string]bool
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ func TestColumnsFitContent(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedTieAndRelay(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -67,6 +67,30 @@ func seedEvent(t *testing.T, db *sql.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedChannelRanks 按已发布成绩补一份 channel_ranks:它是发布 / 重算时由计分引擎全量重建的
|
||||
// 派生数据(devdocs 17 §五),而导出测试直接造库、不走发布流程,不补这一份就读不到任何名次。
|
||||
// 口径与迁移前的读取口径一致:一条已发布且有排名的成绩 × 该小项每条适用通道(scope=team 或
|
||||
// 报名的是个人)各出一行,subject_key 用报名项('e:<entryId>',与引擎一致)。
|
||||
//
|
||||
// 注意:TestExportStandingsMatchesSnapshot 不调用它——那份用例要求导出与 WS 快照同源,
|
||||
// 而快照侧(internal/realtime)本轮尚未改读 channel_ranks,两边都还没有奖牌数据才对得上。
|
||||
func seedChannelRanks(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points,
|
||||
result_value, status)
|
||||
SELECT r.event_id, en.category_id, c.id, c.basis, c.scope, 'e:' || en.id,
|
||||
en.id, r.id, en.athlete_id, en.team_id, r.rank, 0, r.result_value, r.status
|
||||
FROM results r
|
||||
JOIN entries en ON en.id = r.entry_id
|
||||
JOIN event_score_channels c ON c.event_id = r.event_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0
|
||||
AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)`); err != nil {
|
||||
t.Fatalf("补 channel_ranks: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, db *sql.DB, table string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
|
||||
@@ -276,23 +276,24 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。
|
||||
// 与 REST 的 buildRanked、快照的 loadRanked 同源:一条成绩在每个计分通道下各产出一条明细,
|
||||
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌。
|
||||
// 积分以 score_ledger 为准,这里只取名次,不需要重算分值。
|
||||
// loadRanked 读取发布产出的通道名次(channel_ranks),供 domain 统计奖牌。
|
||||
// 与 REST 的 buildRanked、快照的 loadRanked 同口径:每个计分通道各自一行名次,
|
||||
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌(devdocs 17 §五)。
|
||||
// 三个奖牌开关随行携带(events.award_medals + 通道的 count_to_medals / count_to_team_medals),
|
||||
// domain 的榜单函数不必回查库;积分以 score_ledger 为准,这里只取名次,不需要重算分值。
|
||||
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
rows, err := db.Query(`
|
||||
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`)
|
||||
SELECT COALESCE(cr.result_id, 0), cr.event_id, cr.category_id, cr.rank,
|
||||
cr.result_value, cr.status,
|
||||
COALESCE(cr.entry_id, 0), COALESCE(cr.team_id, 0), COALESCE(cr.athlete_id, 0),
|
||||
COALESCE(a.name, ''), cr.scope, cr.channel_id, cr.basis, cr.subject_key,
|
||||
ev.award_medals, c.count_to_medals, c.count_to_team_medals
|
||||
FROM channel_ranks cr
|
||||
JOIN events ev ON ev.id = cr.event_id
|
||||
JOIN event_score_channels c ON c.id = cr.channel_id
|
||||
LEFT JOIN athletes a ON a.id = cr.athlete_id
|
||||
WHERE ev.scored = 1 AND cr.rank > 0
|
||||
ORDER BY cr.event_id, cr.category_id, c.sort_order, c.id, cr.rank, cr.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取名次: %w", err)
|
||||
}
|
||||
@@ -301,22 +302,32 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
var out []domain.RankedResult
|
||||
for rows.Next() {
|
||||
var (
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
rank int
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
status sql.NullString
|
||||
awardMedals int
|
||||
countToMedals int
|
||||
countToTeamMed int
|
||||
)
|
||||
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 {
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rr.Rank, &value, &status,
|
||||
&rr.EntryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID,
|
||||
&rr.Basis, &rr.SubjectKey,
|
||||
&awardMedals, &countToMedals, &countToTeamMed); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描名次: %w", err)
|
||||
}
|
||||
rr.Rank = rank
|
||||
rr.Status = domain.ResultStatus(status.String)
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
rr.ResultValue = &v
|
||||
}
|
||||
rr.AthleteID = athleteID.Int64
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
// 归属份额沿用「主单位独占 100%」:team_id 为空(聚合 / 团体手工名次理论上不会)时不造份额,
|
||||
// 免得给 0 号单位凭空记牌,与 domain 的 wholeShare 一致。
|
||||
if rr.TeamID != 0 {
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
}
|
||||
rr.AwardMedals = awardMedals != 0
|
||||
rr.CountToMedals = countToMedals != 0
|
||||
rr.CountToTeamMedals = countToTeamMed != 0
|
||||
out = append(out, rr)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -445,8 +456,12 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
|
||||
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
|
||||
// 连成绩行都没有的报名项同样占位(NoResult)。
|
||||
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
|
||||
// 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次,
|
||||
// 口径 = scope=athlete 优先,其次通道 sort_order 小者。
|
||||
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
|
||||
// 落不了库)回落到 results.rank,保持名次列不空白。
|
||||
query := `
|
||||
SELECT COALESCE(r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
|
||||
SELECT COALESCE(cr.rank, r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
|
||||
COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''),
|
||||
COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
|
||||
COALESCE((SELECT SUM(l.points) FROM score_ledger l
|
||||
@@ -456,6 +471,13 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
|
||||
JOIN teams t ON t.id = en.team_id
|
||||
LEFT JOIN athletes a ON a.id = en.athlete_id
|
||||
LEFT JOIN results r ON r.entry_id = en.id
|
||||
LEFT JOIN event_score_channels mc ON mc.id = (
|
||||
SELECT c2.id FROM event_score_channels c2
|
||||
WHERE c2.event_id = en.event_id
|
||||
ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id
|
||||
LIMIT 1)
|
||||
LEFT JOIN channel_ranks cr ON cr.channel_id = mc.id AND cr.entry_id = en.id
|
||||
AND cr.category_id = en.category_id
|
||||
WHERE en.event_id = ?`
|
||||
args := []any{eventID}
|
||||
if categoryID != 0 {
|
||||
@@ -548,6 +570,29 @@ func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// teamDetailRankExpr 取一条单位流水的名次(devdocs 17 §11.3):按流水对应的通道与主体关联 channel_ranks。
|
||||
//
|
||||
// score_ledger 上没有通道列(域模型 LedgerEntry.ChannelID 没有落库),所以只能按流水自身能确定的主体来找:
|
||||
// - 有 entry_id 的流水(成绩流水):按报名项关联,通道用「主通道」口径(scope=athlete 优先,其次 sort_order);
|
||||
// - entry_id / result_id 都为空的流水(聚合名次与团体手工名次):写入侧把 source_id 退化为通道 id,
|
||||
// 就拿它 + 主体(单位 / 运动员)关联。
|
||||
//
|
||||
// 组别用该行能唯一确定的那一个(c = 报名项的组别,ca = 回推出来的组别);回推不出来时不猜,名次留空。
|
||||
// channel_ranks 是发布 / 重算才重建的派生数据,没有对应行时回落到 results.rank,不把明细的名次列变成空白。
|
||||
const teamDetailRankExpr = `COALESCE((
|
||||
SELECT cr.rank
|
||||
FROM channel_ranks cr
|
||||
JOIN event_score_channels ch ON ch.id = cr.channel_id
|
||||
WHERE cr.event_id = l.event_id
|
||||
AND cr.category_id = COALESCE(c.id, ca.id)
|
||||
AND ((l.entry_id IS NOT NULL AND cr.entry_id = l.entry_id)
|
||||
OR (l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result'
|
||||
AND cr.channel_id = l.source_id
|
||||
AND ((cr.scope = 'team' AND cr.team_id = l.team_id)
|
||||
OR (cr.scope = 'athlete' AND cr.athlete_id = l.athlete_id))))
|
||||
ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id
|
||||
LIMIT 1), COALESCE(r.rank, 0))`
|
||||
|
||||
func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
// 聚合通道(basis=aggregate,即「按队内平均」)的流水 entry_id / result_id 都为空,
|
||||
// 从 entries 取不到组别。这类流水是「单位在本组别的成绩取平均」的产物,组别在算分时已经确定;
|
||||
@@ -556,7 +601,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
// 回推只对聚合流水生效(与 isAggregateLedger 同判据),人工调整等其它无 entry_id 的流水不受影响。
|
||||
rows, err := db.Query(`
|
||||
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''),
|
||||
COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''),
|
||||
COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''),
|
||||
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
|
||||
@@ -575,7 +620,8 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
LEFT JOIN results r ON r.id = l.result_id
|
||||
LEFT JOIN athletes a ON a.id = l.athlete_id
|
||||
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
|
||||
ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(r.rank, 999999), l.id`,
|
||||
ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order,
|
||||
COALESCE(NULLIF(`+teamDetailRankExpr+`, 0), 999999), l.id`,
|
||||
teamID, teamID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err)
|
||||
|
||||
@@ -66,6 +66,9 @@ func TestLoadRankedExcludesRankOnlyEvents(t *testing.T) {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
// scored=0 的小项照样有通道名次(引擎只排名不计分也会产出名次),
|
||||
// 靠 loadRanked 的 scored 过滤把它们挡在奖牌之外。
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
ranked, err := loadRanked(db)
|
||||
if err != nil {
|
||||
@@ -77,6 +80,17 @@ func TestLoadRankedExcludesRankOnlyEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if countRows(t, db, "channel_ranks") == 0 {
|
||||
t.Fatal("用例前提不成立:channel_ranks 是空的,过滤没被真正考验")
|
||||
}
|
||||
var event2Ranks int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM channel_ranks WHERE event_id = 2`).Scan(&event2Ranks); err != nil {
|
||||
t.Fatalf("统计小项 2 的通道名次: %v", err)
|
||||
}
|
||||
if event2Ranks == 0 {
|
||||
t.Fatal("用例前提不成立:只排名的小项没有通道名次行,scored 过滤没被真正考验")
|
||||
}
|
||||
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出总表: %v", err)
|
||||
@@ -101,6 +115,7 @@ func TestLoadStandingsHonoursTieBreak(t *testing.T) {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
@@ -263,6 +278,190 @@ func findAggregateRow(rows []TeamDetailRow) (TeamDetailRow, bool) {
|
||||
return TeamDetailRow{}, false
|
||||
}
|
||||
|
||||
// 三个奖牌开关随名次行从 events / event_score_channels 带出来(devdocs 17 §4.5):
|
||||
// 关掉小项的 award_medals、或通道的 count_to_medals,奖牌就该消失;
|
||||
// 只关 count_to_team_medals 时个人榜仍有牌、单位榜没有。
|
||||
func TestLoadRankedCarriesMedalSwitches(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
ranked, err := loadRanked(db)
|
||||
if err != nil {
|
||||
t.Fatalf("读取名次: %v", err)
|
||||
}
|
||||
if len(ranked) == 0 {
|
||||
t.Fatal("用例前提不成立:读不到任何名次")
|
||||
}
|
||||
for _, rr := range ranked {
|
||||
if !rr.AwardMedals || !rr.CountToMedals || !rr.CountToTeamMedals {
|
||||
t.Fatalf("三列默认全为 1,开关应全为 true: %+v", rr)
|
||||
}
|
||||
}
|
||||
if gold := standingsGold(t, db, 1); gold != 1 {
|
||||
t.Fatalf("基线 T1 金牌 = %d,期望 1", gold)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE id = 1`); err != nil {
|
||||
t.Fatalf("关闭小项奖牌结算: %v", err)
|
||||
}
|
||||
if gold := standingsGold(t, db, 1); gold != 0 {
|
||||
t.Errorf("award_medals=0 时 T1 金牌 = %d,期望 0", gold)
|
||||
}
|
||||
// 奖牌明细与榜单同口径(medals.go 走 domain.BuildMedalStandings),关掉开关后也不该再出牌。
|
||||
if sheets := medalSheetsOf(t, db); len(sheets) != 0 {
|
||||
t.Errorf("award_medals=0 时奖牌明细仍出分表: %v", sheets)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE events SET award_medals = 1 WHERE id = 1`); err != nil {
|
||||
t.Fatalf("恢复小项奖牌结算: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 0 WHERE event_id = 1`); err != nil {
|
||||
t.Fatalf("关闭通道奖牌: %v", err)
|
||||
}
|
||||
if gold := standingsGold(t, db, 1); gold != 0 {
|
||||
t.Errorf("count_to_medals=0 时 T1 金牌 = %d,期望 0", gold)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 1 WHERE event_id = 1`); err != nil {
|
||||
t.Fatalf("恢复通道奖牌: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team_medals = 0 WHERE event_id = 1`); err != nil {
|
||||
t.Fatalf("关闭个人奖牌并入单位: %v", err)
|
||||
}
|
||||
if gold := standingsGold(t, db, 1); gold != 0 {
|
||||
t.Errorf("count_to_team_medals=0 时 T1 单位金牌 = %d,期望 0", gold)
|
||||
}
|
||||
if gold := athleteGold(t, db, 1); gold != 1 {
|
||||
t.Errorf("count_to_team_medals=0 时张三个人金牌 = %d,期望 1(只影响单位榜)", gold)
|
||||
}
|
||||
}
|
||||
|
||||
// medalSheetsOf 取奖牌明细里出了分表的单位 id(没有奖牌时为空)。
|
||||
func medalSheetsOf(t *testing.T, db *sql.DB) []int64 {
|
||||
t.Helper()
|
||||
wb, err := LoadMedalDetailWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取奖牌明细: %v", err)
|
||||
}
|
||||
out := make([]int64, 0, len(wb.Sheets))
|
||||
for _, s := range wb.Sheets {
|
||||
out = append(out, s.Team.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// standingsGold / athleteGold 取导出总表里某个单位 / 运动员的金牌数。
|
||||
func standingsGold(t *testing.T, db *sql.DB, teamID int64) int {
|
||||
t.Helper()
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取总表: %v", err)
|
||||
}
|
||||
for _, row := range wb.Team {
|
||||
if row.TeamID == teamID {
|
||||
return row.Gold
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func athleteGold(t *testing.T, db *sql.DB, athleteID int64) int {
|
||||
t.Helper()
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取总表: %v", err)
|
||||
}
|
||||
for _, row := range wb.Athlete {
|
||||
if row.AthleteID == athleteID {
|
||||
return row.Gold
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 分项成绩表的名次列取「主通道」名次(devdocs 17 §11.3):双通道项目里 scope=athlete 的通道优先,
|
||||
// 一张报名项只显示一个名次;channel_ranks 里没有对应行时回落到 results.rank。
|
||||
func TestEventRowsUseMainChannelRank(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
// 补一条团体通道,让「100米跑」变成双通道项目(与 TEAM_PULLUP 同形)。
|
||||
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
|
||||
t.Fatalf("补团体通道: %v", err)
|
||||
}
|
||||
seedChannelRanks(t, db)
|
||||
// 个人名次只认 channel_ranks:把 results.rank 改坏,成绩表仍要显示个人通道的 1。
|
||||
if _, err := db.Exec(`UPDATE results SET rank = 4 WHERE id = 1`); err != nil {
|
||||
t.Fatalf("改坏 results.rank: %v", err)
|
||||
}
|
||||
|
||||
wb, err := LoadEventWorkbook(db, 1, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出分项成绩: %v", err)
|
||||
}
|
||||
rows := eventRows(wb.Sheets[0].Rows)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("行数 = %d,期望 2(一条报名项一行,不按通道复制)", len(rows))
|
||||
}
|
||||
// 行序按 results.rank 排(T1 被改坏成 4),所以按号码布找那一行。
|
||||
byBib := make(map[string][]any, len(rows))
|
||||
for _, r := range rows {
|
||||
byBib[r[1].(string)] = r
|
||||
}
|
||||
if got := byBib["101"][0]; got != "1" {
|
||||
t.Errorf("101 的名次列 = %v,期望主通道(个人通道)的 1", got)
|
||||
}
|
||||
if got := byBib["102"][0]; got != "2" {
|
||||
t.Errorf("102 的名次列 = %v,期望 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 团体手工 / 聚合这类没有成绩行的流水也要显示 channel_ranks 里的团体名次
|
||||
// (迁移前这类流水的 rank 恒为 0,devdocs 17 §11.3)。
|
||||
func TestTeamDetailShowsSubjectRank(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
// 团体手工通道:名次按单位录入,流水没有 entry_id / result_id,写入侧把 source_id 退化为通道 id。
|
||||
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
|
||||
t.Fatalf("补团体手工通道: %v", err)
|
||||
}
|
||||
var channelID int64
|
||||
if err := db.QueryRow(`SELECT id FROM event_score_channels WHERE event_id = 1 AND scope = 'team'`).Scan(&channelID); err != nil {
|
||||
t.Fatalf("读通道 id: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points)
|
||||
VALUES (1, 1, ?, 'manual', 'team', 't:1', NULL, NULL, NULL, 1, 2, 0)`, channelID); err != nil {
|
||||
t.Fatalf("造团体手工名次: %v", err)
|
||||
}
|
||||
// 流水与写入侧一致:source_id 退化为通道 id(见 domain.ledgerFromRanked)。
|
||||
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id,
|
||||
source_type, source_id, points, count_to_team)
|
||||
VALUES (1, NULL, 1, NULL, NULL, 'result', ?, 6, 1)`, channelID); err != nil {
|
||||
t.Fatalf("造团体手工流水: %v", err)
|
||||
}
|
||||
|
||||
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取积分明细: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, r := range wb.Rows {
|
||||
if r.Points != 6 {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if r.Rank != 2 {
|
||||
t.Errorf("团体手工流水的名次 = %d,期望 2(来自 channel_ranks)", r.Rank)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("团体手工流水没出现在明细里: %+v", wb.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
// 「个人+团体」项目的个人通道流水(count_to_team = 0)不进单位总分,
|
||||
// 导出侧与 REST / WS 快照必须同口径。
|
||||
func TestLoadStandingsSkipsPersonalOnlyLedger(t *testing.T) {
|
||||
|
||||
@@ -176,6 +176,12 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
|
||||
if err != nil {
|
||||
return MedalDetailWorkbook{}, err
|
||||
}
|
||||
// 聚合通道与团体手工名次没有对应的成绩(channel_ranks.result_id 为空),按成绩 id 查不到展示字段,
|
||||
// 按小项 + 组别补一份兜底。
|
||||
eventCtx, err := loadMedalEventContext(db)
|
||||
if err != nil {
|
||||
return MedalDetailWorkbook{}, err
|
||||
}
|
||||
teams, err := loadTeams(db, false)
|
||||
if err != nil {
|
||||
return MedalDetailWorkbook{}, err
|
||||
@@ -188,7 +194,7 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
|
||||
|
||||
sheets := make([]MedalDetailSheet, 0, len(teams))
|
||||
for _, t := range teams {
|
||||
rows := medalRowsOfTeam(t.ID, hits, ctx)
|
||||
rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx)
|
||||
if len(rows) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -197,11 +203,14 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
|
||||
return MedalDetailWorkbook{Meta: meta, Sheets: sheets}, nil
|
||||
}
|
||||
|
||||
// medalHit 是一枚奖牌:来自哪条成绩、什么名次、什么牌。
|
||||
// medalHit 是一枚奖牌:来自哪个小项 / 组别的哪条成绩、什么名次、什么牌。
|
||||
// 聚合名次与团体手工名次没有单条成绩(resultID 为 0),小项 + 组别是展示字段的兜底来源。
|
||||
type medalHit struct {
|
||||
resultID int64
|
||||
rank int
|
||||
medal string
|
||||
eventID int64
|
||||
categoryID int64
|
||||
resultID int64
|
||||
rank int
|
||||
medal string
|
||||
}
|
||||
|
||||
// medalHits 逐「报名项」判定获奖单位。
|
||||
@@ -222,13 +231,16 @@ func medalHits(ranked []domain.RankedResult, excludeTeam bool) map[int64][]medal
|
||||
rr := group[0]
|
||||
for _, row := range rows {
|
||||
for i := 0; i < row.Gold; i++ {
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 1, medalGold})
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{
|
||||
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 1, medal: medalGold})
|
||||
}
|
||||
for i := 0; i < row.Silver; i++ {
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 2, medalSilver})
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{
|
||||
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 2, medal: medalSilver})
|
||||
}
|
||||
for i := 0; i < row.Bronze; i++ {
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 3, medalBronze})
|
||||
out[row.TeamID] = append(out[row.TeamID], medalHit{
|
||||
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 3, medal: medalBronze})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,12 +339,57 @@ func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// eventCategoryKey 是「小项 + 组别」的键。
|
||||
type eventCategoryKey struct {
|
||||
eventID int64
|
||||
categoryID int64
|
||||
}
|
||||
|
||||
// loadMedalEventContext 按 (小项, 组别) 建一份展示字段,给没有单条成绩的奖牌兜底:
|
||||
// 聚合通道与团体手工名次在 channel_ranks 里 result_id 为空,按成绩 id 查不到来源,
|
||||
// 只能回到小项 + 组别上取大项 / 小项 / 组别名(运动员/队留空——聚合名次不对应单个人)。
|
||||
func loadMedalEventContext(db *sql.DB) (map[eventCategoryKey]medalContext, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT e.id, COALESCE(c.id, 0), COALESCE(s.name, ''), e.name, COALESCE(c.name, ''),
|
||||
COALESCE(s.sort_order, 0), e.sort_order, COALESCE(c.sort_order, 0)
|
||||
FROM events e
|
||||
LEFT JOIN sports s ON s.id = e.sport_id
|
||||
LEFT JOIN event_categories c ON c.event_id = e.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取奖牌小项来源: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[eventCategoryKey]medalContext)
|
||||
for rows.Next() {
|
||||
var (
|
||||
key eventCategoryKey
|
||||
c medalContext
|
||||
)
|
||||
if err := rows.Scan(&key.eventID, &key.categoryID, &c.sportName, &c.eventName, &c.category,
|
||||
&c.sportSort, &c.eventSort, &c.catSort); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描奖牌小项来源: %w", err)
|
||||
}
|
||||
c.eventID = key.eventID
|
||||
c.categoryID = key.categoryID
|
||||
out[key] = c
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("export: 遍历奖牌小项来源: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// medalRowsOfTeam 把某单位的奖牌拼成展示行,按大项/小项/组别/名次的既有顺序排列。
|
||||
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext) []MedalDetailRow {
|
||||
// 展示字段优先按成绩取(能带上运动员 / 接力队名单),取不到再按小项 + 组别兜底。
|
||||
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext) []MedalDetailRow {
|
||||
list := hits[teamID]
|
||||
rows := make([]MedalDetailRow, 0, len(list))
|
||||
for _, h := range list {
|
||||
c := ctx[h.resultID]
|
||||
c, ok := ctx[h.resultID]
|
||||
if !ok {
|
||||
c = eventCtx[eventCategoryKey{eventID: h.eventID, categoryID: h.categoryID}]
|
||||
}
|
||||
rows = append(rows, MedalDetailRow{
|
||||
SportName: c.sportName,
|
||||
EventName: c.eventName,
|
||||
|
||||
@@ -28,6 +28,7 @@ func TestExportMedalDetailXLSX(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db) // T1 第 1 名、T2 第 2 名
|
||||
addBronzeEntry(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
|
||||
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
|
||||
@@ -127,6 +128,7 @@ func TestMedalDetailMatchesBoard(t *testing.T) {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
wb, err := LoadMedalDetailWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
@@ -173,6 +175,58 @@ func TestMedalDetailMatchesBoard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 没有单条成绩的奖牌(团体手工 / 聚合名次,channel_ranks.result_id 为空)也要能印出
|
||||
// 大项 / 小项 / 组别,并与团体奖牌榜逐单位对得上(devdocs 17 §11.3)。
|
||||
func TestMedalDetailShowsSubjectOnlyMedal(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
|
||||
t.Fatalf("补团体手工通道: %v", err)
|
||||
}
|
||||
var channelID int64
|
||||
if err := db.QueryRow(`SELECT id FROM event_score_channels WHERE event_id = 1 AND scope = 'team'`).Scan(&channelID); err != nil {
|
||||
t.Fatalf("读通道 id: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points)
|
||||
VALUES (1, 1, ?, 'manual', 'team', 't:2', NULL, NULL, NULL, 2, 1, 0)`, channelID); err != nil {
|
||||
t.Fatalf("造团体手工名次: %v", err)
|
||||
}
|
||||
|
||||
wb, err := LoadMedalDetailWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取奖牌明细: %v", err)
|
||||
}
|
||||
board, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取团体奖牌榜: %v", err)
|
||||
}
|
||||
for _, row := range board.Medal {
|
||||
gold, silver, bronze := medalCountsOf(wb, row.TeamID)
|
||||
if gold != row.Gold || silver != row.Silver || bronze != row.Bronze {
|
||||
t.Errorf("单位 %d 奖牌明细 金%d/银%d/铜%d,榜单 金%d/银%d/铜%d",
|
||||
row.TeamID, gold, silver, bronze, row.Gold, row.Silver, row.Bronze)
|
||||
}
|
||||
}
|
||||
|
||||
subjectOnly := 0
|
||||
for _, s := range wb.Sheets {
|
||||
for _, r := range s.Rows {
|
||||
if r.SportName == "" || r.EventName == "" || r.Category == "" {
|
||||
t.Errorf("奖牌明细缺大项 / 小项 / 组别: %+v", r)
|
||||
}
|
||||
if r.Participant == "" {
|
||||
subjectOnly++
|
||||
}
|
||||
}
|
||||
}
|
||||
if subjectOnly != 1 {
|
||||
t.Errorf("没有成绩的奖牌行数 = %d,期望 1(团体手工名次),明细 = %+v", subjectOnly, wb.Sheets)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有奖牌(甚至没有小项)时不报错,返回只有表头的总表。
|
||||
func TestExportMedalDetailEmptyEvent(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
@@ -204,6 +258,7 @@ func TestExportMedalDetailEmptyEvent(t *testing.T) {
|
||||
func TestMedalDetailKeepsHiddenTeamWithMedals(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
silent := insertTeam(t, db, "H2", "被隐藏且无牌", "隐藏二", 4, false)
|
||||
hideTeam(t, db, silent)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
func TestExportStandingsXLSX(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
seedChannelRanks(t, db)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "总表.xlsx")
|
||||
if err := ExportStandingsXLSX(db, dst, Options{}); err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi_test
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
@@ -50,6 +51,22 @@ func (e *testEnv) exec(t *testing.T, query string, args ...any) {
|
||||
}
|
||||
}
|
||||
|
||||
// entryBoard 是 GET /api/events/{id}/entries 的响应(设计 17 §七:成绩行 + 随行下发的团体名次)。
|
||||
type entryBoard struct {
|
||||
Entries []map[string]any `json:"entries"`
|
||||
TeamRanks []map[string]any `json:"teamRanks"`
|
||||
}
|
||||
|
||||
// getEntryBoard 取成绩录入接口的完整读数:报名项 + 成绩 + 该小项的团体名次视图。
|
||||
func getEntryBoard(t *testing.T, env *testEnv, client *http.Client, eventID int64) entryBoard {
|
||||
t.Helper()
|
||||
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", eventID), nil, false)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("读成绩录入接口应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
return decodeData[entryBoard](t, resp)
|
||||
}
|
||||
|
||||
func (e *testEnv) insert(t *testing.T, query string, args ...any) int64 {
|
||||
t.Helper()
|
||||
res, err := e.ev.Exec(query, args...)
|
||||
|
||||
@@ -319,6 +319,51 @@ func TestUpdateCategoryMissingReturns404(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 整表替换是「新建 / 重排组别」的唯一入口,字段校验必须与就地更新同口径:
|
||||
// 非法 gender 不能再撞 CHECK 报 500,越界 / 颠倒的年龄也不能静默入库。
|
||||
func TestReplaceCategoriesValidatesFields(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
// 换一个还没有报名数据的小项:有报名时整表替换本来就按 409 拒绝。
|
||||
event := 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, 'pending')`, ids.sport)
|
||||
path := fmt.Sprintf("/api/events/%d/categories", event)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
want string
|
||||
}{
|
||||
{"性别枚举", map[string]any{"name": "男子", "multiplier": 1, "gender": "M"}, "组别 gender 只能是 男 / 女"},
|
||||
{"年龄范围", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 500, "ageMax": -3}, "组别年龄需要在 0-120 之间"},
|
||||
{"年龄区间", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 30, "ageMax": 10}, "组别年龄下限不能大于上限"},
|
||||
} {
|
||||
status, env2 := env.do(client, http.MethodPut, path,
|
||||
map[string]any{"categories": []map[string]any{tc.body}}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
|
||||
}
|
||||
if env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" || env2.Error.Message != tc.want {
|
||||
t.Fatalf("%s 文案应为 %q(VALIDATION_FAILED),得到 %+v", tc.name, tc.want, env2.Error)
|
||||
}
|
||||
// 拒绝时整表替换不能留下半截组别(DELETE 在同一事务里,必须一起回滚)。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM event_categories WHERE event_id = ?`, event); n != 0 {
|
||||
t.Fatalf("%s 被拒后不该留下组别,得到 %d 条", tc.name, n)
|
||||
}
|
||||
}
|
||||
|
||||
// 合法值(含留空 = 不限的性别)照常写入。
|
||||
status, env2 := env.do(client, http.MethodPut, path, map[string]any{"categories": []map[string]any{
|
||||
{"name": "男子", "multiplier": 1, "gender": "男", "ageMin": 18, "ageMax": 35},
|
||||
{"name": "女子", "multiplier": 1},
|
||||
}}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("合法组别应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM event_categories WHERE event_id = ?`, event); n != 2 {
|
||||
t.Fatalf("合法组别应写入 2 条,得到 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 老路径(整表替换)不能被改坏:有报名数据时仍然拒绝,但文案要说清是「不能增删」。
|
||||
func TestReplaceCategoriesStillRejectsWithEntries(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
@@ -59,28 +59,36 @@ type channelDTO struct {
|
||||
// CountToTeam 决定该通道产出的流水是否计入单位总分;请求体里缺省(null)按 true 处理,
|
||||
// 「个人+团体」项目的个人通道传 false。响应里始终是具体值。
|
||||
CountToTeam *bool `json:"countToTeam"`
|
||||
// CountToMedals 决定该通道产出的名次是否发奖牌;缺省(null)按 true 处理(与库中默认值一致)。
|
||||
CountToMedals *bool `json:"countToMedals"`
|
||||
// CountToTeamMedals 决定该通道的个人奖牌是否并入单位奖牌榜;缺省按 true 处理。
|
||||
// 只对 scope=athlete 的通道有意义:scope=team 的名次本来就记在单位头上。
|
||||
CountToTeamMedals *bool `json:"countToTeamMedals"`
|
||||
}
|
||||
|
||||
type eventDTO 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"`
|
||||
AvgMode string `json:"avgMode"`
|
||||
Categories []categoryDTO `json:"categories,omitempty"`
|
||||
Channels []channelDTO `json:"channels,omitempty"`
|
||||
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"`
|
||||
AvgMode string `json:"avgMode"`
|
||||
// AwardMedals 决定该小项是否结算奖牌;请求体缺省按 true 处理(与库中默认值一致)。
|
||||
// 关闭后小项的名次不产生任何奖牌,积分不受影响。
|
||||
AwardMedals bool `json:"awardMedals"`
|
||||
Categories []categoryDTO `json:"categories,omitempty"`
|
||||
Channels []channelDTO `json:"channels,omitempty"`
|
||||
}
|
||||
|
||||
type profileRuleDTO struct {
|
||||
|
||||
@@ -269,50 +269,40 @@ func (s *server) batchEntries(ctx context.Context, action string, ids []int64) (
|
||||
}
|
||||
|
||||
// 按 (小项, 组别) 分组重算:跨小项混选是正常的「按人名勾选」场景,但重算必须逐个范围来。
|
||||
//
|
||||
// 退赛 / 恢复 / 移出都属回滚类动作,延迟结算(live_on_publish=false)下也必须当场重算:
|
||||
// - 它们改的不是「录了多少」,而是「谁还算数」,与撤销发布、作废成绩同一个性质,
|
||||
// 文档 02 §5.1 也承诺退赛会把 results.rank 显式清掉再按新名次重算,没有「延迟结算下除外」;
|
||||
// - 不重算就会留下「退赛者仍挂着名次与分数、同组别人的名次停在旧口径」,移出报名更糟:
|
||||
// 流水已经物理删掉、榜单数据被改,大屏却推不动,永远停在上一次的旧画面;
|
||||
// - 现场可能已经结算过一次(人工立即结算、改组别权重、规则导入都会在延迟模式下写流水)
|
||||
// 而整组还没录完,此时 scopeSettled 返回 false,只按「录完了没」判断就补不上这一刀。
|
||||
actorID := middleware.User(ctx).ID
|
||||
live, err := liveOnPublishQ(ctx, tx)
|
||||
if err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
eventIDs := make([]int64, 0, 2)
|
||||
settled := false
|
||||
for _, scope := range distinctScopes(affected) {
|
||||
// 退赛 / 恢复会改变「这一组该不该等它」:标成退赛后整组可能刚好录完,
|
||||
// 延迟结算下必须补一次结算,否则榜单会永远停在没有流水的那一版。
|
||||
settle := live
|
||||
if !live {
|
||||
var err error
|
||||
settle, err = scopeSettled(ctx, tx, scope.eventID, scope.categoryID)
|
||||
if err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
// 退赛的成绩不再占名次:results.rank(个人名次)必须一并清掉,channel_ranks 由
|
||||
// writeLedgerTx 按当前已发布成绩整组重建,不清就会留下「0 分却还挂着金牌」;
|
||||
// 恢复时由 applyComputedRanks 按新名次写回。
|
||||
if action == "scratch" {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE results SET rank = NULL
|
||||
WHERE state = 'published'
|
||||
AND entry_id IN (SELECT id FROM entries
|
||||
WHERE category_id = ? AND id IN (`+placeholders(len(changedIDs))+`))`,
|
||||
append([]any{scope.categoryID}, idsToArgs(changedIDs)...)...); err != nil {
|
||||
return entryBatchResultDTO{}, internal(err)
|
||||
}
|
||||
}
|
||||
if settle {
|
||||
// 退赛的成绩不再占名次:results.rank 必须一并清掉。
|
||||
// buildRanked(奖牌统计 / 运动员榜)只认 results.rank + state='published',
|
||||
// 不清就会留下「0 分却还挂着金牌」;恢复时由 applyComputedRanks 按新名次写回。
|
||||
if action == "scratch" {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE results SET rank = NULL
|
||||
WHERE state = 'published'
|
||||
AND entry_id IN (SELECT id FROM entries
|
||||
WHERE category_id = ? AND id IN (`+placeholders(len(changedIDs))+`))`,
|
||||
append([]any{scope.categoryID}, idsToArgs(changedIDs)...)...); err != nil {
|
||||
return entryBatchResultDTO{}, internal(err)
|
||||
}
|
||||
}
|
||||
score, err := computeEventScore(ctx, tx, scope.eventID, scope.categoryID)
|
||||
if err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
// 只重建流水不够:成绩表与奖牌统计看的是 results.rank,必须像发布那样把名次写回去。
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
if err := writeLedgerTx(ctx, tx, score, scope.eventID, scope.categoryID, actorID, recomputeReason(action)); err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
settled = true
|
||||
// 与发布 / 立即结算同一套:recomputeLedgerTx(算 + 作废旧流水 + 写新流水 + 重建 channel_ranks)
|
||||
// 之后再把个人名次写回 results.rank,别处改了重算口径这里要跟着改。
|
||||
score, err := recomputeLedgerTx(ctx, tx, scope.eventID, scope.categoryID, actorID, recomputeReason(action))
|
||||
if err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
// 只重建流水不够:成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去;
|
||||
// 奖牌榜与运动员榜看的是 channel_ranks,由 writeLedgerTx 一并重建。
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
if !slices.Contains(eventIDs, scope.eventID) {
|
||||
eventIDs = append(eventIDs, scope.eventID)
|
||||
@@ -324,8 +314,8 @@ func (s *server) batchEntries(ctx context.Context, action string, ids []int64) (
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
}
|
||||
// 延迟结算且没有一组录完时不推榜单:榜单该保持上一次结算的状态。
|
||||
if len(eventIDs) > 0 && settled {
|
||||
// 重算过就必须推榜单:上面刚把名次与流水改成当前口径,不推大屏就还是旧画面。
|
||||
if len(eventIDs) > 0 {
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -409,3 +410,595 @@ func TestEntryBatchRemoveKeepsOtherResultsIntact(t *testing.T) {
|
||||
t.Fatalf("B 的名次应重算为 1 且保持发布态,得到 rank=%+v state=%s", rank, state)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 延迟结算 vs 实时结算:口径一致 ----------
|
||||
|
||||
// ranksOf 读每个报名项当前写回 results 的名次;成绩行不存在(被移出)按「无名次」处理。
|
||||
func ranksOf(t *testing.T, env *testEnv, entryIDs []int64) map[int64]int {
|
||||
t.Helper()
|
||||
out := make(map[int64]int, len(entryIDs))
|
||||
for _, id := range entryIDs {
|
||||
var rank sql.NullInt64
|
||||
err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("读名次: %v", err)
|
||||
}
|
||||
if rank.Valid {
|
||||
out[id] = int(rank.Int64)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ledgerRowsOf 把有效流水 dump 成可比文本;only 非空时只保留这些报名项的流水
|
||||
// (用于确认别的组别没有被牵连)。
|
||||
func ledgerRowsOf(t *testing.T, env *testEnv, eventID int64, only []int64) []string {
|
||||
t.Helper()
|
||||
var allowed map[int64]bool
|
||||
if only != nil {
|
||||
allowed = make(map[int64]bool, len(only))
|
||||
for _, id := range only {
|
||||
allowed[id] = true
|
||||
}
|
||||
}
|
||||
rows, err := env.ev.Query(
|
||||
`SELECT entry_id, team_id, source_type, points FROM score_ledger
|
||||
WHERE event_id = ? AND voided = 0 ORDER BY entry_id, source_type, points`, eventID)
|
||||
if err != nil {
|
||||
t.Fatalf("读流水: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID, teamID sql.NullInt64
|
||||
source string
|
||||
points float64
|
||||
)
|
||||
if err := rows.Scan(&entryID, &teamID, &source, &points); err != nil {
|
||||
t.Fatalf("扫描流水: %v", err)
|
||||
}
|
||||
if allowed != nil && !allowed[entryID.Int64] {
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%v/%v/%s/%v", entryID.Int64, teamID.Int64, source, points))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历流水: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// batchSnapshot 是一次批量操作前后的可比状态:名次、有效流水、榜单原始字节。
|
||||
type batchSnapshot struct {
|
||||
ranks map[int64]int
|
||||
ledger []string
|
||||
standings string
|
||||
}
|
||||
|
||||
func readBatchSnapshot(t *testing.T, env *testEnv, client *http.Client, eventID int64, entryIDs []int64) batchSnapshot {
|
||||
t.Helper()
|
||||
return batchSnapshot{
|
||||
ranks: ranksOf(t, env, entryIDs),
|
||||
ledger: ledgerRowsOf(t, env, eventID, nil),
|
||||
standings: rawData(t, env, client, "/api/standings"),
|
||||
}
|
||||
}
|
||||
|
||||
// standingsPushes 数榜单推送次数:每条都是 action=replace 的整张榜单快照。
|
||||
func standingsPushes(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
return countRows(t, env,
|
||||
`SELECT COUNT(*) FROM change_log WHERE entity_type = 'standings' AND action = 'replace'`)
|
||||
}
|
||||
|
||||
// batchRun 是「同一串操作在某种结算模式下」的结果,两次跑之间只差 live_on_publish 一个开关。
|
||||
type batchRun struct {
|
||||
entries []int64 // 8 条报名项,顺序与 seedBatch 一致
|
||||
teamA, teamB int64
|
||||
before batchSnapshot
|
||||
after batchSnapshot
|
||||
pushesBefore int
|
||||
pushesAfter int
|
||||
}
|
||||
|
||||
// runScenario 用同一串操作跑一遍场景,act 是本次被考察的那个「回滚类」动作:
|
||||
// 8 人报名 → 只录完前 4 条并发布 →(withSettle 时)立即结算 → act。
|
||||
//
|
||||
// live 只控制 standings.live_on_publish 一个开关,其它输入完全一样,
|
||||
// 所以「实时 vs 延迟」两次跑出来的快照差异只可能来自结算模式。
|
||||
// withSettle 区分两种现场:已经人工结算过一次(延迟模式下的复现前置)与延迟窗口内一次都没结算过。
|
||||
func runScenario(t *testing.T, live, withSettle bool,
|
||||
act func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64)) batchRun {
|
||||
t.Helper()
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 8)
|
||||
setLiveOnPublish(t, env, live)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
times := make([]float64, 8)
|
||||
for i := range times {
|
||||
times[i] = 12.0 + float64(i)*0.1
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if withSettle {
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/settle", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "requestId": "settle-before-act",
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("立即结算应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
}
|
||||
|
||||
run := batchRun{
|
||||
entries: entries,
|
||||
teamA: ids.teamA,
|
||||
teamB: ids.teamB,
|
||||
before: readBatchSnapshot(t, env, client, ids.event, entries),
|
||||
pushesBefore: standingsPushes(t, env),
|
||||
}
|
||||
act(t, env, client, ids, entries)
|
||||
run.after = readBatchSnapshot(t, env, client, ids.event, entries)
|
||||
run.pushesAfter = standingsPushes(t, env)
|
||||
assertNoForeignKeyViolation(t, env)
|
||||
return run
|
||||
}
|
||||
|
||||
// runBatchScenario 跑一遍批量动作(scratch / restore / remove)。
|
||||
func runBatchScenario(t *testing.T, live bool, action string, target int, withSettle bool) batchRun {
|
||||
t.Helper()
|
||||
return runScenario(t, live, withSettle,
|
||||
func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64) {
|
||||
t.Helper()
|
||||
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": action, "ids": []int64{entries[target]}, "reason": "结算模式一致用例",
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("批量 %s 应 200,得到 %d %+v", action, status, e.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// voidResultOf 按报名项找到它的成绩行并调作废接口(前端同一条 POST /api/results/{id}/void)。
|
||||
func voidResultOf(t *testing.T, env *testEnv, client *http.Client, entryID int64) {
|
||||
t.Helper()
|
||||
var resultID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, entryID).Scan(&resultID); err != nil {
|
||||
t.Fatalf("读成绩 id: %v", err)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/results/%d/void", resultID), map[string]any{"reason": "结算模式一致用例"}, true); status != http.StatusOK {
|
||||
t.Fatalf("作废成绩应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// runVoidScenario 跑一遍「作废单条成绩」,targets 是要作废的报名项(可为多条,用于「整组作废」边界)。
|
||||
func runVoidScenario(t *testing.T, live bool, withSettle bool, targets ...int) batchRun {
|
||||
t.Helper()
|
||||
return runScenario(t, live, withSettle,
|
||||
func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64) {
|
||||
t.Helper()
|
||||
for _, target := range targets {
|
||||
voidResultOf(t, env, client, entries[target])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// entryLedgerRow 拼一条流水的可比文本,期望值按报名项与单位 id 现算,不写死数字。
|
||||
func entryLedgerRow(entryID, teamID int64, points float64) string {
|
||||
return fmt.Sprintf("%d/%d/result/%v", entryID, teamID, points)
|
||||
}
|
||||
|
||||
// assertSameBefore 断言批量动作发生之前,两种结算模式手里的数据已经一致。
|
||||
func assertSameBefore(t *testing.T, label string, live, deferred batchRun) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(live.before.ranks, deferred.before.ranks) {
|
||||
t.Fatalf("%s:操作前的名次就不一致:实时 %v vs 延迟 %v", label, live.before.ranks, deferred.before.ranks)
|
||||
}
|
||||
if !reflect.DeepEqual(live.before.ledger, deferred.before.ledger) {
|
||||
t.Fatalf("%s:操作前的流水就不一致:实时 %v vs 延迟 %v", label, live.before.ledger, deferred.before.ledger)
|
||||
}
|
||||
if live.before.standings != deferred.before.standings {
|
||||
t.Fatalf("%s:操作前的榜单不一致:\n实时 %s\n延迟 %s", label, live.before.standings, deferred.before.standings)
|
||||
}
|
||||
}
|
||||
|
||||
// assertSameAfter 断言两种结算模式得到的是同一份数据:名次、有效流水、榜单原始字节。
|
||||
// 这是「同一份数据不管实时结算还是延迟结算,最终榜单必须一模一样」的落点。
|
||||
func assertSameAfter(t *testing.T, label string, live, deferred batchRun) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(live.after.ranks, deferred.after.ranks) {
|
||||
t.Fatalf("%s:名次不一致:实时 %v vs 延迟 %v", label, live.after.ranks, deferred.after.ranks)
|
||||
}
|
||||
if !reflect.DeepEqual(live.after.ledger, deferred.after.ledger) {
|
||||
t.Fatalf("%s:流水不一致:实时 %v vs 延迟 %v", label, live.after.ledger, deferred.after.ledger)
|
||||
}
|
||||
if live.after.standings != deferred.after.standings {
|
||||
t.Fatalf("%s:榜单不一致:\n实时 %s\n延迟 %s", label, live.after.standings, deferred.after.standings)
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟结算 + 批量退赛:退赛的人必须当场丢掉名次与分数、同组别人按新名次重算,
|
||||
// 结果与实时结算逐字节一致(报告模块7 [1])。
|
||||
// 两种现场都跑:已经人工结算过一次(报告里的复现前置),以及延迟窗口内一次都没结算过。
|
||||
func TestDeferredBatchScratchMatchesRealTime(t *testing.T) {
|
||||
check := func(t *testing.T, live, deferred batchRun, compareBefore bool) {
|
||||
t.Helper()
|
||||
if compareBefore {
|
||||
assertSameBefore(t, "批量退赛", live, deferred)
|
||||
}
|
||||
assertSameAfter(t, "批量退赛", live, deferred)
|
||||
|
||||
// 前置:前四名 10/8/6/3 已经在实时结算那侧落了账。
|
||||
if len(live.before.ledger) != 4 {
|
||||
t.Fatalf("前置:应已结算 4 条流水,得到 %v", live.before.ledger)
|
||||
}
|
||||
// 退赛的第 1 名:名次被清、流水被作废;剩下 3 人顺推成第 1/2/3 名 10/8/6 分。
|
||||
after := deferred.after
|
||||
if _, ok := after.ranks[live.entries[0]]; ok {
|
||||
t.Fatalf("退赛者不应再占名次,得到 %v", after.ranks)
|
||||
}
|
||||
wantRanks := map[int64]int{
|
||||
live.entries[1]: 1, live.entries[2]: 2, live.entries[3]: 3,
|
||||
}
|
||||
for id, rank := range wantRanks {
|
||||
if after.ranks[id] != rank {
|
||||
t.Fatalf("报名项 %d 应第 %d 名,得到 %v", id, rank, after.ranks)
|
||||
}
|
||||
}
|
||||
wantLedger := []string{
|
||||
entryLedgerRow(live.entries[1], live.teamB, 10),
|
||||
entryLedgerRow(live.entries[2], live.teamA, 8),
|
||||
entryLedgerRow(live.entries[3], live.teamB, 6),
|
||||
}
|
||||
if !reflect.DeepEqual(after.ledger, wantLedger) {
|
||||
t.Fatalf("退赛后流水应重算成 %v,得到 %v", wantLedger, after.ledger)
|
||||
}
|
||||
// 延迟结算下这个动作也必须推榜单:否则大屏还挂着「退赛者仍拿金牌」的旧画面。
|
||||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||||
t.Fatalf("退赛应推 1 次榜单(standings replace),得到 %d", got)
|
||||
}
|
||||
if got := live.pushesAfter - live.pushesBefore; got != 1 {
|
||||
t.Fatalf("实时结算下退赛也应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("现场已人工结算过一次", func(t *testing.T) {
|
||||
check(t,
|
||||
runBatchScenario(t, true, "scratch", 0, true),
|
||||
runBatchScenario(t, false, "scratch", 0, true), true)
|
||||
})
|
||||
t.Run("延迟窗口内一次都没结算过", func(t *testing.T) {
|
||||
// 这一档下延迟侧操作前本就还没有流水/名次,只比操作之后的最终数据。
|
||||
check(t,
|
||||
runBatchScenario(t, true, "scratch", 0, false),
|
||||
runBatchScenario(t, false, "scratch", 0, false), false)
|
||||
})
|
||||
}
|
||||
|
||||
// 延迟结算 + 批量移出报名:流水被正确作废/重建、名次更新、榜单被推送,
|
||||
// 且与实时结算逐字节一致(报告模块7 [10])。
|
||||
func TestDeferredBatchRemoveMatchesRealTime(t *testing.T) {
|
||||
check := func(t *testing.T, live, deferred batchRun, compareBefore bool) {
|
||||
t.Helper()
|
||||
if compareBefore {
|
||||
assertSameBefore(t, "批量移出报名", live, deferred)
|
||||
}
|
||||
assertSameAfter(t, "批量移出报名", live, deferred)
|
||||
|
||||
after := deferred.after
|
||||
// 被移出的人:报名项与成绩都没了(ranks 里没有它),流水一条不剩。
|
||||
if _, ok := after.ranks[live.entries[1]]; ok {
|
||||
t.Fatalf("被移出的报名项不该还有名次,得到 %v", after.ranks)
|
||||
}
|
||||
wantRanks := map[int64]int{
|
||||
live.entries[0]: 1, live.entries[2]: 2, live.entries[3]: 3,
|
||||
}
|
||||
for id, rank := range wantRanks {
|
||||
if after.ranks[id] != rank {
|
||||
t.Fatalf("报名项 %d 应第 %d 名,得到 %v", id, rank, after.ranks)
|
||||
}
|
||||
}
|
||||
// 原来 10/8/6/3:移出第 2 名后剩下 10/8/6(第 3、4 名各升一位)。
|
||||
wantLedger := []string{
|
||||
entryLedgerRow(live.entries[0], live.teamA, 10),
|
||||
entryLedgerRow(live.entries[2], live.teamA, 8),
|
||||
entryLedgerRow(live.entries[3], live.teamB, 6),
|
||||
}
|
||||
if !reflect.DeepEqual(after.ledger, wantLedger) {
|
||||
t.Fatalf("移出后流水应重建为 %v,得到 %v", wantLedger, after.ledger)
|
||||
}
|
||||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||||
t.Fatalf("移出报名应推 1 次榜单(standings replace),得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("现场已人工结算过一次", func(t *testing.T) {
|
||||
check(t,
|
||||
runBatchScenario(t, true, "remove", 1, true),
|
||||
runBatchScenario(t, false, "remove", 1, true), true)
|
||||
})
|
||||
t.Run("延迟窗口内一次都没结算过", func(t *testing.T) {
|
||||
check(t,
|
||||
runBatchScenario(t, true, "remove", 1, false),
|
||||
runBatchScenario(t, false, "remove", 1, false), false)
|
||||
})
|
||||
}
|
||||
|
||||
// 延迟结算 + 作废单条成绩:作废的人当场退出名次与流水、同组别人按新名次重算、榜单被推送,
|
||||
// 结果与实时结算逐字节一致(报告模块7 [2])。
|
||||
// 作废与撤销发布、批量退赛/移出同属回滚动作,改的是「谁还算数」,不受延迟结算约束。
|
||||
func TestDeferredVoidMatchesRealTime(t *testing.T) {
|
||||
check := func(t *testing.T, live, deferred batchRun, compareBefore bool, wantLedger []string, wantRanks map[int64]int) {
|
||||
t.Helper()
|
||||
if compareBefore {
|
||||
assertSameBefore(t, "作废成绩", live, deferred)
|
||||
}
|
||||
assertSameAfter(t, "作废成绩", live, deferred)
|
||||
|
||||
after := deferred.after
|
||||
if !reflect.DeepEqual(after.ledger, wantLedger) {
|
||||
t.Fatalf("作废后流水应为 %v,得到 %v", wantLedger, after.ledger)
|
||||
}
|
||||
if !reflect.DeepEqual(after.ranks, wantRanks) {
|
||||
t.Fatalf("作废后名次应为 %v,得到 %v", wantRanks, after.ranks)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("作废的是最后一名", func(t *testing.T) {
|
||||
// 前四名 10/8/6/3,作废第 4 名:别人名次不动,只剩前三名的 10/8/6,总分 27 → 24。
|
||||
for _, withSettle := range []bool{true, false} {
|
||||
live := runVoidScenario(t, true, withSettle, 3)
|
||||
deferred := runVoidScenario(t, false, withSettle, 3)
|
||||
// 期望值里的 entry/team id 按现跑的 env 现算,不写死数字。
|
||||
check(t, live, deferred, withSettle,
|
||||
[]string{
|
||||
entryLedgerRow(live.entries[0], live.teamA, 10),
|
||||
entryLedgerRow(live.entries[1], live.teamB, 8),
|
||||
entryLedgerRow(live.entries[2], live.teamA, 6),
|
||||
},
|
||||
map[int64]int{live.entries[0]: 1, live.entries[1]: 2, live.entries[2]: 3})
|
||||
// 被作废的人不再占名次(与批量退赛同口径:作废行也让出 results.rank)。
|
||||
if _, ok := deferred.after.ranks[live.entries[3]]; ok {
|
||||
t.Fatalf("被作废的成绩不该再占名次,得到 %v", deferred.after.ranks)
|
||||
}
|
||||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||||
t.Fatalf("作废成绩应推 1 次榜单(standings replace),得到 %d", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("作废后整组只剩无效成绩", func(t *testing.T) {
|
||||
// 四条已发布成绩全部作废:整组没有可排名的成绩,流水清空、名次全空、榜单归零。
|
||||
for _, withSettle := range []bool{true, false} {
|
||||
live := runVoidScenario(t, true, withSettle, 0, 1, 2, 3)
|
||||
deferred := runVoidScenario(t, false, withSettle, 0, 1, 2, 3)
|
||||
check(t, live, deferred, withSettle, []string{}, map[int64]int{})
|
||||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 4 {
|
||||
t.Fatalf("每次作废都应推榜单,4 次应推 4 次,得到 %d", got)
|
||||
}
|
||||
if deferred.pushesAfter-deferred.pushesBefore != live.pushesAfter-live.pushesBefore {
|
||||
t.Fatalf("实时与延迟的推榜次数应一致:%d vs %d",
|
||||
live.pushesAfter-live.pushesBefore, deferred.pushesAfter-deferred.pushesBefore)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 作废立刻重算 ≠ 提前结算:延迟窗口内作废一条成绩后,这一组仍是「未结算」,
|
||||
// 录入页照旧提示「榜单还没反映这批」,其它批次照样等录完才结算。
|
||||
// 这条钉住 scoring.go 里那句「作废不写 state,不会把未录完的组提前变成已结算」。
|
||||
func TestDeferredVoidKeepsGroupUnsettled(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 8)
|
||||
setLiveOnPublish(t, env, false)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
times := make([]float64, 8)
|
||||
for i := range times {
|
||||
times[i] = 12.0 + float64(i)*0.1
|
||||
}
|
||||
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times),
|
||||
}, true)
|
||||
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true)
|
||||
// 发布 4/8 且没结算:这一组在延迟窗口里,流水为空。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event)
|
||||
|
||||
voidResultOf(t, env, client, entries[0])
|
||||
|
||||
// 作废当场生效:剩下 3 条已发布成绩算出 10/8/6,名次写回,榜单被推。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 3, ids.event)
|
||||
if got := rankOfEntry(t, env, entries[1]); got != 1 {
|
||||
t.Fatalf("作废冠军后第 2 名应升到第 1 名,得到 %d", got)
|
||||
}
|
||||
if got := standingsTotal(t, env, client); got != 24 {
|
||||
t.Fatalf("作废后榜单总分应为 10+8+6=24,得到 %v", got)
|
||||
}
|
||||
// 但这一组还没录完(8 人里只录出 4 条,其中 1 条作废),仍应留在未结算清单里。
|
||||
pending := readSettlement(t, env, client, ids.event, ids.category)
|
||||
if len(pending.Groups) != 1 || pending.Groups[0].Required != 8 || pending.Groups[0].Published != 4 {
|
||||
t.Fatalf("作废不该把未录完的组变成已结算,得到 %+v", pending.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
// 反例保护:批量动作只重算被勾选报名项所在的 (小项, 组别),
|
||||
// 同小项另一个组别的名次与流水必须原样不动。
|
||||
func TestDeferredBatchScratchKeepsOtherCategoriesIntact(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
setLiveOnPublish(t, env, false)
|
||||
|
||||
// 同小项的第二个组别:自己一条报名 + 一条已发布成绩。
|
||||
category2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, ids.event, category2, ids.teamA, ids.athleteA)
|
||||
publishResults(t, env, client, ids.event, "cat2",
|
||||
map[string]any{"entryId": entryC, "resultValue": 13.20, "resultText": "13.20", "unit": "s", "status": "ok"},
|
||||
)
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/settle", map[string]any{
|
||||
"eventId": ids.event, "categoryId": category2, "requestId": "settle-cat2",
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("结算组别二应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
// 组别一:录一条已发布成绩,但不结算(延迟窗口内)。
|
||||
publishResults(t, env, client, ids.event, "cat1",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 12.00, "resultText": "12.00", "unit": "s", "status": "ok"},
|
||||
)
|
||||
|
||||
ranksBefore := ranksOf(t, env, []int64{entryC})
|
||||
ledgerBefore := ledgerRowsOf(t, env, ids.event, []int64{entryC})
|
||||
if ranksBefore[entryC] != 1 || !reflect.DeepEqual(ledgerBefore, []string{entryLedgerRow(entryC, ids.teamA, 10)}) {
|
||||
t.Fatalf("前置:组别二应是第 1 名 10 分,得到 ranks=%v ledger=%v", ranksBefore, ledgerBefore)
|
||||
}
|
||||
standingsBefore := rawData(t, env, client, "/api/standings")
|
||||
|
||||
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "scratch", "ids": []int64{ids.entryA},
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
if got := ranksOf(t, env, []int64{entryC}); !reflect.DeepEqual(got, ranksBefore) {
|
||||
t.Fatalf("别的组别的名次不该被改动:%v → %v", ranksBefore, got)
|
||||
}
|
||||
if got := ledgerRowsOf(t, env, ids.event, []int64{entryC}); !reflect.DeepEqual(got, ledgerBefore) {
|
||||
t.Fatalf("别的组别的流水不该被改动:%v → %v", ledgerBefore, got)
|
||||
}
|
||||
if got := entryStatus(t, env, entryC); got != "registered" {
|
||||
t.Fatalf("别的组别的报名状态不该被改动,得到 %s", got)
|
||||
}
|
||||
// 组别一的成绩还没结算、退赛后更不该有分,所以整张榜单的内容一格都不该动。
|
||||
if got := rawData(t, env, client, "/api/standings"); got != standingsBefore {
|
||||
t.Fatalf("别的组别没被牵连时榜单内容不该变:\n之前 %s\n之后 %s", standingsBefore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 破纪录加分跟随状态 ----------
|
||||
|
||||
// 打开破纪录加分并配一档「赛会纪录 +2 分」,用于端到端验证加分随状态出现 / 消失(P1-6)。
|
||||
func enableRecordBonus(t *testing.T, env *testEnv) {
|
||||
t.Helper()
|
||||
env.exec(t, `INSERT INTO settings (key, value) VALUES ('record_bonus.enabled', 'true')
|
||||
ON CONFLICT(key) DO UPDATE SET value = 'true'`)
|
||||
env.exec(t, `INSERT INTO settings (key, value) VALUES ('record_bonus.levels',
|
||||
'[{"code":"meet","name":"赛会纪录","points":2}]')
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
|
||||
}
|
||||
|
||||
// activeRecordBonusPoints 取某报名项名下的有效破纪录加分(无则 0)。
|
||||
func activeRecordBonusPoints(t *testing.T, env *testEnv, entryID int64) float64 {
|
||||
t.Helper()
|
||||
var points sql.NullFloat64
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT SUM(points) FROM score_ledger
|
||||
WHERE entry_id = ? AND source_type = 'record_bonus' AND voided = 0`, entryID).Scan(&points); err != nil {
|
||||
t.Fatalf("统计破纪录加分: %v", err)
|
||||
}
|
||||
return points.Float64
|
||||
}
|
||||
|
||||
// 退赛 / 恢复 / 作废成绩都不能让加分留在榜上:加分与名次分同生同灭(P1-6)。
|
||||
func TestRecordBonusFollowsStatusEndToEnd(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
enableRecordBonus(t, env)
|
||||
publishResults(t, env, client, ids.event, "bonus",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
|
||||
// 登记并确认「赛会纪录」:确认会当场重算,加分流水随之出现。
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
|
||||
"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
|
||||
"resultValue": 11.58, "resultText": "11.58",
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("登记破纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
breakID := int64(decodeData[map[string]any](t, env2)["id"].(float64))
|
||||
if status, env2 = env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("确认破纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||||
t.Fatalf("确认后应记 2 分加分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
|
||||
t.Fatalf("确认后单位总分应是 10+2,得到 %v", got)
|
||||
}
|
||||
|
||||
// 退赛 → 加分与名次一起消失。
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "scratch", "ids": []int64{ids.entryA}, "reason": "破纪录加分口径用例",
|
||||
}, true); status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
|
||||
t.Fatalf("退赛后不该再有破纪录加分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||||
t.Fatalf("退赛后该单位总分应归零,得到 %v", got)
|
||||
}
|
||||
|
||||
// 恢复 → 同一个登记把加分还回来(登记是现场事实,不能被一次性作废)。
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "restore", "ids": []int64{ids.entryA},
|
||||
}, true); status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("批量恢复应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||||
t.Fatalf("恢复后应把加分还回来,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
|
||||
t.Fatalf("恢复后单位总分应回到 12,得到 %v", got)
|
||||
}
|
||||
|
||||
// 作废成绩(results.status = withdrawn)→ 加分同样消失。
|
||||
var resultID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&resultID); err != nil {
|
||||
t.Fatalf("读成绩 id: %v", err)
|
||||
}
|
||||
if status, env2 := env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/results/%d/void", resultID), map[string]any{"reason": "破纪录加分口径用例"}, true); status != http.StatusOK {
|
||||
t.Fatalf("作废成绩应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
|
||||
t.Fatalf("成绩作废后不该再有破纪录加分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||||
t.Fatalf("成绩作废后该单位总分应归零,得到 %v", got)
|
||||
}
|
||||
|
||||
// 把成绩改回有效再发布 → 加分回来(与名次同一判据)。
|
||||
env.exec(t, `UPDATE results SET status = 'ok' WHERE id = ?`, resultID)
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "requestId": "bonus-republish",
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("重新发布应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||||
t.Fatalf("成绩改回有效后应把加分还回来,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
|
||||
t.Fatalf("成绩改回有效后单位总分应回到 12,得到 %v", got)
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestSetEntriesHeatSetsAndClears(t *testing.T) {
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读报名列表应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
items := decodeData[[]map[string]any](t, env2)
|
||||
items := getEntryBoard(t, env, client, ids.event).Entries
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("报名列表应有 2 条,得到 %d", len(items))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
@@ -1133,3 +1134,239 @@ func TestBaseDataImportBlankTextAndWeightKeepTheirRules(t *testing.T) {
|
||||
t.Errorf("权重留空应取 1,得到 %v", multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 枚举列的三态口径:整格留空 = 不改动,显式填值 = 覆盖 ----------
|
||||
//
|
||||
// 这 7 列以前把「整格留空」当成「取系统默认」:类型 team→individual 会改掉计分语义、
|
||||
// 平均口径 all→valid 会改掉团体均分、状态 published→pending 会把已发布小项打回「待录入」,
|
||||
// 而三处用户文档列举「留空怎么处理」时只写了文本 / 年龄 / 布尔 / 权重 / 排序五类,
|
||||
// 读起来像穷举。现在枚举列与「列缺席」同款:留空一律不动,要改就显式填值。
|
||||
|
||||
// baseDataEnumColumn 是一个枚举列:表格里的表头 + 读库里现值的取数函数。
|
||||
type baseDataEnumColumn struct {
|
||||
sheet string
|
||||
header string
|
||||
read func(t *testing.T, env *testEnv, ids seedIDs) string
|
||||
}
|
||||
|
||||
func baseDataEnumColumns() []baseDataEnumColumn {
|
||||
eventCol := func(col string) func(t *testing.T, env *testEnv, ids seedIDs) string {
|
||||
return func(t *testing.T, env *testEnv, ids seedIDs) string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := env.ev.QueryRow(`SELECT `+col+` FROM events WHERE id = ?`, ids.event).Scan(&v); err != nil {
|
||||
t.Fatalf("读 events.%s: %v", col, err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
return []baseDataEnumColumn{
|
||||
{"小项", "类型", eventCol("event_type")},
|
||||
{"小项", "结果模式", eventCol("result_mode")},
|
||||
{"小项", "结果类型", eventCol("result_type")},
|
||||
{"小项", "比较", eventCol("compare")},
|
||||
{"小项", "状态", eventCol("status")},
|
||||
{"小项", "平均口径", eventCol("avg_mode")},
|
||||
{"组别", "性别", func(t *testing.T, env *testEnv, ids seedIDs) string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := env.ev.QueryRow(`SELECT COALESCE(gender, '') FROM event_categories WHERE id = ?`,
|
||||
ids.category).Scan(&v); err != nil {
|
||||
t.Fatalf("读 event_categories.gender: %v", err)
|
||||
}
|
||||
return v
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// seedNonDefaultEnumValues 把 7 个枚举列都改成非默认值。
|
||||
// 原值等于默认值时,「留空 = 不改」与「留空 = 取默认」在库上看不出区别,用例会假绿。
|
||||
func seedNonDefaultEnumValues(t *testing.T, env *testEnv, ids seedIDs) {
|
||||
t.Helper()
|
||||
env.exec(t, `UPDATE events SET event_type = 'team', result_mode = 'manual', result_type = 'time',
|
||||
compare = 'desc', status = 'published', avg_mode = 'all' WHERE id = ?`, ids.event)
|
||||
env.exec(t, `UPDATE event_categories SET gender = '男' WHERE id = ?`, ids.category)
|
||||
}
|
||||
|
||||
// baseDataNonDefaultEnums 是 seedNonDefaultEnumValues 之后的原值,按表头索引。
|
||||
func baseDataNonDefaultEnums() map[string]string {
|
||||
return map[string]string{
|
||||
"类型": "team", "结果模式": "manual", "结果类型": "time", "比较": "desc",
|
||||
"状态": "published", "平均口径": "all", "性别": "男",
|
||||
}
|
||||
}
|
||||
|
||||
// 留空 = 不改动:整格清空既不取系统默认、也不清空原值。
|
||||
func TestBaseDataImportBlankEnumColumnsKeepExisting(t *testing.T) {
|
||||
origin := baseDataNonDefaultEnums()
|
||||
for _, col := range baseDataEnumColumns() {
|
||||
t.Run(col.sheet+"/"+col.header, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedNonDefaultEnumValues(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
rewriteColumn(t, f, col.sheet, col.header, "")
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.ErrorCount != 0 {
|
||||
t.Fatalf("留空不该报错,得到 %v", res.Errors)
|
||||
}
|
||||
if res.Added != 0 || res.Updated != 0 {
|
||||
t.Fatalf("「%s」留空 = 不改动,不该产生差异:新增 %d / 更新 %d %+v",
|
||||
col.header, res.Added, res.Updated, res.Changes)
|
||||
}
|
||||
assertNoFieldDiff(t, res, col.header)
|
||||
|
||||
// 落地一次也一样:预览与写入的口径必须同源。
|
||||
res = importPackage(t, env, client, pack, nil)
|
||||
if res.Added != 0 || res.Updated != 0 {
|
||||
t.Fatalf("「%s」留空落地后仍不该有改动:新增 %d / 更新 %d", col.header, res.Added, res.Updated)
|
||||
}
|
||||
if got := col.read(t, env, ids); got != origin[col.header] {
|
||||
t.Fatalf("「%s」留空后库里应保持原值 %q,得到 %q", col.header, origin[col.header], got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 显式合法值 = 覆盖:预览报出「原值→新值」,落地后库里就是新值。
|
||||
func TestBaseDataImportExplicitEnumValuesAreWritten(t *testing.T) {
|
||||
origin := baseDataNonDefaultEnums()
|
||||
explicit := map[string]string{
|
||||
"类型": "individual", "结果模式": "auto", "结果类型": "distance", "比较": "asc",
|
||||
"状态": "pending", "平均口径": "valid", "性别": "女",
|
||||
}
|
||||
for _, col := range baseDataEnumColumns() {
|
||||
t.Run(col.sheet+"/"+col.header, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedNonDefaultEnumValues(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
want := explicit[col.header]
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
rewriteColumn(t, f, col.sheet, col.header, want)
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.ErrorCount != 0 || res.Updated != 1 {
|
||||
t.Fatalf("显式填 %q 应只更新 1 条,得到 更新 %d / 错误 %d %v",
|
||||
want, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
found := false
|
||||
for _, ch := range res.Changes {
|
||||
for _, fld := range ch.Fields {
|
||||
if fld.Field != col.header {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if fld.From != origin[col.header] || fld.To != want {
|
||||
t.Fatalf("「%s」差异应为 %s→%s,得到 %s→%s",
|
||||
col.header, origin[col.header], want, fld.From, fld.To)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("显式填 %q 应产生「%s」差异,得到 %+v", want, col.header, res.Changes)
|
||||
}
|
||||
|
||||
if res = importPackage(t, env, client, pack, nil); res.Updated != 1 {
|
||||
t.Fatalf("落地后应更新 1 条,得到 %d", res.Updated)
|
||||
}
|
||||
if got := col.read(t, env, ids); got != want {
|
||||
t.Fatalf("「%s」显式填 %q 后库里应为 %q,得到 %q", col.header, want, want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 显式非法值 = 行级错误:预览与落地都不写库(这一行被跳过),错误里带表名与行号。
|
||||
func TestBaseDataImportInvalidEnumValuesAreRowErrors(t *testing.T) {
|
||||
origin := baseDataNonDefaultEnums()
|
||||
illegal := map[string]string{
|
||||
"类型": "individualx", "结果模式": "autox", "结果类型": "timex", "比较": "ascx",
|
||||
"状态": "publishedx", "平均口径": "validx", "性别": "其他",
|
||||
}
|
||||
for _, col := range baseDataEnumColumns() {
|
||||
t.Run(col.sheet+"/"+col.header, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedNonDefaultEnumValues(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
bad := illegal[col.header]
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
rewriteColumn(t, f, col.sheet, col.header, bad)
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.ErrorCount != 1 || res.Added != 0 || res.Updated != 0 {
|
||||
t.Fatalf("非法枚举值应只报 1 条行级错误、其余不改,得到 新增 %d / 更新 %d / 错误 %d %v",
|
||||
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if !strings.Contains(res.Errors[0], col.sheet) || !strings.Contains(res.Errors[0], "行") {
|
||||
t.Fatalf("行级错误应带表名与行号,得到 %q", res.Errors[0])
|
||||
}
|
||||
|
||||
res = importPackage(t, env, client, pack, nil)
|
||||
if res.ErrorCount != 1 || res.Updated != 0 {
|
||||
t.Fatalf("落地时这一行同样要被跳过,得到 更新 %d / 错误 %d %v",
|
||||
res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if got := col.read(t, env, ids); got != origin[col.header] {
|
||||
t.Fatalf("非法值不该落库,「%s」应保持 %q,得到 %q", col.header, origin[col.header], got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 反向:留空 = 不改动只对「库里已有的行」成立。新建行没有现值可沿用,
|
||||
// 留空仍然落建表默认值(类型 individual、结果模式 auto、结果类型 none、比较 none、
|
||||
// 状态 pending、平均口径 valid、性别 NULL),绝不能写成空串去撞 CHECK。
|
||||
func TestBaseDataImportNewRowBlankEnumsUseSchemaDefaults(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
client := baseDataAdminClient(t, env)
|
||||
|
||||
pack := buildTestXLSX(t, []testSheet{
|
||||
{name: "小项", rows: [][]string{
|
||||
{"大项编码", "小项编码", "小项名称", "类型", "结果模式", "结果类型", "比较", "状态", "平均口径"},
|
||||
{"TRACK", "M300", "300米跑"},
|
||||
}},
|
||||
{name: "组别", rows: [][]string{
|
||||
{"小项编码", "组别名", "性别", "年龄下限", "年龄上限"},
|
||||
{"M300", "男子", "", "", ""},
|
||||
}},
|
||||
})
|
||||
res := importPackage(t, env, client, pack, nil)
|
||||
if res.Added != 2 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("应新增 2 条,得到 新增 %d / 更新 %d / 错误 %d %v",
|
||||
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
|
||||
var (
|
||||
eventType, resultMode, resultType, cmpare, status, avgMode string
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT event_type, result_mode, result_type, compare, status, avg_mode
|
||||
FROM events WHERE code = 'M300'`).
|
||||
Scan(&eventType, &resultMode, &resultType, &cmpare, &status, &avgMode); err != nil {
|
||||
t.Fatalf("查询新建小项: %v", err)
|
||||
}
|
||||
if eventType != "individual" || resultMode != "auto" || resultType != "none" ||
|
||||
cmpare != "none" || status != "pending" || avgMode != "valid" {
|
||||
t.Fatalf("新建小项留空的枚举列应落建表默认值,得到 %s/%s/%s/%s/%s/%s",
|
||||
eventType, resultMode, resultType, cmpare, status, avgMode)
|
||||
}
|
||||
|
||||
var gender sql.NullString
|
||||
if err := env.ev.QueryRow(`SELECT c.gender FROM event_categories c JOIN events e ON e.id = c.event_id
|
||||
WHERE e.code = 'M300' AND c.name = '男子'`).Scan(&gender); err != nil {
|
||||
t.Fatalf("查询新建组别: %v", err)
|
||||
}
|
||||
if gender.Valid {
|
||||
t.Fatalf("新建组别留空的性别应是 NULL(不限),得到 %q", gender.String)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,12 @@ func (s *server) exportEventXLSX(w http.ResponseWriter, r *http.Request) (any, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 先确认小项存在:LoadEventWorkbook 对小项不存在只回一句裸错误串(没有哨兵错误),
|
||||
// 落到统一错误处理里就是 500 + 原始文本,而导出接口承诺「资源不存在 → 404」。
|
||||
// 这里复用读接口的同一句话,现场看到的与 GET /api/events/{id} 一致。
|
||||
if _, err := eventByIDQ(r.Context(), s.ev, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wb, err := export.LoadEventWorkbook(s.ev, id, export.Options{})
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -169,6 +175,11 @@ func (s *server) exportTeamXLSX(w http.ResponseWriter, r *http.Request) (any, er
|
||||
}
|
||||
wb, err := export.LoadTeamDetailWorkbook(s.ev, id, export.Options{})
|
||||
if err != nil {
|
||||
// 单位不存在是「资源不存在」,按 404 报(与 GET /api/teams/{id} 同一句话);
|
||||
// 其它错误(读库失败等)才是 500。全部走 internal 会让每次点空单位导出都记一条伪 5xx。
|
||||
if errors.Is(err, export.ErrTeamNotFound) {
|
||||
return nil, notFound("单位不存在")
|
||||
}
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "team.xlsx", "单位积分明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
@@ -794,53 +805,57 @@ func (p *baseDataPlanner) planEvents(rows []export.BaseDataRow) {
|
||||
avgMode: existing.avgMode, sortOrder: existing.sortOrder,
|
||||
venue: existing.venue, remark: existing.remark,
|
||||
}
|
||||
if row.Has["eventType"] {
|
||||
v, err := normalizeEventType(row.Cells["eventType"])
|
||||
// 枚举列的三态口径:列不在表里 / 整格留空都 = 不改动,只有显式填值才覆盖。
|
||||
// 以前格子留空会落到「取系统默认」(类型→individual、状态→pending、平均口径→valid …),
|
||||
// 一次误清格就能把已发布小项打回「待录入」、把团体项目的类型与均分口径改掉,
|
||||
// 而预览里只是一条不显眼的差异。
|
||||
if v := row.Cells["eventType"]; v != "" {
|
||||
parsed, err := normalizeEventType(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.eventType = v
|
||||
plan.eventType = parsed
|
||||
}
|
||||
if row.Has["resultMode"] {
|
||||
v, err := normalizeResultMode(row.Cells["resultMode"])
|
||||
if v := row.Cells["resultMode"]; v != "" {
|
||||
parsed, err := normalizeResultMode(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.resultMode = v
|
||||
plan.resultMode = parsed
|
||||
}
|
||||
if row.Has["resultType"] {
|
||||
v, err := normalizeResultType(row.Cells["resultType"])
|
||||
if v := row.Cells["resultType"]; v != "" {
|
||||
parsed, err := normalizeResultType(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.resultType = v
|
||||
plan.resultType = parsed
|
||||
}
|
||||
if row.Has["compare"] {
|
||||
v, err := normalizeCompare(row.Cells["compare"])
|
||||
if v := row.Cells["compare"]; v != "" {
|
||||
parsed, err := normalizeCompare(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.compare = v
|
||||
plan.compare = parsed
|
||||
}
|
||||
if row.Has["status"] {
|
||||
v, err := normalizeEventStatus(row.Cells["status"])
|
||||
if v := row.Cells["status"]; v != "" {
|
||||
parsed, err := normalizeEventStatus(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.status = v
|
||||
plan.status = parsed
|
||||
}
|
||||
if row.Has["avgMode"] {
|
||||
v, err := normalizeAvgMode(row.Cells["avgMode"])
|
||||
if v := row.Cells["avgMode"]; v != "" {
|
||||
parsed, err := normalizeAvgMode(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.avgMode = v
|
||||
plan.avgMode = parsed
|
||||
}
|
||||
if row.Has["scored"] {
|
||||
v, ok := export.ParseBaseDataBool(row.Cells["scored"], true)
|
||||
@@ -963,13 +978,13 @@ func (p *baseDataPlanner) planCategories(rows []export.BaseDataRow) {
|
||||
}
|
||||
plan.multiplier = v
|
||||
}
|
||||
if row.Has["gender"] {
|
||||
gender := strings.TrimSpace(row.Cells["gender"])
|
||||
if gender != "" && gender != "男" && gender != "女" {
|
||||
p.rowError(sheet, line, "性别只能填 男 / 女(留空表示不限)")
|
||||
// 组别的枚举列同口径:留空 = 不改动(新建行留空仍是「不限」,因为起手值就是空)。
|
||||
if v := strings.TrimSpace(row.Cells["gender"]); v != "" {
|
||||
if v != "男" && v != "女" {
|
||||
p.rowError(sheet, line, "性别只能填 男 / 女(留空表示不改动,新建时留空即不限)")
|
||||
continue
|
||||
}
|
||||
plan.gender = baseDataStrPtr(gender)
|
||||
plan.gender = baseDataStrPtr(v)
|
||||
}
|
||||
if row.Has["ageMin"] {
|
||||
n, ok := export.ParseBaseDataInt(row.Cells["ageMin"])
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi_test
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -176,3 +177,47 @@ func TestExportProjectZipConcurrentUnique(t *testing.T) {
|
||||
t.Errorf("并发导出: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出不存在的小项 / 单位是「资源不存在」,按 404 报(与 GET /api/events/{id}、
|
||||
// GET /api/teams/{id} 同一句话),不能落成 500 + 原始错误串:
|
||||
// 现场点到一个已失效的 id 时,「服务端异常」会把排查方向带偏,还平白记一条伪 5xx。
|
||||
func TestExportPerIDNotFoundIs404(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/api/export/events/999999", "小项不存在"},
|
||||
{"/api/export/teams/999999", "单位不存在"},
|
||||
} {
|
||||
status, _, body := env.getRaw(t, client, tc.path)
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("%s 应 404,得到 %d %s", tc.path, status, body)
|
||||
}
|
||||
var payload struct {
|
||||
OK bool `json:"ok"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("解析 %s 响应: %v (%s)", tc.path, err, body)
|
||||
}
|
||||
if payload.OK || payload.Error.Code != "VALIDATION_FAILED" || payload.Error.Message != tc.want {
|
||||
t.Fatalf("%s 应 404 VALIDATION_FAILED %q,得到 %+v", tc.path, tc.want, payload.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 存在的小项 / 单位照旧 200:404 只针对真的不存在的 id。
|
||||
for _, path := range []string{"/api/export/events/1", "/api/export/teams/1"} {
|
||||
if status, _, body := env.getRaw(t, client, path); status != http.StatusOK {
|
||||
t.Fatalf("%s 应 200,得到 %d %s", path, status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ type athleteStandingRow struct {
|
||||
|
||||
// seedScoredResults 给 seed 的报名项补成绩与积分流水:
|
||||
// 单位 A 的运动员拿第 1 名 10 分,单位 B 的运动员拿第 2 名 8 分。
|
||||
//
|
||||
// 名次同时写进 channel_ranks:奖牌榜与导出都读这张表(见 buildRanked / loadRanked),
|
||||
// results.rank 只是个人名次,不再作为名次来源。
|
||||
func (e *testEnv) seedScoredResults(t *testing.T, ids seedIDs) {
|
||||
t.Helper()
|
||||
for _, in := range []struct {
|
||||
@@ -38,6 +41,15 @@ func (e *testEnv) seedScoredResults(t *testing.T, ids seedIDs) {
|
||||
source_type, source_id, points)
|
||||
SELECT ?, ?, ?, ?, id, 'result', 1, ? FROM results WHERE entry_id = ?`,
|
||||
in.team, in.athlete, ids.event, in.entry, in.points, in.entry)
|
||||
e.exec(t, `INSERT INTO channel_ranks (event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points, status)
|
||||
SELECT r.event_id, e.category_id, c.id, 'result', 'athlete', 'e:' || e.id,
|
||||
e.id, r.id, e.athlete_id, e.team_id, r.rank, ?, r.status
|
||||
FROM results r
|
||||
JOIN entries e ON e.id = r.entry_id
|
||||
JOIN event_score_channels c ON c.event_id = e.event_id
|
||||
WHERE e.id = ?`,
|
||||
in.points, in.entry)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -400,6 +400,10 @@ func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any
|
||||
if !keys.has("recordEligible") {
|
||||
in.RecordEligible = true
|
||||
}
|
||||
// award_medals 同样是 DEFAULT 1(migration 0020):漏传 ≠ 关掉奖牌结算。
|
||||
if !keys.has("awardMedals") {
|
||||
in.AwardMedals = true
|
||||
}
|
||||
created, err := s.createEvent(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -430,6 +434,10 @@ func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any
|
||||
if !keys.has("recordEligible") {
|
||||
in.RecordEligible = before.RecordEligible
|
||||
}
|
||||
// 奖牌结算是 DEFAULT 1 的新开关:漏传等于「保持原值」,不能当成「关掉」。
|
||||
if !keys.has("awardMedals") {
|
||||
in.AwardMedals = before.AwardMedals
|
||||
}
|
||||
updated, err := s.updateEvent(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -454,12 +462,14 @@ func (s *server) deleteEventHandler(w http.ResponseWriter, r *http.Request) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// listEntriesHandler 返回成绩录入页的完整读数(成绩行 + 团体名次,见 entryBoardDTO)。
|
||||
// 旧客户端把它当成绩行数组用;设计 17 §七之后同一响应多带一个 teamRanks,前端两种形状都能读。
|
||||
func (s *server) listEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.listEntriesWithResults(r.Context(), id)
|
||||
return s.listEntryBoard(r.Context(), id)
|
||||
}
|
||||
|
||||
func (s *server) listCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
@@ -585,9 +595,15 @@ func (s *server) getAthleteHandler(w http.ResponseWriter, r *http.Request) (any,
|
||||
|
||||
func (s *server) createAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in athleteDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 缺省 = 启用(与单位 / 大项 / 小项同口径):漏传 active 不该把新人静默建成「停用」,
|
||||
// 停用的人在报名选择器里等于不存在——少写一个字段就变成另一种东西,只能靠键是否存在判断。
|
||||
if !keys.has("active") {
|
||||
in.Active = true
|
||||
}
|
||||
created, err := s.createAthlete(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -602,13 +618,19 @@ func (s *server) updateAthleteHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
return nil, err
|
||||
}
|
||||
var in athleteDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getAthlete(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 更新仍是「全量覆盖」语义,但漏传 active 不等于「把他停用」:沿用库里的现值。
|
||||
// 想停用请显式传 false(或走 DELETE / 批量停用)。
|
||||
if !keys.has("active") {
|
||||
in.Active = before.Active
|
||||
}
|
||||
updated, err := s.updateAthlete(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -660,8 +682,9 @@ type entryImportResult struct {
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
Notices []string `json:"notices"`
|
||||
Conflicts []importConflict `json:"conflicts"`
|
||||
// SameNameNewPersons 是「号码布不同 → 按同名的另一个人新建档案」的清单。
|
||||
// SameNameNewPersons 是「身份字段对不上 → 按同名的另一个人新建档案」的清单。
|
||||
// 只作报告:不弹窗、不阻塞导入,但必须让用户看得见。
|
||||
SameNameNewPersons []sameNameNewPerson `json:"sameNameNewPersons"`
|
||||
}
|
||||
@@ -669,10 +692,13 @@ type entryImportResult struct {
|
||||
// importConflict 是「命中了库里已有人员、但人员字段与库中不一致」的一行。
|
||||
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
|
||||
type importConflict struct {
|
||||
Row int `json:"row"`
|
||||
IDCard string `json:"idCard"`
|
||||
Bib string `json:"bib"`
|
||||
AthleteID int64 `json:"athleteId"`
|
||||
Row int `json:"row"`
|
||||
IDCard string `json:"idCard"`
|
||||
Bib string `json:"bib"`
|
||||
AthleteID int64 `json:"athleteId"`
|
||||
// Duplicate 表示这不是人员字段不一致,而是「同一份表里同一证件号 + 同一个小项同一个组别」的真重复行:
|
||||
// 两行字段可能完全一样,但要不要覆盖已经落库的那条报名必须由用户决定。
|
||||
Duplicate bool `json:"duplicate"`
|
||||
Existing importAthleteView `json:"existing"`
|
||||
Incoming importAthleteView `json:"incoming"`
|
||||
ChangedFields []string `json:"changedFields"`
|
||||
@@ -941,7 +967,9 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
||||
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
|
||||
// 非 nil 时按决策处理命中人员的冲突,未出现在 decisions 里的冲突继续收集。
|
||||
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
|
||||
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{}}
|
||||
result := entryImportResult{
|
||||
Errors: []string{}, Notices: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{},
|
||||
}
|
||||
if len(rows) < 2 {
|
||||
return result, badRequest("报名表为空", nil)
|
||||
}
|
||||
@@ -957,9 +985,17 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错;
|
||||
// 证件号只是可选档案信息,重复不再拦截(同一份表内、跨行、跨表都不因它拦截)。
|
||||
seenBibs := make(map[string]bool)
|
||||
// 号码布不再是人员的唯一标识:同一个人报多个组别时可以重复使用,只有「两个不同的人共用一个号码布」
|
||||
// 才按行报错,所以这里记的是号码布 -> 第一次出现的那个人的证件号。
|
||||
bibOwners := make(map[string]string)
|
||||
// 证件号才是唯一标识:同一份表里重复出现 = 同一个人报多个组别(不报错,收尾统一给 notices 提示);
|
||||
// 但「同一证件号 + 同一个小项同一个组别」是真重复,只有它进冲突面板等决策。
|
||||
idCardCounts := make(map[string]int)
|
||||
seenIdentities := make(map[string]bool)
|
||||
// 受影响的 (小项, 组别):只有「新建了报名项」或「可排名状态跨了界」的行才加进来。
|
||||
// 名单里的姓名 / 号码布 / 批次这类不影响算分的字段改动不该触发重算,
|
||||
// 否则几百行的常规导入会把每个组白算一遍(见 recomputeImportedScopes)。
|
||||
affectedScopes := make([]entryScope, 0, 2)
|
||||
for n, row := range rows[1:] {
|
||||
line := n + 2
|
||||
cell := func(name string) string {
|
||||
@@ -1048,23 +1084,34 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
continue
|
||||
}
|
||||
if bib != "" {
|
||||
if seenBibs[bib] {
|
||||
// 同一个人(证件号相同且都填了)可以共用号码布;其余重复一律按行报错。
|
||||
if owner, seen := bibOwners[bib]; seen && (idCard == "" || owner != idCard) {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
|
||||
continue
|
||||
}
|
||||
seenBibs[bib] = true
|
||||
bibOwners[bib] = idCard
|
||||
}
|
||||
// 真重复判据与服务端认人顺序一致:同一证件号 + 同一个小项同一个组别。
|
||||
// 组别留空时上面已取该小项的第一个组别,这里用解析出的 categoryID 比。
|
||||
duplicate := false
|
||||
if idCard != "" {
|
||||
identity := fmt.Sprintf("%s\x00%d\x00%d", idCard, eventID, categoryID)
|
||||
duplicate = seenIdentities[identity]
|
||||
seenIdentities[identity] = true
|
||||
idCardCounts[idCard]++
|
||||
}
|
||||
|
||||
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
|
||||
Line: line,
|
||||
Name: athleteName,
|
||||
Gender: normalizeGender(cell("gender")),
|
||||
TeamID: teamID,
|
||||
TeamCode: teamCode,
|
||||
IDCard: idCard,
|
||||
Bib: bib,
|
||||
Age: age,
|
||||
Line: line,
|
||||
Name: athleteName,
|
||||
Gender: normalizeGender(cell("gender")),
|
||||
TeamID: teamID,
|
||||
TeamCode: teamCode,
|
||||
IDCard: idCard,
|
||||
Bib: bib,
|
||||
Age: age,
|
||||
Duplicate: duplicate,
|
||||
}, decisions)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
@@ -1075,6 +1122,11 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
||||
continue
|
||||
}
|
||||
if matched.Skip {
|
||||
// 真重复行 + 用户选了「保留已有」:库里那条报名原样不动,本行不落库。
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if matched.SameNameNewPerson != nil {
|
||||
result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson)
|
||||
}
|
||||
@@ -1083,7 +1135,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
teamID = matched.TeamID
|
||||
}
|
||||
|
||||
created, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
EventID: eventID,
|
||||
CategoryID: categoryID,
|
||||
TeamID: teamID,
|
||||
@@ -1098,12 +1150,34 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
case err != nil:
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
case created:
|
||||
case outcome.Created:
|
||||
result.Created++
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
default:
|
||||
result.Updated++
|
||||
if outcome.StatusChanged {
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 同一证件号在本表里出现多次 = 同一个人报多个组别:不报错,但要让现场看见。
|
||||
// 按证件号排序,保证同一份表的提示顺序稳定(前端展示与用例断言都依赖它)。
|
||||
idCards := make([]string, 0, len(idCardCounts))
|
||||
for idCard := range idCardCounts {
|
||||
idCards = append(idCards, idCard)
|
||||
}
|
||||
sort.Strings(idCards)
|
||||
for _, idCard := range idCards {
|
||||
if count := idCardCounts[idCard]; count > 1 {
|
||||
result.Notices = append(result.Notices,
|
||||
fmt.Sprintf("证件号 %s 在本表中出现 %d 次(同一人报名多个组别)", idCard, count))
|
||||
}
|
||||
}
|
||||
// 导入动过「谁还算数」的组,按当前报名与成绩重算名次与流水并推榜。
|
||||
// 与批量退赛/移出、作废成绩同一口径:延迟结算下也要当场算,不然名单改了榜不动。
|
||||
if err := s.recomputeImportedScopes(ctx, affectedScopes, middleware.User(ctx).ID); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -232,7 +233,7 @@ func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
||||
// 证件号命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
||||
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
@@ -286,7 +287,10 @@ func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里证件号重复出现不再报错:证件号是可选档案信息,不参与匹配也不拦截。
|
||||
// 同一份表里同一证件号、同一小项同一个组别出现两次 = 真重复:
|
||||
// 两行都解析到同一个人(不新建第二份档案、不报「证件号已被占用」),
|
||||
// 但要不要覆盖已落库的那条报名由用户决定,所以次行进冲突面板并带 duplicate 标记;
|
||||
// 同时给一条 notices 提示,说明这个证件号在本表里出现了两次。
|
||||
func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
@@ -298,16 +302,35 @@ func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["skipped"] != float64(0) {
|
||||
t.Fatalf("同表重复证件号不应再拦截: %+v", result)
|
||||
if result["created"] != float64(1) || result["updated"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("首行新建、次行等决策(既不算更新也不算跳过): %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("不应有行级报错,得到 %+v", errs)
|
||||
t.Fatalf("同证件号重复不应报行级错误,得到 %+v", errs)
|
||||
}
|
||||
if result["created"] != float64(1) || result["updated"] != float64(1) {
|
||||
t.Fatalf("首行新建、次行按姓名+单位命中同一人并更新报名: %+v", result)
|
||||
notices, _ := result["notices"].([]any)
|
||||
wantNotice := "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)"
|
||||
if len(notices) != 1 || notices[0] != wantNotice {
|
||||
t.Fatalf("应给一条重复提示 %q,得到 %+v", wantNotice, notices)
|
||||
}
|
||||
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("同一个小项同一个组别的重复行应进冲突面板,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["duplicate"] != true || conflict["row"] != float64(3) {
|
||||
t.Fatalf("重复行应带 duplicate 标记并指向次行: %+v", conflict)
|
||||
}
|
||||
var athleteID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athleteID); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if conflict["athleteId"] != float64(athleteID) {
|
||||
t.Fatalf("重复行应指向同一个人 %d: %+v", athleteID, conflict)
|
||||
}
|
||||
|
||||
// 同一个人只应建 1 份档案(次行绝不能再新建或撞 id_card 唯一索引)。
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
@@ -317,28 +340,36 @@ func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 新人带的证件号已被库里另一个人占用 → 明确的中文行级错误,不静默改库、不静默跳过。
|
||||
func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) {
|
||||
// 证件号优先:表里的证件号在库里有主时,命中那个人走冲突面板,而不是报「证件号已被占用」。
|
||||
// 姓名 / 单位 / 号码布都对不上也只当差异交给用户决策——认人是按证件号,不是按号码布或姓名。
|
||||
func TestImportIDCardHitGoesToConflictNotTakenError(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
// 另一个人(李四 / 号码布 B200)拿着已被张伟占用的证件号来建档。
|
||||
// 另一个人(李四 / 号码布 B200)拿着张伟的证件号来建档:证件号命中张伟 → 冲突,不是行级错误。
|
||||
body := importHeader + "\nM100,男子,B,李四,110101199001011234,男,,B200,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200(行级报错),得到 %d %+v", status, env2.Error)
|
||||
t.Fatalf("导入应 200(冲突进面板),得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(1) {
|
||||
t.Fatalf("撞号的证件号应跳过该行: %+v", result)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("证件号命中应进冲突面板,既不算新建也不算跳过: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("应报 1 条行级错误,得到 %+v", errs)
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("证件号能认到人就不该报「已被占用」,得到 %+v", errs)
|
||||
}
|
||||
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
|
||||
if errs[0] != want {
|
||||
t.Fatalf("错误文案不对:\n 得到 %q\n 期望 %q", errs[0], want)
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["athleteId"] != float64(ids.athleteA) || conflict["duplicate"] != false {
|
||||
t.Fatalf("冲突应指向证件号命中的人: %+v", conflict)
|
||||
}
|
||||
changed, _ := conflict["changedFields"].([]any)
|
||||
if len(changed) != 3 || changed[0] != "name" || changed[1] != "teamCode" || changed[2] != "bib" {
|
||||
t.Fatalf("姓名 / 单位 / 号码布三处差异都该列出,得到 %+v", changed)
|
||||
}
|
||||
|
||||
// 库里的张伟一行没动,也没有多出一个人。
|
||||
@@ -351,17 +382,18 @@ func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if name != "张伟" || bib != "A100" {
|
||||
t.Fatalf("撞号不应改动库里已有人员,得到 name=%s bib=%s", name, bib)
|
||||
t.Fatalf("冲突不应改动库里已有人员,得到 name=%s bib=%s", name, bib)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&count); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("撞号不应新建人员,得到 %d 个", count)
|
||||
t.Fatalf("证件号命中不应新建人员,得到 %d 个", count)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布不同 → 按「同名的另一个人」新建档案,并在 sameNameNewPersons 里单独报告,不进冲突面板。
|
||||
// 号码布不同(表里证件号也对不上)→ 按「同名的另一个人」新建档案,
|
||||
// 并在 sameNameNewPersons 里带上证件号 / 号码布两侧取值单独报告,不进冲突面板。
|
||||
func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
|
||||
@@ -386,6 +418,10 @@ func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
|
||||
if item["name"] != "张伟" || item["teamCode"] != "A" || item["newBib"] != "2001" || item["existingBib"] != "1001" {
|
||||
t.Fatalf("同名新建报告字段不对: %+v", item)
|
||||
}
|
||||
// 证件号两侧都没有值也要如实带上,现场看到的「原 / 新」两栏才完整。
|
||||
if item["newIdCard"] != "" || item["existingIdCard"] != "" {
|
||||
t.Fatalf("证件号两侧都为空时应是空串: %+v", item)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE name = '张伟' AND team_id = ?`, ids.teamA).Scan(&athletes); err != nil {
|
||||
@@ -671,7 +707,7 @@ func TestImportBackfillsBibForExistingAthlete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布命中时,表里多给的证件号也回填进库中为空的人员。
|
||||
// 证件号未命中 → 退回号码布命中:对方没登记证件号 → 认定同一个人并回填证件号,不新建。
|
||||
func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
@@ -685,6 +721,12 @@ func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||||
t.Fatalf("号码布命中应更新已有报名: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("号码布命中且对方没有证件号,不该报错: %+v", errs)
|
||||
}
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("号码布命中且对方没有证件号,回填不算冲突: %+v", conflicts)
|
||||
}
|
||||
|
||||
var idCard sql.NullString
|
||||
if err := env.ev.QueryRow(`SELECT id_card FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard); err != nil {
|
||||
@@ -703,7 +745,7 @@ func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里号码布重复出现 → 该行报错并跳过。
|
||||
// 同一文件里两个不同的人共用一个号码布 → 该行报错并跳过;同一个人报多个组别时号码布可以重复。
|
||||
func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
@@ -732,28 +774,114 @@ func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同名 + 号码布不同本应按「同名的另一个人」新建,但表里的证件号已被库里那个人占用时,
|
||||
// 唯一索引的底线更高:明确报出占用者,既不静默改库也不静默跳过。
|
||||
func TestImportSameNameDifferentBibButTakenIDCard(t *testing.T) {
|
||||
// 证件号优先:库里某人 id_card=X / bib=A100,表里同证件号但号码布写错成 A200 时,
|
||||
// 命中同一个人(不新建、不报「证件号已被占用」),号码布差异按既有口径进冲突面板;
|
||||
// 决策 keep-new 后号码布按表里的值更新。
|
||||
func TestImportIDCardPriorityIgnoresWrongBib(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A200,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200(行级报错),得到 %d %+v", status, env2.Error)
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["skipped"] != float64(1) || result["created"] != float64(0) {
|
||||
t.Fatalf("撞号应跳过该行: %+v", result)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("冲突行不落库也不跳过: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
|
||||
if len(errs) != 1 || errs[0] != want {
|
||||
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("证件号命中同一个人,不该报「已被占用」: %+v", errs)
|
||||
}
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("号码布差异应进冲突面板,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["athleteId"] != float64(ids.athleteA) || conflict["duplicate"] != false {
|
||||
t.Fatalf("冲突应指向证件号命中的那个人: %+v", conflict)
|
||||
}
|
||||
changed, _ := conflict["changedFields"].([]any)
|
||||
if len(changed) != 1 || changed[0] != "bib" {
|
||||
t.Fatalf("应只有号码布差异,得到 %+v", changed)
|
||||
}
|
||||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||||
t.Fatalf("新建被证件号唯一索引挡下时不应报告成同名新建: %+v", reported)
|
||||
t.Fatalf("证件号命中同一个人时不该报告成同名新建: %+v", reported)
|
||||
}
|
||||
|
||||
// 未决策前库里那个人一个字段都不动,也没有多出一个人。
|
||||
var bib string
|
||||
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if bib != "A100" {
|
||||
t.Fatalf("未决策不应改号码布,得到 %s", bib)
|
||||
}
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 2 {
|
||||
t.Fatalf("证件号命中不应新建人员,得到 %d 个", athletes)
|
||||
}
|
||||
|
||||
// 决策键是证件号(证件号优先),keep-new 用表里的号码布覆盖。
|
||||
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
|
||||
status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result = decodeData[map[string]any](t, env2)
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
|
||||
}
|
||||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||||
t.Fatalf("keep-new 应更新已有报名: %+v", result)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if bib != "A200" {
|
||||
t.Fatalf("keep-new 应用表里的号码布覆盖,得到 %s", bib)
|
||||
}
|
||||
}
|
||||
|
||||
// 证件号与号码布都没命中 → 按「姓名 + 单位」命中且对方既没证件号也没号码布:
|
||||
// 认定同一个人,把表里的证件号与号码布一并回填,不新建档案。
|
||||
func TestImportBackfillsIDCardAndBibBySameNameTeam(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A100,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["updated"] != float64(1) || result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("姓名 + 单位命中应更新已有报名: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("回填不该报行级错误: %+v", errs)
|
||||
}
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("两边都没登记身份字段时回填不算冲突: %+v", conflicts)
|
||||
}
|
||||
|
||||
var (
|
||||
idCard sql.NullString
|
||||
bib sql.NullString
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT id_card, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard, &bib); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if !idCard.Valid || idCard.String != "110101199001011234" {
|
||||
t.Fatalf("应回填证件号,得到 %+v", idCard)
|
||||
}
|
||||
if !bib.Valid || bib.String != "A100" {
|
||||
t.Fatalf("应回填号码布,得到 %+v", bib)
|
||||
}
|
||||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||||
t.Fatalf("一边没给身份字段只是「未指定」,不该按同名的另一个人新建: %+v", reported)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
@@ -761,7 +889,223 @@ func TestImportSameNameDifferentBibButTakenIDCard(t *testing.T) {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 2 {
|
||||
t.Fatalf("不应新建人员,得到 %d 个", athletes)
|
||||
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 姓名 + 单位命中、对方有不同号码布、且表里的证件号也对不上任何人 → 同名的另一个人:
|
||||
// 新建第二份档案并报告(新档案拿表里的证件号与号码布)。
|
||||
func TestImportSameNameDifferentBibWithUnmatchedIDCardCreatesSecondPerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,2001,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(1) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("同名不同身份字段应新建报名: %+v", result)
|
||||
}
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("同名的另一个人不进冲突面板: %+v", conflicts)
|
||||
}
|
||||
|
||||
reported, _ := result["sameNameNewPersons"].([]any)
|
||||
if len(reported) != 1 {
|
||||
t.Fatalf("应报告 1 条同名新建,得到 %+v", reported)
|
||||
}
|
||||
item := reported[0].(map[string]any)
|
||||
want := map[string]any{
|
||||
"name": "张伟", "teamCode": "A",
|
||||
"newIdCard": "110101199001011234", "existingIdCard": "",
|
||||
"newBib": "2001", "existingBib": "1001",
|
||||
}
|
||||
for key, value := range want {
|
||||
if item[key] != value {
|
||||
t.Fatalf("同名新建报告 %s 应为 %q,得到 %+v", key, value, item)
|
||||
}
|
||||
}
|
||||
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE name = '张伟' AND team_id = ?`, ids.teamA).Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 2 {
|
||||
t.Fatalf("应新建第二个人,得到 %d 个", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 姓名 + 单位命中、对方有另一个证件号、表里也没给号码布 → 同名的另一个人(与号码布不同同一条路径)。
|
||||
func TestImportSameNameDifferentIDCardCreatesSecondPerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001019999', bib = '1001' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(1) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("证件号不同应新建报名: %+v", result)
|
||||
}
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("证件号不同不是冲突,是同名的另一个人: %+v", conflicts)
|
||||
}
|
||||
reported, _ := result["sameNameNewPersons"].([]any)
|
||||
if len(reported) != 1 {
|
||||
t.Fatalf("应报告 1 条同名新建,得到 %+v", reported)
|
||||
}
|
||||
item := reported[0].(map[string]any)
|
||||
if item["newIdCard"] != "110101199001011234" || item["existingIdCard"] != "110101199001019999" {
|
||||
t.Fatalf("同名新建报告应带两侧证件号: %+v", item)
|
||||
}
|
||||
if item["newBib"] != "" || item["existingBib"] != "1001" {
|
||||
t.Fatalf("表里没给号码布时应是空串、库里那条如实带上: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一份表里同一证件号出现多次(同一个人报多个组别):两行都解析到同一个档案、各自落一条报名,
|
||||
// 只给 notices 提示,绝不新建第二个人、也绝不撞 id_card 唯一索引。
|
||||
func TestImportDuplicateIDCardAcrossScopesResolvesToOnePerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
// 加一个组别,让同一人的两行落在不同 (小项, 组别) 上——那才是真正的「报多个组别」。
|
||||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,A100,,registered,\n" +
|
||||
"M100,女子,A,张三,110101199001011234,男,,A100,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(2) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("同一人的两条报名都应落库: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("同证件号重复不该报行级错误,得到 %+v", errs)
|
||||
}
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("不同组别不是真重复,不该进冲突面板: %+v", conflicts)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
if len(notices) != 1 || notices[0] != "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)" {
|
||||
t.Fatalf("应给一条重复提示,得到 %+v", notices)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 1 {
|
||||
t.Fatalf("同一个人只应建 1 个档案,得到 %d", athletes)
|
||||
}
|
||||
var entries int
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT COUNT(*) FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
|
||||
"110101199001011234").Scan(&entries); err != nil {
|
||||
t.Fatalf("统计报名: %v", err)
|
||||
}
|
||||
if entries != 2 {
|
||||
t.Fatalf("同一个人两个组别应各有一条报名,得到 %d", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一份表里同一证件号两行的号码布不同(号码布写错):两行仍是同一个人(冲突指向同一个 athleteId),
|
||||
// 号码布差异按既有口径进冲突面板,不新建第二个人、不报「证件号已被占用」。
|
||||
func TestImportDuplicateIDCardDifferentBibStaysOnePerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,B1,,registered,\n" +
|
||||
"M100,女子,A,张三,110101199001011234,男,,B2,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(1) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("首行建档案与报名,次行等号码布差异的决策: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("不该报「证件号已被占用」这类行级错误,得到 %+v", errs)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
if len(notices) != 1 || notices[0] != "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)" {
|
||||
t.Fatalf("应给一条重复提示,得到 %+v", notices)
|
||||
}
|
||||
|
||||
var athleteID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athleteID); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("号码布写错应进冲突面板,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["athleteId"] != float64(athleteID) {
|
||||
t.Fatalf("次行必须解析到同一个人 %d,得到 %+v", athleteID, conflict)
|
||||
}
|
||||
changed, _ := conflict["changedFields"].([]any)
|
||||
if len(changed) != 1 || changed[0] != "bib" {
|
||||
t.Fatalf("应只有号码布差异,得到 %+v", changed)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 1 {
|
||||
t.Fatalf("同一个人只应建 1 个档案,得到 %d", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布已经在库里被 A 占用,表里这一行的证件号又对不上 A → 两个人共用一个号码布:
|
||||
// 按既有号码布防呆口径报行级错误并跳过,绝不静默改库、也不新建第二个占号的人。
|
||||
func TestImportRejectsBibTakenByDifferentIDCard(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001019999', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,李四,110101199001011234,男,,A100,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200(行级报错),得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(1) {
|
||||
t.Fatalf("两人共用一个号码布应跳过该行: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
want := "第 2 行:号码布 A100 已被其它人员使用"
|
||||
if len(errs) != 1 || errs[0] != want {
|
||||
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
|
||||
}
|
||||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||||
t.Fatalf("被号码布防呆挡下时不该报告成同名新建: %+v", reported)
|
||||
}
|
||||
|
||||
// 库里那条一个字段都没动,也没多出一个人。
|
||||
var (
|
||||
name string
|
||||
bib string
|
||||
count int
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT name, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name, &bib); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if name != "张伟" || bib != "A100" {
|
||||
t.Fatalf("号码布防呆不该改动库里已有人员,得到 name=%s bib=%s", name, bib)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&count); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("不应新建人员,得到 %d 个", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,6 +1399,253 @@ func TestImportRejectsUnknownStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 导入改「谁还算数」必须重算 ----------
|
||||
|
||||
// importZhangWeiIDCard 是种子人员张伟在导入用例里用的证件号(先写库,导入才能按证件号命中他)。
|
||||
const importZhangWeiIDCard = "110101199001011234"
|
||||
|
||||
// importZhangWeiRow 拼一行「M100 / 男子 / A / 张伟」的报名表内容;表头见 importHeader。
|
||||
func importZhangWeiRow(status string) string {
|
||||
return "M100,男子,A,张伟," + importZhangWeiIDCard + ",男,,,," + status + ",\n"
|
||||
}
|
||||
|
||||
// seedZhangWeiIDCard 给张伟补一个证件号:证件号优先的匹配口径下,导入才认得出是同一个人。
|
||||
func seedZhangWeiIDCard(t *testing.T, env *testEnv, ids seedIDs) {
|
||||
t.Helper()
|
||||
env.exec(t, `UPDATE athletes SET id_card = ? WHERE id = ?`, importZhangWeiIDCard, ids.athleteA)
|
||||
}
|
||||
|
||||
// runImportScenario 用与批量动作同一串操作跑一遍「名单导入改状态」:
|
||||
// 8 人报名 → 录完前 4 条并发布 →(withSettle 时)立即结算 → 导入把第 1 名改成 status。
|
||||
//
|
||||
// 直接复用 entries_batch_test.go 的 runScenario,是为了让两个入口(批量退赛 / 名单导入)
|
||||
// 的最终数据能逐字节对照:它们改的是同一件事(谁还算数)。
|
||||
func runImportScenario(t *testing.T, live, withSettle bool, status string) batchRun {
|
||||
t.Helper()
|
||||
return runScenario(t, live, withSettle,
|
||||
func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64) {
|
||||
t.Helper()
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
body := importHeader + "\n" + importZhangWeiRow(status)
|
||||
if st, e := env.postImport(t, client, "/api/entries/import", body, ""); st != http.StatusOK || !e.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", st, e.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 同一批导入里多行改状态、且都落在同一组:按 (小项, 组别) 去重,只重算一次、只推一次榜。
|
||||
// 导入动辄几百行,逐行推榜会让大屏收一串中间态、也会把现场拖慢。
|
||||
func TestImportManyStatusChangesPushOnce(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
env.exec(t, `UPDATE athletes SET id_card = ? WHERE id = ?`, "110101199001015678", ids.athB)
|
||||
publishResults(t, env, client, ids.event, "import-many",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
body := importHeader + "\n" + importZhangWeiRow("scratched") +
|
||||
"M100,男子,B,李强,110101199001015678,男,,,,scratched,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["updated"] != float64(2) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("两行都应就地更新,得到 %+v", result)
|
||||
}
|
||||
// 一个组只推一次榜(两行都改了状态,但 scope 去重成一次重算)。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("同一组的多行改动只应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
// 两人都退出名次与流水:整组没有可排名成绩,榜单归零。
|
||||
if got := ledgerRowsOf(t, env, ids.event, nil); len(got) != 0 {
|
||||
t.Fatalf("两人都退赛后不该有有效流水,得到 %v", got)
|
||||
}
|
||||
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); len(got) != 0 {
|
||||
t.Fatalf("两人都退赛后不该占名次,得到 %v", got)
|
||||
}
|
||||
if got := standingsTotal(t, env, client); got != 0 {
|
||||
t.Fatalf("榜单总分应归零,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入把已发布成绩的人改成退赛:流水作废、名次让出来给别人、榜单被推。
|
||||
// 现场「改完名单重新导入一遍」很自然,不重算就会留下「名单说退赛、榜上还挂着 10 分与金牌」。
|
||||
func TestImportStatusChangeRecomputes(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
publishResults(t, env, client, ids.event, "import-status",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
// 前置:A 第 1 名 10 分、B 第 2 名 8 分。
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||||
t.Fatalf("前置条件:A 应是 10 分,得到 %v", got)
|
||||
}
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+importZhangWeiRow("scratched"), "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["updated"] != float64(1) || result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("应就地更新 1 条报名(不是新建、不跳过),得到 %+v", result)
|
||||
}
|
||||
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||||
t.Fatalf("A 应已改成退赛,得到 %s", got)
|
||||
}
|
||||
|
||||
// 流水作废:旧流水留着当证据,有效流水一条不剩。
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); len(got) != 0 {
|
||||
t.Fatalf("退赛后不应再有有效流水,得到 %v", got)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryA); n == 0 {
|
||||
t.Fatalf("旧流水应被作废留痕,得到 %d 条", n)
|
||||
}
|
||||
// 名次让出来:退赛的人不再占名次,B 升到第 1 名。
|
||||
if got := ranksOf(t, env, []int64{ids.entryA}); len(got) != 0 {
|
||||
t.Fatalf("退赛的报名项不该再占名次,得到 %v", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
|
||||
t.Fatalf("B 应升到第 1 名,得到 %d", got)
|
||||
}
|
||||
// 榜单跟着变:A 归零,B 从第 2 名的 8 分变成第 1 名的 10 分。
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||||
t.Fatalf("A 单位总分应归零,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
|
||||
t.Fatalf("B 单位总分应变成 10,得到 %v", got)
|
||||
}
|
||||
// 大屏要收到新榜单,否则现场看到的还是「退赛者拿金牌」的旧画面。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("导入改状态应推 1 次榜单(standings replace),得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 名单导入与批量退赛是同一口径:实时 / 延迟两种结算模式下最终数据逐字节一致。
|
||||
func TestImportScratchMatchesRealTime(t *testing.T) {
|
||||
for _, withSettle := range []bool{true, false} {
|
||||
live := runImportScenario(t, true, withSettle, "scratched")
|
||||
deferred := runImportScenario(t, false, withSettle, "scratched")
|
||||
if withSettle {
|
||||
assertSameBefore(t, "名单导入改状态", live, deferred)
|
||||
}
|
||||
assertSameAfter(t, "名单导入改状态", live, deferred)
|
||||
|
||||
// 期望值与「批量退赛第 1 名」同一份:退赛者退出名次与流水,剩下 3 人 10/8/6。
|
||||
wantLedger := []string{
|
||||
entryLedgerRow(live.entries[1], live.teamB, 10),
|
||||
entryLedgerRow(live.entries[2], live.teamA, 8),
|
||||
entryLedgerRow(live.entries[3], live.teamB, 6),
|
||||
}
|
||||
if !reflect.DeepEqual(deferred.after.ledger, wantLedger) {
|
||||
t.Fatalf("导入退赛后流水应重算成 %v,得到 %v", wantLedger, deferred.after.ledger)
|
||||
}
|
||||
wantRanks := map[int64]int{live.entries[1]: 1, live.entries[2]: 2, live.entries[3]: 3}
|
||||
if !reflect.DeepEqual(deferred.after.ranks, wantRanks) {
|
||||
t.Fatalf("导入退赛后名次应为 %v,得到 %v", wantRanks, deferred.after.ranks)
|
||||
}
|
||||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||||
t.Fatalf("延迟结算下导入改状态也应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 反例保护:只改不影响算分的字段(号码布 / 名单 / 批次)不触发重算,也不推榜单。
|
||||
// 导入动辄几百行,逐行无条件重算会把现场拖慢,也会让大屏收到一串无意义刷新。
|
||||
func TestImportNonScoringChangeDoesNotRecompute(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
publishResults(t, env, client, ids.event, "import-non-scoring",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
|
||||
ledgerBefore := ledgerRowsOf(t, env, ids.event, nil)
|
||||
ranksBefore := ranksOf(t, env, []int64{ids.entryA, ids.entryB})
|
||||
standingsBefore := rawData(t, env, client, "/api/standings")
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
// 号码布 / 种子 / 团体名单都是展示字段;status 写 registered,与库中现值相同。
|
||||
body := importHeader + "\nM100,男子,A,张伟," + importZhangWeiIDCard + ",男,,A-777,S-3,registered,王五/赵六\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["updated"] != float64(1) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("应就地更新 1 条报名,得到 %+v", result)
|
||||
}
|
||||
// 先证明这一行确实写了(否则「没重算」可能只是行被跳过了)。
|
||||
var bib, seed string
|
||||
if err := env.ev.QueryRow(`SELECT COALESCE(bib, ''), COALESCE(seed, '') FROM entries WHERE id = ?`, ids.entryA).
|
||||
Scan(&bib, &seed); err != nil {
|
||||
t.Fatalf("读报名项: %v", err)
|
||||
}
|
||||
if bib != "A-777" || seed != "S-3" {
|
||||
t.Fatalf("号码布 / 种子应已落库,得到 bib=%q seed=%q", bib, seed)
|
||||
}
|
||||
|
||||
if got := ledgerRowsOf(t, env, ids.event, nil); !reflect.DeepEqual(got, ledgerBefore) {
|
||||
t.Fatalf("不影响算分的改动不该动流水:%v → %v", ledgerBefore, got)
|
||||
}
|
||||
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); !reflect.DeepEqual(got, ranksBefore) {
|
||||
t.Fatalf("不影响算分的改动不该动名次:%v → %v", ranksBefore, got)
|
||||
}
|
||||
if got := rawData(t, env, client, "/api/standings"); got != standingsBefore {
|
||||
t.Fatalf("不影响算分的改动不该动榜单:\n之前 %s\n之后 %s", standingsBefore, got)
|
||||
}
|
||||
if got := standingsPushes(t, env); got != pushesBefore {
|
||||
t.Fatalf("不影响算分的改动不该推榜单:%d → %d", pushesBefore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:同一份名单连导两次,第二次不该再改动流水与榜单(状态已经是 scratched,口径没变)。
|
||||
func TestImportStatusChangeIsIdempotent(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
publishResults(t, env, client, ids.event, "import-idempotent",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
body := importHeader + "\n" + importZhangWeiRow("scratched")
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("第 %d 次导入应 200,得到 %d %+v", i+1, status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["updated"] != float64(1) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("第 %d 次导入应就地更新 1 条,得到 %+v", i+1, result)
|
||||
}
|
||||
if i == 0 {
|
||||
// 第一次确实改了口径:推一次榜。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("第一次导入应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 第二次:状态没变,不该再重算、也不该再推榜。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("重复导入不该再推榜单,得到 %d 次(期望仍是 1)", got)
|
||||
}
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); len(got) != 0 {
|
||||
t.Fatalf("重复导入不该让退赛者重新有流水,得到 %v", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
|
||||
t.Fatalf("重复导入后 B 仍应是第 1 名,得到 %d", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
|
||||
t.Fatalf("重复导入后 B 单位总分应仍是 10,得到 %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 图片上传与静态访问 ----------
|
||||
|
||||
// newUploadEnv 构造带赛事项目目录的测试环境,返回 uploads 目录的真实路径。
|
||||
@@ -1812,3 +2403,71 @@ func TestDeleteUploadRejectsTraversal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 建运动员漏传 active 必须默认「启用」(与单位 / 大项 / 小项同口径):
|
||||
// Go 的 bool 零值是 false,静默建成「停用」的人在报名选择器里等于不存在——
|
||||
// 少写一个字段就变成另一种东西,只能靠 decodeJSONPresence 的「键是否存在」判断。
|
||||
func TestAthleteActivePresenceDefaults(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
// 漏传 active:建出来是启用的,库里的 active 也真的是 1。
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
||||
"name": "无active", "gender": "男", "teamId": ids.teamA,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("建人员应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
created := decodeData[map[string]any](t, env2)
|
||||
if created["active"] != true {
|
||||
t.Fatalf("漏传 active 应建成启用,得到 %+v", created)
|
||||
}
|
||||
newID := int64(created["id"].(float64))
|
||||
var storedActive int
|
||||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||||
t.Fatalf("读人员启用状态: %v", err)
|
||||
}
|
||||
if storedActive != 1 {
|
||||
t.Fatalf("漏传 active 应在库里落 1,得到 %d", storedActive)
|
||||
}
|
||||
|
||||
// 显式 false 仍然是「停用」(不能把默认值做成「永远启用」)。
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
||||
"name": "显式停用", "gender": "女", "teamId": ids.teamA, "active": false,
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("显式 active=false 应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if decodeData[map[string]any](t, env2)["active"] != false {
|
||||
t.Fatalf("显式 active=false 应建成停用")
|
||||
}
|
||||
|
||||
// 更新时漏传 active 沿用库里的现值:不能把在用的人静默停用。
|
||||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", newID), map[string]any{
|
||||
"name": "无active改名", "gender": "男", "teamId": ids.teamA,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if decodeData[map[string]any](t, env2)["active"] != true {
|
||||
t.Fatalf("更新漏传 active 应沿用启用状态,得到 %+v", decodeData[map[string]any](t, env2))
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||||
t.Fatalf("读人员启用状态: %v", err)
|
||||
}
|
||||
if storedActive != 1 {
|
||||
t.Fatalf("更新漏传 active 不该把人停用,库里得到 %d", storedActive)
|
||||
}
|
||||
|
||||
// 显式 false 的更新仍能把人停用(「漏传 = 沿用」不是「永远不能停用」)。
|
||||
if status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", newID), map[string]any{
|
||||
"name": "无active改名", "gender": "男", "teamId": ids.teamA, "active": false,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("显式停用应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||||
t.Fatalf("读人员启用状态: %v", err)
|
||||
}
|
||||
if storedActive != 0 {
|
||||
t.Fatalf("显式 active=false 应把人停用,库里得到 %d", storedActive)
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,16 @@ type channelsPayload struct {
|
||||
Channels []channelDTO `json:"channels"`
|
||||
}
|
||||
|
||||
// teamRanksPayload 是 PUT /api/events/{id}/categories/{cid}/team-ranks 的请求体:
|
||||
// 一次提交某组别某类通道(当前只认 scope=team + basis=manual)的整组团体名次。
|
||||
// ranks 里的 rank 为 0 表示清空该单位的记录;未出现在 ranks 里的单位保持原值,
|
||||
// 录入页只需提交改动过的行,误传部分数据不会把没提到的单位一并清掉。
|
||||
type teamRanksPayload struct {
|
||||
Scope string `json:"scope"`
|
||||
Basis string `json:"basis"`
|
||||
Ranks []teamManualRankDTO `json:"ranks"`
|
||||
}
|
||||
|
||||
// ---------- 用户管理 ----------
|
||||
|
||||
type createUserInput struct {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
)
|
||||
|
||||
// 会话撤销后 /ws 长连接必须断开,且新的握手不能再按原身份进来。
|
||||
//
|
||||
// 这条链路有四个接头:Cookie → Authenticate 把会话写进 context → 握手时固化角色与会话 id →
|
||||
// 心跳里按会话 id 复查。少任何一环,被停用的人只要不关页面就还能一直收推送,
|
||||
// 所以这里用真实的 auth.Service 接真实 Hub,撤销动作也走真实接口(管理员停用用户)。
|
||||
func TestWebSocketClosesWhenSessionRevoked(t *testing.T) {
|
||||
var hub *realtime.Hub
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) {
|
||||
h := realtime.NewHub(o.Event, o.Logger)
|
||||
h.SetHeartbeatInterval(50 * time.Millisecond)
|
||||
h.SetSessionChecker(o.Auth)
|
||||
t.Cleanup(func() { _ = h.Close() })
|
||||
hub = h
|
||||
o.WS = h.Handler()
|
||||
})
|
||||
env.createUser("boss", "boss-pass", "admin")
|
||||
victimID := env.createUser("victim", "victim-pass", "admin")
|
||||
|
||||
// 被停用的人:登录后打开管理端长连接。
|
||||
victim, _ := env.client()
|
||||
env.login(victim, "victim", "victim-pass")
|
||||
victimCookie := sessionCookie(t, victim, env.server.URL)
|
||||
|
||||
c := dialWS(t, env.server.URL, victimCookie)
|
||||
c.send("hello", map[string]any{"role": "admin"})
|
||||
c.recv("snapshot")
|
||||
|
||||
// 管理员在后台停用该用户:HTTP 侧会连带作废他的全部会话。
|
||||
boss, _ := env.client()
|
||||
env.login(boss, "boss", "boss-pass")
|
||||
status, body := env.do(boss, http.MethodPut, "/api/users/"+strconv.FormatInt(victimID, 10),
|
||||
map[string]any{"active": false}, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("停用用户应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
|
||||
// 最迟一个心跳周期内断开,关闭码 1008「登录失效」:前端据此停止重连并引导重新登录。
|
||||
if code := c.waitClose(3 * time.Second); code != websocket.StatusPolicyViolation {
|
||||
t.Fatalf("会话撤销后 /ws 应以 1008 关闭,得到 %d", code)
|
||||
}
|
||||
|
||||
// 握手侧同样要校验会话:仍带着这条失效 Cookie 再连一次,只能降级成 guest——
|
||||
// 连得上(大屏是公开的),但申请管理端房间会被按策略拒绝。
|
||||
re := dialWS(t, env.server.URL, victimCookie)
|
||||
re.send("hello", map[string]any{"role": "admin"})
|
||||
if code := re.waitClose(3 * time.Second); code != websocket.StatusPolicyViolation {
|
||||
t.Fatalf("失效会话的握手不该拿到管理端身份,关闭码得到 %d", code)
|
||||
}
|
||||
if n := len(hub.Instances()); n != 0 {
|
||||
t.Fatalf("被拒的连接不该登记成大屏实例,得到 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 会话仍有效时连接不受影响:复查不能把在线用户误判成失效。
|
||||
func TestWebSocketStaysOpenWhileSessionActive(t *testing.T) {
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) {
|
||||
h := realtime.NewHub(o.Event, o.Logger)
|
||||
h.SetHeartbeatInterval(50 * time.Millisecond)
|
||||
h.SetSessionChecker(o.Auth)
|
||||
t.Cleanup(func() { _ = h.Close() })
|
||||
o.WS = h.Handler()
|
||||
})
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
c := dialWS(t, env.server.URL, sessionCookie(t, client, env.server.URL))
|
||||
c.send("hello", map[string]any{"role": "admin"})
|
||||
c.recv("snapshot")
|
||||
|
||||
// 连收三个心跳仍活着,说明会话复查没有误伤。
|
||||
for i := 0; i < 3; i++ {
|
||||
c.recv("heartbeat")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 测试用 WebSocket 客户端 ----------
|
||||
|
||||
type wsFrame struct {
|
||||
event string
|
||||
err error
|
||||
code websocket.StatusCode
|
||||
}
|
||||
|
||||
type wsTestConn struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
conn *websocket.Conn
|
||||
frames chan wsFrame
|
||||
}
|
||||
|
||||
// dialWS 用浏览器会话 Cookie 打开 /ws,等价于管理端页面里的连接。
|
||||
func dialWS(t *testing.T, serverURL, cookie string) *wsTestConn {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
header := http.Header{}
|
||||
if cookie != "" {
|
||||
header.Set("Cookie", cookie)
|
||||
}
|
||||
conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(serverURL, "http")+"/ws",
|
||||
&websocket.DialOptions{HTTPHeader: header})
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatalf("连接 /ws: %v", err)
|
||||
}
|
||||
|
||||
c := &wsTestConn{t: t, ctx: ctx, cancel: cancel, conn: conn, frames: make(chan wsFrame, 64)}
|
||||
go c.readLoop()
|
||||
t.Cleanup(c.close)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *wsTestConn) readLoop() {
|
||||
defer close(c.frames)
|
||||
for {
|
||||
_, data, err := c.conn.Read(c.ctx)
|
||||
if err != nil {
|
||||
c.frames <- wsFrame{err: err, code: websocket.CloseStatus(err)}
|
||||
return
|
||||
}
|
||||
var env struct {
|
||||
Event string `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &env); err != nil {
|
||||
c.frames <- wsFrame{err: err}
|
||||
return
|
||||
}
|
||||
c.frames <- wsFrame{event: env.Event}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *wsTestConn) close() {
|
||||
_ = c.conn.CloseNow()
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
func (c *wsTestConn) send(event string, data any) {
|
||||
c.t.Helper()
|
||||
msg, err := json.Marshal(struct {
|
||||
Event string `json:"event"`
|
||||
Data any `json:"data"`
|
||||
}{Event: event, Data: data})
|
||||
if err != nil {
|
||||
c.t.Fatalf("序列化 %s 帧: %v", event, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := c.conn.Write(ctx, websocket.MessageText, msg); err != nil {
|
||||
c.t.Fatalf("发送 %s: %v", event, err)
|
||||
}
|
||||
}
|
||||
|
||||
// recv 等待指定事件,跳过心跳等无关消息。
|
||||
func (c *wsTestConn) recv(want string) {
|
||||
c.t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case f, ok := <-c.frames:
|
||||
if !ok || f.err != nil {
|
||||
c.t.Fatalf("等待 %s 时连接已断开: %v", want, f.err)
|
||||
}
|
||||
if f.event == want {
|
||||
return
|
||||
}
|
||||
case <-time.After(time.Until(deadline)):
|
||||
}
|
||||
}
|
||||
c.t.Fatalf("等待事件 %s 超时", want)
|
||||
}
|
||||
|
||||
// waitClose 等待服务端关闭连接并返回关闭码;超时返回 0。
|
||||
func (c *wsTestConn) waitClose(d time.Duration) websocket.StatusCode {
|
||||
c.t.Helper()
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case f, ok := <-c.frames:
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if f.err != nil {
|
||||
return f.code
|
||||
}
|
||||
case <-timer.C:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sessionCookie 从登录后的 Cookie jar 里取会话 Cookie,拼成请求头。
|
||||
func sessionCookie(t *testing.T, client *http.Client, serverURL string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
t.Fatalf("解析测试服务地址: %v", err)
|
||||
}
|
||||
for _, c := range client.Jar.Cookies(u) {
|
||||
if c.Name == auth.CookieSession {
|
||||
return c.Name + "=" + c.Value
|
||||
}
|
||||
}
|
||||
t.Fatal("登录后应拿到会话 Cookie")
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealResult 是一次启动自愈的结果,供调用方记日志、也供测试断言「到底触发没有」。
|
||||
type HealResult struct {
|
||||
// Triggered 表示两个触发条件同时成立、本次确实执行了全量重算。
|
||||
Triggered bool
|
||||
// EventCount 是「有已发布名次的小项数」,即这次自愈要修的那些小项的规模。
|
||||
EventCount int
|
||||
// LedgerRows 是重算写回的积分流水条数(recomputeAllEvents 的返回值)。
|
||||
LedgerRows int
|
||||
}
|
||||
|
||||
// HealChannelRanksOnce 修「老库升级后奖牌榜整块归零」。
|
||||
//
|
||||
// 背景:迁移 0020 只建 channel_ranks 表、不回填历史名次;而奖牌统计与导出侧现在
|
||||
// 都只读这张表(httpapi.buildRanked / realtime.snapshot.loadRanked / export.loadRanked)。
|
||||
// 于是老库升级完成到「下一次发布 / 重算」之间表是空的,大屏与管理面板的金银铜全是 0,
|
||||
// 现场正开着运动会时这是不可接受的。启动时补一次全量重算即可把名次按通道重建出来。
|
||||
//
|
||||
// 触发条件(必须同时成立):
|
||||
// 1. channel_ranks 一行都没有。表非空说明发布 / 重算已经建过名次,不需要也不该再动它;
|
||||
// 2. results 里存在「已发布且名次为正」的行。说明库里确实有该显示在奖牌榜上的历史成绩。
|
||||
//
|
||||
// 两者同时成立才是「老库升级」这一种形态:全新库的表是空的但没有已发布成绩,
|
||||
// 无成绩的库同样是空的但没有名次可补,都不触发。
|
||||
// 规则包导入、发布、模板改动等既有路径本来就会重算(写成非空),
|
||||
// 所以自愈成功后下次启动自然不再触发。
|
||||
//
|
||||
// 调用方必须在**开始监听端口之前**同步调用:一旦对外服务,大屏会先拿到一帧空白奖牌榜。
|
||||
// 参数沿用 httpapi.Options,只用到 Logger 与 Event,方便 main 用同一份配置调用。
|
||||
func HealChannelRanksOnce(ctx context.Context, opts Options) (HealResult, error) {
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if opts.Event == nil {
|
||||
return HealResult{}, errors.New("赛事库未接入,无法自愈通道名次")
|
||||
}
|
||||
|
||||
eventCount, needed, err := channelRankHealNeeded(ctx, opts.Event)
|
||||
if err != nil {
|
||||
return HealResult{}, err
|
||||
}
|
||||
if !needed {
|
||||
return HealResult{EventCount: eventCount}, nil
|
||||
}
|
||||
|
||||
logger.Info("检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算",
|
||||
"events", eventCount)
|
||||
|
||||
// 重新组装一个最小 server:recomputeAllEvents 只依赖 s.ev 与 s.logger,
|
||||
// 复用同一份实现(含事务语义)而不是另起一套重算,避免两条路径的结果漂移。
|
||||
srv := &server{logger: logger, ev: opts.Event}
|
||||
start := time.Now()
|
||||
rows, err := srv.recomputeAllEvents(ctx, 0)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
return HealResult{Triggered: true, EventCount: eventCount}, err
|
||||
}
|
||||
|
||||
logger.Info("一次性全量重算完成,通道名次与奖牌榜已补齐",
|
||||
"events", eventCount, "ledgerRows", rows, "elapsed", elapsed)
|
||||
return HealResult{Triggered: true, EventCount: eventCount, LedgerRows: rows}, nil
|
||||
}
|
||||
|
||||
// channelRankHealNeeded 读出自愈的两个判据,返回「有已发布名次的小项数」与是否需要触发。
|
||||
// 两条读数走同一次查询,避免两次查询之间库被改动导致判据互不自洽。
|
||||
func channelRankHealNeeded(ctx context.Context, event *sql.DB) (eventCount int, needed bool, err error) {
|
||||
var rankRows, publishedRankedEvents int64
|
||||
if err := event.QueryRowContext(ctx,
|
||||
`SELECT (SELECT COUNT(*) FROM channel_ranks),
|
||||
(SELECT COUNT(DISTINCT event_id) FROM results
|
||||
WHERE state = 'published' AND rank IS NOT NULL AND rank > 0)`,
|
||||
).Scan(&rankRows, &publishedRankedEvents); err != nil {
|
||||
return 0, false, fmt.Errorf("检查通道名次自愈条件: %w", err)
|
||||
}
|
||||
return int(publishedRankedEvents), rankRows == 0 && publishedRankedEvents > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||||
)
|
||||
|
||||
// 本文件只覆盖「老库升级自愈」:迁移 0020 只建 channel_ranks、不回填,
|
||||
// 升级后到下一次重算之间奖牌榜会全 0,启动自愈必须在监听端口前把它补回来。
|
||||
|
||||
// healResult 与 httpapi.HealResult 对应;测试里用别名只是为了让断言读起来短一点。
|
||||
type healResult = httpapi.HealResult
|
||||
|
||||
// healLogger 造一个把日志写进 buffer 的 logger:自愈触发与否既有返回值、也有日志,
|
||||
// 两个都断言,避免「返回值对了但根本没打日志」或反之。
|
||||
func healLogger(buf *bytes.Buffer) *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
}
|
||||
|
||||
// runHeal 以 httpapi.Options 最小形态调用自愈(只有 Event 是必需的)。
|
||||
func runHeal(t *testing.T, env *testEnv, logs *bytes.Buffer) healResult {
|
||||
t.Helper()
|
||||
res, err := httpapi.HealChannelRanksOnce(context.Background(),
|
||||
httpapi.Options{Logger: healLogger(logs), Event: env.ev})
|
||||
if err != nil {
|
||||
t.Fatalf("自愈失败: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// healStanding 是 GET /api/standings 里本文件关心的字段。
|
||||
type healStanding struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Gold int `json:"gold"`
|
||||
Silver int `json:"silver"`
|
||||
Bronze int `json:"bronze"`
|
||||
}
|
||||
|
||||
// medalsByTeam 读当前奖牌榜(只读 channel_ranks 的那张),返回「单位 → 金银铜」。
|
||||
func medalsByTeam(t *testing.T, env *testEnv) map[int64]healStanding {
|
||||
t.Helper()
|
||||
client, _ := env.client()
|
||||
status, resp := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("读奖牌榜应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
out := map[int64]healStanding{}
|
||||
for _, row := range decodeData[[]healStanding](t, resp) {
|
||||
out[row.TeamID] = row
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func channelRankCount(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM channel_ranks`).Scan(&n); err != nil {
|
||||
t.Fatalf("统计 channel_ranks: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func activeLedgerCount(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0`).Scan(&n); err != nil {
|
||||
t.Fatalf("统计流水: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// healSeedAndPublish 造出「老库」的原料:两个单位各一名运动员跑 100 米,发布后 A 金 B 银。
|
||||
// 不复用 rules_handlers_test.go 的 publishTwoResults,避免测试之间互相牵制。
|
||||
func healSeedAndPublish(t *testing.T, env *testEnv, ids seedIDs) {
|
||||
t.Helper()
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"requestId": "heal-draft-1",
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"requestId": "heal-publish-1",
|
||||
}, true)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// simulateOldDatabase 把库打回迁移 0020 刚跑完的样子:已发布成绩与 results.rank 都在,
|
||||
// 但 channel_ranks 是空的。此时奖牌榜必然全 0,正是升级当天会出的那个故障。
|
||||
func simulateOldDatabase(t *testing.T, env *testEnv) {
|
||||
t.Helper()
|
||||
if n := channelRankCount(t, env); n == 0 {
|
||||
t.Fatalf("发布后 channel_ranks 应非空,否则这条用例没在模拟老库")
|
||||
}
|
||||
env.exec(t, `DELETE FROM channel_ranks`)
|
||||
}
|
||||
|
||||
func TestHealChannelRanksRebuildsMedals(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
healSeedAndPublish(t, env, ids)
|
||||
|
||||
// 发布后的正常形态:A 金、B 银。
|
||||
before := medalsByTeam(t, env)
|
||||
if before[ids.teamA].Gold != 1 || before[ids.teamB].Silver != 1 {
|
||||
t.Fatalf("发布后应 A 金 B 银,得到 %+v", before)
|
||||
}
|
||||
|
||||
simulateOldDatabase(t, env)
|
||||
// 先确认故障确实复现:只读 channel_ranks 的奖牌榜整块归零。
|
||||
if broken := medalsByTeam(t, env); broken[ids.teamA].Gold != 0 || broken[ids.teamB].Silver != 0 {
|
||||
t.Fatalf("channel_ranks 清空后奖牌榜应为 0(故障复现),得到 %+v", broken)
|
||||
}
|
||||
|
||||
var logs bytes.Buffer
|
||||
res := runHeal(t, env, &logs)
|
||||
|
||||
if !res.Triggered {
|
||||
t.Fatalf("老库形态应触发自愈,得到 %+v", res)
|
||||
}
|
||||
if res.EventCount != 1 {
|
||||
t.Fatalf("影响的小项数应为 1,得到 %d", res.EventCount)
|
||||
}
|
||||
if res.LedgerRows <= 0 {
|
||||
t.Fatalf("重算应写回流水,得到 %d", res.LedgerRows)
|
||||
}
|
||||
if n := channelRankCount(t, env); n == 0 {
|
||||
t.Fatalf("自愈后 channel_ranks 应非空")
|
||||
}
|
||||
after := medalsByTeam(t, env)
|
||||
if after[ids.teamA].Gold != 1 || after[ids.teamB].Silver != 1 {
|
||||
t.Fatalf("自愈后奖牌榜应恢复 A 金 B 银,得到 %+v", after)
|
||||
}
|
||||
|
||||
logText := logs.String()
|
||||
if !strings.Contains(logText, "检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算") {
|
||||
t.Fatalf("触发时应有一条中文 INFO,实际日志:\n%s", logText)
|
||||
}
|
||||
if !strings.Contains(logText, "一次性全量重算完成") {
|
||||
t.Fatalf("完成时应有一条中文 INFO,实际日志:\n%s", logText)
|
||||
}
|
||||
}
|
||||
|
||||
// channel_ranks 非空 = 名次已经建过,哪怕是老库也不该再动:不能触发重算。
|
||||
func TestHealChannelRanksSkipsWhenRanksPresent(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
healSeedAndPublish(t, env, ids)
|
||||
|
||||
before := channelRankCount(t, env)
|
||||
beforeLedger := activeLedgerCount(t, env)
|
||||
|
||||
var logs bytes.Buffer
|
||||
res := runHeal(t, env, &logs)
|
||||
|
||||
if res.Triggered {
|
||||
t.Fatalf("channel_ranks 非空不应触发,得到 %+v", res)
|
||||
}
|
||||
if n := channelRankCount(t, env); n != before {
|
||||
t.Fatalf("不应改动 channel_ranks:before=%d after=%d", before, n)
|
||||
}
|
||||
if n := activeLedgerCount(t, env); n != beforeLedger {
|
||||
t.Fatalf("不应改动流水:before=%d after=%d", beforeLedger, n)
|
||||
}
|
||||
if strings.Contains(logs.String(), "一次性全量重算") {
|
||||
t.Fatalf("未触发时不应有重算日志,实际日志:\n%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 没有「已发布且名次为正」的成绩时(全新库、只有草稿的库)都不该触发。
|
||||
func TestHealChannelRanksSkipsWithoutPublishedRanks(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
|
||||
// 全新库:channel_ranks 空,但没有已发布成绩。
|
||||
var logs bytes.Buffer
|
||||
if res := runHeal(t, env, &logs); res.Triggered {
|
||||
t.Fatalf("全新库不应触发,得到 %+v", res)
|
||||
}
|
||||
|
||||
// 只有草稿、且草稿连名次都填了:state=draft 仍不算「已发布」。
|
||||
env.exec(t,
|
||||
`INSERT INTO results (event_id, entry_id, result_value, result_text, unit, status, state, rank)
|
||||
VALUES (?, ?, 11.58, '11.58', 's', 'ok', 'draft', 1)`, ids.event, ids.entryA)
|
||||
logs.Reset()
|
||||
if res := runHeal(t, env, &logs); res.Triggered {
|
||||
t.Fatalf("只有草稿不应触发,得到 %+v", res)
|
||||
}
|
||||
if logs.Len() != 0 {
|
||||
t.Fatalf("未触发时不应有日志,实际:\n%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 两次启动(两次调用)结果一致:第一次补上名次,第二次因表非空而跳过,数据不翻倍。
|
||||
func TestHealChannelRanksIsIdempotent(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
healSeedAndPublish(t, env, ids)
|
||||
simulateOldDatabase(t, env)
|
||||
|
||||
var firstLogs bytes.Buffer
|
||||
first := runHeal(t, env, &firstLogs)
|
||||
if !first.Triggered {
|
||||
t.Fatalf("第一次应触发,得到 %+v", first)
|
||||
}
|
||||
ranksAfterFirst := channelRankCount(t, env)
|
||||
ledgerAfterFirst := activeLedgerCount(t, env)
|
||||
medalsAfterFirst := medalsByTeam(t, env)
|
||||
|
||||
var secondLogs bytes.Buffer
|
||||
second := runHeal(t, env, &secondLogs)
|
||||
if second.Triggered {
|
||||
t.Fatalf("第二次不应再触发,得到 %+v", second)
|
||||
}
|
||||
if n := channelRankCount(t, env); n != ranksAfterFirst {
|
||||
t.Fatalf("两次调用后 channel_ranks 应一致:%d → %d", ranksAfterFirst, n)
|
||||
}
|
||||
if n := activeLedgerCount(t, env); n != ledgerAfterFirst {
|
||||
t.Fatalf("两次调用后流水应一致(不翻倍):%d → %d", ledgerAfterFirst, n)
|
||||
}
|
||||
if after := medalsByTeam(t, env); after[ids.teamA] != medalsAfterFirst[ids.teamA] ||
|
||||
after[ids.teamB] != medalsAfterFirst[ids.teamB] {
|
||||
t.Fatalf("两次调用后奖牌榜应一致:%+v → %+v", medalsAfterFirst, after)
|
||||
}
|
||||
|
||||
// 再换一个 logger 调一次(模拟重启后新建 logger 的进程),结论必须一样。
|
||||
discard := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if res, err := httpapi.HealChannelRanksOnce(context.Background(),
|
||||
httpapi.Options{Logger: discard, Event: env.ev}); err != nil || res.Triggered {
|
||||
t.Fatalf("第三次调用应跳过:res=%+v err=%v", res, err)
|
||||
}
|
||||
}
|
||||
@@ -502,6 +502,22 @@ func validateAthleteAge(age *int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAthleteGender 性别可以留空(历史档案里就有空的),填了就必须是「男」或「女」。
|
||||
//
|
||||
// athletes.gender 有 CHECK (gender IN ('男', '女')),不在写入侧拦下来,现场把性别填成
|
||||
// M / male 就只能看到 500 + 英文约束原文 —— 同一类错在组别就地更新路径上是 400。
|
||||
// 与组别那条(validateCategoryGender)同口径:不做 TrimSpace,带空格的值按非法拒绝,
|
||||
// 免得「校验通过、落库撞 CHECK」这种更难看的结果。
|
||||
func validateAthleteGender(gender *string) error {
|
||||
if gender == nil {
|
||||
return nil
|
||||
}
|
||||
if v := *gender; v != "" && v != "男" && v != "女" {
|
||||
return badRequest("gender 只能是 男 / 女", gender)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) {
|
||||
if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 {
|
||||
return athleteDTO{}, badRequest("name 与 teamId 必填", nil)
|
||||
@@ -509,6 +525,9 @@ func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO,
|
||||
if err := validateAthleteAge(in.Age); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
if err := validateAthleteGender(in.Gender); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
// 证件号与号码布都是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。
|
||||
if err := s.checkAthleteIdentity(ctx, in, 0); err != nil {
|
||||
return athleteDTO{}, err
|
||||
@@ -560,6 +579,9 @@ func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (at
|
||||
if err := validateAthleteAge(in.Age); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
if err := validateAthleteGender(in.Gender); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
if err := s.checkAthleteIdentity(ctx, in, id); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
@@ -676,8 +698,9 @@ func (s *server) purgeAthlete(ctx context.Context, id int64) (athletePurge, erro
|
||||
return athletePurge{}, err
|
||||
}
|
||||
|
||||
// 重算:删完成绩后按组别重建流水,别人的名次与得分随新排序更新。
|
||||
// 只重建流水还不够——成绩表与奖牌统计看的是 results.rank,必须像发布那样把名次写回去。
|
||||
// 重算:删完成绩后按组别重建流水与通道名次,别人的名次与得分随新排序更新。
|
||||
// 只重建流水不够——成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去;
|
||||
// 奖牌榜与运动员榜看的是 channel_ranks,由 recomputeLedgerTx 一并重建。
|
||||
actorID := middleware.User(ctx).ID
|
||||
eventIDs := make([]int64, 0, len(scopes))
|
||||
for _, sc := range scopes {
|
||||
@@ -1210,6 +1233,19 @@ func (s *server) getProfile(ctx context.Context, id int64) (profileDTO, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// validateAwardRanks 校验积分模板的「录取名次」(scoring_profiles.award_ranks)。
|
||||
//
|
||||
// 口径照建表约束来(migrations/event/0003:CHECK (award_ranks >= 0)),不自己发明上限:
|
||||
// 0 表示不按名次封顶——算分引擎只在 award_ranks > 0 时才按名次截断(domain.basePoints),
|
||||
// 规则包侧(rules/validate.go)也只拒负数。不在这里拦,-1 会一路撞 CHECK 报 500 +
|
||||
// 原始约束文本,而同一张表的 tieMode / recordBonusMode 早就是 400。
|
||||
func validateAwardRanks(awardRanks int) error {
|
||||
if awardRanks < 0 {
|
||||
return badRequest("awardRanks 需要是不小于 0 的整数(0 表示不按名次封顶)", awardRanks)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
|
||||
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
|
||||
return profileDTO{}, badRequest("code 与 name 必填", nil)
|
||||
@@ -1222,6 +1258,9 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
|
||||
if err != nil {
|
||||
return profileDTO{}, err
|
||||
}
|
||||
if err := validateAwardRanks(in.AwardRanks); err != nil {
|
||||
return profileDTO{}, err
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return profileDTO{}, internal(err)
|
||||
@@ -1248,6 +1287,15 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
|
||||
if err := tx.Commit(); err != nil {
|
||||
return profileDTO{}, internal(err)
|
||||
}
|
||||
// 新建的模板此刻还没有任何引用(sports.default_profile_id 与
|
||||
// event_score_channels.profile_id 都是指向 scoring_profiles(id) 的外键,
|
||||
// 模板必须先存在才挂得上),所以这一次重算实际上是空转;留在这里是为了与
|
||||
// 「改模板」走同一条落地路径,免得以后给新建流程加上「顺手挂到某处」的逻辑时漏掉重算。
|
||||
// recomputeAllEvents 自带事务与推榜,别再包一层。
|
||||
if _, err := s.recomputeAllEvents(ctx, middleware.User(ctx).ID); err != nil {
|
||||
s.logger.Error("积分模板新建后重算失败", "err", err)
|
||||
return profileDTO{}, internalMsg("积分模板已保存,但积分重算失败,成绩与积分尚未同步;请重试保存", err)
|
||||
}
|
||||
return s.getProfile(ctx, id)
|
||||
}
|
||||
|
||||
@@ -1260,6 +1308,9 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr
|
||||
if err != nil {
|
||||
return profileDTO{}, err
|
||||
}
|
||||
if err := validateAwardRanks(in.AwardRanks); err != nil {
|
||||
return profileDTO{}, err
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return profileDTO{}, internal(err)
|
||||
@@ -1288,6 +1339,12 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr
|
||||
if err := tx.Commit(); err != nil {
|
||||
return profileDTO{}, internal(err)
|
||||
}
|
||||
// 模板分值是算分公式的输入:改了就必须全量重算已发布成绩的积分与名次,并把新榜单推给大屏。
|
||||
// recomputeAllEvents 内部自建事务并写 standings 版本,这里不要再套事务(嵌套会死锁在单连接上)。
|
||||
if _, err := s.recomputeAllEvents(ctx, middleware.User(ctx).ID); err != nil {
|
||||
s.logger.Error("积分模板更新后重算失败", "err", err)
|
||||
return profileDTO{}, internalMsg("积分模板已保存,但积分重算失败,成绩与积分尚未同步;请重试保存", err)
|
||||
}
|
||||
return s.getProfile(ctx, id)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,16 +16,19 @@ import (
|
||||
// ---------- events ----------
|
||||
|
||||
const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode`
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
|
||||
award_medals`
|
||||
|
||||
func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
|
||||
var (
|
||||
e eventDTO
|
||||
scheduledAt, venue, note sql.NullString
|
||||
awardMedals int
|
||||
)
|
||||
err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType,
|
||||
&e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status,
|
||||
&scheduledAt, &venue, &e.SortOrder, ¬e, &e.AvgMode)
|
||||
&scheduledAt, &venue, &e.SortOrder, ¬e, &e.AvgMode, &awardMedals)
|
||||
e.AwardMedals = awardMedals != 0
|
||||
e.ScheduledAt = strPtr(scheduledAt)
|
||||
e.Venue = strPtr(venue)
|
||||
e.Remark = strPtr(note)
|
||||
@@ -124,12 +127,14 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
|
||||
|
||||
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, avg_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
|
||||
award_medals)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.SportID, in.Code, in.Name,
|
||||
eventType, resultMode, resultType, compare,
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode)
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode,
|
||||
boolToInt(in.AwardMedals))
|
||||
if isUniqueViolation(err) {
|
||||
return eventDTO{}, conflict("小项编码已存在")
|
||||
}
|
||||
@@ -184,15 +189,25 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// 先读旧值:multiplier / avg_mode / scored / award_medals 这四个字段进算分与奖牌口径,
|
||||
// 「是否真变」决定要不要重算,拿不到旧值就无从判断。
|
||||
before, err := eventByIDQ(ctx, tx, id)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
newMultiplier := multiplierOrOne(in.Multiplier)
|
||||
|
||||
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 = ?, avg_mode = ?
|
||||
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?,
|
||||
award_medals = ?
|
||||
WHERE id = ?`,
|
||||
in.SportID, in.Code, in.Name,
|
||||
eventType, resultMode, resultType, compare,
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode, id)
|
||||
in.RecordEligible, in.Scored, newMultiplier, status,
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode,
|
||||
boolToInt(in.AwardMedals), id)
|
||||
if isUniqueViolation(err) {
|
||||
return eventDTO{}, conflict("小项编码已存在")
|
||||
}
|
||||
@@ -205,9 +220,30 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
|
||||
if err := requireChanges(res, "小项不存在"); err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
// 只有这四个进公式的字段真的变了才重算:改名 / 换场地这类改动重算全项既慢又会
|
||||
// 无谓地作废、重写一遍流水。avgMode 之类的空值口径已在 normalize 阶段落成具体值,可直接比。
|
||||
recompute := multiplierChanged(before.Multiplier, newMultiplier) ||
|
||||
before.AvgMode != avgMode ||
|
||||
before.Scored != in.Scored ||
|
||||
before.AwardMedals != in.AwardMedals
|
||||
actorID := middleware.User(ctx).ID
|
||||
if recompute {
|
||||
score, err := recomputeLedgerTx(ctx, tx, id, 0, actorID, "小项计分配置变更重算")
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
}
|
||||
if err := s.bumpEvent(ctx, tx, id); err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
if recompute {
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return eventDTO{}, internal(err)
|
||||
}
|
||||
@@ -307,6 +343,10 @@ func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryD
|
||||
//
|
||||
// 被拦下的只是「增删组别」:改名 / 改年龄 / 改性别 / 改权重走 updateCategory 就地更新,
|
||||
// 所以这里的文案要点明范围,别让只改了个名字的人以为改名也不许。
|
||||
//
|
||||
// 字段校验与 updateCategory 共用同一套(validateCategoryGender / validateCategoryAges):
|
||||
// 新建或重排组别时这条路是唯一入口,漏校验就会让 ageMin=500、gender='M' 这样的值直接进库
|
||||
// (gender 撞 CHECK 报 500,年龄则静默入库),而就地更新路径又拒绝原样保存。
|
||||
func (s *server) replaceCategories(ctx context.Context, eventID int64, in []categoryDTO) ([]categoryDTO, error) {
|
||||
has, err := exists(ctx, s.ev,
|
||||
`SELECT 1 FROM entries e JOIN event_categories c ON c.id = e.category_id
|
||||
@@ -331,6 +371,12 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
|
||||
if strings.TrimSpace(c.Name) == "" {
|
||||
return nil, badRequest("组别 name 必填", i)
|
||||
}
|
||||
if err := validateCategoryGender(c.Gender); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateCategoryAges(c.AgeMin, c.AgeMax); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO event_categories (event_id, name, multiplier, gender, age_min, age_max, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
@@ -375,7 +421,8 @@ func (s *server) getCategory(ctx context.Context, eventID, categoryID int64) (ca
|
||||
}
|
||||
|
||||
// validateCategoryAges 年龄区间可以留空,填了就必须是 0–120 且下限不大于上限。
|
||||
// 整表替换的老路径不校验这两列,脏值进得去又改不动;就地更新这条路径补上。
|
||||
// 两条写入路径(整表替换与就地更新)共用:以前只有就地更新校验,整表替换能建出
|
||||
// ageMin=500 / ageMax=-3 的组别,脏值进得去、之后就地更新又拒绝原样保存。
|
||||
func validateCategoryAges(ageMin, ageMax *int) error {
|
||||
for _, v := range []*int{ageMin, ageMax} {
|
||||
if v != nil && (*v < 0 || *v > 120) {
|
||||
@@ -388,6 +435,21 @@ func validateCategoryAges(ageMin, ageMax *int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateCategoryGender 组别性别可以留空(= 不限),填了就必须是「男」或「女」。
|
||||
//
|
||||
// event_categories.gender 有 CHECK (gender IN ('男','女')),不拦就落成 500 + 原始约束文本;
|
||||
// 整表替换与就地更新共用这一条,两条路径的 400 语义才一致。
|
||||
// 刻意不做 TrimSpace:带空格的值按非法拒绝,「校验放行、落库撞 CHECK」比直接报错更难查。
|
||||
func validateCategoryGender(gender *string) error {
|
||||
if gender == nil {
|
||||
return nil
|
||||
}
|
||||
if v := *gender; v != "" && v != "男" && v != "女" {
|
||||
return badRequest("组别 gender 只能是 男 / 女", gender)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// multiplierRecomputeEpsilon 是「组别权重是否真的变了」的判定容差。
|
||||
//
|
||||
// 权重是算分公式的输入,改了就必须重算;但同一个数字经 JSON 往返一圈可能差 1e-16,
|
||||
@@ -414,8 +476,8 @@ func (s *server) updateCategory(ctx context.Context, eventID, categoryID int64,
|
||||
if name == "" {
|
||||
return nil, false, badRequest("组别 name 必填", nil)
|
||||
}
|
||||
if in.Gender != nil && *in.Gender != "" && *in.Gender != "男" && *in.Gender != "女" {
|
||||
return nil, false, badRequest("组别 gender 只能是 男 / 女", in.Gender)
|
||||
if err := validateCategoryGender(in.Gender); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := validateCategoryAges(in.AgeMin, in.AgeMax); err != nil {
|
||||
return nil, false, err
|
||||
@@ -516,7 +578,8 @@ func (s *server) updateCategoryHandler(w http.ResponseWriter, r *http.Request) (
|
||||
|
||||
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
|
||||
count_to_medals, count_to_team_medals
|
||||
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -526,13 +589,18 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO,
|
||||
out := make([]channelDTO, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
c channelDTO
|
||||
countToTeam int
|
||||
c channelDTO
|
||||
countToTeam int
|
||||
countToMedals int
|
||||
countToTeamMedals int
|
||||
)
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
|
||||
&countToMedals, &countToTeamMedals); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
c.CountToTeam = boolPtr(countToTeam != 0)
|
||||
c.CountToMedals = boolPtr(countToMedals != 0)
|
||||
c.CountToTeamMedals = boolPtr(countToTeamMedals != 0)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -544,6 +612,12 @@ func channelCountsToTeam(c channelDTO) bool {
|
||||
return c.CountToTeam == nil || *c.CountToTeam
|
||||
}
|
||||
|
||||
// channelFlag 是通道两个奖牌开关的缺省口径:缺省(null)按 true 处理,
|
||||
// 与「升级前所有通道都发牌、个人奖牌计入单位榜」的既有行为一致。
|
||||
func channelFlag(v *bool) bool {
|
||||
return v == nil || *v
|
||||
}
|
||||
|
||||
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -567,9 +641,11 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
|
||||
return nil, badRequest("通道 profileId 必填", i)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c))); err != nil {
|
||||
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team,
|
||||
count_to_medals, count_to_team_medals)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c)),
|
||||
boolToInt(channelFlag(c.CountToMedals)), boolToInt(channelFlag(c.CountToTeamMedals))); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, conflict("同一 scope+basis 只能配置一个通道")
|
||||
}
|
||||
@@ -579,9 +655,23 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
|
||||
return nil, internal(err)
|
||||
}
|
||||
}
|
||||
// 这是「DELETE 全表 + 重新 INSERT」:通道 id 每次都变,挂在旧 id 上的 channel_ranks
|
||||
// 会被外键级联清空。不重算就会留下「通道换了、名次是空的」——大屏奖牌榜随之清零。
|
||||
// 名次与流水一起按新通道重建,并推榜单。团体手工名次不挂通道外键,录入数据不受影响。
|
||||
actorID := middleware.User(ctx).ID
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "通道配置变更重算")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
)
|
||||
|
||||
func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64, error) {
|
||||
@@ -25,6 +28,10 @@ type athleteImportRow struct {
|
||||
IDCard string
|
||||
Bib string
|
||||
Age *int
|
||||
// Duplicate 表示这一行是「同一份表里同一个证件号 + 同一个小项同一个组别」的重复行。
|
||||
// 同一个人报多个组别是合法的,只有落进同一个组别才是真重复;真重复两行的字段可能完全一样,
|
||||
// 但要不要用表里这行覆盖已经落库的报名必须由用户决定,所以它一律进冲突面板。
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// decisionKey 是冲突决策的键:优先证件号,没有证件号时用号码布。
|
||||
@@ -37,20 +44,25 @@ func (in athleteImportRow) decisionKey() string {
|
||||
|
||||
// athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。
|
||||
// SameNameNewPerson 非空表示本次按「同名的另一个人」新建了档案(只作报告,不拦截)。
|
||||
// Skip 表示本行是已决策为「保留已有」的真重复行:连报名也不写,库里那条原样不动。
|
||||
type athleteImportMatch struct {
|
||||
AthleteID int64
|
||||
TeamID int64
|
||||
Conflict *importConflict
|
||||
SameNameNewPerson *sameNameNewPerson
|
||||
Skip bool
|
||||
}
|
||||
|
||||
// sameNameNewPerson 是「号码布不同 → 按同名的另一个人新建」的清单项:
|
||||
// sameNameNewPerson 是「身份字段不同 → 按同名的另一个人新建」的清单项:
|
||||
// 导入响应里单独列出来给现场看,避免新建了档案却毫无提示。
|
||||
// 证件号与号码布都要带上:现场要核对的是「表里这两个号有没有写错」。
|
||||
type sameNameNewPerson struct {
|
||||
Name string `json:"name"`
|
||||
TeamCode string `json:"teamCode"`
|
||||
NewBib string `json:"newBib"`
|
||||
ExistingBib string `json:"existingBib"`
|
||||
Name string `json:"name"`
|
||||
TeamCode string `json:"teamCode"`
|
||||
NewIDCard string `json:"newIdCard"`
|
||||
ExistingIDCard string `json:"existingIdCard"`
|
||||
NewBib string `json:"newBib"`
|
||||
ExistingBib string `json:"existingBib"`
|
||||
}
|
||||
|
||||
// existingAthlete 是库中已有人员的可比字段。
|
||||
@@ -65,14 +77,30 @@ type existingAthlete struct {
|
||||
Age *int
|
||||
}
|
||||
|
||||
// matchImportedAthlete 按新口径匹配人员:号码布 → 姓名 + 单位 → 新建。
|
||||
// 号码布是单人唯一标识,证件号不参与匹配(只作为可选档案信息写入/更新)。
|
||||
// 命中人员字段与表里不一致时记为冲突;decisions 里对同一身份有决策时按决策落库。
|
||||
// matchImportedAthlete 按新口径匹配人员:证件号 → 号码布 → 姓名 + 单位 → 新建。
|
||||
//
|
||||
// 证件号才是人员的唯一标识,命中即认定同一个人(号码布差异按既有冲突口径处理,该进面板就进);
|
||||
// 证件号没命中(或表里没给)才退到号码布。号码布命中的那个人已经拿着另一个证件号时,
|
||||
// 说明「两个不同的人共用一个号码布」,这不是认人分歧而是数据错误,按既有号码布防呆口径报行级错误。
|
||||
func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
||||
if in.IDCard != "" {
|
||||
ex, err := s.athleteByIDCard(ctx, in.IDCard)
|
||||
switch {
|
||||
case err == nil:
|
||||
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
}
|
||||
if in.Bib != "" {
|
||||
ex, err := s.athleteByBib(ctx, in.Bib)
|
||||
switch {
|
||||
case err == nil:
|
||||
if in.IDCard != "" && ex.IDCard != nil && *ex.IDCard != "" && *ex.IDCard != in.IDCard {
|
||||
// 号码布在库里有主,且那个人的证件号与表里对不上:两个人共用一个号码布。
|
||||
// 沿用 createImportedAthlete 撞号码布唯一索引时的同一句话,现场看到的口径一致。
|
||||
return athleteImportMatch{}, conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib))
|
||||
}
|
||||
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return athleteImportMatch{}, internal(err)
|
||||
@@ -81,9 +109,9 @@ func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow,
|
||||
return s.matchByNameTeam(ctx, in, decisions)
|
||||
}
|
||||
|
||||
// matchByNameTeam 处理「号码布为空、或按号码布没找到」的情况,按姓名 + 单位找候选:
|
||||
// 0 条 → 新建;1 条且对方没有号码布 → 命中并回填;1 条但对方号码布不同 → 同名的另一个人,新建并报告;
|
||||
// 多条 → 仍有歧义,进冲突面板等用户决策。
|
||||
// matchByNameTeam 处理「证件号与号码布都没命中」的情况,按姓名 + 单位找候选:
|
||||
// 0 条 → 新建;1 条且身份字段有一边没填或都对得上 → 命中并回填;
|
||||
// 1 条但两边都有值且都对不上 → 同名的另一个人,新建并报告;多条 → 仍有歧义,进冲突面板等用户决策。
|
||||
func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
||||
candidates, err := s.athletesByNameTeam(ctx, in.Name, in.TeamID)
|
||||
if err != nil {
|
||||
@@ -94,18 +122,21 @@ func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decis
|
||||
return s.createImportedAthlete(ctx, in)
|
||||
case 1:
|
||||
ex := candidates[0]
|
||||
// 只有表里也给了号码布、且与库里不同时才算「同名的另一个人」:
|
||||
// 表里没给号码布只是「未指定」,不能凭空多建一个没有号码布的同名人。
|
||||
if ex.Bib != nil && *ex.Bib != "" && in.Bib != "" && *ex.Bib != in.Bib {
|
||||
// 两边都给了证件号 / 号码布、且对不上时才算「同名的另一个人」:
|
||||
// 一边没给只是「未指定」,不能凭空多建一个缺身份的同名人。
|
||||
// 证件号能对上的人前面就命中了,走到这里说明表里的证件号确实不属于他。
|
||||
if identityDiffers(ex.IDCard, in.IDCard) || identityDiffers(ex.Bib, in.Bib) {
|
||||
created, err := s.createImportedAthlete(ctx, in)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
created.SameNameNewPerson = &sameNameNewPerson{
|
||||
Name: in.Name,
|
||||
TeamCode: in.TeamCode,
|
||||
NewBib: in.Bib,
|
||||
ExistingBib: *ex.Bib,
|
||||
Name: in.Name,
|
||||
TeamCode: in.TeamCode,
|
||||
NewIDCard: in.IDCard,
|
||||
ExistingIDCard: strValue(ex.IDCard),
|
||||
NewBib: in.Bib,
|
||||
ExistingBib: strValue(ex.Bib),
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
@@ -123,15 +154,15 @@ func (s *server) matchAmbiguousCandidates(ctx context.Context, in athleteImportR
|
||||
if _, decided := decisions[in.decisionKey()]; decided {
|
||||
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||
}
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changedAthleteFields(ex, in))
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changedAthleteFields(ex, in), in.Duplicate)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
return athleteImportMatch{Conflict: &conflict}, nil
|
||||
}
|
||||
|
||||
// createImportedAthlete 新建人员:证件号只作为可选档案信息写入,
|
||||
// 但它与库里另一个人撞号时仍然明确报错(id_card 的部分唯一索引保留不动,防的是录入错误)。
|
||||
// createImportedAthlete 新建人员:证件号是唯一标识,能走到新建说明它在库里还没有主,
|
||||
// 但录入错误 / 并发仍可能让唯一索引撞上,撞上时明确报出占用者是谁。
|
||||
func (s *server) createImportedAthlete(ctx context.Context, in athleteImportRow) (athleteImportMatch, error) {
|
||||
if err := s.idCardTakenError(ctx, in.IDCard, 0); err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
@@ -159,7 +190,8 @@ func (s *server) createImportedAthlete(ctx context.Context, in athleteImportRow)
|
||||
}
|
||||
|
||||
// idCardTakenError 检查证件号是否已被另一个人占用,占用时返回可直接给现场看的中文错误。
|
||||
// 证件号不参与匹配,但库里一个证件号只能属于一个人(唯一索引保留):撞号时必须报清楚占用者是谁,
|
||||
// 证件号优先匹配的人本来就会命中占用者、走冲突面板;这里兜的是「新建 / 回填一个库里已有主的证件号」
|
||||
// 这条异常路径(正常导入走不到,录入错误与并发下才会):撞号时必须报清楚占用者是谁,
|
||||
// 不允许静默改库或静默跳过。excludeID 是被更新的人(新建传 0),允许他自己继续持有该证件号。
|
||||
func (s *server) idCardTakenError(ctx context.Context, idCard string, excludeID int64) error {
|
||||
if idCard == "" {
|
||||
@@ -194,21 +226,35 @@ func importRowMessage(err error) string {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// matchExistingAthlete 处理号码布或姓名 + 单位命中的人员:无差异时直接复用并补空字段,
|
||||
// matchExistingAthlete 处理证件号 / 号码布 / 姓名 + 单位命中的人员:无差异时直接复用并补空字段,
|
||||
// 有差异时走冲突流程,decisions 里对该身份有决策才落库。
|
||||
// 真重复行(同证件号 + 同小项同组别)同样要用户拍板:未决策时进面板;已决策为保留已有时连报名都不写。
|
||||
func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
|
||||
changed := changedAthleteFields(ex, in)
|
||||
action, decided := decisions[in.decisionKey()]
|
||||
if in.Duplicate {
|
||||
if !decided {
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changed, true)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
return athleteImportMatch{Conflict: &conflict}, nil
|
||||
}
|
||||
if action == decisionKeepExisting {
|
||||
// 保留已有:人员字段不动,库里那条报名也不动——本行整个跳过。
|
||||
return athleteImportMatch{AthleteID: ex.ID, TeamID: ex.TeamID, Skip: true}, nil
|
||||
}
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
if err := s.backfillEmptyAthleteFields(ctx, ex, in); err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
return athleteImportMatch{AthleteID: ex.ID, TeamID: in.TeamID}, nil
|
||||
}
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changed)
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changed, false)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
action, decided := decisions[in.decisionKey()]
|
||||
switch {
|
||||
case !decided:
|
||||
return athleteImportMatch{Conflict: &conflict}, nil
|
||||
@@ -249,8 +295,14 @@ func (s *server) athleteByBib(ctx context.Context, bib string) (existingAthlete,
|
||||
`SELECT `+importAthleteColumns+` FROM athletes WHERE bib = ? LIMIT 1`, bib))
|
||||
}
|
||||
|
||||
// athleteByIDCard 按证件号找人员。证件号上有部分唯一索引,最多一行。
|
||||
func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) {
|
||||
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+importAthleteColumns+` FROM athletes WHERE id_card = ? LIMIT 1`, idCard))
|
||||
}
|
||||
|
||||
// athletesByNameTeam 按「姓名 + 单位」列出全部候选,供调用方按候选数量决定是回填、新建还是进冲突面板。
|
||||
// 证件号不参与匹配,所以这里不再过滤证件号为空的人。
|
||||
// 证件号与号码布都已在前面按优先级查过,这里不再过滤。
|
||||
func (s *server) athletesByNameTeam(ctx context.Context, name string, teamID int64) ([]existingAthlete, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT `+importAthleteColumns+` FROM athletes WHERE name = ? AND team_id = ? ORDER BY id`, name, teamID)
|
||||
@@ -330,7 +382,7 @@ func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthl
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex existingAthlete, changed []string) (importConflict, error) {
|
||||
func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex existingAthlete, changed []string, duplicate bool) (importConflict, error) {
|
||||
existingCode, err := s.teamCodeByID(ctx, ex.TeamID)
|
||||
if err != nil {
|
||||
return importConflict{}, err
|
||||
@@ -340,6 +392,7 @@ func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex
|
||||
IDCard: in.IDCard,
|
||||
Bib: in.Bib,
|
||||
AthleteID: ex.ID,
|
||||
Duplicate: duplicate,
|
||||
Existing: importAthleteView{
|
||||
Name: ex.Name, Gender: ex.Gender, TeamCode: existingCode, EmployeeNo: ex.EmployeeNo,
|
||||
IDCard: ex.IDCard, Bib: ex.Bib, Age: ex.Age,
|
||||
@@ -388,39 +441,140 @@ func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in at
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回 true 表示新建。
|
||||
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, error) {
|
||||
var existing int64
|
||||
var err error
|
||||
// entryImportOutcome 是一次导入写入的落点,供调用方判断「要不要重算这一组」。
|
||||
//
|
||||
// 报名导入会改 entries.status(现场「改完名单重新导入一遍」很自然),
|
||||
// 而 status 正是「谁还算数」的开关:把正常参赛的人改成退赛/失格,
|
||||
// 他在名次与流水里就必须当场消失,否则会出现「名单说退赛、榜上还挂着分数与金牌」。
|
||||
type entryImportOutcome struct {
|
||||
// Created 表示这次是新建报名项:名单变了,团体平均口径(basis=aggregate + avg_mode=all)
|
||||
// 的分母跟着变,所以新建同样要重算这一组。
|
||||
Created bool
|
||||
// EventID / CategoryID 是这条报名项所在的 (小项, 组别);重算与推榜的最小单位。
|
||||
EventID int64
|
||||
CategoryID int64
|
||||
// StatusChanged 表示「可排名 / 不可排名」跨了界(registered ↔ scratched / dq / dns)。
|
||||
// 同一侧的状态改动(scratched → dq)不影响谁算数,不触发重算。
|
||||
StatusChanged bool
|
||||
}
|
||||
|
||||
// entryStatusRankable 与 domain.EntryStatus.rankable() 同一口径:
|
||||
// 空值按 registered 处理,只有 registered 可参与排名(见 domain/types.go)。
|
||||
// 那个方法是 domain 包内未导出的,httpapi 侧只能在此复述口径;domain 那边改了这里要一起改。
|
||||
func entryStatusRankable(status string) bool {
|
||||
return status == "" || status == string(domain.EntryStatusRegistered)
|
||||
}
|
||||
|
||||
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。
|
||||
//
|
||||
// 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把
|
||||
// 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。
|
||||
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImportOutcome, error) {
|
||||
var (
|
||||
existing int64
|
||||
oldStatus string
|
||||
err error
|
||||
)
|
||||
if in.AthleteID != nil {
|
||||
err = s.ev.QueryRowContext(ctx,
|
||||
`SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id = ?`,
|
||||
in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing)
|
||||
`SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id = ?`,
|
||||
in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing, &oldStatus)
|
||||
} else {
|
||||
err = s.ev.QueryRowContext(ctx,
|
||||
`SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`,
|
||||
in.CategoryID, in.TeamID).Scan(&existing)
|
||||
`SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`,
|
||||
in.CategoryID, in.TeamID).Scan(&existing, &oldStatus)
|
||||
}
|
||||
|
||||
status := orDefault(in.Status, "registered")
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered")); err != nil {
|
||||
return false, err
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status); err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
return true, nil
|
||||
return entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}, nil
|
||||
case err != nil:
|
||||
return false, err
|
||||
return entryImportOutcome{}, err
|
||||
default:
|
||||
if _, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET member_names = ?, bib = ?, seed = ?, heat = ?, status = ? WHERE id = ?`,
|
||||
marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat),
|
||||
orDefault(in.Status, "registered"), existing); err != nil {
|
||||
return false, err
|
||||
status, existing); err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
return false, nil
|
||||
return entryImportOutcome{
|
||||
EventID: in.EventID,
|
||||
CategoryID: in.CategoryID,
|
||||
StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// recomputeImportedScopes 重算报名导入动过的 (小项, 组别),并推小项与榜单。
|
||||
//
|
||||
// 改的是「谁还算数」,与批量退赛/恢复/移出、作废成绩、撤销发布同一口径:
|
||||
// 延迟结算(standings.live_on_publish=false)下也必须当场重算并推榜,
|
||||
// 否则名单里明明已经退赛的人会一直挂着名次与分数,直到下一次结算。
|
||||
//
|
||||
// 性能:调用方只把「新建过报名项」或「可排名状态跨了界」的组传进来,且已按组去重,
|
||||
// 所以几百行的导入通常只落到个位数组别;每组一次 recomputeLedgerTx。
|
||||
// 全部组共用一个事务:任一失败就整体回滚,不会留下「只重算了一半」的榜;
|
||||
// 榜单只在最后推一次,避免大屏收一串中间态(推榜次数也是用例的观察点)。
|
||||
func (s *server) recomputeImportedScopes(ctx context.Context, scopes []entryScope, actorID int64) error {
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
eventIDs := make([]int64, 0, 2)
|
||||
for _, scope := range scopes {
|
||||
// 改成退赛 / 失格的人不能再占名次:results.rank 这一列必须让出来。
|
||||
// applyComputedRanks 只写不清,不清就会留下「名单说退赛、名次还挂在他名下」——
|
||||
// 导出的分项成绩表读的正是这一列,会把他继续列成第 N 名。
|
||||
// 与批量退赛同口径(entries_batch 对退赛行显式 UPDATE ... rank = NULL)。
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE results SET rank = NULL
|
||||
WHERE state = 'published' AND rank IS NOT NULL
|
||||
AND entry_id IN (SELECT id FROM entries
|
||||
WHERE event_id = ? AND category_id = ? AND status <> 'registered')`,
|
||||
scope.eventID, scope.categoryID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
score, err := recomputeLedgerTx(ctx, tx, scope.eventID, scope.categoryID, actorID, "报名导入后重算")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 只重建流水不够:成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去。
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Contains(eventIDs, scope.eventID) {
|
||||
eventIDs = append(eventIDs, scope.eventID)
|
||||
}
|
||||
}
|
||||
for _, eventID := range eventIDs {
|
||||
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// addEntryScope 把受影响范围按出现顺序去重:同一批导入里每个 (小项, 组别) 只重算一次。
|
||||
func addEntryScope(scopes []entryScope, eventID, categoryID int64) []entryScope {
|
||||
scope := entryScope{eventID: eventID, categoryID: categoryID}
|
||||
if slices.Contains(scopes, scope) {
|
||||
return scopes
|
||||
}
|
||||
return append(scopes, scope)
|
||||
}
|
||||
@@ -570,20 +570,24 @@ func (s *server) domainAthletes(ctx context.Context, q queryer) ([]domain.Athlet
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// buildRanked 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。
|
||||
// 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致;
|
||||
// 只排名不计分(scored=0)的项目不产出奖牌,必须与导出侧同口径。
|
||||
// buildRanked 从 channel_ranks 读名次明细(用于奖牌统计与个人榜)。
|
||||
//
|
||||
// 数据源是「发布/重算时按通道全量重建」的 channel_ranks,而不是 results.rank:
|
||||
// 后者只是个人名次(见 applyComputedRanks),团体名次与聚合名次根本没有对应的成绩行。
|
||||
// 三个奖牌开关随行携带(events.award_medals + 通道两个开关),domain 的榜单函数不必回查库。
|
||||
// 只排名不计分(scored=0)的项目不产出奖牌,必须与导出侧同口径(export/load.loadRanked)。
|
||||
func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedResult, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status,
|
||||
e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id
|
||||
FROM results r
|
||||
JOIN entries e ON e.id = r.entry_id
|
||||
JOIN events ev ON ev.id = r.event_id AND ev.scored = 1
|
||||
JOIN event_score_channels c ON c.event_id = r.event_id
|
||||
LEFT JOIN athletes a ON a.id = e.athlete_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
|
||||
AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)`)
|
||||
`SELECT COALESCE(cr.result_id, 0), cr.event_id, cr.category_id, cr.rank, cr.result_value, cr.status,
|
||||
COALESCE(cr.entry_id, 0), COALESCE(cr.team_id, 0), COALESCE(cr.athlete_id, 0),
|
||||
COALESCE(a.name, ''), cr.scope, cr.channel_id, cr.basis, cr.subject_key,
|
||||
ev.award_medals, c.count_to_medals, c.count_to_team_medals
|
||||
FROM channel_ranks cr
|
||||
JOIN events ev ON ev.id = cr.event_id AND ev.scored = 1
|
||||
JOIN event_score_channels c ON c.id = cr.channel_id
|
||||
LEFT JOIN athletes a ON a.id = cr.athlete_id
|
||||
WHERE cr.rank > 0
|
||||
ORDER BY cr.event_id, cr.category_id, c.sort_order, c.id, cr.rank, cr.id`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
@@ -592,27 +596,61 @@ func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedRes
|
||||
out := make([]domain.RankedResult, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
rank int
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
status sql.NullString
|
||||
awardMedals int
|
||||
countToMedals int
|
||||
countToTeamMedals int
|
||||
)
|
||||
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 {
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rr.Rank, &value, &status,
|
||||
&rr.EntryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID,
|
||||
&rr.Basis, &rr.SubjectKey, &awardMedals, &countToMedals, &countToTeamMedals); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
rr.Rank = rank
|
||||
rr.Status = domain.ResultStatus(status.String)
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
rr.ResultValue = &v
|
||||
}
|
||||
rr.AthleteID = athleteID.Int64
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
// 归属份额沿用「主单位独占 100%」:team_id 为空(理论上不会)时不造份额,
|
||||
// 免得给 0 号单位凭空记牌,与 domain 的 wholeShare 一致。
|
||||
if rr.TeamID != 0 {
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
}
|
||||
rr.AwardMedals = awardMedals != 0
|
||||
rr.CountToMedals = countToMedals != 0
|
||||
rr.CountToTeamMedals = countToTeamMedals != 0
|
||||
out = append(out, rr)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// breakdownRankExpr 取一条单位流水的名次(devdocs 17 §11.3):优先按流水对应的通道与主体
|
||||
// 关联 channel_ranks,关联不上时回落到 results.rank。
|
||||
//
|
||||
// score_ledger 上没有通道列(LedgerEntry.ChannelID 没有落库),只能按流水自身能确定的主体找:
|
||||
// - 有 entry_id 的成绩流水:按报名项关联,多通道命中时取「主通道」(scope=athlete 优先,其次 sort_order);
|
||||
// - entry_id / result_id 都为空的聚合 / 团体手工名次流水:写入侧把 source_id 退化为通道 id,就用它 + 主体关联。
|
||||
//
|
||||
// 组别用该行能唯一确定的那一个:报名项的组别(cat)优先;聚合流水没有报名项,
|
||||
// 靠 ca 回推(该单位在本小项只参加了一个组别时那个组别才是答案,见下方子查询)。
|
||||
// 回推不出来也不按「碰巧撞上的另一组」报数:放宽到不限组别取最优名次,宁可留空也不张冠李戴。
|
||||
const breakdownRankExpr = `COALESCE((
|
||||
SELECT cr.rank
|
||||
FROM channel_ranks cr
|
||||
JOIN event_score_channels ch ON ch.id = cr.channel_id
|
||||
WHERE cr.event_id = l.event_id
|
||||
AND ((cat.id IS NOT NULL AND cr.category_id = cat.id)
|
||||
OR (cat.id IS NULL AND (ca.id IS NULL OR cr.category_id = ca.id)))
|
||||
AND ((l.entry_id IS NOT NULL AND cr.entry_id = l.entry_id)
|
||||
OR (l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result'
|
||||
AND cr.channel_id = l.source_id
|
||||
AND ((cr.scope = 'team' AND cr.team_id = l.team_id)
|
||||
OR (cr.scope = 'athlete' AND cr.athlete_id = l.athlete_id))))
|
||||
ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id
|
||||
LIMIT 1), r.rank)`
|
||||
|
||||
func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdownDTO, error) {
|
||||
team, err := s.getTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
@@ -628,17 +666,28 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
|
||||
if weight <= 0 {
|
||||
weight = 1
|
||||
}
|
||||
// 名次不再只读 results.rank:那是个人名次(见 applyComputedRanks),
|
||||
// 团体名次与聚合名次只落在 channel_ranks,读旧列会让下钻的名次列整列变空。
|
||||
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, '')
|
||||
l.athlete_id, a.name, `+breakdownRankExpr+`, r.result_text, l.points, COALESCE(l.created_at, '')
|
||||
FROM score_ledger l
|
||||
LEFT JOIN events ev ON ev.id = l.event_id
|
||||
LEFT JOIN entries e ON e.id = l.entry_id
|
||||
LEFT JOIN event_categories cat ON cat.id = e.category_id
|
||||
LEFT JOIN (
|
||||
SELECT en2.event_id, MIN(en2.category_id) AS category_id
|
||||
FROM entries en2
|
||||
WHERE en2.team_id = ?
|
||||
GROUP BY en2.event_id
|
||||
HAVING COUNT(DISTINCT en2.category_id) = 1
|
||||
) ac ON ac.event_id = l.event_id AND l.entry_id IS NULL AND l.result_id IS NULL
|
||||
AND l.source_type = 'result'
|
||||
LEFT JOIN event_categories ca ON ca.id = ac.category_id
|
||||
LEFT JOIN athletes a ON a.id = l.athlete_id
|
||||
LEFT JOIN results r ON r.id = l.result_id
|
||||
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
|
||||
ORDER BY l.id DESC`, teamID)
|
||||
ORDER BY l.id DESC`, teamID, teamID)
|
||||
if err != nil {
|
||||
return teamBreakdownDTO{}, internal(err)
|
||||
}
|
||||
|
||||
@@ -191,6 +191,12 @@ func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, i
|
||||
if it.EntryID == 0 {
|
||||
return nil, badRequest("results[].entryId 必填", i)
|
||||
}
|
||||
// 名次是手填名次项目的输入,results.rank 的约束是 rank IS NULL OR rank >= 1:
|
||||
// 留空 = 不计名次(合法),填了就必须不小于 1。不在这里拦就会一路落到 SQLite,
|
||||
// 报成 500 + 原始约束文本,录入员看不出是哪一格填错了。
|
||||
if it.Rank != nil && *it.Rank < 1 {
|
||||
return nil, badRequest("名次需要是不小于 1 的整数(留空表示不计名次)", it.Rank)
|
||||
}
|
||||
var entryEvent int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, it.EntryID).Scan(&entryEvent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi_test
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -111,3 +112,53 @@ func TestSaveDraftKeepsUnitWhenOmitted(t *testing.T) {
|
||||
t.Fatalf("显式传 unit 应覆盖为 min,得到 %+v", unit)
|
||||
}
|
||||
}
|
||||
|
||||
// 手填名次只接受「不传」或「>= 1」:0 / 负数撞 results.rank 的 CHECK
|
||||
// (rank IS NULL OR rank >= 1),落库才炸就是 500 + 原始约束文本,
|
||||
// 录入员看到「服务端异常」根本不知道是哪一格错了。
|
||||
func TestSaveDraftRejectsNonPositiveRank(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")
|
||||
|
||||
for _, rank := range []int{0, -1} {
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "rank": rank, "resultValue": 11.58, "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("rank=%d 应 400 VALIDATION_FAILED,得到 %d %+v", rank, status, env2.Error)
|
||||
}
|
||||
if !strings.Contains(env2.Error.Message, "名次") {
|
||||
t.Fatalf("400 文案应点明「名次」与合法范围,得到 %q", env2.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// 整批拒绝:非法名次不该把同一次请求里的其它行顺手写进去。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM results`); n != 0 {
|
||||
t.Fatalf("被拒的存草稿不该写库,得到 %d 条成绩", n)
|
||||
}
|
||||
|
||||
// 留空(不传 rank)与 rank=1 都是合法输入,照常保存。
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.58, "status": "ok"},
|
||||
{"entryId": ids.entryB, "rank": 1, "resultValue": 11.92, "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("留空名次与 rank=1 应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
var rank sql.NullInt64
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&rank); err != nil {
|
||||
t.Fatalf("读名次: %v", err)
|
||||
}
|
||||
if !rank.Valid || rank.Int64 != 1 {
|
||||
t.Fatalf("rank=1 应落库,得到 %+v", rank)
|
||||
}
|
||||
}
|
||||
@@ -321,3 +321,172 @@ func TestAthleteAgeReadWriteAndRange(t *testing.T) {
|
||||
t.Fatalf("越界的 400 不该改库中年龄,得到 %+v", age)
|
||||
}
|
||||
}
|
||||
|
||||
// 运动员性别与组别同口径:非法值在接口层就 400(带字段名与非法值),
|
||||
// 不再原样落库撞 athletes.gender 的 CHECK 报 500 + 英文约束原文。
|
||||
func TestAthleteGenderInvalidIsBadRequest(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")
|
||||
|
||||
for _, gender := range []string{"M", "male", "未知"} {
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
||||
"name": "张三", "gender": gender, "teamId": ids.teamA, "active": true,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("新建 gender=%q 应 400 VALIDATION_FAILED,得到 %d %+v", gender, status, env2.Error)
|
||||
}
|
||||
if !strings.Contains(env2.Error.Message, "gender") || !strings.Contains(env2.Error.Message, "男") {
|
||||
t.Fatalf("400 文案应点明字段与合法取值,得到 %q", env2.Error.Message)
|
||||
}
|
||||
if !strings.Contains(string(env2.Error.Detail), gender) {
|
||||
t.Fatalf("400 的 detail 应带上非法值 %q,得到 %s", gender, env2.Error.Detail)
|
||||
}
|
||||
|
||||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athleteA), map[string]any{
|
||||
"name": "张伟", "gender": gender, "teamId": ids.teamA, "active": true,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("改 gender=%q 应 400 VALIDATION_FAILED,得到 %d %+v", gender, status, env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 被拒的两次都没改库:性别仍是「男」。
|
||||
var gender string
|
||||
if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil {
|
||||
t.Fatalf("读性别: %v", err)
|
||||
}
|
||||
if gender != "男" {
|
||||
t.Fatalf("400 不该改库中性别,得到 %q", gender)
|
||||
}
|
||||
|
||||
// 留空(历史档案里就有空性别)与「男 / 女」照常可写。
|
||||
for _, g := range []any{nil, "女"} {
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athleteA), map[string]any{
|
||||
"name": "张伟", "gender": g, "teamId": ids.teamA, "active": true,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("gender=%v 应可保存,得到 %d %+v", g, status, env2.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 积分模板的「录取名次」(scoring_profiles.award_ranks)在写入侧没有校验:
|
||||
// -1 一路落库撞建表 CHECK(migrations/event/0003:CHECK (award_ranks >= 0)),
|
||||
// 报出来是 500 + 原始约束文本,而同表的 tieMode / recordBonusMode 早就是 400
|
||||
// (见 enums_test.go 的 TestProfileEnumValidation)。这里钉住新建与更新两条路径。
|
||||
func TestProfileAwardRanksValidation(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
client := recorderClient(t, env)
|
||||
|
||||
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
||||
"code": "P-BAD", "name": "非法录取名次", "awardRanks": -1,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("awardRanks=-1 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if !strings.Contains(resp.Error.Message, "awardRanks") {
|
||||
t.Fatalf("400 文案应点明字段名,得到 %q", resp.Error.Message)
|
||||
}
|
||||
if !strings.Contains(string(resp.Error.Detail), "-1") {
|
||||
t.Fatalf("400 的 detail 应带上非法值 -1,得到 %s", resp.Error.Detail)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'P-BAD'`); n != 0 {
|
||||
t.Fatalf("被拒的模板不该落库,得到 %d 行", n)
|
||||
}
|
||||
|
||||
// 合法值:0 表示不按名次封顶(domain.basePoints 只在 award_ranks > 0 时才截断),正整数照常。
|
||||
for _, want := range []int{0, 8} {
|
||||
status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
||||
"code": fmt.Sprintf("P-OK-%d", want), "name": "合法录取名次", "awardRanks": want,
|
||||
}, true)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("awardRanks=%d 应 200,得到 %d %+v", want, status, resp.Error)
|
||||
}
|
||||
if got := decodeData[map[string]any](t, resp)["awardRanks"]; got != float64(want) {
|
||||
t.Fatalf("awardRanks=%d 应原样落库,得到 %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新路径同口径。
|
||||
status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
||||
"code": "P-UPD", "name": "待改模板", "awardRanks": 3,
|
||||
}, true)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("建待改模板应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
profileID := int64(decodeData[map[string]any](t, resp)["id"].(float64))
|
||||
path := fmt.Sprintf("/api/scoring-profiles/%d", profileID)
|
||||
|
||||
status, resp = env.do(client, http.MethodPut, path, map[string]any{
|
||||
"code": "P-UPD", "name": "待改模板", "awardRanks": -5,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("更新 awardRanks=-5 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if !strings.Contains(resp.Error.Message, "awardRanks") {
|
||||
t.Fatalf("更新的 400 文案也应点明字段名,得到 %q", resp.Error.Message)
|
||||
}
|
||||
var stored int
|
||||
if err := env.ev.QueryRow(`SELECT award_ranks FROM scoring_profiles WHERE id = ?`, profileID).Scan(&stored); err != nil {
|
||||
t.Fatalf("读录取名次: %v", err)
|
||||
}
|
||||
if stored != 3 {
|
||||
t.Fatalf("被拒的更新不该改库,得到 %d", stored)
|
||||
}
|
||||
|
||||
if status, resp = env.do(client, http.MethodPut, path, map[string]any{
|
||||
"code": "P-UPD", "name": "待改模板", "awardRanks": 4,
|
||||
}, true); status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("更新 awardRanks=4 应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT award_ranks FROM scoring_profiles WHERE id = ?`, profileID).Scan(&stored); err != nil {
|
||||
t.Fatalf("读录取名次: %v", err)
|
||||
}
|
||||
if stored != 4 {
|
||||
t.Fatalf("合法更新应落库 4,得到 %d", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// last_place_zero 没有补写入侧校验,因为它取不到 CHECK (last_place_zero IN (0, 1)) 之外的值:
|
||||
// profileDTO.LastPlaceZero 是 Go 的 bool,落库的 0/1 由类型直接决定,传数字会被 JSON 解码
|
||||
// 拦成 400(而不是 500)。这条用例把「本来就有的保证」钉住,以后谁把字段改成 int 就会变红。
|
||||
func TestProfileLastPlaceZeroIsBooleanTyped(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
client := recorderClient(t, env)
|
||||
|
||||
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
||||
"code": "P-LPZ-BAD", "name": "非布尔", "lastPlaceZero": 2,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("lastPlaceZero=2 应 400 VALIDATION_FAILED(类型不合法),得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'P-LPZ-BAD'`); n != 0 {
|
||||
t.Fatalf("类型不合法的模板不该落库,得到 %d 行", n)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
code string
|
||||
set bool
|
||||
want int
|
||||
}{
|
||||
{"P-LPZ-TRUE", true, 1},
|
||||
{"P-LPZ-FALSE", false, 0},
|
||||
} {
|
||||
status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
||||
"code": tc.code, "name": "布尔模板", "lastPlaceZero": tc.set,
|
||||
}, true)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("%s lastPlaceZero=%v 应 200,得到 %d %+v", tc.code, tc.set, status, resp.Error)
|
||||
}
|
||||
var stored int
|
||||
if err := env.ev.QueryRow(`SELECT last_place_zero FROM scoring_profiles WHERE code = ?`, tc.code).Scan(&stored); err != nil {
|
||||
t.Fatalf("读最后名次计分: %v", err)
|
||||
}
|
||||
if stored != tc.want {
|
||||
t.Fatalf("%s last_place_zero 应为 %d,得到 %d", tc.code, tc.want, stored)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,17 @@ func loadEventQ(ctx context.Context, q queryer, eventID int64) (domain.Event, er
|
||||
e domain.Event
|
||||
scheduledAt sql.NullString
|
||||
venue, remark sql.NullString
|
||||
awardMedals int
|
||||
)
|
||||
err := q.QueryRowContext(ctx,
|
||||
`SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
|
||||
award_medals
|
||||
FROM events WHERE id = ?`, eventID).
|
||||
Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType, &e.Compare,
|
||||
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark, &e.AvgMode)
|
||||
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark, &e.AvgMode,
|
||||
&awardMedals)
|
||||
e.AwardMedals = awardMedals != 0
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Event{}, notFound("小项不存在")
|
||||
}
|
||||
@@ -81,7 +85,8 @@ func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.Ev
|
||||
|
||||
func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
|
||||
count_to_medals, count_to_team_medals
|
||||
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -91,21 +96,66 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor
|
||||
out := make([]domain.ScoreChannel, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
c domain.ScoreChannel
|
||||
countToTeam int
|
||||
c domain.ScoreChannel
|
||||
countToTeam int
|
||||
countToMedals int
|
||||
countToTeamMedals int
|
||||
)
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
|
||||
&countToMedals, &countToTeamMedals); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分)。
|
||||
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分 / 发奖牌)。
|
||||
if countToTeam == 0 {
|
||||
c.CountToTeam = boolPtr(false)
|
||||
}
|
||||
if countToMedals == 0 {
|
||||
c.CountToMedals = boolPtr(false)
|
||||
}
|
||||
if countToTeamMedals == 0 {
|
||||
c.CountToTeamMedals = boolPtr(false)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// loadTeamRanksQ 读「按单位录入的团体名次」(channel_manual_ranks),按组别分组返回。
|
||||
//
|
||||
// 按组别分组是必须的:computeEventScore 会逐组别调用引擎,组别名次只在组别内有效,
|
||||
// 一次查询整小项再分发,避免每个组别各发一条 SQL。
|
||||
// 只取 scope=team + basis=manual 的行:这两列是这张表的定位键,
|
||||
// 其它 scope/basis 的行属于别的通道形态,引擎的团体手工通道也不该看到它们。
|
||||
func loadTeamRanksQ(ctx context.Context, q queryer, eventID, categoryID int64) (map[int64][]domain.TeamManualRank, error) {
|
||||
query := `SELECT category_id, team_id, rank FROM channel_manual_ranks
|
||||
WHERE event_id = ? AND scope = 'team' AND basis = 'manual'`
|
||||
args := []any{eventID}
|
||||
if categoryID > 0 {
|
||||
query += ` AND category_id = ?`
|
||||
args = append(args, categoryID)
|
||||
}
|
||||
query += ` ORDER BY category_id, rank, team_id`
|
||||
|
||||
rows, err := q.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64][]domain.TeamManualRank)
|
||||
for rows.Next() {
|
||||
var (
|
||||
catID int64
|
||||
r domain.TeamManualRank
|
||||
)
|
||||
if err := rows.Scan(&catID, &r.TeamID, &r.Rank); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out[catID] = append(out[catID], r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadProfilesQ(ctx context.Context, q queryer, ids map[int64]bool) (map[int64]domain.ScoringProfile, error) {
|
||||
out := make(map[int64]domain.ScoringProfile, len(ids))
|
||||
for id := range ids {
|
||||
@@ -309,6 +359,10 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
teamRanks, err := loadTeamRanksQ(ctx, q, eventID, categoryID)
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
|
||||
var total domain.EventScore
|
||||
for _, cat := range cats {
|
||||
@@ -324,6 +378,8 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
|
||||
Results: results,
|
||||
RecordBonus: &bonusCfg,
|
||||
RecordBreaks: breaks,
|
||||
// 每个组别只拿自己组别的团体名次:名次在组别内有效,串组会算出别人的分。
|
||||
TeamRanks: teamRanks[cat.ID],
|
||||
})
|
||||
if err != nil {
|
||||
return domain.EventScore{}, mapDomainError(err)
|
||||
@@ -373,25 +429,25 @@ func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eve
|
||||
|
||||
ledger := score.Ledger
|
||||
if categoryID > 0 {
|
||||
// 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废;
|
||||
// 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。
|
||||
hasAggregate, err := hasAggregateChannel(ctx, tx, eventID)
|
||||
// 「无报名项归属」的流水无法按组别区分,上面已把本小项的全部这类流水一并作废;
|
||||
// 这里补算整项的同类流水,避免按组别重算丢掉其它组别的得分。
|
||||
hasSubject, err := hasSubjectChannel(ctx, tx, eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasAggregate {
|
||||
if hasSubject {
|
||||
all, err := computeEventScore(ctx, tx, eventID, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
|
||||
for _, l := range score.Ledger {
|
||||
if !isAggregateLedger(l) {
|
||||
if !isSubjectLedger(l) {
|
||||
ledger = append(ledger, l)
|
||||
}
|
||||
}
|
||||
for _, l := range all.Ledger {
|
||||
if isAggregateLedger(l) {
|
||||
if isSubjectLedger(l) {
|
||||
ledger = append(ledger, l)
|
||||
}
|
||||
}
|
||||
@@ -406,35 +462,98 @@ func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eve
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// 名次与流水同源同事务:流水改了名次必然跟着变,分开落库就会出现「榜上有分、名次还是旧的」。
|
||||
return writeChannelRanksTx(ctx, tx, score, eventID, categoryID)
|
||||
}
|
||||
|
||||
// isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员,
|
||||
// 落库时 entry_id 与 result_id 都为空。
|
||||
func isAggregateLedger(l domain.LedgerEntry) bool {
|
||||
// isSubjectLedger 判断流水是否来自「按主体排名」的通道,这类流水的 EntryID 与 ResultID 都为空:
|
||||
//
|
||||
// - 聚合通道(basis=aggregate):名次主体是单位(team)或运动员(athlete),
|
||||
// 不对应任何一条报名项;
|
||||
// - 团体手工通道(scope=team + basis=manual,主体来自 channel_manual_ranks):
|
||||
// 录入的是单位名次,同样没有报名项与成绩行(走团体报名项兜底时才有 entry_id,
|
||||
// 那种流水仍归属组别,不算在这里)。
|
||||
//
|
||||
// 判据必须涵盖这两类:按组别重算时它们都无法靠 entry_id 定位(见 writeLedgerTx 的作废范围),
|
||||
// 少认一类就会让另一类旧流水作废不掉或补不回来,该通道的分数随之翻倍或丢失。
|
||||
func isSubjectLedger(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) {
|
||||
// hasSubjectChannel 判断小项是否配置了「按主体排名」的通道(聚合 / 团体手工);
|
||||
// 没有时按组别重算无需补算整项流水。
|
||||
func hasSubjectChannel(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 {
|
||||
`SELECT COUNT(*) FROM event_score_channels
|
||||
WHERE event_id = ? AND (basis = 'aggregate' OR (scope = 'team' AND basis = 'manual'))`, eventID).Scan(&n); err != nil {
|
||||
return false, internal(err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。
|
||||
// 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。
|
||||
// writeChannelRanksTx 按引擎产出的名次明细全量重建 channel_ranks:先删范围内旧行,再逐条插入。
|
||||
//
|
||||
// 结算范围与流水一致:categoryID > 0 只重建该组别,否则重建整个小项。
|
||||
// SubjectKey 是 (通道, 组别, 主体) 的唯一键,聚合与团体手工名次没有报名项,靠它落库。
|
||||
// AwardMedals / CountToMedals / CountToTeamMedals 是读取侧 join events / event_score_channels
|
||||
// 得到的,不落这张表;shares 不进表(当前只有「主单位独占 100%」一种口径,读取侧按 team_id 重建)。
|
||||
func writeChannelRanksTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID int64) error {
|
||||
if categoryID > 0 {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM channel_ranks
|
||||
WHERE category_id = ? AND channel_id IN (SELECT id FROM event_score_channels WHERE event_id = ?)`,
|
||||
categoryID, eventID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
} else if _, err := tx.ExecContext(ctx, `DELETE FROM channel_ranks WHERE event_id = ?`, eventID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
|
||||
for _, rr := range score.Ranked {
|
||||
// 没有通道(只排名不计分的小项走退化路径,通道 id 为 0)与非法名次都落不了这张表:
|
||||
// 表上 channel_id 有外键、rank 有 CHECK(rank >= 1),硬插只会整笔事务失败。
|
||||
if rr.ChannelID == 0 || rr.Rank <= 0 || rr.SubjectKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO channel_ranks (event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points, result_value, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
eventID, rr.CategoryID, rr.ChannelID, rr.Basis, rr.Scope, rr.SubjectKey,
|
||||
nullID(rr.EntryID), nullID(rr.ResultID), nullID(rr.AthleteID), nullID(rr.TeamID),
|
||||
rr.Rank, rr.Points, nullF64(rr.ResultValue), nullStr(resultStatusPtr(rr.Status))); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resultStatusPtr 把成绩状态转成可空串:空状态落 NULL,与 results.status 的口径一致。
|
||||
func resultStatusPtr(s domain.ResultStatus) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
v := string(s)
|
||||
return &v
|
||||
}
|
||||
|
||||
// applyComputedRanks 把引擎算出的「个人名次」写回 results.rank。
|
||||
//
|
||||
// results.rank 的语义收窄为个人名次:只处理 scope=athlete 且带真实成绩行的名次。
|
||||
// 团体通道(scope=team)与聚合 / 团体手工名次(ResultID=0)一律不碰这一列——
|
||||
// 它们过去把个人名次覆盖成团体名次,双通道项目第二次发布会把同一份名次再发一次分(P0)。
|
||||
//
|
||||
// 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次),
|
||||
// 其次通道 id 小者,保证同一份输入每次写回同一个值。
|
||||
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
|
||||
best := make(map[int64]domain.RankedResult)
|
||||
for _, rr := range ranked {
|
||||
if rr.ResultID == 0 || rr.Rank <= 0 {
|
||||
if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
|
||||
continue
|
||||
}
|
||||
cur, ok := best[rr.ResultID]
|
||||
if !ok || (cur.Scope == domain.ScopeTeam && rr.Scope == domain.ScopeAthlete) {
|
||||
if !ok || preferComputedRank(rr, cur) {
|
||||
best[rr.ResultID] = rr
|
||||
}
|
||||
}
|
||||
@@ -446,6 +565,14 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
|
||||
return nil
|
||||
}
|
||||
|
||||
// preferComputedRank 判断候选 a 是否比已选中的 b 更该写回 results.rank。
|
||||
func preferComputedRank(a, b domain.RankedResult) bool {
|
||||
if (a.Basis == domain.BasisResult) != (b.Basis == domain.BasisResult) {
|
||||
return a.Basis == domain.BasisResult
|
||||
}
|
||||
return a.ChannelID < b.ChannelID
|
||||
}
|
||||
|
||||
// ---------- 发布 / 撤销 / 作废 ----------
|
||||
|
||||
// publishableResultCond 判断一条 results 行是否承载了「可以上屏的成绩」:
|
||||
@@ -611,7 +738,7 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
||||
}
|
||||
}
|
||||
if settle {
|
||||
// 名次也要等结算才写:奖牌榜统计读的就是 results.rank(见 buildRanked),
|
||||
// 名次也要等结算才写:名次(results.rank 与 channel_ranks)只在这里落,
|
||||
// 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动,
|
||||
// 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。
|
||||
score, err := computeEventScore(ctx, tx, in.EventID, catID)
|
||||
@@ -783,29 +910,26 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
reason, resultID); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
// 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。
|
||||
// withdrawn 仍是「可发布成绩」,作废不会让该组变成没录完,所以录完过的组照样能结算;
|
||||
// 没录完的组按延迟结算处理:名次与流水都等结算,只把作废这件事落库。
|
||||
settle := true
|
||||
if live, err := liveOnPublishQ(ctx, tx); err != nil {
|
||||
return resultDTO{}, err
|
||||
} else if !live {
|
||||
settle, err = scopeSettled(ctx, tx, eventID, catID)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
// 作废的成绩不再占名次:results.rank 这一列必须让出来。作废行仍是 state='published',
|
||||
// 而导出(分项成绩表)读的正是 results.rank,不清就会把作废行列成第 1 名、与真正的冠军并列;
|
||||
// 整组全部作废时更是几条成绩都挂着第 1 名。与批量退赛同口径(entries_batch 显式清 rank)。
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE results SET rank = NULL WHERE id = ?`, resultID); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
if settle {
|
||||
score, err := computeEventScore(ctx, tx, eventID, catID)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := writeLedgerTx(ctx, tx, score, eventID, catID, userID, "作废成绩:"+reason); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
// 作废是回滚动作(改的是「谁还算数」,不是「录了多少」),与撤销发布、批量退赛/恢复/移出
|
||||
// 同一口径:无条件重算并推榜,不看 standings.live_on_publish、也不看 scopeSettled。
|
||||
// 否则没录完的组里作废一条成绩会留下一整套旧名次与旧流水(榜上有分、成绩却撤了),
|
||||
// 被作废的人会一直挂到下一次结算。
|
||||
// 这一刀不会把「未录完的组」提前变成「已结算」:结算判据只数已发布成绩,作废不写 state
|
||||
// (仍是 published、仍算「已录出」),延迟窗口里其它批次照旧等录完才结算。
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
// 只重建流水不够:成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去;
|
||||
// 奖牌榜与运动员榜看的是 channel_ranks,由 recomputeLedgerTx 一并重建。
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
|
||||
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
|
||||
@@ -815,10 +939,9 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if settle {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
// 名次与流水刚改成当前口径,必须推榜单,否则大屏还挂着作废前的旧画面。
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
|
||||
@@ -229,6 +229,10 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("PUT /api/events/{id}/categories/{categoryId}", s.write(auth.RoleRecorder, s.updateCategoryHandler))
|
||||
mux.HandleFunc("GET /api/events/{id}/channels", s.public(s.listChannelsHandler))
|
||||
mux.HandleFunc("PUT /api/events/{id}/channels", s.write(auth.RoleRecorder, s.replaceChannelsHandler))
|
||||
// 团体手工名次(devdocs/17 §七):成绩页要读(guest 也能看成绩),录入员可写。
|
||||
// 与成绩读取同权限:组别下没有团体手工通道时回空视图,不是 404。
|
||||
mux.HandleFunc("GET /api/events/{id}/categories/{categoryId}/team-ranks", s.public(s.teamRanksHandler))
|
||||
mux.HandleFunc("PUT /api/events/{id}/categories/{categoryId}/team-ranks", s.write(auth.RoleRecorder, s.updateTeamRanksHandler))
|
||||
|
||||
mux.HandleFunc("GET /api/scoring-profiles", s.public(s.listProfilesHandler))
|
||||
mux.HandleFunc("POST /api/scoring-profiles", s.write(auth.RoleRecorder, s.createProfileHandler))
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
)
|
||||
|
||||
// 本文件实现「团体手工名次」的读写(devdocs/17 §七)。
|
||||
//
|
||||
// 团体名次不再进 results.rank:它属于单位而不是某一条报名项,落在 channel_manual_ranks,
|
||||
// 由 (小项, 组别, 单位, scope, basis) 定位。通道 id 会随规则包导入 / 通道替换而变,
|
||||
// 所以这张表故意不挂通道外键,读写也都按 (scope, basis) 重新找通道。
|
||||
|
||||
// teamManualRankDTO 是一条「单位 → 名次」记录;rank 为 0 只出现在请求体里,表示清空。
|
||||
type teamManualRankDTO struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Rank int `json:"rank"`
|
||||
}
|
||||
|
||||
// teamRankChannelDTO 是团体名次读数里点名用的通道引用:只带渲染与写回所需的字段,
|
||||
// 不把整条通道配置(权重、开关等)复制一份出来,避免两处契约各自漂移。
|
||||
type teamRankChannelDTO struct {
|
||||
ChannelID int64 `json:"channelId"`
|
||||
Scope string `json:"scope"`
|
||||
Basis string `json:"basis"`
|
||||
ProfileID int64 `json:"profileId"`
|
||||
}
|
||||
|
||||
// teamRankTeamDTO 是启用单位清单的一行:录入页据此渲染「每个单位一个名次输入框」。
|
||||
type teamRankTeamDTO struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
ShortName *string `json:"shortName"`
|
||||
}
|
||||
|
||||
// teamRanksViewDTO 是团体名次读数(GET 的响应体,也是成绩录入接口随行下发的元素)。
|
||||
// categoryId 只在随行下发时出现:按组别路径取时路径已经表明了组别,不再重复。
|
||||
type teamRanksViewDTO struct {
|
||||
CategoryID int64 `json:"categoryId,omitempty"`
|
||||
Channels []teamRankChannelDTO `json:"channels"`
|
||||
Teams []teamRankTeamDTO `json:"teams"`
|
||||
Ranks []teamManualRankDTO `json:"ranks"`
|
||||
}
|
||||
|
||||
// entryBoardDTO 是成绩录入接口(GET /api/events/{id}/entries)的响应体:成绩行 + 团体名次。
|
||||
// 设计 17 §七要求录入页一次请求拿到两者,避免为了团体名次多开一轮。
|
||||
// teamRanks 只在存在团体手工通道时非空(按组别各一个视图),没有时是空数组而不是 null。
|
||||
type entryBoardDTO struct {
|
||||
Entries []entryWithResultDTO `json:"entries"`
|
||||
TeamRanks []teamRanksViewDTO `json:"teamRanks"`
|
||||
}
|
||||
|
||||
// listEntryBoard 组装成绩录入页的完整读数。
|
||||
func (s *server) listEntryBoard(ctx context.Context, eventID int64) (entryBoardDTO, error) {
|
||||
entries, err := s.listEntriesWithResults(ctx, eventID)
|
||||
if err != nil {
|
||||
return entryBoardDTO{}, err
|
||||
}
|
||||
views, err := s.teamRanksViews(ctx, s.ev, eventID)
|
||||
if err != nil {
|
||||
return entryBoardDTO{}, err
|
||||
}
|
||||
return entryBoardDTO{Entries: entries, TeamRanks: views}, nil
|
||||
}
|
||||
|
||||
// teamManualChannels 从整小项的通道里挑出团体手工通道(scope=team + basis=manual)。
|
||||
// 只有这类通道会读 channel_manual_ranks,录入入口也只认它。
|
||||
func teamManualChannels(channels []domain.ScoreChannel) []domain.ScoreChannel {
|
||||
out := make([]domain.ScoreChannel, 0, 1)
|
||||
for _, ch := range channels {
|
||||
if ch.Scope == domain.ScopeTeam && ch.Basis == domain.BasisManual {
|
||||
out = append(out, ch)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// activeTeams 读启用单位清单(active=1 且未假删除);假删除的单位不出现在录入页。
|
||||
func (s *server) activeTeams(ctx context.Context, q queryer) ([]teamRankTeamDTO, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT id, code, name, short_name FROM teams
|
||||
WHERE active = 1 AND deleted_at IS NULL
|
||||
ORDER BY sort_order, id`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]teamRankTeamDTO, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
t teamRankTeamDTO
|
||||
shortName sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&t.TeamID, &t.Code, &t.Name, &shortName); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
t.ShortName = strPtr(shortName)
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// teamRanksView 组装某组别的团体名次读数:已填名次 + 团体手工通道 + 启用单位清单。
|
||||
// 没有团体手工通道时 channels 为空、ranks 为空(不是 404):录入页据此隐藏整块录入区。
|
||||
func (s *server) teamRanksView(ctx context.Context, q queryer, eventID, categoryID int64, withCategoryID bool) (teamRanksViewDTO, error) {
|
||||
channels, err := loadChannelsQ(ctx, q, eventID)
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
teams, err := s.activeTeams(ctx, q)
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
ranks, err := loadTeamRanksQ(ctx, q, eventID, categoryID)
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
view := buildTeamRanksView(channels, teams, ranks[categoryID])
|
||||
if withCategoryID {
|
||||
view.CategoryID = categoryID
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// teamRanksViews 组装整小项的团体名次读数(每个有团体手工通道的组别一个视图),
|
||||
// 供成绩录入接口随行下发:录入页一次请求就拿到「成绩行 + 团体名次」,少发一轮。
|
||||
//
|
||||
// 三个基础读数各查一次再分发:逐组别调用 teamRanksView 会把通道、单位清单重复查 N 遍。
|
||||
// 没有团体手工通道的小项直接回空列表——不返回「看起来像有配置」的空视图。
|
||||
func (s *server) teamRanksViews(ctx context.Context, q queryer, eventID int64) ([]teamRanksViewDTO, error) {
|
||||
channels, err := loadChannelsQ(ctx, q, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(teamManualChannels(channels)) == 0 {
|
||||
return []teamRanksViewDTO{}, nil
|
||||
}
|
||||
categories, err := loadCategoriesQ(ctx, q, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
teams, err := s.activeTeams(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ranks, err := loadTeamRanksQ(ctx, q, eventID, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]teamRanksViewDTO, 0, len(categories))
|
||||
for _, cat := range categories {
|
||||
view := buildTeamRanksView(channels, teams, ranks[cat.ID])
|
||||
view.CategoryID = cat.ID
|
||||
out = append(out, view)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildTeamRanksView 把已读到的通道 / 单位 / 名次拼成一个视图,不做任何查询。
|
||||
func buildTeamRanksView(channels []domain.ScoreChannel, teams []teamRankTeamDTO, ranks []domain.TeamManualRank) teamRanksViewDTO {
|
||||
view := teamRanksViewDTO{
|
||||
Channels: make([]teamRankChannelDTO, 0, 1),
|
||||
Teams: teams,
|
||||
Ranks: make([]teamManualRankDTO, 0, len(ranks)),
|
||||
}
|
||||
for _, ch := range teamManualChannels(channels) {
|
||||
view.Channels = append(view.Channels, teamRankChannelDTO{
|
||||
ChannelID: ch.ID,
|
||||
Scope: string(ch.Scope),
|
||||
Basis: string(ch.Basis),
|
||||
ProfileID: ch.ProfileID,
|
||||
})
|
||||
}
|
||||
for _, r := range ranks {
|
||||
view.Ranks = append(view.Ranks, teamManualRankDTO{TeamID: r.TeamID, Rank: r.Rank})
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// updateTeamRanks 写入某组别的团体手工名次并同步重算。
|
||||
//
|
||||
// 校验口径(devdocs/17 §4.6):rank 为负 → 400(0 是合法的「清空」);teamId 不存在或已假删除 → 400;
|
||||
// scope/basis 与该小项的通道对不上 → 400。同名次允许多个单位(并列),引擎按 tieMode 处理。
|
||||
//
|
||||
// 写成功后必须重算:名次进通道算分公式,不重算就是「界面显示已保存、榜单还是旧分」。
|
||||
// 重算会一并重建该组别的 channel_ranks 并推榜单(recomputeLedgerTx + bumpStandings),
|
||||
// 大屏不需要等下一次发布。
|
||||
func (s *server) updateTeamRanks(ctx context.Context, eventID, categoryID int64, in teamRanksPayload) (teamRanksViewDTO, error) {
|
||||
scope := domain.Scope(strings.TrimSpace(in.Scope))
|
||||
basis := domain.Basis(strings.TrimSpace(in.Basis))
|
||||
if scope != domain.ScopeTeam || basis != domain.BasisManual {
|
||||
return teamRanksViewDTO{}, badRequest("团体名次只支持 scope=team + basis=manual", in)
|
||||
}
|
||||
|
||||
channels, err := loadChannelsQ(ctx, s.ev, eventID)
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
if len(teamManualChannels(channels)) == 0 {
|
||||
return teamRanksViewDTO{}, badRequest("该小项没有 scope=team + basis=manual 的计分通道", in)
|
||||
}
|
||||
|
||||
for i, r := range in.Ranks {
|
||||
if r.TeamID == 0 {
|
||||
return teamRanksViewDTO{}, badRequest("ranks[].teamId 必填", i)
|
||||
}
|
||||
if r.Rank < 0 {
|
||||
return teamRanksViewDTO{}, badRequest("ranks[].rank 不能为负数(0 表示清空)", i)
|
||||
}
|
||||
}
|
||||
if err := s.checkTeamRankTargets(ctx, in.Ranks); err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
actorID := middleware.User(ctx).ID
|
||||
for _, r := range in.Ranks {
|
||||
if r.Rank <= 0 {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM channel_manual_ranks
|
||||
WHERE event_id = ? AND category_id = ? AND team_id = ? AND scope = ? AND basis = ?`,
|
||||
eventID, categoryID, r.TeamID, scope, basis); err != nil {
|
||||
return teamRanksViewDTO{}, internal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO channel_manual_ranks (event_id, category_id, team_id, scope, basis, rank, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(event_id, category_id, team_id, scope, basis) DO UPDATE SET
|
||||
rank = excluded.rank, updated_by = excluded.updated_by, updated_at = datetime('now')`,
|
||||
eventID, categoryID, r.TeamID, scope, basis, r.Rank, nullID(actorID)); err != nil {
|
||||
return teamRanksViewDTO{}, internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, categoryID, actorID, "团体名次录入")
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return teamRanksViewDTO{}, internal(err)
|
||||
}
|
||||
return s.teamRanksView(ctx, s.ev, eventID, categoryID, false)
|
||||
}
|
||||
|
||||
// checkTeamRankTargets 校验写入目标单位存在且未被假删除。
|
||||
// 只校验要落名次的行(rank > 0):清空(rank = 0)是删除动作,单位后来被删了也照样该能清掉。
|
||||
// 批量查一次再比对,缺哪个报哪个,现场能直接照着改。
|
||||
func (s *server) checkTeamRankTargets(ctx context.Context, ranks []teamManualRankDTO) error {
|
||||
ids := make([]int64, 0, len(ranks))
|
||||
seen := make(map[int64]bool, len(ranks))
|
||||
for _, r := range ranks {
|
||||
if r.TeamID == 0 || r.Rank <= 0 || seen[r.TeamID] {
|
||||
continue
|
||||
}
|
||||
seen[r.TeamID] = true
|
||||
ids = append(ids, r.TeamID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id FROM teams WHERE deleted_at IS NULL AND id IN (`+placeholders(len(ids))+`)`, args...)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
exists := make(map[int64]bool, len(ids))
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
exists[id] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
missing := make([]int64, 0)
|
||||
for _, id := range ids {
|
||||
if !exists[id] {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return badRequest("teamId 不存在或已删除", missing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// teamRanksHandler 是 GET /api/events/{id}/categories/{cid}/team-ranks。
|
||||
// 录入页(guest 也能看成绩)据此渲染团体名次区块,故与成绩读取同权限。
|
||||
func (s *server) teamRanksHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
eventID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
categoryID, err := pathID(r, "categoryId")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 组别必须属于该小项:路径拼错时要明确报「组别不存在」,不能拿别的小项的组别名次回话。
|
||||
if _, err := s.getCategory(r.Context(), eventID, categoryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.teamRanksView(r.Context(), s.ev, eventID, categoryID, false)
|
||||
}
|
||||
|
||||
// updateTeamRanksHandler 是 PUT 同路径:写团体手工名次并重算,返回保存后的读数。
|
||||
func (s *server) updateTeamRanksHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
eventID, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
categoryID, err := pathID(r, "categoryId")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in teamRanksPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getCategory(r.Context(), eventID, categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.updateTeamRanks(r.Context(), eventID, categoryID, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "event.team_ranks.update", "event_category", categoryID,
|
||||
map[string]any{"category": before, "ranks": in.Ranks}, updated)
|
||||
return updated, nil
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住设计 17 的服务端行为:团体手工名次入口、channel_ranks 派生名次、
|
||||
// 改配置即重算,以及 P0(同一份数据发布两次结果必须一致)。
|
||||
|
||||
// ---------- 造数与读数助手 ----------
|
||||
|
||||
// profileIDByCode 按编码取积分模板 id。
|
||||
func profileIDByCode(t *testing.T, env *testEnv, code string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id); err != nil {
|
||||
t.Fatalf("读积分模板 %s: %v", code, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// addTeamManualChannel 给某小项加一条「团体手工名次」通道,返回通道 id。
|
||||
func addTeamManualChannel(t *testing.T, env *testEnv, eventID int64) int64 {
|
||||
t.Helper()
|
||||
return env.insert(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, 'team', 'manual', ?, 9)`, eventID, profileIDByCode(t, env, "team_top6"))
|
||||
}
|
||||
|
||||
// ledgerActiveDump 把未作废流水导出成文本:比条数与分值更严格,重复 / 丢失 / 顺序变化都能看出来。
|
||||
func ledgerActiveDump(t *testing.T, env *testEnv) string {
|
||||
t.Helper()
|
||||
return tableDump(t, env, `SELECT team_id, COALESCE(entry_id, 0) AS entry_id, COALESCE(result_id, 0) AS result_id,
|
||||
source_type, source_id, points, count_to_team
|
||||
FROM score_ledger WHERE voided = 0
|
||||
ORDER BY team_id, entry_id, result_id, source_type, source_id, points`)
|
||||
}
|
||||
|
||||
// channelRanksDump 导出通道名次明细(不含自增 id 与时间戳,便于发布前后逐字节比较)。
|
||||
func channelRanksDump(t *testing.T, env *testEnv) string {
|
||||
t.Helper()
|
||||
return tableDump(t, env, `SELECT event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
COALESCE(entry_id, 0) AS entry_id, COALESCE(result_id, 0) AS result_id,
|
||||
COALESCE(athlete_id, 0) AS athlete_id, COALESCE(team_id, 0) AS team_id, rank, points
|
||||
FROM channel_ranks ORDER BY event_id, category_id, channel_id, subject_key`)
|
||||
}
|
||||
|
||||
// standingsChangeRows 统计 change_log 里推给大屏的榜单版本条数。
|
||||
func standingsChangeRows(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
return countRows(t, env, `SELECT COUNT(*) FROM change_log WHERE entity_type = 'standings'`)
|
||||
}
|
||||
|
||||
// activeLedgerTotal 统计未作废流水的分值合计。
|
||||
func activeLedgerTotal(t *testing.T, env *testEnv) float64 {
|
||||
t.Helper()
|
||||
var total float64
|
||||
if err := env.ev.QueryRow(`SELECT COALESCE(SUM(points), 0) FROM score_ledger WHERE voided = 0`).Scan(&total); err != nil {
|
||||
t.Fatalf("统计流水合计: %v", err)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// putTeamRanks 调团体名次写接口。
|
||||
func putTeamRanks(t *testing.T, env *testEnv, client *http.Client, eventID, categoryID int64, ranks []map[string]any) (int, envelope) {
|
||||
t.Helper()
|
||||
return env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", eventID, categoryID),
|
||||
map[string]any{"scope": "team", "basis": "manual", "ranks": ranks}, true)
|
||||
}
|
||||
|
||||
// getTeamRanksRaw 调团体名次读接口,返回原始响应体(用于断言空数组等结构)。
|
||||
func getTeamRanksRaw(t *testing.T, env *testEnv, client *http.Client, eventID, categoryID int64) (int, envelope) {
|
||||
t.Helper()
|
||||
return env.do(client, http.MethodGet,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", eventID, categoryID), nil, false)
|
||||
}
|
||||
|
||||
// publishEvent 只调发布接口(不重新存草稿):成绩已发布后再存草稿会被 409 拦下,
|
||||
// 而「同一份数据发布两次」要验证的正是重复发布本身。
|
||||
func publishEvent(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string) {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/results/publish",
|
||||
map[string]any{"eventId": eventID, "requestId": tag + "-publish"}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// seedTeamEvent 建一个纯团体小项:一条团体报名项(athlete_id 为空)+ 一条按成绩排名的团体通道。
|
||||
func seedTeamEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID, entryID int64) {
|
||||
t.Helper()
|
||||
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
|
||||
compare, scored, multiplier, status)
|
||||
VALUES (?, 'T4X100', '4x100 米接力', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
||||
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '男子', 1, 0)`, eventID)
|
||||
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, 'team', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "team_top6"))
|
||||
entryID = env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, ids.teamA)
|
||||
return eventID, categoryID, entryID
|
||||
}
|
||||
|
||||
// ---------- 团体名次读写 ----------
|
||||
|
||||
// 团体手工名次走接口写入后:名次落 channel_manual_ranks、按单位算分、大屏榜单被推送。
|
||||
func TestTeamManualRanksWriteRecomputesAndPushesStandings(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
|
||||
// 录入前:有通道、有单位清单,但没有任何名次。
|
||||
status, env2 := getTeamRanksRaw(t, env, client, ids.event, ids.category)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读团体名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
view := decodeData[map[string]any](t, env2)
|
||||
if len(view["channels"].([]any)) != 1 || len(view["teams"].([]any)) != 2 || len(view["ranks"].([]any)) != 0 {
|
||||
t.Fatalf("录入前的视图应为「1 通道 + 2 单位 + 0 名次」,得到 %+v", view)
|
||||
}
|
||||
|
||||
before := standingsChangeRows(t, env)
|
||||
status, env2 = putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 1},
|
||||
{"teamId": ids.teamB, "rank": 2},
|
||||
})
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
filled := decodeData[map[string]any](t, env2)
|
||||
if len(filled["ranks"].([]any)) != 2 {
|
||||
t.Fatalf("写后应回填两条名次,得到 %+v", filled["ranks"])
|
||||
}
|
||||
if filled["categoryId"] != nil {
|
||||
t.Fatalf("按组别路径取时响应不该带 categoryId,得到 %+v", filled["categoryId"])
|
||||
}
|
||||
|
||||
// 团体名次只记单位:流水的 entry_id / result_id 都为空(「无报名项归属」那一类)。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 0`); n != 2 {
|
||||
t.Fatalf("团体名次应产出 2 条流水,得到 %d 条", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger
|
||||
WHERE voided = 0 AND (entry_id IS NOT NULL OR result_id IS NOT NULL)`); n != 0 {
|
||||
t.Fatalf("团体名次流水不该挂报名项/成绩,得到 %d 条", n)
|
||||
}
|
||||
|
||||
// 名次与流水同源:A 第 1 名 15 分、B 第 2 名 10 分。
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
|
||||
t.Fatalf("A 单位总分应为 15,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
|
||||
t.Fatalf("B 单位总分应为 10,得到 %v", got)
|
||||
}
|
||||
|
||||
// channel_ranks 是榜单读取来源:主体是单位,名次与分值同引擎口径。
|
||||
for _, want := range []struct {
|
||||
teamID int64
|
||||
rank int
|
||||
points float64
|
||||
}{{ids.teamA, 1, 15}, {ids.teamB, 2, 10}} {
|
||||
n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks
|
||||
WHERE subject_key = ? AND scope = 'team' AND basis = 'manual'
|
||||
AND team_id = ? AND rank = ? AND points = ?`,
|
||||
fmt.Sprintf("t:%d", want.teamID), want.teamID, want.rank, want.points)
|
||||
if n != 1 {
|
||||
t.Fatalf("channel_ranks 应有一行 team_id=%d rank=%d points=%v,得到 %d 行:%s",
|
||||
want.teamID, want.rank, want.points, n, channelRanksDump(t, env))
|
||||
}
|
||||
}
|
||||
|
||||
// 大屏:写名次必须推榜单,否则界面说保存了、榜单还是旧分。
|
||||
if after := standingsChangeRows(t, env); after <= before {
|
||||
t.Fatalf("写团体名次后应推榜单:之前 %d 条,之后 %d 条", before, after)
|
||||
}
|
||||
|
||||
// rank=0 表示清空该单位的记录:B 回到无分。
|
||||
status, env2 = putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamB, "rank": 0},
|
||||
})
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("清空 B 的名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_manual_ranks WHERE team_id = ?`, ids.teamB); n != 0 {
|
||||
t.Fatalf("rank=0 应删掉该单位的记录,得到 %d 条", n)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
|
||||
t.Fatalf("清空后 B 应无分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
|
||||
t.Fatalf("清空 B 不该动 A,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 按组别重算只动该组别:团体名次的流水是「无报名项归属」那一类,
|
||||
// 按组别重算时若只作废不补算,另一组的名次分就会凭空消失(设计 17 §十一.2)。
|
||||
func TestTeamManualRanksCategoryRecomputeKeepsOtherGroups(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
// 两个组别各录一轮团体名次(名次只在组别内有效,A/B 的先后可以不同)。
|
||||
if status, env2 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 1},
|
||||
{"teamId": ids.teamB, "rank": 2},
|
||||
}); status != http.StatusOK {
|
||||
t.Fatalf("写男子组名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if status, env2 := putTeamRanks(t, env, client, ids.event, otherCat, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 2},
|
||||
{"teamId": ids.teamB, "rank": 1},
|
||||
}); status != http.StatusOK {
|
||||
t.Fatalf("写女子组名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
// A = 15 + 10、B = 10 + 15;四类「无报名项归属」流水都在。
|
||||
if total := activeLedgerTotal(t, env); total != 50 {
|
||||
t.Fatalf("两组名次分合计应为 50,得到 %v", total)
|
||||
}
|
||||
ledgerBefore := ledgerActiveDump(t, env)
|
||||
ranksBefore := channelRanksDump(t, env)
|
||||
|
||||
// 只重写男子组:女子组的团体名次分必须原样保留(既不丢也不翻倍)。
|
||||
status, env2 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 1},
|
||||
{"teamId": ids.teamB, "rank": 2},
|
||||
})
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("重写男子组名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if after := ledgerActiveDump(t, env); after != ledgerBefore {
|
||||
t.Fatalf("按组别重算不该动其它组别的名次分:\n之前 %s\n之后 %s", ledgerBefore, after)
|
||||
}
|
||||
if after := channelRanksDump(t, env); after != ranksBefore {
|
||||
t.Fatalf("按组别重算不该动其它组别的名次:\n之前 %s\n之后 %s", ranksBefore, after)
|
||||
}
|
||||
if total := activeLedgerTotal(t, env); total != 50 {
|
||||
t.Fatalf("重算后合计应仍为 50,得到 %v", total)
|
||||
}
|
||||
}
|
||||
|
||||
// P0 回归:同一份数据连发两次,流水与名次必须逐字节一致。
|
||||
func TestPublishTwiceKeepsLedgerAndRanksIdentical(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
|
||||
// 团体名次先录好:双通道项目(个人按成绩 + 团体按录入名次)。
|
||||
if status, env2 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 1},
|
||||
{"teamId": ids.teamB, "rank": 2},
|
||||
}); status != http.StatusOK {
|
||||
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
publishResults(t, env, client, ids.event, "p1",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
// 个人通道 10 / 8 分;团体手工通道读 channel_manual_ranks,不受 results.rank 影响。
|
||||
if total := activeLedgerTotal(t, env); total != 10+8+15+10 {
|
||||
t.Fatalf("首次发布后合计应为 43(10+8+15+10),得到 %v", total)
|
||||
}
|
||||
ledgerFirst := ledgerActiveDump(t, env)
|
||||
ranksFirst := channelRanksDump(t, env)
|
||||
// channel_ranks 每个通道每个主体一行:个人 2 行 + 团体 2 行。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks`); n != 4 {
|
||||
t.Fatalf("channel_ranks 应有 4 行(个人 2 + 团体 2),得到 %d", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE category_id = ?`, ids.category); n != 4 {
|
||||
t.Fatalf("该组别应有 4 行名次,得到 %d", n)
|
||||
}
|
||||
|
||||
// 换一个 requestId,确保第二次是真执行而不是幂等回放(成绩已发布,不再重存草稿)。
|
||||
publishEvent(t, env, client, ids.event, "p2")
|
||||
if total := activeLedgerTotal(t, env); total != 43 {
|
||||
t.Fatalf("第二次发布后合计应仍为 43(不得翻倍),得到 %v", total)
|
||||
}
|
||||
if after := ledgerActiveDump(t, env); after != ledgerFirst {
|
||||
t.Fatalf("同一份数据发布两次流水应完全一致:\n第一次 %s\n第二次 %s", ledgerFirst, after)
|
||||
}
|
||||
if after := channelRanksDump(t, env); after != ranksFirst {
|
||||
t.Fatalf("同一份数据发布两次名次应完全一致:\n第一次 %s\n第二次 %s", ranksFirst, after)
|
||||
}
|
||||
}
|
||||
|
||||
// 团体通道的名次不进 results.rank:个人名次照写,团体报名项的名次留在 channel_ranks。
|
||||
func TestApplyComputedRanksLeavesTeamRanksAlone(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, categoryID, teamEntry := seedTeamEvent(t, env, ids)
|
||||
|
||||
// 个人小项:两条个人成绩,rank 应写回 results。
|
||||
publishResults(t, env, client, ids.event, "ind",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
// 纯团体小项:团体报名项(athlete_id 为空)按成绩排名。
|
||||
publishResults(t, env, client, eventID, "team",
|
||||
map[string]any{"entryId": teamEntry, "resultValue": 45.20, "resultText": "45.20", "unit": "s", "status": "ok"},
|
||||
)
|
||||
|
||||
var rankA, rankB, teamRank any
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryA).Scan(&rankA); err != nil {
|
||||
t.Fatalf("读个人名次: %v", err)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&rankB); err != nil {
|
||||
t.Fatalf("读个人名次: %v", err)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, teamEntry).Scan(&teamRank); err != nil {
|
||||
t.Fatalf("读团体名次: %v", err)
|
||||
}
|
||||
if rankA != int64(1) || rankB != int64(2) {
|
||||
t.Fatalf("个人名次应回写 results.rank(1 / 2),得到 %v / %v", rankA, rankB)
|
||||
}
|
||||
if teamRank != nil {
|
||||
t.Fatalf("团体名次不该写进 results.rank,得到 %v", teamRank)
|
||||
}
|
||||
|
||||
// 但团体名次必须落在 channel_ranks,奖牌榜才看得到。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks
|
||||
WHERE event_id = ? AND category_id = ? AND scope = 'team' AND rank = 1`, eventID, categoryID); n != 1 {
|
||||
t.Fatalf("团体名次应落 channel_ranks 一行 rank=1,得到 %d 行", n)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 15+10 {
|
||||
t.Fatalf("A 应拿团体 15 分与个人 10 分,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 改配置即重算 ----------
|
||||
|
||||
// 整体替换通道会重建通道 id(channel_ranks 随之级联清空):必须重算重建 + 推榜单。
|
||||
func TestReplaceChannelsRecomputesAndPushesStandings(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "cfg",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
ledgerBefore := ledgerActiveDump(t, env)
|
||||
before := standingsChangeRows(t, env)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event),
|
||||
map[string]any{"channels": []map[string]any{
|
||||
{"scope": "athlete", "basis": "result", "profileId": profileIDByCode(t, env, "individual_top6"), "sortOrder": 0},
|
||||
}}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("替换通道应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if after := ledgerActiveDump(t, env); after != ledgerBefore {
|
||||
t.Fatalf("同配置替换通道后流水应一致:\n之前 %s\n之后 %s", ledgerBefore, after)
|
||||
}
|
||||
// 通道 id 变了、旧名次被级联删掉;不重算就会是空榜。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE event_id = ?`, ids.event); n != 2 {
|
||||
t.Fatalf("替换通道后应重建 2 行名次,得到 %d", n)
|
||||
}
|
||||
if after := standingsChangeRows(t, env); after <= before {
|
||||
t.Fatalf("替换通道应推榜单:之前 %d,之后 %d", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// updateEvent 只在 multiplier / avg_mode / scored / award_medals 真的变了时重算并推榜。
|
||||
func TestUpdateEventRecomputesOnlyOnScoringFieldChange(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
mutate func(body map[string]any)
|
||||
check func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client)
|
||||
}
|
||||
|
||||
baseBody := func(ids seedIDs) map[string]any {
|
||||
return map[string]any{
|
||||
"sportId": ids.sport, "code": "M100", "name": "100米跑",
|
||||
"eventType": "individual", "resultMode": "auto", "resultType": "time",
|
||||
"compare": "asc", "multiplier": 1, "status": "ongoing", "avgMode": "valid",
|
||||
}
|
||||
}
|
||||
|
||||
cases := []tc{
|
||||
{
|
||||
name: "multiplier 真变",
|
||||
mutate: func(b map[string]any) { b["multiplier"] = 2 },
|
||||
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
|
||||
if total := activeLedgerTotal(t, env); total != 20+16 {
|
||||
t.Fatalf("权重翻倍后合计应为 36,得到 %v", total)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "avg_mode 真变",
|
||||
mutate: func(b map[string]any) { b["avgMode"] = "all" },
|
||||
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
|
||||
// 个人按成绩的通道不受平均值口径影响:分值不变,但流水被重写了一遍。
|
||||
if total := activeLedgerTotal(t, env); total != 18 {
|
||||
t.Fatalf("改平均值口径不该改个人通道分值,得到 %v", total)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n == 0 {
|
||||
t.Fatalf("改平均值口径应触发重算(应有作废流水)")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scored 真变",
|
||||
mutate: func(b map[string]any) { b["scored"] = false },
|
||||
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
|
||||
if total := activeLedgerTotal(t, env); total != 0 {
|
||||
t.Fatalf("关掉计分后应无流水,得到 %v", total)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||||
t.Fatalf("关掉计分后榜单应清零,得到 %v", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "award_medals 真变",
|
||||
mutate: func(b map[string]any) { b["awardMedals"] = false },
|
||||
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
|
||||
if total := activeLedgerTotal(t, env); total != 18 {
|
||||
t.Fatalf("关掉奖牌不该动积分,得到 %v", total)
|
||||
}
|
||||
if gold := teamGold(t, env, client, ids.teamA); gold != 0 {
|
||||
t.Fatalf("关掉奖牌结算后 A 不该有金牌,得到 %d", gold)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "evt",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
if gold := teamGold(t, env, client, ids.teamA); gold != 1 {
|
||||
t.Fatalf("前置条件:A 应有 1 金,得到 %d", gold)
|
||||
}
|
||||
before := standingsChangeRows(t, env)
|
||||
|
||||
body := baseBody(ids)
|
||||
c.mutate(body)
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), body, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("更新小项应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if after := standingsChangeRows(t, env); after <= before {
|
||||
t.Fatalf("改算分字段应推榜单:之前 %d,之后 %d", before, after)
|
||||
}
|
||||
c.check(t, env, ids, client)
|
||||
})
|
||||
}
|
||||
|
||||
// 只改名不该触发重算:既不重写流水,也不推榜单(重算会白作废一遍、还多一次推送)。
|
||||
t.Run("只改名不重算", func(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "noop",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
before := standingsChangeRows(t, env)
|
||||
ledgerBefore := ledgerActiveDump(t, env)
|
||||
|
||||
body := baseBody(ids)
|
||||
body["name"] = "100米跑(改名)"
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), body, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("更新小项应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 0 {
|
||||
t.Fatalf("只改名不该重算,得到 %d 条作废流水", n)
|
||||
}
|
||||
if after := ledgerActiveDump(t, env); after != ledgerBefore {
|
||||
t.Fatalf("只改名不该动流水:\n之前 %s\n之后 %s", ledgerBefore, after)
|
||||
}
|
||||
if after := standingsChangeRows(t, env); after != before {
|
||||
t.Fatalf("只改名不该推榜单:之前 %d,之后 %d", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 积分模板分值变更要全量重算(模板是算分公式的输入)。
|
||||
func TestUpdateProfileRecomputesAllEvents(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "prof",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
if total := activeLedgerTotal(t, env); total != 18 {
|
||||
t.Fatalf("前置条件:合计应为 18,得到 %v", total)
|
||||
}
|
||||
before := standingsChangeRows(t, env)
|
||||
|
||||
profileID := profileIDByCode(t, env, "individual_top6")
|
||||
status, env2 := env.do(client, http.MethodGet, fmt.Sprintf("/api/scoring-profiles/%d", profileID), nil, false)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("读积分模板应 200,得到 %d", status)
|
||||
}
|
||||
body := decodeData[map[string]any](t, env2)
|
||||
body["rules"] = []map[string]any{
|
||||
{"rank": 1, "points": 20},
|
||||
{"rank": 2, "points": 16},
|
||||
{"rank": 3, "points": 6},
|
||||
{"rank": 4, "points": 3},
|
||||
{"rank": 5, "points": 2},
|
||||
{"rank": 6, "points": 1},
|
||||
}
|
||||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/scoring-profiles/%d", profileID), body, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("更新积分模板应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if total := activeLedgerTotal(t, env); total != 20+16 {
|
||||
t.Fatalf("模板改分后应全量重算为 36,得到 %v", total)
|
||||
}
|
||||
if after := standingsChangeRows(t, env); after <= before {
|
||||
t.Fatalf("模板改分应推榜单:之前 %d,之后 %d", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 鉴权与校验 ----------
|
||||
|
||||
func TestTeamRanksAuthAndValidation(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
|
||||
// guest 只读:读 200。
|
||||
guest, _ := env.client()
|
||||
if status, _ := getTeamRanksRaw(t, env, guest, ids.event, ids.category); status != http.StatusOK {
|
||||
t.Fatalf("guest 读团体名次应 200,得到 %d", status)
|
||||
}
|
||||
// guest 写被拦(与其它写路由同口径:未登录 → 401 AUTH_REQUIRED,不是 200)。
|
||||
// 未登录请求由 RequireWrite 处理,不带 CSRF 也无需 CSRF。
|
||||
status, env2 := env.do(guest, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", ids.event, ids.category),
|
||||
map[string]any{"scope": "team", "basis": "manual", "ranks": []map[string]any{{"teamId": ids.teamA, "rank": 1}}},
|
||||
false)
|
||||
if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
|
||||
t.Fatalf("guest 写团体名次应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_manual_ranks`); n != 0 {
|
||||
t.Fatalf("被拦下的写请求不该落库,得到 %d 条", n)
|
||||
}
|
||||
|
||||
// recorder 可写。
|
||||
if status, env2 := putTeamRanks(t, env, client, ids.event, ids.category,
|
||||
[]map[string]any{{"teamId": ids.teamA, "rank": 1}}); status != http.StatusOK {
|
||||
t.Fatalf("recorder 写团体名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// 校验失败一律 400 + 标准错误体(不是 500,也不能静默成功)。
|
||||
checkBadRequest := func(name string, wantCode any, ranks []map[string]any, scope string) {
|
||||
t.Helper()
|
||||
status, resp := env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", ids.event, ids.category),
|
||||
map[string]any{"scope": scope, "basis": "manual", "ranks": ranks}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("%s 应 400,得到 %d %+v", name, status, resp.Error)
|
||||
}
|
||||
if resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" || resp.Error.Message == "" {
|
||||
t.Fatalf("%s 应返回 VALIDATION_FAILED 标准错误体,得到 %+v", name, resp.Error)
|
||||
}
|
||||
if wantCode != nil && !containsDetail(resp.Error.Detail, wantCode) {
|
||||
t.Fatalf("%s 的 detail 应指向 %v,得到 %s", name, wantCode, resp.Error.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
checkBadRequest("rank 为负", nil, []map[string]any{{"teamId": ids.teamA, "rank": -1}}, "team")
|
||||
checkBadRequest("teamId 不存在", float64(999999), []map[string]any{{"teamId": 999999, "rank": 1}}, "team")
|
||||
|
||||
// 已假删除的单位也不能写。
|
||||
env.exec(t, `UPDATE teams SET deleted_at = datetime('now'), active = 0 WHERE id = ?`, ids.teamB)
|
||||
checkBadRequest("teamId 已假删除", float64(ids.teamB), []map[string]any{{"teamId": ids.teamB, "rank": 1}}, "team")
|
||||
|
||||
// scope/basis 与该小项的通道对不上。
|
||||
checkBadRequest("scope 不匹配", "athlete", []map[string]any{{"teamId": ids.teamA, "rank": 1}}, "athlete")
|
||||
}
|
||||
|
||||
// 单位积分下钻的名次列也要读 channel_ranks:团体名次没有 results.rank 可读(设计 17 §11.3)。
|
||||
func TestTeamBreakdownShowsChannelRanks(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
publishResults(t, env, client, ids.event, "bd",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
if status, env2 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 1},
|
||||
{"teamId": ids.teamB, "rank": 2},
|
||||
}); status != http.StatusOK {
|
||||
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
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)
|
||||
ranks := make(map[float64]any)
|
||||
for _, raw := range breakdown["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
ranks[item["points"].(float64)] = item["rank"]
|
||||
}
|
||||
// 个人流水(10 分)与团体名次流水(15 分)都要能显示名次:A 两项都是第 1 名。
|
||||
if ranks[10] != float64(1) {
|
||||
t.Fatalf("个人流水应显示名次 1,得到 %+v", ranks)
|
||||
}
|
||||
if ranks[15] != float64(1) {
|
||||
t.Fatalf("团体名次流水应显示名次 1(读 channel_ranks),得到 %+v", ranks)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 录入页载荷 ----------
|
||||
|
||||
// 成绩录入接口随行下发团体名次:有团体手工通道的组别才出现在 teamRanks 里。
|
||||
func TestEntryBoardCarriesTeamRanks(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
// 没有团体手工通道时:teamRanks 是空数组,不是 null / 不是缺字段。
|
||||
board := getEntryBoard(t, env, client, ids.event)
|
||||
if board.TeamRanks == nil || len(board.TeamRanks) != 0 {
|
||||
t.Fatalf("无团体手工通道时 teamRanks 应为空数组,得到 %+v", board.TeamRanks)
|
||||
}
|
||||
if len(board.Entries) != 2 {
|
||||
t.Fatalf("报名行应照旧下发 2 条,得到 %d", len(board.Entries))
|
||||
}
|
||||
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
if status, env2 := putTeamRanks(t, env, client, ids.event, otherCat, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 3},
|
||||
}); status != http.StatusOK {
|
||||
t.Fatalf("写女子组名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
board = getEntryBoard(t, env, client, ids.event)
|
||||
if len(board.TeamRanks) != 2 {
|
||||
t.Fatalf("两个组别都应随行下发团体名次视图,得到 %+v", board.TeamRanks)
|
||||
}
|
||||
byCategory := make(map[float64]map[string]any, len(board.TeamRanks))
|
||||
for _, view := range board.TeamRanks {
|
||||
id, _ := view["categoryId"].(float64)
|
||||
byCategory[id] = view
|
||||
}
|
||||
other := byCategory[float64(otherCat)]
|
||||
if other == nil {
|
||||
t.Fatalf("随行视图应带 categoryId 供录入页按组别取用,得到 %+v", board.TeamRanks)
|
||||
}
|
||||
if len(other["channels"].([]any)) != 1 || len(other["teams"].([]any)) != 2 || len(other["ranks"].([]any)) != 1 {
|
||||
t.Fatalf("女子组视图应为「1 通道 + 2 单位 + 1 名次」,得到 %+v", other)
|
||||
}
|
||||
if v := byCategory[float64(ids.category)]; v == nil || len(v["ranks"].([]any)) != 0 {
|
||||
t.Fatalf("男子组视图应存在但没有名次,得到 %+v", byCategory)
|
||||
}
|
||||
}
|
||||
|
||||
// containsDetail 判断错误 detail 里是否出现某个值(detail 可能是数字或字符串数组)。
|
||||
func containsDetail(detail []byte, want any) bool {
|
||||
s := string(detail)
|
||||
switch v := want.(type) {
|
||||
case string:
|
||||
return strings.Contains(s, v)
|
||||
case float64:
|
||||
return strings.Contains(s, fmt.Sprintf("%d", int64(v)))
|
||||
default:
|
||||
return strings.Contains(s, fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
||||
// teamGold 从榜单读某单位的金牌数。
|
||||
func teamGold(t *testing.T, env *testEnv, client *http.Client, teamID int64) int {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读积分榜应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
for _, row := range decodeData[[]map[string]any](t, env2) {
|
||||
if row["teamId"] == float64(teamID) {
|
||||
gold, _ := row["gold"].(float64)
|
||||
return int(gold)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func TestRestoreTeamBringsHiddenTeamBack(t *testing.T) {
|
||||
if status != http.StatusOK || !env3.OK {
|
||||
t.Fatalf("读报名应 200,得到 %d %+v", status, env3.Error)
|
||||
}
|
||||
entry := findEntry(decodeData[[]map[string]any](t, env3), ids.entryA)
|
||||
entry := findEntry(getEntryBoard(t, env, client, ids.event).Entries, ids.entryA)
|
||||
if entry == nil {
|
||||
t.Fatalf("恢复后报名不该消失")
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
ctxKeyUser ctxKey = iota
|
||||
ctxKeyRequestID
|
||||
ctxKeyAudit
|
||||
ctxKeySession
|
||||
)
|
||||
|
||||
// User 返回当前请求的身份;未经 Authenticate 中间件时是 guest。
|
||||
@@ -33,6 +34,24 @@ func WithUser(ctx context.Context, u *auth.User) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyUser, u)
|
||||
}
|
||||
|
||||
// Session 返回当前请求背后的会话;未登录或未经 Authenticate 中间件时为 nil。
|
||||
//
|
||||
// /ws 这类长连接要拿它做会话复查:握手时身份固化,会话被撤销后必须能对应到具体这一条连接。
|
||||
func Session(ctx context.Context) *auth.Session {
|
||||
if s, ok := ctx.Value(ctxKeySession).(*auth.Session); ok {
|
||||
return s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithSession 把会话写入 context,供测试或非标准链路使用;nil 表示没有会话。
|
||||
func WithSession(ctx context.Context, s *auth.Session) context.Context {
|
||||
if s == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, ctxKeySession, s)
|
||||
}
|
||||
|
||||
// GetRequestID 返回本次请求的 request_id。
|
||||
func GetRequestID(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(ctxKeyRequestID).(string); ok {
|
||||
|
||||
@@ -106,11 +106,14 @@ func Authenticate(svc *auth.Service, logger *slog.Logger) func(http.Handler) htt
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := &auth.User{Role: auth.RoleGuest}
|
||||
ctx := r.Context()
|
||||
if c, err := r.Cookie(auth.CookieSession); err == nil && c.Value != "" {
|
||||
found, _, err := svc.Lookup(r.Context(), c.Value)
|
||||
found, sess, err := svc.Lookup(ctx, c.Value)
|
||||
switch {
|
||||
case err == nil:
|
||||
u = found
|
||||
// 记下会话:实时层靠它复查长连接背后的会话是否还有效(撤销后要断开)。
|
||||
ctx = WithSession(ctx, sess)
|
||||
case errors.Is(err, auth.ErrSessionNotFound):
|
||||
// 会话缺失/过期是正常情况,静默按 guest 放行。
|
||||
default:
|
||||
@@ -118,7 +121,7 @@ func Authenticate(svc *auth.Service, logger *slog.Logger) func(http.Handler) htt
|
||||
"err", err, "path", r.URL.Path, "requestId", GetRequestID(r.Context()))
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), u)))
|
||||
next.ServeHTTP(w, r.WithContext(WithUser(ctx, u)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
)
|
||||
|
||||
// currentVersion 返回服务端版本号,即 change_log 的最大 id;空表为 0。
|
||||
@@ -109,3 +111,45 @@ func payloadJSON(payload string) json.RawMessage {
|
||||
}
|
||||
return json.RawMessage(payload)
|
||||
}
|
||||
|
||||
// entitySettings 是赛事配置在 change_log.entity_type 里的取值;
|
||||
// 它的载荷是完整的 dto.EventSettings,含只有 admin 能读的系统配置字段。
|
||||
const entitySettings = "settings"
|
||||
|
||||
// redactChange 按连接身份裁剪一条变更的载荷,返回不与其它连接共享的新对象。
|
||||
//
|
||||
// 快照对非 admin 会套 WithoutAdminFields 砍掉 defaultProfileId / requireApproval /
|
||||
// individualToTeamWeight,但 change 与 catchup 走的是另一条路径:不在这里做同样处理,
|
||||
// 任何打开 /ws 的浏览器都能从增量里读到这三个字段,快照上的裁剪等于被旁路。
|
||||
// 只动 settings 这一个实体,其余实体的载荷本来就是公开的赛事数据。
|
||||
func redactChange(ch Change, settingsAdmin bool) Change {
|
||||
if settingsAdmin || ch.Entity != entitySettings {
|
||||
return ch
|
||||
}
|
||||
var settings dto.EventSettings
|
||||
if err := json.Unmarshal(ch.Data, &settings); err != nil {
|
||||
// 载荷不是 EventSettings 形状(历史数据 / 手工改库):宁可只下发空对象,
|
||||
// 也不能把可能含系统配置的原文透给非 admin。
|
||||
ch.Data = json.RawMessage(`{}`)
|
||||
return ch
|
||||
}
|
||||
data, err := json.Marshal(settings.WithoutAdminFields())
|
||||
if err != nil {
|
||||
ch.Data = json.RawMessage(`{}`)
|
||||
return ch
|
||||
}
|
||||
ch.Data = data
|
||||
return ch
|
||||
}
|
||||
|
||||
// redactChanges 批量裁剪;admin 连接原样返回,不做无谓的复制。
|
||||
func redactChanges(changes []Change, settingsAdmin bool) []Change {
|
||||
if settingsAdmin {
|
||||
return changes
|
||||
}
|
||||
out := make([]Change, 0, len(changes))
|
||||
for _, ch := range changes {
|
||||
out = append(out, redactChange(ch, settingsAdmin))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -33,25 +33,37 @@ type client struct {
|
||||
userID int64
|
||||
sessionRole string
|
||||
sessionUserID int64
|
||||
instanceID string
|
||||
// sessionID 是握手时的会话 id(未登录为空):会话撤销后要据此复查并断开本连接。
|
||||
sessionID string
|
||||
instanceID string
|
||||
// settingsAdmin 表示会话是否为 admin:recorder 也进管理端房间,但读不到系统配置。
|
||||
settingsAdmin bool
|
||||
|
||||
ackVersion atomic.Int64 // 客户端已确认应用到的版本
|
||||
lastSent atomic.Int64 // 服务端已发出的最大版本
|
||||
slow atomic.Bool
|
||||
// snapshotAt 是本连接最近一次发出整库快照的时间(unix 纳秒)。
|
||||
// 它是「整库快照窗口」的记账点:resync 与不带 lastVersion 的 hello 都会走到发快照,
|
||||
// 两者共用一个窗口,不能靠交替发把限流绕过去。
|
||||
snapshotAt atomic.Int64
|
||||
// snapshotPending 表示「有请求要过整库快照,但被窗口挡下了」:
|
||||
// 心跳循环会在窗口过后补发一份,见 flushPendingSnapshot。
|
||||
// 不能把被挡下的请求直接丢掉——大屏收到它不认识的变更时会主动 resync,
|
||||
// 丢了就可能让那块屏一直停在旧数据上,直到下一条变更到来。
|
||||
snapshotPending atomic.Bool
|
||||
slow atomic.Bool
|
||||
}
|
||||
|
||||
func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) *client {
|
||||
func newClient(h *Hub, conn *websocket.Conn, remoteAddr string, id wsIdentity) *client {
|
||||
return &client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
remoteAddr: remoteAddr,
|
||||
send: make(chan []byte, defaultSendBuffer),
|
||||
done: make(chan struct{}),
|
||||
sessionRole: sessionRole,
|
||||
sessionUserID: sessionUserID,
|
||||
settingsAdmin: settingsAdmin,
|
||||
sessionRole: id.Role,
|
||||
sessionUserID: id.UserID,
|
||||
sessionID: id.SessionID,
|
||||
settingsAdmin: id.SettingsAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +97,29 @@ func (c *client) sendEvent(event string, data any) bool {
|
||||
return c.sendRaw(msg)
|
||||
}
|
||||
|
||||
// allowSnapshot 按连接给「整库快照」记账:窗口内只放行一次。
|
||||
//
|
||||
// 触发整库快照的入口有两个(resync,以及不带 lastVersion 的 hello),它们共用这一个窗口:
|
||||
// 一条 15 字节的帧就是一次 DB 事务 + 8 条查询,而赛事库是单连接(SetMaxOpenConns(1)),
|
||||
// 快照读取还在全局发送锁里——不限流的话,一个写坏的客户端就能把库连接与所有人的推送一起堵住。
|
||||
// 窗口(defaultSnapshotMinInterval)比客户端自己的 3s 冷却短,正常客户端按自己的节奏回来时
|
||||
// 窗口已经过期,重连补齐行为因此不受影响。
|
||||
func (c *client) allowSnapshot(minInterval time.Duration) bool {
|
||||
if minInterval <= 0 {
|
||||
return true
|
||||
}
|
||||
now := time.Now().UnixNano()
|
||||
for {
|
||||
last := c.snapshotAt.Load()
|
||||
if last != 0 && now-last < int64(minInterval) {
|
||||
return false
|
||||
}
|
||||
if c.snapshotAt.CompareAndSwap(last, now) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) sendRaw(msg []byte) bool {
|
||||
select {
|
||||
case <-c.done:
|
||||
@@ -163,6 +198,7 @@ func (c *client) handleMessage(ctx context.Context, data []byte) {
|
||||
c.hub.log.Debug("忽略 hello 之前的 resync", "remote", c.remoteAddr)
|
||||
return
|
||||
}
|
||||
// 整库快照的限流统一在 sendInitial 里判定(resync 与重复 hello 共用同一个窗口)。
|
||||
c.hub.sendInitial(ctx, c, nil)
|
||||
|
||||
default:
|
||||
|
||||
+225
-32
@@ -26,6 +26,17 @@ const (
|
||||
defaultWriteTimeout = 10 * time.Second
|
||||
defaultCloseTimeout = 2 * time.Second
|
||||
defaultSendBuffer = 64
|
||||
// defaultSnapshotMinInterval 是同一连接两次「整库快照」之间的最小间隔。
|
||||
//
|
||||
// 取 2.5s,比客户端自己的 3s 冷却(web/src/shared/realtime.ts 的 resyncCooldownMs)短半秒:
|
||||
// 客户端按自己的节奏回来时(≥ 它的上次发送 + 3s),服务端的窗口必须已经过期,
|
||||
// 否则两个等长的窗口会因收发延迟抖动而被随机判定成「超频」,把正常的补齐请求挡掉。
|
||||
// 反过来,2.5s 也已经把放大面压到「每个连接每 2.5 秒最多一份整库快照」
|
||||
// (一份 = 一个事务 + 8 条查询,赛事库还是单连接)。
|
||||
defaultSnapshotMinInterval = 2500 * time.Millisecond
|
||||
// sessionCheckTimeout 是会话复查查询的超时:系统库同样只有一条连接,
|
||||
// 被长操作占住时不能把心跳循环一起卡住。
|
||||
sessionCheckTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// slowCloseCode 是踢掉慢客户端时使用的自定义关闭码。
|
||||
@@ -63,6 +74,16 @@ type Hub struct {
|
||||
helloTO time.Duration
|
||||
writeTO time.Duration
|
||||
allowedOrigins []string
|
||||
// snapshotMinInterval 是同一连接两次整库快照之间的最小间隔;<=0 表示不限流。
|
||||
snapshotMinInterval time.Duration
|
||||
// sessionChecker 复查长连接背后的会话是否还有效;未注入时只在握手时校验一次。
|
||||
sessionChecker SessionChecker
|
||||
}
|
||||
|
||||
// SessionChecker 判定一条会话当前是否仍然有效(未被撤销 / 未过期 / 用户未停用)。
|
||||
// auth.Service 满足这个接口;测试可注入桩实现。
|
||||
type SessionChecker interface {
|
||||
SessionActive(ctx context.Context, sessionID string) (bool, error)
|
||||
}
|
||||
|
||||
// NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。
|
||||
@@ -73,19 +94,20 @@ func NewHub(db *sql.DB, log *slog.Logger) *Hub {
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h := &Hub{
|
||||
db: db,
|
||||
log: log,
|
||||
rooms: make(map[string]map[*client]struct{}),
|
||||
clients: make(map[*client]struct{}),
|
||||
instances: make(map[string]*instanceState),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
heartbeatInterval: defaultHeartbeatInterval,
|
||||
heartbeatReset: make(chan struct{}, 1),
|
||||
maxCatchup: defaultMaxCatchup,
|
||||
slowLag: defaultSlowLag,
|
||||
helloTO: defaultHelloTimeout,
|
||||
writeTO: defaultWriteTimeout,
|
||||
db: db,
|
||||
log: log,
|
||||
rooms: make(map[string]map[*client]struct{}),
|
||||
clients: make(map[*client]struct{}),
|
||||
instances: make(map[string]*instanceState),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
heartbeatInterval: defaultHeartbeatInterval,
|
||||
heartbeatReset: make(chan struct{}, 1),
|
||||
maxCatchup: defaultMaxCatchup,
|
||||
slowLag: defaultSlowLag,
|
||||
helloTO: defaultHelloTimeout,
|
||||
writeTO: defaultWriteTimeout,
|
||||
snapshotMinInterval: defaultSnapshotMinInterval,
|
||||
}
|
||||
if db != nil {
|
||||
if v, err := h.currentVersion(context.Background()); err == nil {
|
||||
@@ -177,6 +199,38 @@ func (h *Hub) SetAllowedOrigins(origins []string) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetSessionChecker 注入会话复查器:连接建立后周期性复查会话是否仍然有效,失效即断开。
|
||||
//
|
||||
// /ws 的身份在握手时固化,而会话随时可能被撤销(登出、改密、管理员重置密码、停用用户)。
|
||||
// 不复查的话,那条长连接会一直以原角色收推送——被停用的人只要不关页面,管理端就还挂着。
|
||||
func (h *Hub) SetSessionChecker(c SessionChecker) {
|
||||
h.mu.Lock()
|
||||
h.sessionChecker = c
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetSnapshotMinInterval 设置同一连接两次整库快照之间的最小间隔;<= 0 表示不限流(仅测试用)。
|
||||
//
|
||||
// 撞到窗口的请求不会拿到第二份全量:客户端本来就刚拿到过一份,心跳里的版本号会让它
|
||||
// 在需要时重新发起;越过窗口后一切照旧。
|
||||
func (h *Hub) SetSnapshotMinInterval(d time.Duration) {
|
||||
h.mu.Lock()
|
||||
h.snapshotMinInterval = d
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) sessionCheckerValue() SessionChecker {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.sessionChecker
|
||||
}
|
||||
|
||||
func (h *Hub) snapshotMinIntervalValue() time.Duration {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.snapshotMinInterval
|
||||
}
|
||||
|
||||
func (h *Hub) heartbeatIntervalValue() time.Duration {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
@@ -237,26 +291,45 @@ func (h *Hub) Handler() http.Handler {
|
||||
defer h.connWG.Done()
|
||||
|
||||
// 身份只认会话:/ws 必须被 Authenticate 中间件包住,否则一律按 guest 处理。
|
||||
role, userID, settingsAdmin := wsIdentity(r.Context())
|
||||
h.serve(conn, r.RemoteAddr, role, userID, settingsAdmin)
|
||||
h.serve(conn, r.RemoteAddr, wsIdentityFromContext(r.Context()))
|
||||
})
|
||||
}
|
||||
|
||||
// wsIdentity 从请求上下文取会话身份并折算成实时层角色。
|
||||
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
|
||||
// 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, false
|
||||
}
|
||||
return RoleAdmin, u.ID, u.Role.Allows(auth.RoleAdmin)
|
||||
// wsIdentity 是握手时从会话取到的身份;整条连接的生命周期都用它。
|
||||
//
|
||||
// sessionID 不进协议,只为会话复查保留:会话被撤销后要据此找到并断开对应的连接。
|
||||
type wsIdentity struct {
|
||||
Role string
|
||||
UserID int64
|
||||
SessionID string
|
||||
SettingsAdmin bool
|
||||
}
|
||||
|
||||
func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) {
|
||||
// wsIdentityFromContext 从请求上下文取会话身份并折算成实时层角色。
|
||||
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
|
||||
// settingsAdmin 单独返回真实的 admin 判定:recorder 能进管理端房间,但读不到系统配置。
|
||||
func wsIdentityFromContext(ctx context.Context) wsIdentity {
|
||||
id := wsIdentity{}
|
||||
if sess := middleware.Session(ctx); sess != nil {
|
||||
id.SessionID = sess.ID
|
||||
}
|
||||
u := middleware.User(ctx)
|
||||
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
|
||||
// 会话无效时 Authenticate 已经降级成 guest,这里拿不到会话 id,
|
||||
// 因此降级后的连接不会被会话复查误伤。
|
||||
id.Role = RoleDisplay
|
||||
return id
|
||||
}
|
||||
id.Role = RoleAdmin
|
||||
id.UserID = u.ID
|
||||
id.SettingsAdmin = u.Role.Allows(auth.RoleAdmin)
|
||||
return id
|
||||
}
|
||||
|
||||
func (h *Hub) serve(conn *websocket.Conn, remoteAddr string, id wsIdentity) {
|
||||
conn.SetReadLimit(1 << 20)
|
||||
|
||||
c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID, settingsAdmin)
|
||||
c := newClient(h, conn, remoteAddr, id)
|
||||
h.addClient(c)
|
||||
|
||||
ctx, cancel := context.WithCancel(h.ctx)
|
||||
@@ -402,6 +475,8 @@ func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) {
|
||||
|
||||
// sendInitial 决定首包是 snapshot 还是 catchup:
|
||||
// 没有 lastVersion、版本超前、或增量条数超阈值,一律回落全量快照。
|
||||
//
|
||||
// 两个入口共用这一条路径:hello(首包 / 翻页补的那条)与 resync。
|
||||
func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) {
|
||||
h.sendMu.Lock()
|
||||
defer h.sendMu.Unlock()
|
||||
@@ -419,34 +494,91 @@ func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) {
|
||||
h.setVersion(cur)
|
||||
|
||||
if lastVersion == nil || *lastVersion <= 0 || *lastVersion > cur {
|
||||
h.sendSnapshotLocked(ctx, c)
|
||||
h.sendSnapshotGuarded(ctx, c)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := h.countChangesSince(ctx, *lastVersion)
|
||||
if err != nil {
|
||||
h.log.Warn("统计增量失败,回落快照", "err", err)
|
||||
h.sendSnapshotLocked(ctx, c)
|
||||
h.sendSnapshotGuarded(ctx, c)
|
||||
return
|
||||
}
|
||||
if n > h.maxCatchupValue() {
|
||||
h.sendSnapshotLocked(ctx, c)
|
||||
h.sendSnapshotGuarded(ctx, c)
|
||||
return
|
||||
}
|
||||
|
||||
changes, err := h.changesSince(ctx, *lastVersion, 0)
|
||||
if err != nil {
|
||||
h.log.Warn("读取增量失败,回落快照", "err", err)
|
||||
h.sendSnapshotLocked(ctx, c)
|
||||
h.sendSnapshotGuarded(ctx, c)
|
||||
return
|
||||
}
|
||||
if !c.sendEvent(EventCatchup, catchupPayload{FromVersion: *lastVersion, Version: cur, Changes: changes}) {
|
||||
// catchup 与 change 一样按连接身份裁剪:重连补齐不能成为读系统配置的后门。
|
||||
payload := catchupPayload{
|
||||
FromVersion: *lastVersion,
|
||||
Version: cur,
|
||||
Changes: redactChanges(changes, c.settingsAdmin),
|
||||
}
|
||||
if !c.sendEvent(EventCatchup, payload) {
|
||||
return
|
||||
}
|
||||
c.lastSent.Store(cur)
|
||||
c.ackVersion.Store(cur)
|
||||
}
|
||||
|
||||
// sendSnapshotGuarded 是「要发整库快照」的唯一出口(调用方须持有 sendMu,同 sendSnapshotLocked)。
|
||||
//
|
||||
// 整库快照的代价是一个 DB 事务 + 8 条查询,而赛事库是单连接(SetMaxOpenConns(1)),
|
||||
// 读取还在全局发送锁里:不限流的话,一条 15 字节的帧(resync,或重复的不带 lastVersion 的
|
||||
// hello)就能反复触发,把库连接与所有人的推送一起堵住。这里按连接记账,窗口内已经发过
|
||||
// 整库快照就不再发第二份。
|
||||
//
|
||||
// 只拦「整库快照」这一种:带 lastVersion 的 hello 走 catchup(增量)不受影响;
|
||||
// 每次断线重连都是新连接(新的 client,窗口为空),第一份快照照发。
|
||||
//
|
||||
// 被挡下的请求不丢弃,只在连接上挂一个标记,由心跳循环补发(见 flushPendingSnapshot):
|
||||
// 请求方拿到的是「稍微晚一点的一份」,而不是「什么都没有」。
|
||||
func (h *Hub) sendSnapshotGuarded(ctx context.Context, c *client) bool {
|
||||
interval := h.snapshotMinIntervalValue()
|
||||
if !c.allowSnapshot(interval) {
|
||||
c.snapshotPending.Store(true)
|
||||
h.log.Debug("整库快照窗口内,改为心跳补发", "remote", c.remoteAddr, "interval", interval.String())
|
||||
return false
|
||||
}
|
||||
// 这一份已经满足了挂起的请求(否则它会在下个心跳里变成多余的第三份)。
|
||||
c.snapshotPending.Store(false)
|
||||
h.sendSnapshotLocked(ctx, c)
|
||||
return true
|
||||
}
|
||||
|
||||
// flushPendingSnapshot 在心跳里补发被窗口挡下的整库快照;返回 true 表示补发了一份。
|
||||
//
|
||||
// 规则:只有在「有待发标记」且「窗口确实已过」时才发,发完清标记——
|
||||
// 不能做成每次心跳都发一份(那等于把限流从 2.5s 放大成心跳频率)。
|
||||
// 最迟一个心跳周期(默认 10s)内一定补上,正常客户端感知不到差别。
|
||||
func (h *Hub) flushPendingSnapshot(ctx context.Context, c *client) bool {
|
||||
if !c.snapshotPending.Load() {
|
||||
return false
|
||||
}
|
||||
h.sendMu.Lock()
|
||||
defer h.sendMu.Unlock()
|
||||
|
||||
// 加锁后再看一次:期间可能有别的路径已经发过(那时标记已被清掉)。
|
||||
if !c.snapshotPending.Load() {
|
||||
return false
|
||||
}
|
||||
if !c.allowSnapshot(h.snapshotMinIntervalValue()) {
|
||||
// 窗口还没过(刚有别的请求发过一份):留给下一个心跳。
|
||||
return false
|
||||
}
|
||||
c.snapshotPending.Store(false)
|
||||
h.log.Debug("心跳补发挂起的整库快照", "remote", c.remoteAddr)
|
||||
h.sendSnapshotLocked(ctx, c)
|
||||
return true
|
||||
}
|
||||
|
||||
// sendSnapshotLocked 必须在持有 sendMu 时调用:快照读取与「已发送版本」的推进
|
||||
// 必须原子完成,否则并发的 change 会插到快照前面。
|
||||
func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) {
|
||||
@@ -503,12 +635,13 @@ func (h *Hub) fanout(room string, changes []Change) {
|
||||
}
|
||||
|
||||
last := c.lastSent.Load()
|
||||
settingsAdmin := c.settingsAdmin
|
||||
filtered := make([]Change, 0, len(changes))
|
||||
for _, ch := range changes {
|
||||
if ch.Version > 0 && ch.Version <= last {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, ch)
|
||||
filtered = append(filtered, redactChange(ch, settingsAdmin))
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
continue
|
||||
@@ -671,6 +804,9 @@ func (h *Hub) beat() {
|
||||
now := time.Now()
|
||||
data := heartbeatPayload{Version: version, ServerTime: nowISO(now)}
|
||||
|
||||
// 挂起补发单独收集,等心跳全部发完再做:补发要现建一份快照(慢,且要抢 sendMu),
|
||||
// 不能让排在后面的大屏连心跳都跟着等(落线判定的看门狗就在客户端那边盯着心跳)。
|
||||
var pending []*client
|
||||
for _, c := range h.roomClients("") {
|
||||
c.mu.Lock()
|
||||
greeted := c.greeted
|
||||
@@ -678,8 +814,65 @@ func (h *Hub) beat() {
|
||||
if !greeted {
|
||||
continue
|
||||
}
|
||||
// 会话失效(撤销 / 过期 / 用户停用)的连接先断开,不再给它下发心跳。
|
||||
if h.sessionRevoked(c) {
|
||||
continue
|
||||
}
|
||||
c.sendEvent(EventHeartbeat, data)
|
||||
h.touchInstance(c, now)
|
||||
h.checkSlow(c, version)
|
||||
|
||||
if c.snapshotPending.Load() {
|
||||
pending = append(pending, c)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range pending {
|
||||
h.flushPendingSnapshot(h.ctx, c)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionRevoked 复查连接背后的会话是否仍然有效,失效即发起断开;返回 true 表示已断开。
|
||||
//
|
||||
// 会话撤销(登出 / 改密 / 管理员重置密码 / 停用用户)会删掉会话行或停用用户,
|
||||
// 但 /ws 的身份在握手时就固化了:不复查的话,那条长连接会一直以原角色收推送,
|
||||
// 只有写操作被 HTTP 侧拦住。复查挂在心跳上,最迟一个心跳周期(默认 10s)内断开。
|
||||
//
|
||||
// 只有带会话的连接需要复查:guest 大屏连接没有会话,撤销语义不适用。
|
||||
// 查询失败(库忙 / 系统库被长操作占住)一律按「仍有效」处理并留痕——
|
||||
// 一次查询抖动不该把正在比赛的大屏整片踢下线。
|
||||
func (h *Hub) sessionRevoked(c *client) bool {
|
||||
checker := h.sessionCheckerValue()
|
||||
if checker == nil {
|
||||
return false
|
||||
}
|
||||
c.mu.Lock()
|
||||
sessionID, role := c.sessionID, c.role
|
||||
c.mu.Unlock()
|
||||
if sessionID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(h.ctx, sessionCheckTimeout)
|
||||
defer cancel()
|
||||
active, err := checker.SessionActive(ctx, sessionID)
|
||||
if err != nil {
|
||||
h.log.Warn("复查会话失败,本次保留连接", "session", sessionID, "remote", c.remoteAddr, "err", err)
|
||||
return false
|
||||
}
|
||||
if active {
|
||||
return false
|
||||
}
|
||||
|
||||
// 管理端连接按「登录失效」关闭:前端对所有 1008 会停止重连并引导重新登录,
|
||||
// 这正是被撤销的会话应有的表现。大屏连接改用 1001 让它自动重连——
|
||||
// 重连时握手会按会话失效降级成 guest(HTTP 侧本来也是这样),
|
||||
// 现场大屏不该因为操作员登出而永久冻结。
|
||||
code, reason := websocket.StatusGoingAway, "会话已失效,按游客身份重连"
|
||||
if role == RoleAdmin {
|
||||
code, reason = websocket.StatusPolicyViolation, "会话已失效,请重新登录"
|
||||
}
|
||||
h.log.Info("会话已失效,断开实时连接", "session", sessionID, "role", role, "remote", c.remoteAddr)
|
||||
c.stop(code, reason)
|
||||
return true
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||||
)
|
||||
@@ -40,12 +43,23 @@ func newTestEnv(t *testing.T) *testEnv {
|
||||
// newTestEnvAs 注入会话身份,等价于真实链路里 Authenticate 中间件写进 context 的 User。
|
||||
func newTestEnvAs(t *testing.T, u *auth.User) *testEnv {
|
||||
t.Helper()
|
||||
return newTestEnvAsSession(t, u, "")
|
||||
}
|
||||
|
||||
// newTestEnvAsSession 在身份之外再注入会话 id,等价于 Authenticate 中间件写进 context 的
|
||||
// *auth.Session:会话复查(撤销即断开)要按会话 id 判定,测试必须能给出这一条。
|
||||
func newTestEnvAsSession(t *testing.T, u *auth.User, sessionID string) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
db := newTestDB(t)
|
||||
h := NewHub(db, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
handler := h.Handler()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handler.ServeHTTP(w, r.WithContext(middleware.WithUser(r.Context(), u)))
|
||||
ctx := middleware.WithUser(r.Context(), u)
|
||||
if sessionID != "" {
|
||||
ctx = middleware.WithSession(ctx, &auth.Session{ID: sessionID, UserID: u.ID})
|
||||
}
|
||||
handler.ServeHTTP(w, r.WithContext(ctx))
|
||||
}))
|
||||
|
||||
t.Cleanup(func() {
|
||||
@@ -179,6 +193,37 @@ func (c *wsClient) recvEvent(want string) json.RawMessage {
|
||||
return nil
|
||||
}
|
||||
|
||||
// awaitSnapshot 在 d 内等待一份整库快照(心跳等无关消息跳过);等到返回 true。
|
||||
func (c *wsClient) awaitSnapshot(d time.Duration) bool {
|
||||
c.t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
ev, _, ok := c.recvWithin(time.Until(deadline))
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if ev == EventSnapshot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// noSnapshotWithin 断言 d 内没有整库快照;心跳等无关消息不算(连接断开则直接失败)。
|
||||
func (c *wsClient) noSnapshotWithin(d time.Duration) {
|
||||
c.t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
ev, _, ok := c.recvWithin(time.Until(deadline))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ev == EventSnapshot {
|
||||
c.t.Fatalf("这段时间内不该有整库快照")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expectClosed 断言连接在 d 内被服务端关闭,且关闭前没有下发任何事件。
|
||||
func (c *wsClient) expectClosed(d time.Duration) {
|
||||
c.t.Helper()
|
||||
@@ -459,9 +504,11 @@ func TestDuplicateDeliveryIgnored(t *testing.T) {
|
||||
}
|
||||
|
||||
// 心跳携带版本号与服务器时间;版本落后时客户端 resync 能拿到最新快照。
|
||||
// 本用例测的是协议本身(请求 → 响应),整库快照窗口另有专门用例,这里关闭限流。
|
||||
func TestHeartbeatAndResync(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||||
env.hub.SetSnapshotMinInterval(0)
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
c.recvEvent(EventSnapshot)
|
||||
@@ -486,8 +533,10 @@ func TestHeartbeatAndResync(t *testing.T) {
|
||||
}
|
||||
|
||||
// 大屏实例注册表:instanceId、在线状态、当前页、最后心跳可查。
|
||||
// 重复 hello 会回一份快照(窗口内属于限流范围,这里关掉限流以保持用例只关心注册表)。
|
||||
func TestDisplayInstancesRegistry(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetSnapshotMinInterval(0)
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1", Page: "standings"})
|
||||
c.recvEvent(EventSnapshot)
|
||||
|
||||
@@ -691,6 +740,7 @@ func TestSnapshotSettingsFullForAdmin(t *testing.T) {
|
||||
// resync 在 hello 之前不可用:否则会绕过房间分配并提前清掉 hello 超时。
|
||||
func TestResyncBeforeHelloIgnored(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetSnapshotMinInterval(200 * time.Millisecond)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
c := env.dial(t, nil)
|
||||
@@ -703,6 +753,9 @@ func TestResyncBeforeHelloIgnored(t *testing.T) {
|
||||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||||
t.Fatalf("hello 后应收到版本 1 的快照,得到 %d", snap.Version)
|
||||
}
|
||||
// hello 之后 resync 仍然可用,只是要与刚发出去的整库快照共用同一个窗口
|
||||
// (生产默认窗口与客户端 3s 冷却一致;这里把它调短,免得测试睡 3 秒)。
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
c.send(EventResync, struct{}{})
|
||||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||||
t.Fatalf("hello 后 resync 应拿到快照,得到 %d", snap.Version)
|
||||
@@ -733,7 +786,7 @@ func TestInstanceRegistryPrunesStaleOffline(t *testing.T) {
|
||||
// 读写 goroutine 的 panic 必须被兜住并断开该连接,而不是带走进程。
|
||||
func TestGuardLoopRecoversPanic(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
c := newClient(env.hub, nil, "test", RoleDisplay, 0, false)
|
||||
c := newClient(env.hub, nil, "test", wsIdentity{Role: RoleDisplay})
|
||||
|
||||
func() {
|
||||
defer c.guardLoop("读")
|
||||
@@ -908,3 +961,441 @@ func TestDisplayInstancesReportScreen(t *testing.T) {
|
||||
t.Fatalf("局域网浏览器不带屏号,应为空串,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionCheckerFunc 把桩会话复查器写成普通函数:用例里只需回答「这条会话还在不在」。
|
||||
type sessionCheckerFunc func(ctx context.Context, sessionID string) (bool, error)
|
||||
|
||||
func (f sessionCheckerFunc) SessionActive(ctx context.Context, sessionID string) (bool, error) {
|
||||
return f(ctx, sessionID)
|
||||
}
|
||||
|
||||
// ---------- 会话撤销:长连接必须跟着断开 ----------
|
||||
|
||||
// 会话被撤销(登出 / 改密 / 管理员重置密码 / 停用用户)后,已经建立的 /ws 连接必须被断开,
|
||||
// 不能再以原角色继续收推送;管理端连接按 1008 关闭,前端据此停止重连并引导重新登录。
|
||||
func TestSessionRevokedClosesAdminConnection(t *testing.T) {
|
||||
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-1")
|
||||
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
|
||||
|
||||
var revoked atomic.Bool
|
||||
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
|
||||
return !revoked.Load(), nil
|
||||
}))
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||||
c.recvEvent(EventSnapshot)
|
||||
|
||||
// 会话仍有效时照常收推送:复查不能误伤在线用户。
|
||||
insertChange(t, env.db, "teams", 1, ActionUpsert, `{"id":1}`)
|
||||
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
|
||||
c.recvEvent(EventChange)
|
||||
|
||||
// 撤销之后:最迟一个心跳周期内断开,关闭码为「登录失效」。
|
||||
revoked.Store(true)
|
||||
c.expectClosedWithCodeDraining(3*time.Second, websocket.StatusPolicyViolation)
|
||||
|
||||
// 断开后这条连接不再是任何广播的投递对象:后续变更推不到它。
|
||||
waitFor(t, "失效连接退出", func() bool { return len(env.hub.roomClients("")) == 0 })
|
||||
insertChange(t, env.db, "teams", 2, ActionUpsert, `{"id":2}`)
|
||||
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
|
||||
if n := len(env.hub.roomClients("")); n != 0 {
|
||||
t.Fatalf("会话失效后不应再有投递对象,得到 %d 条连接", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 大屏连接(role=display 但带会话)同样要断开,但用 1001:客户端会自动重连,
|
||||
// 重连时握手按会话失效降级成 guest,现场大屏不该因为操作员登出而永久冻结。
|
||||
func TestSessionRevokedClosesDisplayConnectionAsGoingAway(t *testing.T) {
|
||||
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-2")
|
||||
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
|
||||
|
||||
var revoked atomic.Bool
|
||||
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
|
||||
return !revoked.Load(), nil
|
||||
}))
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1"})
|
||||
c.recvEvent(EventSnapshot)
|
||||
|
||||
revoked.Store(true)
|
||||
c.expectClosedWithCodeDraining(3*time.Second, websocket.StatusGoingAway)
|
||||
}
|
||||
|
||||
// 没有会话的连接(局域网大屏)不做复查:撤销语义不适用于 guest,也不该白查一次库。
|
||||
func TestSessionCheckSkippedWithoutSession(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
|
||||
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
|
||||
return false, nil // 一旦真被问到就会判死,但 guest 连接根本不该被问
|
||||
}))
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "lan-1"})
|
||||
c.recvEvent(EventSnapshot)
|
||||
|
||||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); !ok || ev != EventHeartbeat {
|
||||
t.Fatalf("guest 连接应继续收心跳,得到 %q ok=%v", ev, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 复查失败(系统库忙 / 被长操作占住)按「仍有效」处理:一次查询抖动不该把大屏整片踢下线。
|
||||
func TestSessionCheckFailureKeepsConnection(t *testing.T) {
|
||||
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-3")
|
||||
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
|
||||
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
|
||||
return false, errors.New("库忙")
|
||||
}))
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||||
c.recvEvent(EventSnapshot)
|
||||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); !ok || ev != EventHeartbeat {
|
||||
t.Fatalf("复查失败时应保留连接并继续心跳,得到 %q ok=%v", ev, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 增量载荷按角色裁剪 ----------
|
||||
|
||||
// fullSettingsPayload 是 settings 实体的变更载荷:既有 admin 专属的系统配置,
|
||||
// 也有大屏要用的展示字段(公告、标题、榜单开关)。
|
||||
const fullSettingsPayload = `{"event":{"name":"测试运动会","subtitle":null,"host":null,"logoPath":null,"primaryColor":null},` +
|
||||
`"display":{"background":null,"logo":null,"title":"测试运动会","subtitle":null,"primaryColor":null,` +
|
||||
`"rotateSeconds":15,"announce":"请注意检录","fontTitle":null,"fontBody":null,"liveEvents":[],"liveSource":"auto"},` +
|
||||
`"standings":{"individualToTeamWeight":0.5,"rankingMode":"points","showIndividualBoard":true,` +
|
||||
`"showIndividualMedals":true,"liveOnPublish":true},"defaultProfileId":1,"requireApproval":true}`
|
||||
|
||||
// assertSettingsPayload 校验一条 settings 载荷是否按身份裁过:
|
||||
// 非 admin 不得出现默认积分模板 / 审核开关 / 个人折算系数,但公告与标题这类大屏字段必须保留。
|
||||
func assertSettingsPayload(t *testing.T, raw json.RawMessage, wantFull bool) {
|
||||
t.Helper()
|
||||
var got dto.EventSettings
|
||||
if err := json.Unmarshal(raw, &got); err != nil {
|
||||
t.Fatalf("settings 载荷无法解析: %s", raw)
|
||||
}
|
||||
if wantFull {
|
||||
if got.DefaultProfileID == nil || !got.RequireApproval || got.Standings.IndividualToTeamWeight != 0.5 {
|
||||
t.Fatalf("admin 应拿到完整系统配置: %s", raw)
|
||||
}
|
||||
} else {
|
||||
if got.DefaultProfileID != nil {
|
||||
t.Fatalf("非 admin 不应看到 defaultProfileId: %s", raw)
|
||||
}
|
||||
if got.RequireApproval {
|
||||
t.Fatalf("非 admin 不应看到 requireApproval: %s", raw)
|
||||
}
|
||||
if got.Standings.IndividualToTeamWeight != 0 {
|
||||
t.Fatalf("非 admin 不应看到 individualToTeamWeight: %s", raw)
|
||||
}
|
||||
}
|
||||
if got.Event.Name != "测试运动会" || got.Display.Announce == nil || *got.Display.Announce != "请注意检录" {
|
||||
t.Fatalf("大屏展示字段不该被裁剪: %s", raw)
|
||||
}
|
||||
if got.Standings.RankingMode != "points" || !got.Standings.ShowIndividualBoard || !got.Standings.LiveOnPublish {
|
||||
t.Fatalf("榜单展示开关不该被裁剪: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// 快照裁剪之外,change 与 catchup 两条增量路径也必须按角色裁剪:
|
||||
// 否则任何打开 /ws 的浏览器都能从增量里读到系统配置,快照上的裁剪等于被旁路。
|
||||
func TestSettingsChangeRedactedByRole(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
user *auth.User
|
||||
role string
|
||||
full bool
|
||||
}{
|
||||
{"guest", &auth.User{Role: auth.RoleGuest}, RoleDisplay, false},
|
||||
{"recorder", &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true}, RoleAdmin, false},
|
||||
{"admin", &auth.User{ID: 1, Username: "boss", Role: auth.RoleAdmin, Active: true}, RoleAdmin, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env := newTestEnvAs(t, tc.user)
|
||||
|
||||
// 一条历史变更:用来构造 catchup(重连补齐)。先垫一条无关变更,
|
||||
// 让下面的 lastVersion 落在增量区间内(0 会走全量快照)。
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
old := insertChange(t, env.db, entitySettings, 0, ActionUpsert, fullSettingsPayload)
|
||||
|
||||
// change:在线连接收到一条新的 settings 变更。
|
||||
live := env.dial(t, &helloPayload{Role: tc.role, InstanceID: tc.name + "-live"})
|
||||
live.recvEvent(EventSnapshot)
|
||||
insertChange(t, env.db, entitySettings, 0, ActionUpsert, fullSettingsPayload)
|
||||
sent := loadChanges(t, env.hub, old, 0)
|
||||
env.hub.Broadcast(sent)
|
||||
|
||||
got := decode[changePayload](t, live.recvEvent(EventChange))
|
||||
if len(got.Changes) != 1 || got.Changes[0].Entity != entitySettings {
|
||||
t.Fatalf("应收到一条 settings 变更,得到 %+v", got.Changes)
|
||||
}
|
||||
assertSettingsPayload(t, got.Changes[0].Data, tc.full)
|
||||
// 裁剪必须产出新对象:就地改写共享的 Change 会让 admin 连接也拿到裁剪版。
|
||||
if string(sent[0].Data) != fullSettingsPayload {
|
||||
t.Fatalf("裁剪不应改写共享的 Change: %s", sent[0].Data)
|
||||
}
|
||||
|
||||
// catchup:重连连接按 lastVersion 补齐,同样不能漏出系统配置。
|
||||
catchupClient := env.dial(t, &helloPayload{
|
||||
Role: tc.role, InstanceID: tc.name + "-catchup", LastVersion: ptrInt64(old - 1),
|
||||
})
|
||||
cp := decode[catchupPayload](t, catchupClient.recvEvent(EventCatchup))
|
||||
var found bool
|
||||
for _, ch := range cp.Changes {
|
||||
if ch.Entity != entitySettings {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
assertSettingsPayload(t, ch.Data, tc.full)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("catchup 应包含 settings 变更,得到 %+v", cp.Changes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 载荷不是 EventSettings 形状时宁可只下发空对象,也不能把可能含系统配置的原文透给非 admin。
|
||||
func TestSettingsChangeUnknownShapeRedacted(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
c.recvEvent(EventSnapshot)
|
||||
|
||||
insertChange(t, env.db, entitySettings, 0, ActionUpsert, `"旧版扁平配置"`)
|
||||
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
|
||||
|
||||
got := decode[changePayload](t, c.recvEvent(EventChange))
|
||||
if string(got.Changes[0].Data) != `{}` {
|
||||
t.Fatalf("无法识别的 settings 载荷应降级成空对象,得到 %s", got.Changes[0].Data)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 整库快照限流 ----------
|
||||
//
|
||||
// 整库快照有两个入口:resync,和不带 lastVersion 的 hello(首包 / 翻页补的那条)。
|
||||
// 两者在 sendInitial 里共用同一个 per-connection 窗口,所以「交替发」绕不过去。
|
||||
|
||||
// 窗口内连发多次 resync 只出一份整库快照;越过窗口后恢复正常,
|
||||
// 客户端「落后了要一次全量」的补齐行为不受影响。
|
||||
func TestSnapshotThrottleCoversResync(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetSnapshotMinInterval(300 * time.Millisecond)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
// 新连接的第一份快照照发,并且它就是本窗口里的那一份。
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||||
t.Fatalf("新连接应收到版本 1 的快照,得到 %d", snap.Version)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
c.send(EventResync, struct{}{})
|
||||
}
|
||||
if ev, _, ok := c.recvWithin(150 * time.Millisecond); ok {
|
||||
t.Fatalf("同一窗口内不该有第二份整库快照,收到 %s", ev)
|
||||
}
|
||||
|
||||
// 越过窗口后再连发 5 次:只放行一份。
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
for i := 0; i < 5; i++ {
|
||||
c.send(EventResync, struct{}{})
|
||||
}
|
||||
decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||||
if ev, _, ok := c.recvWithin(150 * time.Millisecond); ok {
|
||||
t.Fatalf("一个窗口只应放行一份整库快照,收到 %s", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// 不带 lastVersion 的重复 hello 与 resync 等价:也是要一份整库快照,同样按窗口限流。
|
||||
func TestSnapshotThrottleCoversRepeatHello(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetSnapshotMinInterval(300 * time.Millisecond)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||||
t.Fatalf("新连接应收到版本 1 的快照,得到 %d", snap.Version)
|
||||
}
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
}
|
||||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
|
||||
t.Fatalf("窗口内重复 hello 只应回一份快照(首包那份),收到 %s", ev)
|
||||
}
|
||||
|
||||
// 越过窗口后恢复:真实节奏下翻页补的那条 hello 远晚于窗口,照旧生效。
|
||||
time.Sleep(350 * time.Millisecond)
|
||||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||||
}
|
||||
|
||||
// hello 与 resync 交替发也绕不过限流:两者共用一个窗口,而不是各记各的。
|
||||
func TestSnapshotThrottleSharedByHelloAndResync(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetSnapshotMinInterval(time.Minute)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||||
|
||||
resync := func() { c.send(EventResync, struct{}{}) }
|
||||
hello := func() { c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"}) }
|
||||
resync()
|
||||
hello()
|
||||
resync()
|
||||
hello()
|
||||
|
||||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
|
||||
t.Fatalf("交替 hello / resync 不应绕过整库快照窗口,收到 %s", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// 不变式:带 lastVersion 的 hello 走 catchup(增量),与整库快照无关——
|
||||
// 刚发过快照也不能把它挡住,而且它不会留下「待发快照」的挂起标记。
|
||||
func TestSnapshotThrottleDoesNotAffectCatchup(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
// 窗口与心跳都调短:万一 catchup 请求被误当成「要整库快照」,下面的
|
||||
// noSnapshotWithin 立刻就能看见那次多余的补发。
|
||||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||||
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||||
|
||||
// 窗口内的第二条 hello 带 lastVersion:必须立刻拿到增量。
|
||||
insertChange(t, env.db, "result", 2, ActionUpsert, `{"rank":2}`)
|
||||
c.send(EventHello, helloPayload{
|
||||
Role: RoleDisplay, InstanceID: "d1", LastVersion: ptrInt64(snap.Version),
|
||||
})
|
||||
cp := decode[catchupPayload](t, c.recvEvent(EventCatchup))
|
||||
if cp.FromVersion != snap.Version || cp.Version != snap.Version+1 {
|
||||
t.Fatalf("catchup 应为 %d → %d,得到 %d → %d",
|
||||
snap.Version, snap.Version+1, cp.FromVersion, cp.Version)
|
||||
}
|
||||
if len(cp.Changes) != 1 || cp.Changes[0].ID != 2 {
|
||||
t.Fatalf("catchup 应补上那一条新变更,得到 %+v", cp.Changes)
|
||||
}
|
||||
// catchup 不是整库快照:不该挂起、也不该在窗口过后补发。
|
||||
c.noSnapshotWithin(400 * time.Millisecond)
|
||||
}
|
||||
|
||||
// 不变式:窗口按连接记账。一条连接把自己的窗口占满,不影响别的连接的第一份快照;
|
||||
// 断线重连也是新连接(新的 client,窗口为空),第一份快照同样照发。
|
||||
func TestSnapshotThrottleIsScopedToConnection(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetSnapshotMinInterval(time.Minute)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
// A 连发 resync 把自己的窗口占满。
|
||||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
|
||||
a.recvEvent(EventSnapshot)
|
||||
for i := 0; i < 3; i++ {
|
||||
a.send(EventResync, struct{}{})
|
||||
}
|
||||
if ev, _, ok := a.recvWithin(200 * time.Millisecond); ok {
|
||||
t.Fatalf("A 的窗口内不该再有整库快照,收到 %s", ev)
|
||||
}
|
||||
|
||||
// B 是新连接:第一份快照照发,不受 A 影响。
|
||||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "b"})
|
||||
if snap := decode[Snapshot](t, b.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||||
t.Fatalf("B 作为新连接应立刻拿到快照,得到版本 %d", snap.Version)
|
||||
}
|
||||
|
||||
// 断线重连同样是新连接:现场刷新页面 / 网络抖动都不该被限流影响。
|
||||
a.close()
|
||||
reconnected := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
|
||||
if snap := decode[Snapshot](t, reconnected.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||||
t.Fatalf("重连应立刻拿到快照,得到版本 %d", snap.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 被窗口挡下的整库快照:挂起 + 心跳补发 ----------
|
||||
|
||||
// 窗口内连发多次 resync:当下不回(否则等于没限流),但请求被挂起,
|
||||
// 窗口过后由心跳补一份——大屏不会因为限流静默停在旧数据上。
|
||||
func TestPendingSnapshotFlushedByHeartbeat(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||||
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
c.recvEvent(EventSnapshot) // 首包即时发,窗口从这一份开始记账
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
c.send(EventResync, struct{}{})
|
||||
}
|
||||
c.noSnapshotWithin(100 * time.Millisecond)
|
||||
|
||||
if !c.awaitSnapshot(2 * time.Second) {
|
||||
t.Fatal("窗口过后应由心跳补发一份整库快照")
|
||||
}
|
||||
// 补发即清标记:之后的心跳不再重复发(否则限流就退化成心跳频率了)。
|
||||
c.noSnapshotWithin(300 * time.Millisecond)
|
||||
}
|
||||
|
||||
// 不带 lastVersion 的重复 hello 与 resync 走同一条挂起路径:也只补一份。
|
||||
func TestPendingSnapshotFromRepeatHelloFlushedOnce(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||||
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
c.recvEvent(EventSnapshot)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
}
|
||||
c.noSnapshotWithin(100 * time.Millisecond)
|
||||
|
||||
if !c.awaitSnapshot(2 * time.Second) {
|
||||
t.Fatal("窗口过后应由心跳补发一份整库快照")
|
||||
}
|
||||
c.noSnapshotWithin(300 * time.Millisecond)
|
||||
}
|
||||
|
||||
// hello 与 resync 交替触发时共用一个挂起标记:补发仍然只有一份。
|
||||
func TestPendingSnapshotSharedByHelloAndResync(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||||
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
c.recvEvent(EventSnapshot)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
c.send(EventResync, struct{}{})
|
||||
}
|
||||
c.noSnapshotWithin(100 * time.Millisecond)
|
||||
|
||||
if !c.awaitSnapshot(2 * time.Second) {
|
||||
t.Fatal("窗口过后应由心跳补发一份整库快照")
|
||||
}
|
||||
c.noSnapshotWithin(300 * time.Millisecond)
|
||||
}
|
||||
|
||||
// 补发的量级仍然是「每窗口一份」:两条连接各自挂起,各自只补一份。
|
||||
func TestPendingSnapshotFlushIsPerConnection(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
|
||||
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
|
||||
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "b"})
|
||||
a.recvEvent(EventSnapshot)
|
||||
b.recvEvent(EventSnapshot)
|
||||
|
||||
a.send(EventResync, struct{}{})
|
||||
b.send(EventResync, struct{}{})
|
||||
// 只对 A 做一次「当下不回」的断言:两条连接的静默窗口不能串行等待,
|
||||
// 后一条会把自己的补发也等进去(B 的那份本来就该在窗口过后到)。
|
||||
a.noSnapshotWithin(100 * time.Millisecond)
|
||||
|
||||
if !a.awaitSnapshot(2*time.Second) || !b.awaitSnapshot(2*time.Second) {
|
||||
t.Fatal("两条挂起的连接都应在窗口过后各补一份快照")
|
||||
}
|
||||
a.noSnapshotWithin(300 * time.Millisecond)
|
||||
b.noSnapshotWithin(300 * time.Millisecond)
|
||||
}
|
||||
@@ -283,21 +283,22 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// loadRanked 与 REST 的 buildRanked 同源:必须 join event_score_channels,
|
||||
// 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌;
|
||||
// loadRanked 与 REST 的 buildRanked、导出的 loadRanked 同源:数据源是发布/重算时
|
||||
// 按通道全量重建的 channel_ranks,一条名次一行(多通道项目因此不会把一枚奖牌算成多枚,
|
||||
// 没配通道的项目也不会有名次行)。三个奖牌开关随行携带,domain 这边不必回查库;
|
||||
// 同时排除只排名不计分(scored=0)的项目,与导出侧口径一致。
|
||||
func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status,
|
||||
e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id
|
||||
FROM results r
|
||||
JOIN entries e ON e.id = r.entry_id
|
||||
JOIN events ev ON ev.id = r.event_id AND ev.scored = 1
|
||||
JOIN event_score_channels c ON c.event_id = r.event_id
|
||||
LEFT JOIN athletes a ON a.id = e.athlete_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
|
||||
AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)
|
||||
ORDER BY r.id, c.sort_order, c.id`)
|
||||
SELECT COALESCE(cr.result_id, 0), cr.event_id, cr.category_id, cr.rank, cr.result_value, cr.status,
|
||||
COALESCE(cr.entry_id, 0), COALESCE(cr.team_id, 0), COALESCE(cr.athlete_id, 0),
|
||||
COALESCE(a.name, ''), cr.scope, cr.channel_id, cr.basis, cr.subject_key,
|
||||
ev.award_medals, c.count_to_medals, c.count_to_team_medals
|
||||
FROM channel_ranks cr
|
||||
JOIN events ev ON ev.id = cr.event_id AND ev.scored = 1
|
||||
JOIN event_score_channels c ON c.id = cr.channel_id
|
||||
LEFT JOIN athletes a ON a.id = cr.athlete_id
|
||||
WHERE cr.rank > 0
|
||||
ORDER BY cr.event_id, cr.category_id, c.sort_order, c.id, cr.rank, cr.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取奖牌名次: %w", err)
|
||||
}
|
||||
@@ -306,22 +307,30 @@ func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error)
|
||||
var out []domain.RankedResult
|
||||
for rows.Next() {
|
||||
var (
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
rank int
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
status sql.NullString
|
||||
awardMedals int
|
||||
countToMedals int
|
||||
countToTeamMedals int
|
||||
)
|
||||
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 {
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rr.Rank, &value, &status,
|
||||
&rr.EntryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID,
|
||||
&rr.Basis, &rr.SubjectKey, &awardMedals, &countToMedals, &countToTeamMedals); err != nil {
|
||||
return nil, fmt.Errorf("解析奖牌名次: %w", err)
|
||||
}
|
||||
rr.Rank = rank
|
||||
rr.Status = domain.ResultStatus(status.String)
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
rr.ResultValue = &v
|
||||
}
|
||||
rr.AthleteID = athleteID.Int64
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
// 与 REST 同口径:份额只有「主单位独占 100%」一种,team_id 为空时不造份额。
|
||||
if rr.TeamID != 0 {
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
}
|
||||
rr.AwardMedals = awardMedals != 0
|
||||
rr.CountToMedals = countToMedals != 0
|
||||
rr.CountToTeamMedals = countToTeamMedals != 0
|
||||
out = append(out, rr)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -65,8 +66,9 @@ func TestSnapshotWireContract(t *testing.T) {
|
||||
cat1 := insertCategory(t, db, event1, "青年组")
|
||||
athlete1 := insertAthlete(t, db, "张伟", team1)
|
||||
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
|
||||
insertChannel(t, db, event1, "athlete")
|
||||
ch1 := insertChannel(t, db, event1, "athlete")
|
||||
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
|
||||
insertChannelRank(t, db, event1, cat1, ch1, entry1, result1, team1, athlete1, "athlete", "result", 1, 11.58)
|
||||
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 9)
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
@@ -307,8 +309,9 @@ func TestSnapshotStandingsMedalSwitch(t *testing.T) {
|
||||
event1 := insertEvent(t, db, "E1", "4x100 米接力")
|
||||
cat1 := insertCategory(t, db, event1, "男子")
|
||||
entry1 := insertEntry(t, db, event1, cat1, team1, 0)
|
||||
insertChannel(t, db, event1, "team")
|
||||
insertResult(t, db, event1, entry1, 1, "45.20", 45.20, "2026-09-09T10:00:00Z")
|
||||
ch1 := insertChannel(t, db, event1, "team")
|
||||
r1 := insertResult(t, db, event1, entry1, 1, "45.20", 45.20, "2026-09-09T10:00:00Z")
|
||||
insertChannelRank(t, db, event1, cat1, ch1, entry1, r1, team1, 0, "team", "result", 1, 45.20)
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
@@ -346,8 +349,9 @@ func TestSnapshotStandingsOnlyScoredChannels(t *testing.T) {
|
||||
event2 := insertEvent(t, db, "E2", "团体项目")
|
||||
cat2 := insertCategory(t, db, event2, "男子")
|
||||
entry2 := insertEntry(t, db, event2, cat2, team2, 0)
|
||||
insertChannel(t, db, event2, "team")
|
||||
insertResult(t, db, event2, entry2, 1, "9.99", 9.99, "2026-09-09T10:00:01Z")
|
||||
ch2 := insertChannel(t, db, event2, "team")
|
||||
r2 := insertResult(t, db, event2, entry2, 1, "9.99", 9.99, "2026-09-09T10:00:01Z")
|
||||
insertChannelRank(t, db, event2, cat2, ch2, entry2, r2, team2, 0, "team", "result", 1, 9.99)
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
@@ -479,10 +483,43 @@ func insertEntry(t *testing.T, db *sql.DB, eventID, categoryID, teamID, athleteI
|
||||
return lastID(t, res)
|
||||
}
|
||||
|
||||
func insertChannel(t *testing.T, db *sql.DB, eventID int64, scope string) {
|
||||
// insertChannel 建一条按成绩排名的通道,返回通道 id(写通道名次时要用)。
|
||||
func insertChannel(t *testing.T, db *sql.DB, eventID int64, scope string) int64 {
|
||||
t.Helper()
|
||||
mustExec(t, db, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
res, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, ?, 'result', 1, 0)`, eventID, scope)
|
||||
if err != nil {
|
||||
t.Fatalf("插入计分通道: %v", err)
|
||||
}
|
||||
return lastID(t, res)
|
||||
}
|
||||
|
||||
// insertChannelRank 写一条通道名次:大屏奖牌榜的读取来源(loadRanked 读 channel_ranks)。
|
||||
// entryID 为 0 时主体是单位(subject_key 用 t:<teamID>),否则是报名项的 e:<entryID>。
|
||||
func insertChannelRank(t *testing.T, db *sql.DB, eventID, categoryID, channelID, entryID, resultID, teamID, athleteID int64,
|
||||
scope, basis string, rank int, value float64) {
|
||||
t.Helper()
|
||||
subject := fmt.Sprintf("e:%d", entryID)
|
||||
if entryID == 0 {
|
||||
subject = fmt.Sprintf("t:%d", teamID)
|
||||
}
|
||||
var entry, result, athlete, team any
|
||||
if entryID != 0 {
|
||||
entry = entryID
|
||||
}
|
||||
if resultID != 0 {
|
||||
result = resultID
|
||||
}
|
||||
if athleteID != 0 {
|
||||
athlete = athleteID
|
||||
}
|
||||
if teamID != 0 {
|
||||
team = teamID
|
||||
}
|
||||
mustExec(t, db, `INSERT INTO channel_ranks (event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points, result_value, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 'ok')`,
|
||||
eventID, categoryID, channelID, basis, scope, subject, entry, result, athlete, team, rank, value)
|
||||
}
|
||||
|
||||
func insertResult(t *testing.T, db *sql.DB, eventID, entryID int64, rank int, text string, value float64, verifiedAt string) int64 {
|
||||
@@ -537,13 +574,15 @@ func TestSnapshotTieBreakFromSettings(t *testing.T) {
|
||||
|
||||
// T1:银牌 + 10 分;T2:金牌 + 0 分。默认「总分优先」T1 在前,按金牌优先则 T2 在前。
|
||||
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
|
||||
insertChannel(t, db, event1, "athlete")
|
||||
ch1 := insertChannel(t, db, event1, "athlete")
|
||||
result1 := insertResult(t, db, event1, entry1, 2, "11.58", 11.58, "2026-09-09T10:00:00Z")
|
||||
insertChannelRank(t, db, event1, cat1, ch1, entry1, result1, team1, athlete1, "athlete", "result", 2, 11.58)
|
||||
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")
|
||||
ch2 := insertChannel(t, db, event2, "athlete")
|
||||
result2 := insertResult(t, db, event2, entry2, 1, "22.10", 22.10, "2026-09-09T10:00:01Z")
|
||||
insertChannelRank(t, db, event2, cat2, ch2, entry2, result2, team2, athlete2, "athlete", "result", 1, 22.10)
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
|
||||
@@ -85,9 +85,9 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("项目 %s 的通道引用了不存在的模板 %q", code, ch.Profile)
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i,
|
||||
boolToInt(ch.CountsToTeam())); err != nil {
|
||||
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team, count_to_medals, count_to_team_medals)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i,
|
||||
boolToInt(ch.CountsToTeam()), boolToInt(ch.CountsToMedals()), boolToInt(ch.CountsToTeamMedals())); err != nil {
|
||||
return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err)
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
|
||||
// 4. 项目开关:只改 override 里显式给出的字段。
|
||||
for _, ov := range next.EventOverrides {
|
||||
eventID, ok := eventIDs[ov.EventCode]
|
||||
if !ok || (ov.Scored == nil && ov.Multiplier == nil) {
|
||||
if !ok || (ov.Scored == nil && ov.Multiplier == nil && ov.AwardMedals == nil) {
|
||||
continue
|
||||
}
|
||||
scored, err := currentScored(tx, eventID)
|
||||
@@ -125,8 +125,15 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
|
||||
if ov.Multiplier != nil {
|
||||
multiplier = *ov.Multiplier
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE events SET scored = ?, multiplier = ? WHERE id = ?`,
|
||||
boolToInt(scored), multiplier, eventID); err != nil {
|
||||
awardMedals, err := currentAwardMedals(tx, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ov.AwardMedals != nil {
|
||||
awardMedals = *ov.AwardMedals
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE events SET scored = ?, multiplier = ?, award_medals = ? WHERE id = ?`,
|
||||
boolToInt(scored), multiplier, boolToInt(awardMedals), eventID); err != nil {
|
||||
return nil, fmt.Errorf("更新项目 %s: %w", ov.EventCode, err)
|
||||
}
|
||||
res.EventsUpdated++
|
||||
@@ -241,6 +248,14 @@ func currentMultiplier(tx *sql.Tx, eventID int64) (float64, error) {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func currentAwardMedals(tx *sql.Tx, eventID int64) (bool, error) {
|
||||
var v int
|
||||
if err := tx.QueryRow(`SELECT award_medals FROM events WHERE id = ?`, eventID).Scan(&v); err != nil {
|
||||
return false, fmt.Errorf("读取项目奖牌结算开关: %w", err)
|
||||
}
|
||||
return v != 0, nil
|
||||
}
|
||||
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
|
||||
@@ -32,7 +32,7 @@ func (p Profile) ToDomain(id int64) domain.ScoringProfile {
|
||||
}
|
||||
|
||||
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
|
||||
// countToTeam 缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致。
|
||||
// 三个开关缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致。
|
||||
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
|
||||
out := domain.ScoreChannel{
|
||||
EventID: eventID,
|
||||
@@ -44,6 +44,12 @@ func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreC
|
||||
if !c.CountsToTeam() {
|
||||
out.CountToTeam = BoolPtr(false)
|
||||
}
|
||||
if !c.CountsToMedals() {
|
||||
out.CountToMedals = BoolPtr(false)
|
||||
}
|
||||
if !c.CountsToTeamMedals() {
|
||||
out.CountToTeamMedals = BoolPtr(false)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +252,12 @@ func channelsEqual(a, b []Channel) bool {
|
||||
if a[i].CountsToTeam() != b[i].CountsToTeam() {
|
||||
return false
|
||||
}
|
||||
if a[i].CountsToMedals() != b[i].CountsToMedals() {
|
||||
return false
|
||||
}
|
||||
if a[i].CountsToTeamMedals() != b[i].CountsToTeamMedals() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
)
|
||||
|
||||
// 奖牌结算三个开关(devdocs 17 §三 / §六):
|
||||
// - Channel.countToMedals / countToTeamMedals(缺省 true)
|
||||
// - EventOverride.awardMedals(三态,nil = 不动该字段)
|
||||
// 这里覆盖解析、落库、回读、差异比对四条路,保证「导出 → 导入」幂等。
|
||||
|
||||
const medalFlagsPackYAML = `format: scoreboard-rules
|
||||
formatVersion: 1
|
||||
meta: { code: demo, name: 奖牌开关 }
|
||||
profiles:
|
||||
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
|
||||
channelTemplates:
|
||||
- match: { eventType: individual }
|
||||
channels:
|
||||
- { scope: athlete, basis: result, profile: p1, countToTeam: false, countToMedals: false, countToTeamMedals: false }
|
||||
eventOverrides:
|
||||
- eventCode: EVT_IND
|
||||
awardMedals: false
|
||||
channels:
|
||||
- { scope: athlete, basis: result, profile: p1 }
|
||||
- eventCode: EVT_TEAM
|
||||
channels:
|
||||
- { scope: team, basis: manual, profile: p1 }
|
||||
`
|
||||
|
||||
func TestParseMedalFlags(t *testing.T) {
|
||||
pack, err := Parse([]byte(medalFlagsPackYAML))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
|
||||
ch := pack.ChannelTemplates[0].Channels[0]
|
||||
if ch.CountToMedals == nil || *ch.CountToMedals {
|
||||
t.Fatalf("countToMedals: false 应落到结构体,实际 %+v", ch.CountToMedals)
|
||||
}
|
||||
if ch.CountsToMedals() {
|
||||
t.Error("countToMedals: false 时 CountsToMedals() 应为 false")
|
||||
}
|
||||
if ch.CountToTeamMedals == nil || *ch.CountToTeamMedals {
|
||||
t.Fatalf("countToTeamMedals: false 应落到结构体,实际 %+v", ch.CountToTeamMedals)
|
||||
}
|
||||
if ch.CountsToTeamMedals() {
|
||||
t.Error("countToTeamMedals: false 时 CountsToTeamMedals() 应为 false")
|
||||
}
|
||||
|
||||
ind := pack.EventOverrides[0]
|
||||
if ind.AwardMedals == nil || *ind.AwardMedals {
|
||||
t.Fatalf("awardMedals: false 应落到结构体,实际 %+v", ind.AwardMedals)
|
||||
}
|
||||
if pack.EventOverrides[1].AwardMedals != nil {
|
||||
t.Errorf("未写 awardMedals 时应保持 nil,实际 %v", *pack.EventOverrides[1].AwardMedals)
|
||||
}
|
||||
|
||||
// 缺省即 true:不写三个键的通道既要保持 nil(不凭空造显式值),又要按 true 生效。
|
||||
def := pack.EventOverrides[1].Channels
|
||||
if def == nil || len(*def) != 1 {
|
||||
t.Fatalf("EVT_TEAM 通道解析错误: %+v", def)
|
||||
}
|
||||
if (*def)[0].CountToMedals != nil || (*def)[0].CountToTeamMedals != nil {
|
||||
t.Errorf("未写奖牌开关时应保持 nil,实际 %+v", (*def)[0])
|
||||
}
|
||||
if !(*def)[0].CountsToMedals() || !(*def)[0].CountsToTeamMedals() {
|
||||
t.Error("未写奖牌开关时应按缺省 true 生效")
|
||||
}
|
||||
|
||||
// 序列化后再解析必须完全一致(三个键要能出现在导出文件里)。
|
||||
reparsed, err := Parse(mustMarshal(t, pack))
|
||||
if err != nil {
|
||||
t.Fatalf("重新 Parse: %v", err)
|
||||
}
|
||||
if !channelsEqual(pack.ChannelTemplates[0].Channels, reparsed.ChannelTemplates[0].Channels) {
|
||||
t.Errorf("模板通道往返不一致:\n%+v\n%+v", pack.ChannelTemplates[0].Channels, reparsed.ChannelTemplates[0].Channels)
|
||||
}
|
||||
again := reparsed.EventOverrides[0]
|
||||
if again.AwardMedals == nil || *again.AwardMedals {
|
||||
t.Errorf("awardMedals 往返不一致: %+v", again.AwardMedals)
|
||||
}
|
||||
if reparsed.EventOverrides[1].AwardMedals != nil {
|
||||
t.Errorf("未写的 awardMedals 往返后应保持 nil,实际 %v", *reparsed.EventOverrides[1].AwardMedals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelToDomainCarriesMedalFlags(t *testing.T) {
|
||||
pack, err := Parse([]byte(medalFlagsPackYAML))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
|
||||
ch := pack.ChannelTemplates[0].Channels[0].ToDomain(3, 7, 0)
|
||||
if ch.CountToMedals == nil || *ch.CountToMedals {
|
||||
t.Fatalf("ToDomain 应带上 countToMedals: false,实际 %+v", ch.CountToMedals)
|
||||
}
|
||||
if ch.CountToTeamMedals == nil || *ch.CountToTeamMedals {
|
||||
t.Fatalf("ToDomain 应带上 countToTeamMedals: false,实际 %+v", ch.CountToTeamMedals)
|
||||
}
|
||||
|
||||
// 缺省的通道不落指针,与 countToTeam 同口径。
|
||||
def := (*pack.EventOverrides[1].Channels)[0].ToDomain(3, 7, 0)
|
||||
if def.CountToMedals != nil || def.CountToTeamMedals != nil {
|
||||
t.Errorf("缺省通道不应写显式值: %+v", def)
|
||||
}
|
||||
}
|
||||
|
||||
// 库里 award_medals=0、别的都缺省的项目,导出时必须写成只带 awardMedals 的 override,
|
||||
// 否则「导出 → 导入」会把关闭的奖牌开关丢掉。
|
||||
func TestLoadExportsAwardMedalsOnlyOverride(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvents(t, db)
|
||||
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE code = 'EVT_IND'`); err != nil {
|
||||
t.Fatalf("预置奖牌开关: %v", err)
|
||||
}
|
||||
|
||||
snap, err := Load(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
var ov *EventOverride
|
||||
for i := range snap.Pack.EventOverrides {
|
||||
if snap.Pack.EventOverrides[i].EventCode == "EVT_IND" {
|
||||
ov = &snap.Pack.EventOverrides[i]
|
||||
}
|
||||
}
|
||||
if ov == nil {
|
||||
t.Fatalf("导出应包含 EVT_IND 的 override: %+v", snap.Pack.EventOverrides)
|
||||
}
|
||||
if ov.AwardMedals == nil || *ov.AwardMedals {
|
||||
t.Fatalf("EVT_IND 应导出 awardMedals: false,实际 %+v", ov.AwardMedals)
|
||||
}
|
||||
if ov.Scored != nil || ov.Multiplier != nil || ov.Channels != nil {
|
||||
t.Errorf("其余字段保持缺省,不该被一起写出来: %+v", ov)
|
||||
}
|
||||
|
||||
// 导出结果重新导入必须无差异。
|
||||
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
reparsed, err := Parse(mustMarshal(t, exported))
|
||||
if err != nil {
|
||||
t.Fatalf("重新 Parse: %v", err)
|
||||
}
|
||||
cur, err := Load(db)
|
||||
if err != nil {
|
||||
t.Fatalf("再次 Load: %v", err)
|
||||
}
|
||||
cs, err := Diff(cur, reparsed)
|
||||
if err != nil {
|
||||
t.Fatalf("Diff: %v", err)
|
||||
}
|
||||
if !cs.Empty() {
|
||||
t.Fatalf("导出再导入不应有差异: %+v", cs)
|
||||
}
|
||||
}
|
||||
|
||||
// 落库 → 直接查列 → Load 回读,断言与输入一致,并且导出再导入无差异。
|
||||
func TestApplyMedalFlagsThenLoadRoundTrip(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvents(t, db)
|
||||
|
||||
const packYAML = `format: scoreboard-rules
|
||||
formatVersion: 1
|
||||
meta: { code: demo, name: 奖牌开关 }
|
||||
standings:
|
||||
individualToTeamWeight: 1
|
||||
tieBreak: [points, gold]
|
||||
profiles:
|
||||
- code: individual_top6
|
||||
name: 个人项目前六名
|
||||
awardRanks: 1
|
||||
tieMode: skip
|
||||
rules:
|
||||
- { rank: 1, points: 10 }
|
||||
- code: team_top6
|
||||
name: 团体项目前六名
|
||||
awardRanks: 1
|
||||
tieMode: skip
|
||||
rules:
|
||||
- { rank: 1, points: 15 }
|
||||
eventOverrides:
|
||||
- eventCode: EVT_IND
|
||||
awardMedals: false
|
||||
channels:
|
||||
- { scope: athlete, basis: result, profile: individual_top6, countToMedals: false, countToTeamMedals: false }
|
||||
- eventCode: EVT_TEAM
|
||||
channels:
|
||||
- { scope: team, basis: manual, profile: team_top6, countToMedals: false }
|
||||
`
|
||||
next, err := Parse([]byte(packYAML))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
|
||||
// 小项级开关。
|
||||
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_IND'`); got != 0 {
|
||||
t.Errorf("EVT_IND award_medals = %d,期望 0", got)
|
||||
}
|
||||
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_TEAM'`); got != 1 {
|
||||
t.Errorf("未写 awardMedals 的项目应保持缺省 1,实际 %d", got)
|
||||
}
|
||||
|
||||
// 通道级开关:EVT_IND 的两个都关,EVT_TEAM 只关奖牌、团体奖牌并入照旧。
|
||||
wants := map[string][3]int{
|
||||
"EVT_IND": {1, 0, 0},
|
||||
"EVT_TEAM": {1, 0, 1},
|
||||
}
|
||||
rows, err := db.Query(`SELECT e.code, c.count_to_team, c.count_to_medals, c.count_to_team_medals
|
||||
FROM event_score_channels c JOIN events e ON e.id = c.event_id ORDER BY e.code, c.sort_order, c.id`)
|
||||
if err != nil {
|
||||
t.Fatalf("查询通道: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
seen := map[string]int{}
|
||||
for rows.Next() {
|
||||
var code string
|
||||
var countToTeam, countToMedals, countToTeamMedals int
|
||||
if err := rows.Scan(&code, &countToTeam, &countToMedals, &countToTeamMedals); err != nil {
|
||||
t.Fatalf("扫描通道: %v", err)
|
||||
}
|
||||
want, ok := wants[code]
|
||||
if !ok {
|
||||
t.Fatalf("出现预期外的通道: %s", code)
|
||||
}
|
||||
got := [3]int{countToTeam, countToMedals, countToTeamMedals}
|
||||
if got != want {
|
||||
t.Fatalf("%s 通道开关 = %v,期望 %v", code, got, want)
|
||||
}
|
||||
seen[code]++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历通道: %v", err)
|
||||
}
|
||||
if seen["EVT_IND"] != 1 || seen["EVT_TEAM"] != 1 {
|
||||
t.Fatalf("通道数异常: %v", seen)
|
||||
}
|
||||
|
||||
// Load 回读:三个字段都要还原成「显式 false」而不是缺省 true。
|
||||
snap, err := Load(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
ind, ok := snap.EventByCode("EVT_IND")
|
||||
if !ok {
|
||||
t.Fatal("Load 结果里缺少 EVT_IND")
|
||||
}
|
||||
if ind.AwardMedals {
|
||||
t.Error("Load 应读出 EVT_IND award_medals = 0")
|
||||
}
|
||||
if len(ind.Channels) != 1 {
|
||||
t.Fatalf("EVT_IND 通道数 = %d,期望 1", len(ind.Channels))
|
||||
}
|
||||
if ind.Channels[0].CountsToMedals() || ind.Channels[0].CountsToTeamMedals() {
|
||||
t.Errorf("EVT_IND 通道应按显式 false 读回: %+v", ind.Channels[0])
|
||||
}
|
||||
team, ok := snap.EventByCode("EVT_TEAM")
|
||||
if !ok {
|
||||
t.Fatal("Load 结果里缺少 EVT_TEAM")
|
||||
}
|
||||
if !team.AwardMedals {
|
||||
t.Error("Load 应读出 EVT_TEAM award_medals = 1")
|
||||
}
|
||||
if team.Channels[0].CountsToMedals() {
|
||||
t.Error("EVT_TEAM 通道 count_to_medals 应为 false")
|
||||
}
|
||||
if !team.Channels[0].CountsToTeamMedals() {
|
||||
t.Error("EVT_TEAM 未关闭 count_to_team_medals,应保持 true")
|
||||
}
|
||||
|
||||
// 导出再导入必须无差异:显式 false 与缺省 true 不能被读串。
|
||||
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
reparsed, err := Parse(mustMarshal(t, exported))
|
||||
if err != nil {
|
||||
t.Fatalf("重新 Parse: %v", err)
|
||||
}
|
||||
cur, err := Load(db)
|
||||
if err != nil {
|
||||
t.Fatalf("再次 Load: %v", err)
|
||||
}
|
||||
cs, err := Diff(cur, reparsed)
|
||||
if err != nil {
|
||||
t.Fatalf("Diff: %v", err)
|
||||
}
|
||||
if !cs.Empty() {
|
||||
t.Fatalf("导出再导入不应有差异: %+v", cs)
|
||||
}
|
||||
}
|
||||
|
||||
// 只写 awardMedals 的 EventOverride 也要生效:判空若只看 scored/multiplier,这条会被整条跳过。
|
||||
func TestApplyAwardMedalsOnlyOverride(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvents(t, db)
|
||||
|
||||
const packYAML = `format: scoreboard-rules
|
||||
formatVersion: 1
|
||||
meta: { code: demo, name: 只写奖牌开关 }
|
||||
profiles:
|
||||
- { code: individual_top6, name: 个人项目前六名, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 10 } ] }
|
||||
- { code: team_top6, name: 团体项目前六名, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 15 } ] }
|
||||
eventOverrides:
|
||||
- eventCode: EVT_IND
|
||||
awardMedals: false
|
||||
- eventCode: EVT_TEAM
|
||||
multiplier: 2
|
||||
`
|
||||
// 先把 EVT_TEAM 的奖牌开关也关掉,验证「override 不提该字段就不动它」。
|
||||
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE code = 'EVT_TEAM'`); err != nil {
|
||||
t.Fatalf("预置 EVT_TEAM 开关: %v", err)
|
||||
}
|
||||
|
||||
next, err := Parse([]byte(packYAML))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
res, err := Apply(db, &ChangeSet{Next: next})
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if res.EventsUpdated != 2 {
|
||||
t.Errorf("项目更新数 = %d,期望 2(只写 awardMedals 的也要落库)", res.EventsUpdated)
|
||||
}
|
||||
if res.ChannelsWritten != 0 {
|
||||
t.Errorf("没有模板/通道配置,不该重建通道,实际 %d", res.ChannelsWritten)
|
||||
}
|
||||
|
||||
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_IND'`); got != 0 {
|
||||
t.Errorf("EVT_IND award_medals = %d,期望 0", got)
|
||||
}
|
||||
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_TEAM'`); got != 0 {
|
||||
t.Errorf("override 未写 awardMedals 时不应改动该列,实际 %d", got)
|
||||
}
|
||||
if got := queryFloat(t, db, `SELECT multiplier FROM events WHERE code = 'EVT_TEAM'`); got != 2 {
|
||||
t.Errorf("EVT_TEAM 权重 = %v,期望 2", got)
|
||||
}
|
||||
// 种子里的通道既没被删也没被改。
|
||||
if got := countRows(t, db, "event_score_channels"); got != 1 {
|
||||
t.Errorf("通道数 = %d,期望 1(未被规则包提到)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 通道只差奖牌开关也必须报差异,否则前端预览看不到、用户以为没改动。
|
||||
func TestDiffDetectsMedalFlagChanges(t *testing.T) {
|
||||
base := Channel{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(Channel) Channel
|
||||
}{
|
||||
{"countToMedals", func(c Channel) Channel { c.CountToMedals = BoolPtr(false); return c }},
|
||||
{"countToTeamMedals", func(c Channel) Channel { c.CountToTeamMedals = BoolPtr(false); return c }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cur := currentSnapshot(t)
|
||||
pack := *cur.Pack
|
||||
pack.EventOverrides = []EventOverride{{
|
||||
EventCode: "EVT_IND",
|
||||
Channels: ChannelListPtr([]Channel{tc.mut(base)}),
|
||||
}}
|
||||
cs, err := Diff(cur, &pack)
|
||||
if err != nil {
|
||||
t.Fatalf("Diff: %v", err)
|
||||
}
|
||||
if len(cs.Channels) != 1 || cs.Channels[0].EventCode != "EVT_IND" {
|
||||
t.Fatalf("应报出 EVT_IND 的通道变化,实际 %+v", cs.Channels)
|
||||
}
|
||||
if cs.Channels[0].Kind != ChangeModified {
|
||||
t.Errorf("变化类型 = %q,期望 modified", cs.Channels[0].Kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("缺省与显式 true 不算变化", func(t *testing.T) {
|
||||
cur := currentSnapshot(t)
|
||||
pack := *cur.Pack
|
||||
pack.EventOverrides = []EventOverride{{
|
||||
EventCode: "EVT_IND",
|
||||
Channels: ChannelListPtr([]Channel{{
|
||||
Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6",
|
||||
CountToMedals: BoolPtr(true), CountToTeamMedals: BoolPtr(true),
|
||||
}}),
|
||||
}}
|
||||
cs, err := Diff(cur, &pack)
|
||||
if err != nil {
|
||||
t.Fatalf("Diff: %v", err)
|
||||
}
|
||||
if len(cs.Channels) != 0 {
|
||||
t.Fatalf("显式 true 与缺省同义,不应报差异: %+v", cs.Channels)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,15 +10,16 @@ import (
|
||||
|
||||
// EventInfo 是展开通道模板所需的最小项目信息。
|
||||
type EventInfo struct {
|
||||
ID int64
|
||||
Code string
|
||||
Name string
|
||||
SportCode string
|
||||
EventType domain.EventType
|
||||
ResultType domain.ResultType
|
||||
Scored bool
|
||||
Multiplier float64
|
||||
Channels []Channel
|
||||
ID int64
|
||||
Code string
|
||||
Name string
|
||||
SportCode string
|
||||
EventType domain.EventType
|
||||
ResultType domain.ResultType
|
||||
Scored bool
|
||||
Multiplier float64
|
||||
AwardMedals bool
|
||||
Channels []Channel
|
||||
}
|
||||
|
||||
// Snapshot 是赛事库当前的规则状态:规则包 + 项目目录。
|
||||
@@ -64,10 +65,10 @@ func Load(db *sql.DB) (*Snapshot, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 库里只有逐项目的通道、没有模板表,因此导出时把「有通道/不计分/权重非 1」的项目
|
||||
// 库里只有逐项目的通道、没有模板表,因此导出时把「有通道/不计分/不结算奖牌/权重非 1」的项目
|
||||
// 全部写成 eventOverrides,保证「导出 → 导入」能还原当前配置。
|
||||
for _, e := range events {
|
||||
needOverride := len(e.Channels) > 0 || !e.Scored || e.Multiplier != 1
|
||||
needOverride := len(e.Channels) > 0 || !e.Scored || !e.AwardMedals || e.Multiplier != 1
|
||||
if !needOverride {
|
||||
continue
|
||||
}
|
||||
@@ -78,6 +79,9 @@ func Load(db *sql.DB) (*Snapshot, error) {
|
||||
if e.Multiplier != 1 {
|
||||
ov.Multiplier = Float64Ptr(e.Multiplier)
|
||||
}
|
||||
if !e.AwardMedals {
|
||||
ov.AwardMedals = BoolPtr(false)
|
||||
}
|
||||
if len(e.Channels) > 0 {
|
||||
ov.Channels = ChannelListPtr(append([]Channel(nil), e.Channels...))
|
||||
}
|
||||
@@ -204,7 +208,7 @@ func settingByKey(db *sql.DB, key string) (string, error) {
|
||||
}
|
||||
|
||||
func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
|
||||
rows, err := db.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.scored, e.multiplier
|
||||
rows, err := db.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.scored, e.multiplier, e.award_medals
|
||||
FROM events e JOIN sports s ON s.id = e.sport_id
|
||||
ORDER BY e.sort_order, e.code`)
|
||||
if err != nil {
|
||||
@@ -216,34 +220,36 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
|
||||
index := map[int64]int{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
code, name string
|
||||
sportCode string
|
||||
eventType string
|
||||
resultType string
|
||||
scoredRaw int
|
||||
multiplier float64
|
||||
id int64
|
||||
code, name string
|
||||
sportCode string
|
||||
eventType string
|
||||
resultType string
|
||||
scoredRaw int
|
||||
multiplier float64
|
||||
awardMedalsRaw int
|
||||
)
|
||||
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier); err != nil {
|
||||
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier, &awardMedalsRaw); err != nil {
|
||||
return nil, fmt.Errorf("读取项目: %w", err)
|
||||
}
|
||||
index[id] = len(events)
|
||||
events = append(events, EventInfo{
|
||||
ID: id,
|
||||
Code: code,
|
||||
Name: name,
|
||||
SportCode: sportCode,
|
||||
EventType: domain.EventType(eventType),
|
||||
ResultType: domain.ResultType(resultType),
|
||||
Scored: scoredRaw != 0,
|
||||
Multiplier: multiplier,
|
||||
ID: id,
|
||||
Code: code,
|
||||
Name: name,
|
||||
SportCode: sportCode,
|
||||
EventType: domain.EventType(eventType),
|
||||
ResultType: domain.ResultType(resultType),
|
||||
Scored: scoredRaw != 0,
|
||||
Multiplier: multiplier,
|
||||
AwardMedals: awardMedalsRaw != 0,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("读取项目: %w", err)
|
||||
}
|
||||
|
||||
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team
|
||||
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team, c.count_to_medals, c.count_to_team_medals
|
||||
FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id
|
||||
ORDER BY c.event_id, c.sort_order, c.id`)
|
||||
if err != nil {
|
||||
@@ -253,14 +259,16 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
|
||||
|
||||
for chRows.Next() {
|
||||
var (
|
||||
eventID int64
|
||||
scope string
|
||||
basis string
|
||||
code string
|
||||
sortOrd int
|
||||
countToTeam int
|
||||
eventID int64
|
||||
scope string
|
||||
basis string
|
||||
code string
|
||||
sortOrd int
|
||||
countToTeam int
|
||||
countToMedals int
|
||||
countToTeamMedals int
|
||||
)
|
||||
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam); err != nil {
|
||||
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam, &countToMedals, &countToTeamMedals); err != nil {
|
||||
return nil, fmt.Errorf("读取计分通道: %w", err)
|
||||
}
|
||||
idx, ok := index[eventID]
|
||||
@@ -276,6 +284,12 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
|
||||
if countToTeam == 0 {
|
||||
ch.CountToTeam = BoolPtr(false)
|
||||
}
|
||||
if countToMedals == 0 {
|
||||
ch.CountToMedals = BoolPtr(false)
|
||||
}
|
||||
if countToTeamMedals == 0 {
|
||||
ch.CountToTeamMedals = BoolPtr(false)
|
||||
}
|
||||
events[idx].Channels = append(events[idx].Channels, ch)
|
||||
}
|
||||
if err := chRows.Err(); err != nil {
|
||||
|
||||
@@ -104,11 +104,23 @@ type Channel struct {
|
||||
// CountToTeam 决定该通道产出的流水是否计入单位总分;缺省 true。
|
||||
// 「个人+团体」项目的个人通道写 false:分只记在个人头上,不进单位总分。
|
||||
CountToTeam *bool `yaml:"countToTeam,omitempty" json:"countToTeam,omitempty"`
|
||||
// CountToMedals 决定该通道产出的名次是否参与奖牌结算;缺省 true。
|
||||
// 与项目级 events.award_medals 是「与」的关系(devdocs 17 §4.5)。
|
||||
CountToMedals *bool `yaml:"countToMedals,omitempty" json:"countToMedals,omitempty"`
|
||||
// CountToTeamMedals 决定该通道产出的个人奖牌是否并入单位奖牌榜;缺省 true。
|
||||
// 团体通道(scope=team)的奖牌本来就记在单位头上,不受该开关影响。
|
||||
CountToTeamMedals *bool `yaml:"countToTeamMedals,omitempty" json:"countToTeamMedals,omitempty"`
|
||||
}
|
||||
|
||||
// CountsToTeam 返回补齐缺省值后的开关(缺省 true)。
|
||||
func (c Channel) CountsToTeam() bool { return boolOrDefault(c.CountToTeam, true) }
|
||||
|
||||
// CountsToMedals 返回补齐缺省值后的奖牌结算开关(缺省 true)。
|
||||
func (c Channel) CountsToMedals() bool { return boolOrDefault(c.CountToMedals, true) }
|
||||
|
||||
// CountsToTeamMedals 返回补齐缺省值后的「个人奖牌并入单位榜」开关(缺省 true)。
|
||||
func (c Channel) CountsToTeamMedals() bool { return boolOrDefault(c.CountToTeamMedals, true) }
|
||||
|
||||
// ChannelMatch 是模板的适用条件;三个条件都为空时匹配全部(校验层会拒绝这种模板)。
|
||||
type ChannelMatch struct {
|
||||
EventType domain.EventType `yaml:"eventType,omitempty" json:"eventType,omitempty"`
|
||||
@@ -124,14 +136,15 @@ type ChannelTemplate struct {
|
||||
|
||||
// EventOverride 是单个项目的例外配置。
|
||||
//
|
||||
// Scored / Multiplier 为 nil 表示「不涉及」;Channels 用指针是为了区分
|
||||
// Scored / Multiplier / AwardMedals 为 nil 表示「不涉及」;Channels 用指针是为了区分
|
||||
// 「没写 channels」(不动该项目的通道)与「channels: []」(清空通道)。
|
||||
type EventOverride struct {
|
||||
EventCode string `yaml:"eventCode" json:"eventCode"`
|
||||
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||
Scored *bool `yaml:"scored,omitempty" json:"scored,omitempty"`
|
||||
Multiplier *float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
|
||||
Channels *[]Channel `yaml:"channels,omitempty" json:"channels,omitempty"`
|
||||
EventCode string `yaml:"eventCode" json:"eventCode"`
|
||||
Name string `yaml:"name,omitempty" json:"name,omitempty"`
|
||||
Scored *bool `yaml:"scored,omitempty" json:"scored,omitempty"`
|
||||
Multiplier *float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
|
||||
AwardMedals *bool `yaml:"awardMedals,omitempty" json:"awardMedals,omitempty"`
|
||||
Channels *[]Channel `yaml:"channels,omitempty" json:"channels,omitempty"`
|
||||
}
|
||||
|
||||
// RecordBonus 是破纪录加分配置;本期 enabled=false,结构保留。
|
||||
|
||||
@@ -116,6 +116,7 @@ func TestMigrateEvent(t *testing.T) {
|
||||
tables := []string{
|
||||
"schema_migrations", "settings",
|
||||
"teams", "athletes", "sports", "events", "event_categories", "event_score_channels",
|
||||
"channel_ranks", "channel_manual_ranks",
|
||||
"scoring_profiles", "scoring_profile_rules",
|
||||
"entries", "results", "records", "record_breaks",
|
||||
"score_ledger", "point_adjustments", "result_revisions", "change_log", "audit_logs",
|
||||
|
||||
+27
-16
@@ -124,6 +124,8 @@ func main() {
|
||||
authSvc := auth.New(st.DB(), auth.Options{Logger: logger})
|
||||
hub := realtime.NewHub(eventDB, logger)
|
||||
hub.SetAllowedOrigins(cfg.Server.AllowedOrigins)
|
||||
// 会话复查:/ws 的身份在握手时固化,会话被撤销后必须断开对应的长连接。
|
||||
hub.SetSessionChecker(authSvc)
|
||||
defer func() { _ = hub.Close() }()
|
||||
|
||||
// 可信代理:默认空,表示不采信任何代理头(见 devdocs/08 第 1 节)。
|
||||
@@ -148,23 +150,32 @@ func main() {
|
||||
|
||||
backupSvc := export.NewService(st.DB(), eventDB, filepath.Join(cfg.DataDir, "backups"), cfg.Backup.Keep, logger)
|
||||
|
||||
apiOpts := httpapi.Options{
|
||||
Logger: logger,
|
||||
Version: version,
|
||||
System: st.DB(),
|
||||
Event: eventDB,
|
||||
Auth: authSvc,
|
||||
DataDir: cfg.DataDir,
|
||||
Backup: backupSvc,
|
||||
// POST /api/shutdown 复用同一个关闭信号:取消 ctx 后走下面的 Shutdown + 关库。
|
||||
Shutdown: stop,
|
||||
ProjectDir: filepath.Join(cfg.DataDir, "event_projs", projectName),
|
||||
Display: displaySender{hub: hub},
|
||||
Realtime: hub,
|
||||
WS: hub.Handler(),
|
||||
}
|
||||
|
||||
// 老库升级自愈(迁移 0020 只建 channel_ranks、不回填历史名次)。
|
||||
// 必须在这台大屏开始拉快照之前同步跑完,否则奖牌榜会先亮出一帧全 0。
|
||||
// 失败不拦启动:现场正开着赛事,重算留到下一次发布 / 规则导入重试,服务本身要能起来。
|
||||
if _, err := httpapi.HealChannelRanksOnce(ctx, apiOpts); err != nil {
|
||||
logger.Error("通道名次自愈失败,奖牌榜可能仍为空;可重新发布成绩或导入规则包触发重算", "err", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Server.Addr(),
|
||||
Handler: httpapi.New(httpapi.Options{
|
||||
Logger: logger,
|
||||
Version: version,
|
||||
System: st.DB(),
|
||||
Event: eventDB,
|
||||
Auth: authSvc,
|
||||
DataDir: cfg.DataDir,
|
||||
Backup: backupSvc,
|
||||
// POST /api/shutdown 复用同一个关闭信号:取消 ctx 后走下面的 Shutdown + 关库。
|
||||
Shutdown: stop,
|
||||
ProjectDir: filepath.Join(cfg.DataDir, "event_projs", projectName),
|
||||
Display: displaySender{hub: hub},
|
||||
Realtime: hub,
|
||||
WS: hub.Handler(),
|
||||
}),
|
||||
Addr: cfg.Server.Addr(),
|
||||
Handler: httpapi.New(apiOpts),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
-- 通道名次与奖牌通道(devdocs/17 第三节的实现)。
|
||||
--
|
||||
-- 为什么要有这一版:
|
||||
-- 1) P0 根因是 results.rank 一列同时承担「个人名次」「手工录入位置」「成绩表读取来源」
|
||||
-- 三种角色。双通道项目(个人通道 + 团体手工通道)里,发布把个人名次写回 results.rank
|
||||
-- 之后,团体通道又把同一列当成团体名次再发一次团体分,同一份数据连发两次总分跳变。
|
||||
-- 本版把引擎产出的名次单独落一张 channel_ranks(按通道各自成行),并把 results.rank
|
||||
-- 的语义收窄为「个人名次」——团体名次改由 channel_manual_ranks 按单位录入。
|
||||
-- 2) 奖牌口径要能从「哪些通道算奖牌、个人奖牌算不算进单位榜」按通道控制,
|
||||
-- 于是 events 加 award_medals、event_score_channels 加 count_to_medals /
|
||||
-- count_to_team_medals,三列默认 1 = 与升级前行为完全一致(老库升级不改变既有榜单)。
|
||||
-- 3) channel_ranks 是发布/重算时按 channel_id 全量重建的派生数据(先删后插),
|
||||
-- 唯一索引 (channel_id, category_id, subject_key) 保证同一通道同一组别内一个主体一行。
|
||||
-- 4) channel_manual_ranks 故意不挂 channel_id 外键:规则包导入会删掉并重建通道(id 变化),
|
||||
-- 若挂外键会把录入员填好的团体名次一并级联删掉,改用 (小项, 组别, 单位, scope, basis) 定位。
|
||||
--
|
||||
-- 所有 DDL 都是「加列」或「新建表」,不动任何既有数据行,升级可重复执行(IF NOT EXISTS)。
|
||||
|
||||
-- 1) 小项级奖牌结算开关:默认 1(与升级前行为一致:scored=1 的小项前三名发牌)
|
||||
ALTER TABLE events ADD COLUMN award_medals INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (award_medals IN (0, 1));
|
||||
|
||||
-- 2) 通道级奖牌开关
|
||||
ALTER TABLE event_score_channels ADD COLUMN count_to_medals INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (count_to_medals IN (0, 1));
|
||||
ALTER TABLE event_score_channels ADD COLUMN count_to_team_medals INTEGER NOT NULL DEFAULT 1
|
||||
CHECK (count_to_team_medals IN (0, 1));
|
||||
|
||||
-- 3) 通道名次(引擎产出,发布时按 channel_id 全量重建)
|
||||
CREATE TABLE IF NOT EXISTS channel_ranks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
category_id INTEGER NOT NULL REFERENCES event_categories(id) ON DELETE CASCADE,
|
||||
channel_id INTEGER NOT NULL REFERENCES event_score_channels(id) ON DELETE CASCADE,
|
||||
basis TEXT NOT NULL,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('athlete', 'team')),
|
||||
subject_key TEXT NOT NULL, -- 'e:<entryId>' / 'a:<athleteId>' / 't:<teamId>'
|
||||
entry_id INTEGER REFERENCES entries(id) ON DELETE SET NULL,
|
||||
result_id INTEGER REFERENCES results(id) ON DELETE SET NULL,
|
||||
athlete_id INTEGER REFERENCES athletes(id) ON DELETE SET NULL,
|
||||
team_id INTEGER REFERENCES teams(id) ON DELETE SET NULL,
|
||||
rank INTEGER NOT NULL CHECK (rank >= 1),
|
||||
points REAL NOT NULL DEFAULT 0, -- 未乘个人→单位系数,与 RankedResult.Points 同口径
|
||||
result_value REAL,
|
||||
status TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_ranks_unique
|
||||
ON channel_ranks(channel_id, category_id, subject_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_ranks_event ON channel_ranks(event_id, category_id);
|
||||
|
||||
-- 4) 按单位录入的通道手工名次(dual 项目的团体手工通道用)
|
||||
-- 故意不建到 channel_id 的外键:规则包导入会删掉并重建通道(id 变化),
|
||||
-- 若挂外键会把录入员填好的团体名次一并级联删掉。用 (小项, 组别, 单位, scope, basis) 定位。
|
||||
CREATE TABLE IF NOT EXISTS channel_manual_ranks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
category_id INTEGER NOT NULL REFERENCES event_categories(id) ON DELETE CASCADE,
|
||||
team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('athlete', 'team')),
|
||||
basis TEXT NOT NULL,
|
||||
rank INTEGER NOT NULL CHECK (rank >= 1),
|
||||
updated_by INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (event_id, category_id, team_id, scope, basis)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_manual_ranks_event
|
||||
ON channel_manual_ranks(event_id, category_id);
|
||||
@@ -616,3 +616,266 @@ func TestMigration0019AddsHeatForExistingEntries(t *testing.T) {
|
||||
t.Fatalf("清 heat: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// tableColumns 返回 PRAGMA table_info 的列名集合,用于比对迁移新建表的结构。
|
||||
func tableColumns(t *testing.T, db *sql.DB, table string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||
if err != nil {
|
||||
t.Fatalf("读表结构 %s: %v", table, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
cols := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid int
|
||||
name string
|
||||
ctype string
|
||||
notNull int
|
||||
dflt sql.NullString
|
||||
pk int
|
||||
)
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬Null, &dflt, &pk); err != nil {
|
||||
t.Fatalf("扫描表结构 %s: %v", table, err)
|
||||
}
|
||||
cols[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历表结构 %s: %v", table, err)
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// extraColumns 返回实际列里多出来的部分,仅用于列数不符时的报错信息。
|
||||
func extraColumns(got map[string]bool, want []string) []string {
|
||||
known := make(map[string]bool, len(want))
|
||||
for _, c := range want {
|
||||
known[c] = true
|
||||
}
|
||||
var extra []string
|
||||
for c := range got {
|
||||
if !known[c] {
|
||||
extra = append(extra, c)
|
||||
}
|
||||
}
|
||||
sort.Strings(extra)
|
||||
return extra
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 0020 迁移:events 加 award_medals,event_score_channels 加 count_to_medals /
|
||||
// count_to_team_medals(三列默认 1,与升级前行为一致),新建 channel_ranks 与
|
||||
// channel_manual_ranks。本用例守住「老数据不丢 + 默认值正确 + 新表结构 / 唯一约束 /
|
||||
// CHECK 生效 + 迁移幂等」这几条底线。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 老库升级:applyBefore 构造 0020 之前的老库,塞入老数据后走真实升级路径补跑 0020。
|
||||
func TestMigration0020ChannelRanksAndMedals(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
applyBefore(t, db, "0020")
|
||||
|
||||
stmts := []string{
|
||||
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
|
||||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||||
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
|
||||
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
|
||||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id) VALUES (1, 'athlete', 'result', 1)`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
|
||||
VALUES (1, 1, 1, '11.58', 11.58, 'published')`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 升级前不该有 0020 的列与表。
|
||||
var probe sql.NullInt64
|
||||
if err := db.QueryRow(`SELECT award_medals FROM events`).Scan(&probe); err == nil {
|
||||
t.Fatal("升级前不该有 events.award_medals 列")
|
||||
}
|
||||
for _, table := range []string{"channel_ranks", "channel_manual_ranks"} {
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&n); err != nil {
|
||||
t.Fatalf("查表 %s: %v", table, err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("升级前不该有 %s 表", table)
|
||||
}
|
||||
}
|
||||
|
||||
// 走真实升级路径;ADD COLUMN 只登记一次,重放会报 duplicate column。
|
||||
for i := 1; i <= 2; i++ {
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("第 %d 次迁移: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want {
|
||||
t.Errorf("迁移登记数 = %d,期望 %d", got, want)
|
||||
}
|
||||
|
||||
// 老数据一行不少,关键字段原样保留。
|
||||
for table, want := range map[string]int{"events": 1, "event_score_channels": 1, "results": 1} {
|
||||
if got := countRows(t, db, table); got != want {
|
||||
t.Errorf("%s 升级后行数 = %d,期望 %d", table, got, want)
|
||||
}
|
||||
}
|
||||
var (
|
||||
code string
|
||||
status string
|
||||
rank int
|
||||
state string
|
||||
channel int
|
||||
)
|
||||
if err := db.QueryRow(`SELECT code, status FROM events WHERE id = 1`).Scan(&code, &status); err != nil {
|
||||
t.Fatalf("读 events: %v", err)
|
||||
}
|
||||
if code != "E1" || status != "published" {
|
||||
t.Errorf("老小项被改动: code=%q status=%q", code, status)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT rank, state FROM results WHERE entry_id = 1`).Scan(&rank, &state); err != nil {
|
||||
t.Fatalf("读成绩: %v", err)
|
||||
}
|
||||
if rank != 1 || state != "published" {
|
||||
t.Errorf("老成绩被改动: rank=%d state=%q", rank, state)
|
||||
}
|
||||
var scope, basis string
|
||||
if err := db.QueryRow(`SELECT id, scope, basis FROM event_score_channels WHERE event_id = 1`).
|
||||
Scan(&channel, &scope, &basis); err != nil {
|
||||
t.Fatalf("读通道: %v", err)
|
||||
}
|
||||
if scope != "athlete" || basis != "result" {
|
||||
t.Errorf("老通道被改动: scope=%q basis=%q", scope, basis)
|
||||
}
|
||||
|
||||
// 三个新列在老行上取默认值 1(与升级前「算奖牌、计入总分与单位奖牌」一致)。
|
||||
var awardMedals int
|
||||
if err := db.QueryRow(`SELECT award_medals FROM events WHERE id = 1`).Scan(&awardMedals); err != nil {
|
||||
t.Fatalf("读 events.award_medals: %v", err)
|
||||
}
|
||||
if awardMedals != 1 {
|
||||
t.Errorf("老小项 award_medals = %d,期望 1", awardMedals)
|
||||
}
|
||||
var countToMedals, countToTeamMedals int
|
||||
if err := db.QueryRow(`SELECT count_to_medals, count_to_team_medals FROM event_score_channels WHERE event_id = 1`).
|
||||
Scan(&countToMedals, &countToTeamMedals); err != nil {
|
||||
t.Fatalf("读 event_score_channels 奖牌开关: %v", err)
|
||||
}
|
||||
if countToMedals != 1 || countToTeamMedals != 1 {
|
||||
t.Errorf("老通道奖牌开关 = (%d, %d),期望 (1, 1)", countToMedals, countToTeamMedals)
|
||||
}
|
||||
|
||||
// 新列可显式置 0;CHECK 拒绝 0/1 之外的取值。
|
||||
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE id = 1`); err != nil {
|
||||
t.Fatalf("写 award_medals=0: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 0, count_to_team_medals = 0 WHERE event_id = 1`); err != nil {
|
||||
t.Fatalf("写通道奖牌开关 0: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE events SET award_medals = 2 WHERE id = 1`); err == nil {
|
||||
t.Error("award_medals=2 应被 CHECK 拒绝")
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, award_medals)
|
||||
VALUES (1, 'E2', '非法开关', 'individual', 2)`); err == nil {
|
||||
t.Error("插入 award_medals=2 应被 CHECK 拒绝")
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 2 WHERE event_id = 1`); err == nil {
|
||||
t.Error("count_to_medals=2 应被 CHECK 拒绝")
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team_medals = 2 WHERE event_id = 1`); err == nil {
|
||||
t.Error("count_to_team_medals=2 应被 CHECK 拒绝")
|
||||
}
|
||||
|
||||
// 两张新表存在,列集合与迁移文件一致(少列、多列都会在这里暴露)。
|
||||
for _, tc := range []struct {
|
||||
table string
|
||||
want []string
|
||||
}{
|
||||
{"channel_ranks", []string{
|
||||
"id", "event_id", "category_id", "channel_id", "basis", "scope", "subject_key",
|
||||
"entry_id", "result_id", "athlete_id", "team_id", "rank", "points",
|
||||
"result_value", "status", "updated_at",
|
||||
}},
|
||||
{"channel_manual_ranks", []string{
|
||||
"id", "event_id", "category_id", "team_id", "scope", "basis", "rank",
|
||||
"updated_by", "updated_at",
|
||||
}},
|
||||
} {
|
||||
got := tableColumns(t, db, tc.table)
|
||||
if len(got) == 0 {
|
||||
t.Errorf("%s 表不存在", tc.table)
|
||||
continue
|
||||
}
|
||||
for _, col := range tc.want {
|
||||
if !got[col] {
|
||||
t.Errorf("%s 缺列 %s", tc.table, col)
|
||||
}
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Errorf("%s 列数 = %d,期望 %d(多余列: %v)", tc.table, len(got), len(tc.want), extraColumns(got, tc.want))
|
||||
}
|
||||
}
|
||||
|
||||
// channel_ranks 的唯一索引:同一通道 / 组别 / 主体只能一行。
|
||||
var indexCount int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'index' AND name = 'idx_channel_ranks_unique'`).Scan(&indexCount); err != nil {
|
||||
t.Fatalf("查唯一索引: %v", err)
|
||||
}
|
||||
if indexCount != 1 {
|
||||
t.Errorf("channel_ranks 唯一索引 idx_channel_ranks_unique 缺失")
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, entry_id, result_id, rank, points)
|
||||
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 1, 10)`); err != nil {
|
||||
t.Fatalf("写入 channel_ranks: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, entry_id, result_id, rank, points)
|
||||
VALUES (1, 1, 1, 'result', 'athlete', 'e:1', 1, 1, 2, 8)`); err == nil {
|
||||
t.Error("channel_ranks 重复 (channel_id, category_id, subject_key) 应被唯一索引拒绝")
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, entry_id, result_id, rank, points)
|
||||
VALUES (1, 1, 1, 'result', 'athlete', 'e:2', 1, 1, 2, 8)`); err != nil {
|
||||
t.Errorf("channel_ranks 不同 subject_key 应可写入: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank)
|
||||
VALUES (1, 1, 1, 'result', 'bogus', 'x:1', 1)`); err == nil {
|
||||
t.Error("channel_ranks.scope='bogus' 应被 CHECK 拒绝")
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank)
|
||||
VALUES (1, 1, 1, 'result', 'athlete', 'x:1', 0)`); err == nil {
|
||||
t.Error("channel_ranks.rank=0 应被 CHECK 拒绝")
|
||||
}
|
||||
|
||||
// channel_manual_ranks 的 UNIQUE(event_id, category_id, team_id, scope, basis)。
|
||||
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
|
||||
VALUES (1, 1, 1, 'team', 'manual', 1)`); err != nil {
|
||||
t.Fatalf("写入 channel_manual_ranks: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
|
||||
VALUES (1, 1, 1, 'team', 'manual', 2)`); err == nil {
|
||||
t.Error("channel_manual_ranks 重复 (event_id, category_id, team_id, scope, basis) 应被唯一约束拒绝")
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
|
||||
VALUES (1, 1, 1, 'team', 'manual_alt', 2)`); err != nil {
|
||||
t.Errorf("channel_manual_ranks 不同 basis 应可写入: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO channel_manual_ranks(event_id, category_id, team_id, scope, basis, rank)
|
||||
VALUES (1, 1, 1, 'team', 'manual0', 0)`); err == nil {
|
||||
t.Error("channel_manual_ranks.rank=0 应被 CHECK 拒绝")
|
||||
}
|
||||
|
||||
// 迁移可重复执行:再跑一次 MigrateEvent 不报错、新表与老表行数不变。
|
||||
countsBefore := []int{countRows(t, db, "channel_ranks"), countRows(t, db, "channel_manual_ranks"), countRows(t, db, "events")}
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("重复迁移: %v", err)
|
||||
}
|
||||
countsAfter := []int{countRows(t, db, "channel_ranks"), countRows(t, db, "channel_manual_ranks"), countRows(t, db, "events")}
|
||||
for i, name := range []string{"channel_ranks", "channel_manual_ranks", "events"} {
|
||||
if countsBefore[i] != countsAfter[i] {
|
||||
t.Errorf("重复迁移 %s 行数 %d → %d", name, countsBefore[i], countsAfter[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { ApiError, createApiClient } from '@/shared/api'
|
||||
import type { AthletePurgeResult, DisplayInstance } from '@/shared/api'
|
||||
import type { AthletePurgeResult, DisplayInstance, EntryBoard } from '@/shared/api'
|
||||
import type {
|
||||
AthleteStanding,
|
||||
AuditLog,
|
||||
@@ -25,6 +25,8 @@ import type {
|
||||
StandingRow,
|
||||
Team,
|
||||
TeamBreakdown,
|
||||
TeamRanksInput,
|
||||
TeamRanksView,
|
||||
} from '@/shared'
|
||||
import { mockDb } from './mock'
|
||||
|
||||
@@ -43,6 +45,45 @@ const FALLBACK_CODES = ['NETWORK', 'TIMEOUT']
|
||||
export const mockMode = ref(false)
|
||||
export const mockReason = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 页面级降级标记也参与「示例数据」判定。
|
||||
*
|
||||
* 只看全局 mockMode 不够:服务端整体正常、只有某个页面的接口尚未实现时,
|
||||
* bootstrap 会把 mockMode 复位成 false,那个页面却仍在用示例数据。
|
||||
*/
|
||||
const fallbackSources = new Set<() => boolean>()
|
||||
|
||||
export function registerFallbackSource(source: () => boolean): void {
|
||||
fallbackSources.add(source)
|
||||
}
|
||||
|
||||
/** 统一的「当前是否处于示例数据模式」判定;破坏性写操作一律先问它。 */
|
||||
export function isSampleData(): boolean {
|
||||
if (mockMode.value) return true
|
||||
for (const source of fallbackSources) {
|
||||
if (source()) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 示例数据模式下写操作被拦下时的错误码。 */
|
||||
export const SAMPLE_DATA_BLOCKED = 'SAMPLE_DATA_BLOCKED'
|
||||
|
||||
/**
|
||||
* 示例数据模式下破坏性操作的统一拦截错误。
|
||||
*
|
||||
* 用它把「没有提交」与「真的失败了」分开:页面据此给出「未连接服务端」的说明,
|
||||
* 而不是把内存里的改动当成落库成功。
|
||||
*/
|
||||
export function sampleDataBlockedError(action: string): ApiError {
|
||||
return new ApiError(SAMPLE_DATA_BLOCKED, `当前是示例数据,未连接服务端,无法${action}`, 0)
|
||||
}
|
||||
|
||||
/** 与上一条同源的提示文案(标题 / 说明),供页面 toast 直接使用。 */
|
||||
export function sampleDataBlockedNotice(action: string): { title: string; detail: string } {
|
||||
return { title: `示例数据模式不能${action}`, detail: '未连接服务端,操作不会提交' }
|
||||
}
|
||||
|
||||
let unauthorizedHandler: (() => void) | null = null
|
||||
|
||||
export function setUnauthorizedHandler(handler: () => void): void {
|
||||
@@ -142,6 +183,26 @@ export const adminApi = {
|
||||
entries: (eventId: number): Promise<EntryWithResult[]> =>
|
||||
call(() => client.events.entries(eventId), () => mockDb.listEntries(eventId)),
|
||||
|
||||
/**
|
||||
* 录入页的完整读数:成绩行 + 随行的团体手工名次。
|
||||
*
|
||||
* 与 entries 是同一个接口,只是这里把 teamRanks 也留下;拿不到时页面再退到
|
||||
* `GET .../team-ranks`,不会因为老服务端不多发这个字段就丢掉已有名次。
|
||||
*/
|
||||
entryBoard: (eventId: number): Promise<EntryBoard> =>
|
||||
call(() => client.events.entryBoard(eventId), () => mockDb.entryBoard(eventId)),
|
||||
|
||||
/** 读某个组别的团体手工名次(设计 17 §七)。 */
|
||||
teamRanks: (eventId: number, categoryId: number): Promise<TeamRanksView> =>
|
||||
call(() => client.events.teamRanks(eventId, categoryId), () => mockDb.teamRanks(eventId, categoryId)),
|
||||
|
||||
/**
|
||||
* 写团体手工名次:写操作不降级。示例数据模式下没有后端可落库,
|
||||
* 页面会先看 data.isMock 再决定是否调用(与成绩草稿、发布同一口径)。
|
||||
*/
|
||||
saveTeamRanks: (eventId: number, categoryId: number, input: TeamRanksInput): Promise<TeamRanksView> =>
|
||||
callWrite(() => client.events.saveTeamRanks(eventId, categoryId, input)),
|
||||
|
||||
/**
|
||||
* 批量设置 / 清除批次(heat)。写操作不降级:示例数据模式下没有后端可落库,
|
||||
* 页面会先看 data.isMock 再决定是否调用。
|
||||
|
||||
@@ -26,6 +26,9 @@ import type {
|
||||
Team,
|
||||
TeamBreakdown,
|
||||
BreakdownItem,
|
||||
TeamRankEntry,
|
||||
TeamRanksInput,
|
||||
TeamRanksView,
|
||||
} from '@/shared'
|
||||
import { computeEventScore, type ScorableRow } from './scoring'
|
||||
|
||||
@@ -110,6 +113,8 @@ interface EventSeed {
|
||||
resultType: ResultType
|
||||
compare: Event['compare']
|
||||
scored: boolean
|
||||
/** 是否结算奖牌;缺省 true(与库里的 award_medals 默认值同口径)。 */
|
||||
awardMedals?: boolean
|
||||
categoryName: string
|
||||
/** 「个人+团体」的团体成绩平均口径;缺省按 valid。 */
|
||||
avgMode?: AvgMode
|
||||
@@ -221,6 +226,7 @@ const EVENT_SEEDS: EventSeed[] = [
|
||||
resultType: 'none',
|
||||
compare: 'none',
|
||||
scored: false,
|
||||
awardMedals: false,
|
||||
categoryName: '混合组',
|
||||
},
|
||||
{
|
||||
@@ -295,6 +301,8 @@ class MockDb {
|
||||
private readonly categories: EventCategory[]
|
||||
private readonly channels: ScoreChannel[]
|
||||
private readonly entries: Entry[] = []
|
||||
/** 团体手工名次,键为 `eventId:categoryId`(与库表 (小项, 组别, scope, basis) 的定位口径一致)。 */
|
||||
private readonly teamManualRanks = new Map<string, TeamRankEntry[]>()
|
||||
private readonly results = new Map<number, Result>()
|
||||
private readonly logs: AuditLog[] = []
|
||||
private nextResultId = 9000
|
||||
@@ -312,6 +320,7 @@ class MockDb {
|
||||
compare: seed.compare,
|
||||
recordEligible: seed.resultType === 'time' || seed.resultType === 'distance',
|
||||
scored: seed.scored,
|
||||
awardMedals: seed.awardMedals ?? true,
|
||||
multiplier: 1,
|
||||
status: seed.published === undefined ? 'pending' : 'published',
|
||||
scheduledAt: null,
|
||||
@@ -332,12 +341,16 @@ class MockDb {
|
||||
sortOrder: 1,
|
||||
}))
|
||||
|
||||
// 「个人+团体」按规则包默认模板配两条通道:个人通道只记个人分,团体通道取队内平均。
|
||||
// 「个人+团体」按规则包默认模板配通道:个人通道只记个人分,团体通道取队内平均,
|
||||
// 另加一条按单位手工录名次的团体通道(团体引体向上就是这么记分的),
|
||||
// 录入页的「团体名次」区块在示例数据里也就能走通。
|
||||
this.channels = EVENT_SEEDS.flatMap((seed): ScoreChannel[] => {
|
||||
if (seed.eventType === 'individual_team') {
|
||||
return [
|
||||
{ id: seed.id * 10 + 1, eventId: seed.id, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: false },
|
||||
{ id: seed.id * 10 + 2, eventId: seed.id, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 2, countToTeam: true },
|
||||
{ id: seed.id * 10 + 1, eventId: seed.id, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: false, countToMedals: true, countToTeamMedals: true },
|
||||
// 队内平均通道只出积分、不发牌,正好演示「已关闭奖牌」的徽标。
|
||||
{ id: seed.id * 10 + 2, eventId: seed.id, scope: 'team', basis: 'aggregate', profileId: 2, sortOrder: 2, countToTeam: true, countToMedals: false, countToTeamMedals: true },
|
||||
{ id: seed.id * 10 + 3, eventId: seed.id, scope: 'team', basis: 'manual', profileId: 2, sortOrder: 3, countToTeam: true, countToMedals: true, countToTeamMedals: true },
|
||||
]
|
||||
}
|
||||
return [{
|
||||
@@ -348,9 +361,17 @@ class MockDb {
|
||||
profileId: seed.eventType === 'individual' ? 1 : 2,
|
||||
sortOrder: 1,
|
||||
countToTeam: true,
|
||||
countToMedals: true,
|
||||
countToTeamMedals: true,
|
||||
}]
|
||||
})
|
||||
|
||||
// 团体手工名次:TPULL(108)预先填两个单位,用来演示「已有值回填」。
|
||||
this.teamManualRanks.set(teamRanksKey(108, 108 * 10 + 1), [
|
||||
{ teamId: this.teams[0]?.id ?? 1, rank: 1 },
|
||||
{ teamId: this.teams[1]?.id ?? 2, rank: 2 },
|
||||
])
|
||||
|
||||
for (const seed of EVENT_SEEDS) this.seedEntries(seed)
|
||||
for (const seed of EVENT_SEEDS) this.seedResults(seed)
|
||||
}
|
||||
@@ -549,6 +570,60 @@ class MockDb {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩录入接口的完整读数:成绩行 + 该小项的团体手工名次。
|
||||
*
|
||||
* 真服务端把 teamRanks 随行下发是为了省一轮请求;示例数据同样给,
|
||||
* 录入页的「团体名次」区块在示例数据模式下才走得到与真服务端一致的分支。
|
||||
*/
|
||||
entryBoard(eventId: number): { entries: EntryWithResult[]; teamRanks: TeamRanksView[] } {
|
||||
return { entries: this.listEntries(eventId), teamRanks: this.teamRanksViewsOf(eventId) }
|
||||
}
|
||||
|
||||
/** 该小项所有配了团体手工通道的组别视图。 */
|
||||
private teamRanksViewsOf(eventId: number): TeamRanksView[] {
|
||||
return this.categories
|
||||
.filter(
|
||||
(category) =>
|
||||
category.eventId === eventId &&
|
||||
this.channels.some(
|
||||
(channel) =>
|
||||
channel.eventId === eventId && channel.scope === 'team' && channel.basis === 'manual',
|
||||
),
|
||||
)
|
||||
.map((category) => this.teamRanksView(eventId, category.id))
|
||||
}
|
||||
|
||||
teamRanks(eventId: number, categoryId: number): TeamRanksView {
|
||||
return this.teamRanksView(eventId, categoryId)
|
||||
}
|
||||
|
||||
private teamRanksView(eventId: number, categoryId: number): TeamRanksView {
|
||||
return {
|
||||
categoryId,
|
||||
channels: this.channels
|
||||
.filter((channel) => channel.eventId === eventId && channel.scope === 'team' && channel.basis === 'manual')
|
||||
.map((channel) => ({
|
||||
channelId: channel.id,
|
||||
scope: channel.scope,
|
||||
basis: channel.basis,
|
||||
profileId: channel.profileId,
|
||||
})),
|
||||
teams: this.teams
|
||||
.filter((team) => team.active)
|
||||
.map((team) => ({ teamId: team.id, code: team.code, name: team.name, shortName: team.shortName })),
|
||||
ranks: (this.teamManualRanks.get(teamRanksKey(eventId, categoryId)) ?? []).map((entry) => ({ ...entry })),
|
||||
}
|
||||
}
|
||||
|
||||
/** 整组替换该组别的团体手工名次;rank <= 0 表示清空(与真服务端一致)。 */
|
||||
saveTeamRanks(eventId: number, categoryId: number, input: TeamRanksInput): TeamRanksView {
|
||||
const ranks = input.ranks.filter((entry) => entry.rank > 0).map((entry) => ({ ...entry }))
|
||||
this.teamManualRanks.set(teamRanksKey(eventId, categoryId), ranks)
|
||||
this.log('channel.team_ranks', 'event', eventId, `保存团体名次 ${ranks.length} 条`)
|
||||
return this.teamRanksView(eventId, categoryId)
|
||||
}
|
||||
|
||||
saveDraft(payload: SaveDraftPayload): Result[] {
|
||||
const saved = this.writeResults(payload.eventId, payload.categoryId, payload.results, 'draft')
|
||||
this.log('result.draft', 'result', payload.eventId, `保存草稿 ${saved.length} 条`)
|
||||
@@ -765,4 +840,9 @@ function round(value: number): number {
|
||||
return Number(value.toFixed(2))
|
||||
}
|
||||
|
||||
/** 团体手工名次的定位键:(小项, 组别) —— 与 channel_manual_ranks 的唯一键同口径。 */
|
||||
function teamRanksKey(eventId: number, categoryId: number): string {
|
||||
return `${eventId}:${categoryId}`
|
||||
}
|
||||
|
||||
export const mockDb = new MockDb()
|
||||
@@ -144,6 +144,8 @@ function reportBatch(verb: string, result: AthleteBatchResult, labels: Map<numbe
|
||||
|
||||
function askBatchDisable(): void {
|
||||
if (selectedCount.value === 0) return
|
||||
// 示例数据模式下停用只改内存、不会落库,提示「已停用」等于谎报;先拦下。
|
||||
if (data.blockDestructiveWrite('批量停用')) return
|
||||
batchTargetIds.value = [...selectedIds.value]
|
||||
batchMessage.value = null
|
||||
batchDisableOpen.value = true
|
||||
@@ -169,6 +171,8 @@ async function confirmBatchDisable(): Promise<void> {
|
||||
/** 先预览再确认:彻底删除不可逆,必须让操作员看到会删掉谁、重算哪些小项。 */
|
||||
async function askBatchPurge(): Promise<void> {
|
||||
if (selectedCount.value === 0) return
|
||||
// 示例数据里「删掉」只是内存变化,连预览都不该发;直接给出未连接服务端的说明。
|
||||
if (data.blockDestructiveWrite('批量彻底删除')) return
|
||||
batchTargetIds.value = [...selectedIds.value]
|
||||
batchMessage.value = null
|
||||
bulkPurgePreview.value = null
|
||||
@@ -383,6 +387,8 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
}
|
||||
|
||||
function askPurge(athlete: Athlete): void {
|
||||
// 单条彻底删除与批量同一口径:示例数据模式下不弹确认框、不发请求。
|
||||
if (data.blockDestructiveWrite('彻底删除')) return
|
||||
purgeTarget.value = athlete
|
||||
purgeError.value = null
|
||||
purgeOpen.value = true
|
||||
@@ -620,10 +626,10 @@ async function confirmPurge(): Promise<void> {
|
||||
<FormField label="工号 / 学号" hint="可留空,用于对账">
|
||||
<input v-model="form.employeeNo" class="mx-cell-input" placeholder="例如 F1-001" />
|
||||
</FormField>
|
||||
<FormField label="证件号" hint="可选;不参与认人,但不能与别人重号">
|
||||
<FormField label="证件号" hint="人员的唯一标识;有就填,且不能与别人重号">
|
||||
<input v-model="form.idCard" class="mx-cell-input" placeholder="例如 110101199001011234" />
|
||||
</FormField>
|
||||
<FormField label="号码布" hint="认人依据;建议每人一条且全库唯一">
|
||||
<FormField label="号码布" hint="证件号为空时按它认人;建议每人一条">
|
||||
<input v-model="form.bib" class="mx-cell-input" placeholder="例如 101" />
|
||||
</FormField>
|
||||
<FormField label="状态" wide>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeCountToTeam } from '@/shared'
|
||||
import { normalizeCountToMedals, normalizeCountToTeam, normalizeCountToTeamMedals } from '@/shared'
|
||||
import type { ChannelBasis, ChannelScope, ScoreChannel, ScoreChannelInput } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
@@ -30,6 +30,10 @@ interface ChannelRow {
|
||||
profileId: string
|
||||
sortOrder: string
|
||||
countToTeam: boolean
|
||||
/** 该通道名次是否发奖牌(缺省发)。 */
|
||||
countToMedals: boolean
|
||||
/** 该通道的个人奖牌是否并入单位金牌榜(缺省并入)。scope=team 的行不显示,但值照样回传。 */
|
||||
countToTeamMedals: boolean
|
||||
}
|
||||
|
||||
interface RowError {
|
||||
@@ -81,6 +85,8 @@ function toRow(channel: ScoreChannel): ChannelRow {
|
||||
profileId: String(channel.profileId),
|
||||
sortOrder: String(channel.sortOrder),
|
||||
countToTeam: normalizeCountToTeam(channel.countToTeam),
|
||||
countToMedals: normalizeCountToMedals(channel.countToMedals),
|
||||
countToTeamMedals: normalizeCountToTeamMedals(channel.countToTeamMedals),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +100,9 @@ function addRow(): void {
|
||||
profileId: data.profiles.value[0] === undefined ? '' : String(data.profiles.value[0].id),
|
||||
sortOrder: String(rows.value.length + 1),
|
||||
countToTeam: true,
|
||||
// 新通道默认发奖牌并并入单位榜,与库表的两个默认值一致。
|
||||
countToMedals: true,
|
||||
countToTeamMedals: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -136,6 +145,10 @@ function buildInputs(): ScoreChannelInput[] {
|
||||
profileId: Number(row.profileId),
|
||||
sortOrder: Number(row.sortOrder) || index + 1,
|
||||
countToTeam: row.countToTeam,
|
||||
// 两个奖牌开关必须一起回传:PUT 是整体替换,漏掉就等于把它们重置成缺省(发牌)。
|
||||
// scope=team 的行不显示 countToTeamMedals,但值原样带上,字段不会因为切换 scope 被清掉。
|
||||
countToMedals: row.countToMedals,
|
||||
countToTeamMedals: row.countToTeamMedals,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -212,6 +225,9 @@ async function save(): Promise<void> {
|
||||
|
||||
<div class="bd-note count-to-team-note">
|
||||
「计入单位总分」关掉后,该通道产生的分只记在个人头上,不进单位总分(例如「个人+团体」小项的个人通道)。
|
||||
<br />
|
||||
「发奖牌」关掉后该通道只出积分不出奖牌;「个人奖牌并入单位金牌榜」只对个人名次的通道有意义,
|
||||
关掉后这些个人奖牌仍进个人奖牌榜,但不计入单位金牌榜。
|
||||
</div>
|
||||
|
||||
<div class="mx-table-wrap">
|
||||
@@ -222,6 +238,7 @@ async function save(): Promise<void> {
|
||||
<th class="bd-select">名次来源</th>
|
||||
<th>积分模板</th>
|
||||
<th class="bd-select">计入单位总分</th>
|
||||
<th class="bd-select">奖牌通道</th>
|
||||
<th class="bd-cell-mini">排序</th>
|
||||
<th class="right">操作</th>
|
||||
</tr>
|
||||
@@ -256,6 +273,33 @@ async function save(): Promise<void> {
|
||||
<span>{{ row.countToTeam ? '计入' : '不计入' }}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="medal-toggles">
|
||||
<label
|
||||
class="medal-toggle"
|
||||
:title="row.countToMedals ? '该通道的名次发奖牌' : '该通道的名次只出积分、不发奖牌'"
|
||||
>
|
||||
<input v-model="row.countToMedals" type="checkbox" :disabled="!data.canWrite.value" />
|
||||
<span>发奖牌</span>
|
||||
</label>
|
||||
<!--
|
||||
scope=team 的名次本来就记在单位头上,没有「个人奖牌并入单位榜」这回事,
|
||||
所以不显示;但值仍原样回传,切换 scope 时不会被清掉。
|
||||
-->
|
||||
<label
|
||||
v-if="row.scope === 'athlete'"
|
||||
class="medal-toggle"
|
||||
:title="
|
||||
row.countToTeamMedals
|
||||
? '该通道的个人奖牌计入单位金牌榜'
|
||||
: '该通道的个人奖牌只进个人奖牌榜,不计入单位金牌榜'
|
||||
"
|
||||
>
|
||||
<input v-model="row.countToTeamMedals" type="checkbox" :disabled="!data.canWrite.value" />
|
||||
<span>个人奖牌并入单位金牌榜</span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input v-model="row.sortOrder" class="mx-cell-input bd-mono" type="number" min="0" max="9999" />
|
||||
</td>
|
||||
@@ -268,7 +312,7 @@ async function save(): Promise<void> {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="rows.length === 0">
|
||||
<td colspan="6">
|
||||
<td colspan="7">
|
||||
<div class="mx-empty bd-empty">
|
||||
<div class="mx-empty-title">该小项没有计分通道</div>
|
||||
<div class="mx-empty-desc">只排名不计分的项目保持为空即可</div>
|
||||
@@ -327,4 +371,19 @@ async function save(): Promise<void> {
|
||||
color: var(--mx-t2);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 两个奖牌开关竖排:表格已经很宽,横排会把「个人奖牌并入单位金牌榜」挤断行。 */
|
||||
.medal-toggles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.medal-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--mx-t2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -438,7 +438,7 @@ async function submitTeam(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<p class="bd-note">个人项目:勾选的人各生成一条报名,已报名的运动员不可重复勾选。</p>
|
||||
<p class="bd-note">报名会自动带上该运动员的证件号与号码布(认人看号码布);种子等字段仍通过下方报名表导入填写。</p>
|
||||
<p class="bd-note">报名会自动带上该运动员的证件号与号码布(认人看证件号,没有证件号时看号码布);种子等字段仍通过下方报名表导入填写。</p>
|
||||
<p class="bd-note">证件号与号码布都没有的运动员不能报名个人项目,请先在「运动员」分区补齐其中一项。</p>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ConflictAction, EntryImportResult, ImportConflict } from '@/shared'
|
||||
import type { ConflictAction, EntryImportResult, ImportConflict, SameNameNewPerson } from '@/shared'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import EntryPicker from './EntryPicker.vue'
|
||||
import { useBaseData } from './useBaseData'
|
||||
@@ -130,6 +130,18 @@ async function applyDecisions(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 同名新建报告的一行说明:只列真的对不上的那几项身份字段。 */
|
||||
function sameNameIdentityText(person: SameNameNewPerson): string {
|
||||
const parts: string[] = []
|
||||
if (person.existingIdCard !== person.newIdCard) {
|
||||
parts.push(`证件号 ${person.existingIdCard || '—'} → ${person.newIdCard || '—'}`)
|
||||
}
|
||||
if (person.existingBib !== person.newBib) {
|
||||
parts.push(`号码布 ${person.existingBib || '—'} → ${person.newBib || '—'}`)
|
||||
}
|
||||
return parts.length > 0 ? parts.join(';') : '身份字段与库里那条相同'
|
||||
}
|
||||
|
||||
function onPick(event: Event): void {
|
||||
const input = event.target
|
||||
if (!(input instanceof HTMLInputElement)) return
|
||||
@@ -193,10 +205,10 @@ function downloadTemplate(): void {
|
||||
表头为:{{ REQUIRED_COLUMNS.join(' / ') }}(大小写不敏感)。id_card、gender、age、heat 可缺省,
|
||||
但有姓名的行必须填证件号或号码布之一,否则该行会报错并跳过。年龄可留空,填了必须是 0–120 的整数。
|
||||
heat 是批次号:可留空(表示未分批),填了必须是 1–999 的整数;批次只用于录入 / 展示分组,不影响名次与积分。
|
||||
号码布是人员的唯一标识:先按号码布匹配;号码布为空或没匹配到,再按「姓名 + 单位」找——
|
||||
单位内恰好一人且对方还没有号码布就命中并回填;恰好一人但对方已有不同的号码布,按「同名的另一个人」新建档案
|
||||
(结果里会单独列出,不报错);多人则算歧义,列入冲突逐条决定。证件号是可选档案信息,只写入 / 更新、不参与匹配,
|
||||
但一个人的证件号不能与库里另一个人相同,撞号会明确报错并指出占用者。
|
||||
证件号是人员的唯一标识:先按证件号匹配;证件号为空、或库里那条还没填证件号,再按号码布匹配;
|
||||
两者都没命中才按「姓名 + 单位」找——单位内恰好一人且身份字段对得上(或有一边没填)就命中并把证件号 / 号码布回填进档案;
|
||||
恰好一人但两边的证件号(或号码布)对不上,按「同名的另一个人」新建档案(结果里会单独列出,不报错);
|
||||
多人则算歧义,列入冲突逐条决定。一个证件号在库里只能属于一个人。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -264,16 +276,26 @@ function downloadTemplate(): void {
|
||||
</div>
|
||||
<p v-if="result.errors.length === 0 && conflicts.length === 0" class="bd-note">全部行导入成功,没有错误行。</p>
|
||||
|
||||
<div v-if="result.notices.length > 0" class="notices">
|
||||
<div class="errors-head">
|
||||
<span class="mx-badge mx-badge-neutral">提示(不影响导入)</span>
|
||||
<span class="bd-note">这些行已经导入成功,仅提醒核对。</span>
|
||||
</div>
|
||||
<ul class="error-list">
|
||||
<li v-for="(notice, index) in result.notices" :key="index">{{ notice }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="result.sameNameNewPersons.length > 0" class="same-name">
|
||||
<div class="errors-head">
|
||||
<span class="mx-badge mx-badge-warning">
|
||||
以下 {{ result.sameNameNewPersons.length }} 人按同名的另一个人新建了档案(号码布不同)
|
||||
以下 {{ result.sameNameNewPersons.length }} 人按同名的另一个人新建了档案(证件号 / 号码布不同)
|
||||
</span>
|
||||
<span class="bd-note">这些行已导入成功,仅提醒核对;若号码布填错,请到「运动员」里改回并删除多余档案。</span>
|
||||
<span class="bd-note">这些行已导入成功,仅提醒核对;若证件号或号码布填错,请到「运动员」里改回并删除多余档案。</span>
|
||||
</div>
|
||||
<ul class="error-list">
|
||||
<li v-for="(person, index) in result.sameNameNewPersons" :key="index">
|
||||
{{ person.name }}({{ person.teamCode }}):原有号码布 {{ person.existingBib }} → 新号码布 {{ person.newBib }}
|
||||
{{ person.name }}({{ person.teamCode }}):{{ sameNameIdentityText(person) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -282,7 +304,7 @@ function downloadTemplate(): void {
|
||||
<div class="conflicts-head">
|
||||
<span class="mx-badge mx-badge-warning">{{ conflicts.length }} 人信息冲突</span>
|
||||
<span class="bd-note">
|
||||
号码布(或姓名 + 单位)命中了库里已有人员,但姓名 / 性别 / 年龄 / 单位 / 号码布与表里不一致,
|
||||
证件号(或号码布、姓名 + 单位)命中了库里已有人员,但姓名 / 证件号 / 性别 / 年龄 / 单位 / 号码布与表里不一致,
|
||||
本轮未落库,也不建报名。
|
||||
</span>
|
||||
</div>
|
||||
@@ -346,6 +368,10 @@ function downloadTemplate(): void {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="conflict.duplicate" class="bd-note conflict-duplicate">
|
||||
本表内重复行:同一证件号 + 同一个小项同一个组别出现了两次。字段下方没有高亮是正常的——
|
||||
保留已有 = 已导入的那条报名保持不动,使用新数据 = 用本行的值覆盖它。
|
||||
</p>
|
||||
<div class="conflict-diffs">
|
||||
<div
|
||||
v-for="diff in conflictDiffs(conflict)"
|
||||
@@ -464,7 +490,8 @@ function downloadTemplate(): void {
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.same-name {
|
||||
.same-name,
|
||||
.notices {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.error-list {
|
||||
@@ -533,6 +560,9 @@ function downloadTemplate(): void {
|
||||
.conflict-head .conflict-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.conflict-duplicate {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.conflict-diffs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import {
|
||||
normalizeAvgMode,
|
||||
normalizeAwardMedals,
|
||||
normalizeCountToMedals,
|
||||
normalizeCountToTeam,
|
||||
normalizeCountToTeamMedals,
|
||||
} from '@/shared'
|
||||
import type {
|
||||
AvgMode,
|
||||
CompareMode,
|
||||
@@ -116,6 +122,8 @@ const channelSummary = computed(() =>
|
||||
profile: profile?.name ?? `模板 #${channel.profileId}`,
|
||||
award: profile?.awardRanks ?? null,
|
||||
countToTeam: normalizeCountToTeam(channel.countToTeam),
|
||||
countToMedals: normalizeCountToMedals(channel.countToMedals),
|
||||
countToTeamMedals: normalizeCountToTeamMedals(channel.countToTeamMedals),
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -219,6 +227,8 @@ async function submitSport(): Promise<void> {
|
||||
|
||||
function askDeleteSport(): void {
|
||||
if (selectedSport.value === null) return
|
||||
// 删除不可恢复:示例数据模式下只会在内存里消失,不能弹确认框假装能删。
|
||||
if (data.blockDestructiveWrite('删除大项')) return
|
||||
if (sportEvents.value.length > 0) {
|
||||
toast.warning('该大项下还有小项', `请先删除 ${sportEvents.value.length} 个小项,再删除大项`)
|
||||
return
|
||||
@@ -251,6 +261,8 @@ interface EventForm {
|
||||
resultType: ResultType
|
||||
compare: CompareMode
|
||||
scored: boolean
|
||||
/** 该小项是否结算奖牌;关掉后只出积分不出奖牌。 */
|
||||
awardMedals: boolean
|
||||
recordEligible: boolean
|
||||
multiplier: string
|
||||
status: EventStatus
|
||||
@@ -291,6 +303,8 @@ function emptyEventForm(): EventForm {
|
||||
resultType: 'time',
|
||||
compare: 'asc',
|
||||
scored: true,
|
||||
// 新建小项默认结算奖牌,与库表 award_medals 的默认值一致。
|
||||
awardMedals: true,
|
||||
recordEligible: true,
|
||||
multiplier: '1',
|
||||
status: 'pending',
|
||||
@@ -338,6 +352,7 @@ function openEditEvent(event: Event): void {
|
||||
resultType: event.resultType,
|
||||
compare: event.compare,
|
||||
scored: event.scored,
|
||||
awardMedals: normalizeAwardMedals(event.awardMedals),
|
||||
recordEligible: event.recordEligible,
|
||||
multiplier: String(event.multiplier),
|
||||
status: event.status,
|
||||
@@ -384,6 +399,7 @@ async function submitEvent(): Promise<void> {
|
||||
compare: eventForm.value.compare,
|
||||
recordEligible: eventForm.value.recordEligible,
|
||||
scored: eventForm.value.scored,
|
||||
awardMedals: eventForm.value.awardMedals,
|
||||
multiplier: Number(eventForm.value.multiplier),
|
||||
status: eventForm.value.status,
|
||||
scheduledAt: fromLocalInput(eventForm.value.scheduledAt),
|
||||
@@ -405,6 +421,8 @@ async function submitEvent(): Promise<void> {
|
||||
}
|
||||
|
||||
function askDeleteEvent(event: Event): void {
|
||||
// 与删除大项同一口径:示例数据模式下不弹确认框、不提交。
|
||||
if (data.blockDestructiveWrite('删除小项')) return
|
||||
eventConfirmTarget.value = event
|
||||
eventConfirmOpen.value = true
|
||||
}
|
||||
@@ -588,6 +606,9 @@ async function confirmDeleteEvent(): Promise<void> {
|
||||
<span class="bd-mono">{{ channel.profile }}</span>
|
||||
<span class="bd-note">录取 {{ channel.award === null ? '—' : `前 ${channel.award} 名` }}</span>
|
||||
<span v-if="!channel.countToTeam" class="mx-badge mx-badge-warning">不计入单位总分</span>
|
||||
<!-- 关掉奖牌的通道在这里明说,否则要翻回「计分通道」页才知道它不发牌。 -->
|
||||
<span v-if="!channel.countToMedals" class="mx-badge mx-badge-warning">不发奖牌</span>
|
||||
<span v-if="!channel.countToTeamMedals" class="mx-badge mx-badge-warning">个人奖牌不并入单位榜</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -696,8 +717,15 @@ async function confirmDeleteEvent(): Promise<void> {
|
||||
<FormField label="选项" wide>
|
||||
<div class="check-row">
|
||||
<label class="bd-check"><input v-model="eventForm.scored" type="checkbox" /> 计分(取消则只排名不计分)</label>
|
||||
<label
|
||||
class="bd-check"
|
||||
title="关闭后该小项只出积分不出奖牌:名次与积分照算,奖牌榜里不再出现它"
|
||||
>
|
||||
<input v-model="eventForm.awardMedals" type="checkbox" /> 该项启用奖牌结算
|
||||
</label>
|
||||
<label class="bd-check"><input v-model="eventForm.recordEligible" type="checkbox" /> 参与纪录榜</label>
|
||||
</div>
|
||||
<span class="bd-note">关闭「该项启用奖牌结算」后,该小项只出积分不出奖牌。</span>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormDialog>
|
||||
|
||||
@@ -238,6 +238,8 @@ const purgeDetails = computed(() =>
|
||||
)
|
||||
|
||||
function askPurge(team: Team): void {
|
||||
// 与运动员页同一口径:示例数据模式下彻底删除不提交,先给出未连接服务端的说明。
|
||||
if (data.blockDestructiveWrite('彻底删除')) return
|
||||
purgeTarget.value = team
|
||||
purgeError.value = null
|
||||
purgeOpen.value = true
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { ref } from 'vue'
|
||||
import { ApiError, adminApi, mockMode, mockReason } from '../../api/client'
|
||||
import {
|
||||
ApiError,
|
||||
adminApi,
|
||||
isSampleData,
|
||||
mockMode,
|
||||
mockReason,
|
||||
registerFallbackSource,
|
||||
sampleDataBlockedError,
|
||||
} from '../../api/client'
|
||||
import type { AthleteBatchAction, AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
|
||||
import type {
|
||||
Athlete,
|
||||
@@ -28,8 +36,8 @@ import { mockBaseData } from './mockBaseData'
|
||||
* 只有「服务端未就绪」(网络/超时/解析失败/404)才落回示例数据,
|
||||
* 业务错误(鉴权、校验、冲突)原样抛出,避免把真实报错伪装成示例数据。
|
||||
*
|
||||
* 之所以不直接用 adminApi 的封装:api/client.ts 不在本页允许修改的范围内,
|
||||
* 且这里需要区分「基础数据接口没实现」与「整个后端没起来」两种降级。
|
||||
* 例外是破坏性操作(彻底删除、批量停用、删大项/小项):示例数据模式下不提交也不降级,
|
||||
* 统一走 callDestructive,免得内存里的「删掉了」被当成真删了(07 §5.3)。
|
||||
*/
|
||||
|
||||
const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED']
|
||||
@@ -41,6 +49,7 @@ const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED']
|
||||
* 此时 adminApi.bootstrap 会把 mockMode 重置为 false,页面却仍在用示例数据。
|
||||
*/
|
||||
export const baseDataFallback = ref(false)
|
||||
registerFallbackSource(() => baseDataFallback.value)
|
||||
|
||||
interface CallOptions {
|
||||
/**
|
||||
@@ -82,6 +91,27 @@ async function call<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 破坏性写操作的统一入口(彻底删除、批量停用等)。
|
||||
*
|
||||
* 与 call 的两点差别:
|
||||
* 1. 示例数据模式下先拦下——不发请求、也不返回假「成功」,否则操作员会以为人真被删了;
|
||||
* 2. 连不上服务端时不落到 mock——内存里删掉的人会在后端恢复后全部回来,比直接报错更糟。
|
||||
*/
|
||||
async function callDestructive<T>(action: string, real: () => Promise<T>): Promise<T> {
|
||||
if (isSampleData()) throw sampleDataBlockedError(action)
|
||||
try {
|
||||
return await real()
|
||||
} catch (error) {
|
||||
if (!isFallback(error, false)) throw error
|
||||
// 后端在请求途中掉线:与读路径同口径切到示例数据,并明确告知这次没有提交。
|
||||
baseDataFallback.value = true
|
||||
mockMode.value = true
|
||||
mockReason.value = error instanceof Error ? error.message : '无法连接服务端'
|
||||
throw sampleDataBlockedError(action)
|
||||
}
|
||||
}
|
||||
|
||||
export const baseDataApi = {
|
||||
teams: {
|
||||
list: (): Promise<Team[]> => call(() => adminApi.client.teams.list(), () => mockBaseData.listTeams()),
|
||||
@@ -92,11 +122,11 @@ export const baseDataApi = {
|
||||
/**
|
||||
* 彻底删除(假删除):单位从列表里消失,数据全部保留,失败原样抛出即可。
|
||||
*
|
||||
* 与 athletes.purge 同理,这里不让 404 降级:404 是「单位已不在」的真实回答,
|
||||
* 落到示例数据上会让页面把「没删掉」提示成「已彻底删除」。网络 / 超时仍照常降级。
|
||||
* 破坏性操作:示例数据模式下直接拦下(不发请求、也不假装删掉了),
|
||||
* 连不上服务端时同样不落到 mock;404 是「单位已不在」的真实回答,原样抛出。
|
||||
*/
|
||||
purge: (id: number): Promise<void> =>
|
||||
call(() => adminApi.purgeTeam(id), () => mockBaseData.purgeTeam(id), { fallbackOn404: false }),
|
||||
callDestructive('彻底删除参赛单位', () => adminApi.purgeTeam(id)),
|
||||
},
|
||||
|
||||
sports: {
|
||||
@@ -106,7 +136,7 @@ export const baseDataApi = {
|
||||
update: (id: number, input: SportInput): Promise<Sport> =>
|
||||
call(() => adminApi.client.sports.update(id, input), () => mockBaseData.updateSport(id, input)),
|
||||
remove: (id: number): Promise<void> =>
|
||||
call(() => adminApi.client.sports.remove(id), () => mockBaseData.removeSport(id)),
|
||||
callDestructive('删除大项', () => adminApi.client.sports.remove(id)),
|
||||
},
|
||||
|
||||
events: {
|
||||
@@ -117,7 +147,7 @@ export const baseDataApi = {
|
||||
update: (id: number, input: EventInput): Promise<Event> =>
|
||||
call(() => adminApi.client.events.update(id, input), () => mockBaseData.updateEvent(id, input)),
|
||||
remove: (id: number): Promise<void> =>
|
||||
call(() => adminApi.client.events.remove(id), () => mockBaseData.removeEvent(id)),
|
||||
callDestructive('删除小项', () => adminApi.client.events.remove(id)),
|
||||
saveCategories: (eventId: number, inputs: EventCategoryInput[]): Promise<EventCategory[]> =>
|
||||
call(
|
||||
() => adminApi.client.events.saveCategories(eventId, inputs),
|
||||
@@ -162,24 +192,22 @@ export const baseDataApi = {
|
||||
/**
|
||||
* 彻底删除:服务端物理删除该人员及其报名、成绩并重算积分,返回删除规模统计。
|
||||
*
|
||||
* 与同组的 remove 不同,这里故意不让 404 降级:404 是「人员已不在」的真实回答,
|
||||
* 落到示例数据上会让页面把「没删掉」提示成「已彻底删除」,现场就出事了。
|
||||
* 后端整体连不上(网络 / 超时)时仍照常降级,示例数据里可以照删。
|
||||
* 破坏性操作:示例数据模式下先拦下,连不上服务端时也不落到 mock——
|
||||
* 内存里「删掉」会让人以为真删了,后端恢复后这些人又会全部回来。
|
||||
* 404 是「人员已不在」的真实回答,原样抛出。
|
||||
*/
|
||||
purge: (id: number): Promise<AthletePurgeResult> =>
|
||||
call(() => adminApi.purgeAthlete(id), () => mockBaseData.purgeAthlete(id), {
|
||||
fallbackOn404: false,
|
||||
}),
|
||||
callDestructive('彻底删除运动员', () => adminApi.purgeAthlete(id)),
|
||||
/**
|
||||
* 批量停用 / 批量彻底删除:一个请求完成整批,逐条成败在响应里。
|
||||
*
|
||||
* 与 purge 同理不让 404 降级:404 是「这批人已经被别人处理过」的真实回答,
|
||||
* 落回示例数据会把「一个都没删掉」显示成「删掉了」。
|
||||
* 与 purge 同一口径:示例数据模式下这两个按钮不提交(07 §5.3 的承诺),
|
||||
* 连不上服务端时也不落到 mock;404 是「这批人已经被别人处理过」的真实回答,原样抛出。
|
||||
*/
|
||||
batch: (action: AthleteBatchAction, ids: number[], dryRun = false): Promise<AthleteBatchResult> =>
|
||||
call(() => adminApi.client.athletes.batch(action, ids, dryRun), () => mockBaseData.batchAthletes(action, ids, dryRun), {
|
||||
fallbackOn404: false,
|
||||
}),
|
||||
callDestructive(action === 'purge' ? '批量彻底删除' : '批量停用', () =>
|
||||
adminApi.client.athletes.batch(action, ids, dryRun),
|
||||
),
|
||||
},
|
||||
|
||||
entries: {
|
||||
|
||||
@@ -289,6 +289,7 @@ const CONFLICT_FIELD_LABELS: Record<ImportConflictField, string> = {
|
||||
age: '年龄',
|
||||
teamCode: '单位',
|
||||
employeeNo: '工号 / 学号',
|
||||
idCard: '证件号',
|
||||
bib: '号码布',
|
||||
}
|
||||
|
||||
@@ -317,25 +318,25 @@ function formatFieldValue(value: string | number | null | undefined): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 冲突的决策键:号码布优先,号码布为空再回落证件号,两样都没有时回落姓名 + 单位。
|
||||
* 冲突的决策键:证件号优先,没有证件号时用号码布,两样都没有时回落姓名 + 单位。
|
||||
*
|
||||
* 号码布才是人员的唯一标识;证件号自「号码布优先匹配」之后不再参与匹配,
|
||||
* 同一份表里两个不同的人可以共用一个证件号,拿它当键会让两条冲突共用一行决策
|
||||
* (面板 :key 重复、勾选互相串)。
|
||||
* 证件号才是人员的唯一标识;同一份表里重复出现同一证件号 = 同一个人(多半是报了多个组别),
|
||||
* 所以同一条决策正好覆盖那个人的所有行。与后端 repo_import.go 的 athleteImportRow.decisionKey()
|
||||
* 必须逐字一致,否则用户点的决策会落到别的冲突上(或两条冲突并成一条)。
|
||||
*
|
||||
* 这个键只管面板内的分组与勾选:发给服务端的载荷带的是 idCard + bib 两个字段,
|
||||
* 服务端按自己的 decisionKey()(repo_import.go)重算,键本身不出现在载荷里。
|
||||
* 服务端按同一口径重算,键本身不出现在载荷里。
|
||||
*/
|
||||
export function decisionKeyOf(conflict: ImportConflict): string {
|
||||
if (conflict.bib !== '') return conflict.bib
|
||||
if (conflict.idCard !== '') return conflict.idCard
|
||||
if (conflict.bib !== '') return conflict.bib
|
||||
return `${conflict.incoming.name}·${conflict.incoming.teamCode}`
|
||||
}
|
||||
|
||||
/** 冲突行的标识:与决策键同源,键为空时给占位符,避免面板显示空白。 */
|
||||
export function conflictLabel(conflict: ImportConflict): string {
|
||||
const key = decisionKeyOf(conflict)
|
||||
return key === '' ? '(无号码布 / 证件号 / 姓名)' : key
|
||||
return key === '' ? '(无证件号 / 号码布 / 姓名)' : key
|
||||
}
|
||||
|
||||
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { normalizeAvgMode, normalizeCountToTeam } from '@/shared'
|
||||
import {
|
||||
normalizeAvgMode,
|
||||
normalizeAwardMedals,
|
||||
normalizeCountToMedals,
|
||||
normalizeCountToTeam,
|
||||
normalizeCountToTeamMedals,
|
||||
} from '@/shared'
|
||||
import type { AthleteBatchAction, AthleteBatchItem, AthleteBatchResult, AthletePurgeResult } from '@/shared/api'
|
||||
import type {
|
||||
Athlete,
|
||||
@@ -97,16 +103,22 @@ const SEED_PROFILES: ScoringProfile[] = [
|
||||
]
|
||||
|
||||
interface EventSeed {
|
||||
/** avgMode 统一由构造函数补默认值,示例数据里不必逐个写。 */
|
||||
event: Omit<Event, 'id' | 'categories' | 'channels' | 'avgMode'>
|
||||
/** avgMode / awardMedals 统一由构造函数补默认值,示例数据里不必逐个写。 */
|
||||
event: Omit<Event, 'id' | 'categories' | 'channels' | 'avgMode' | 'awardMedals'>
|
||||
category: string
|
||||
scope: ScoreChannel['scope']
|
||||
basis: ScoreChannel['basis']
|
||||
profileId: number
|
||||
/** 通道是否计入单位总分;缺省 true。 */
|
||||
countToTeam?: boolean
|
||||
/** 该通道是否发奖牌;缺省 true。 */
|
||||
countToMedals?: boolean
|
||||
/** 该通道的个人奖牌是否并入单位榜;缺省 true。 */
|
||||
countToTeamMedals?: boolean
|
||||
/** 团体成绩的平均口径;缺省 valid。 */
|
||||
avgMode?: Event['avgMode']
|
||||
/** 小项是否结算奖牌;缺省 true。 */
|
||||
awardMedals?: boolean
|
||||
/** 示例报名条数:服务端有报名就不允许删项目 / 整体替换组别,这里同步模拟。 */
|
||||
entryCount: number
|
||||
}
|
||||
@@ -189,15 +201,20 @@ const IMPORT_COLUMNS = MANDATORY_COLUMNS
|
||||
const XLSX_HINT = '示例数据模式无法解析 xlsx:请改用 CSV 模板,或连接后端后重试'
|
||||
|
||||
/**
|
||||
* 冲突决策键:与服务端一致,证件号优先、为空时取号码布。
|
||||
* 冲突决策键:与服务端 athleteImportRow.decisionKey() 一致,证件号优先、为空时取号码布。
|
||||
*
|
||||
* 载荷里 idCard 与 bib 两个字段都带,服务端按自己的口径重算键(repo_import.go 的 decisionKey()),
|
||||
* 载荷里 idCard 与 bib 两个字段都带,服务端按同一口径重算键(repo_import.go),
|
||||
* 所以这里必须与服务端同序,否则决策会落到另一条冲突上。
|
||||
*/
|
||||
function importDecisionKey(key: { idCard: string; bib: string }): string {
|
||||
return key.idCard !== '' ? key.idCard : key.bib
|
||||
}
|
||||
|
||||
/** 身份字段(证件号 / 号码布)对不上:两边都给了值且不一致才算。 */
|
||||
function identityDiffers(existing: string | null, incoming: string): boolean {
|
||||
return incoming !== '' && existing !== null && existing !== '' && existing !== incoming
|
||||
}
|
||||
|
||||
/** 报名表里一行的人员信息(姓名已确定非空),供匹配逻辑使用。 */
|
||||
interface ImportRow {
|
||||
line: number
|
||||
@@ -207,14 +224,18 @@ interface ImportRow {
|
||||
idCard: string
|
||||
bib: string
|
||||
team: Team
|
||||
/** 与同一份表里的前一行「同证件号 + 同一个小项同一个组别」= 真重复行。 */
|
||||
duplicate: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 人员匹配结果:冲突行不落库、等用户决策;error 是可直接回显的行级报错文案。
|
||||
* sameNameNewPerson 只在「号码布不同 → 按同名的另一个人新建」时出现,仅作报告。
|
||||
* sameNameNewPerson 只在「姓名 + 单位相同、证件号 / 号码布不同 → 按同名的另一个人新建」时出现,仅作报告。
|
||||
* skipped 是真重复行 + 用户选了「保留已有」:这一行不落库。
|
||||
*/
|
||||
type AthleteMatch =
|
||||
| { kind: 'reused' }
|
||||
| { kind: 'skipped' }
|
||||
| { kind: 'conflict'; conflict: ImportConflict }
|
||||
| { kind: 'created'; sameNameNewPerson?: SameNameNewPerson }
|
||||
| { kind: 'error'; message: string }
|
||||
@@ -245,7 +266,12 @@ export class MockBaseData {
|
||||
constructor() {
|
||||
SEED_EVENTS.forEach((seed, index) => {
|
||||
const id = 101 + index
|
||||
this.events.push({ ...seed.event, id, avgMode: normalizeAvgMode(seed.avgMode) })
|
||||
this.events.push({
|
||||
...seed.event,
|
||||
id,
|
||||
avgMode: normalizeAvgMode(seed.avgMode),
|
||||
awardMedals: normalizeAwardMedals(seed.awardMedals),
|
||||
})
|
||||
this.entryCounts.set(id, seed.entryCount)
|
||||
this.categories.push({
|
||||
id: (this.nextCategoryId += 1),
|
||||
@@ -265,6 +291,8 @@ export class MockBaseData {
|
||||
profileId: seed.profileId,
|
||||
sortOrder: 1,
|
||||
countToTeam: seed.countToTeam ?? true,
|
||||
countToMedals: seed.countToMedals ?? true,
|
||||
countToTeamMedals: seed.countToTeamMedals ?? true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -368,7 +396,12 @@ export class MockBaseData {
|
||||
createEvent(input: EventInput): Event {
|
||||
if (this.events.some((event) => event.code === input.code)) throw new Error(`小项代码 ${input.code} 已存在`)
|
||||
const id = (this.nextEventId += 1)
|
||||
const event: Event = { ...input, id, avgMode: normalizeAvgMode(input.avgMode) }
|
||||
const event: Event = {
|
||||
...input,
|
||||
id,
|
||||
avgMode: normalizeAvgMode(input.avgMode),
|
||||
awardMedals: normalizeAwardMedals(input.awardMedals),
|
||||
}
|
||||
this.events = [...this.events, event]
|
||||
this.entryCounts.set(id, 0)
|
||||
return { ...event }
|
||||
@@ -380,7 +413,12 @@ export class MockBaseData {
|
||||
if (this.events.some((event) => event.id !== id && event.code === input.code)) {
|
||||
throw new Error(`小项代码 ${input.code} 已存在`)
|
||||
}
|
||||
const event: Event = { ...input, id, avgMode: normalizeAvgMode(input.avgMode) }
|
||||
const event: Event = {
|
||||
...input,
|
||||
id,
|
||||
avgMode: normalizeAvgMode(input.avgMode),
|
||||
awardMedals: normalizeAwardMedals(input.awardMedals),
|
||||
}
|
||||
this.events[index] = event
|
||||
return { ...event }
|
||||
}
|
||||
@@ -456,6 +494,8 @@ export class MockBaseData {
|
||||
id: (this.nextChannelId += 1),
|
||||
eventId,
|
||||
countToTeam: normalizeCountToTeam(input.countToTeam),
|
||||
countToMedals: normalizeCountToMedals(input.countToMedals),
|
||||
countToTeamMedals: normalizeCountToTeamMedals(input.countToTeamMedals),
|
||||
})
|
||||
}
|
||||
return this.getEvent(eventId).channels ?? []
|
||||
@@ -514,7 +554,7 @@ export class MockBaseData {
|
||||
return { ...athlete }
|
||||
}
|
||||
|
||||
/** 证件号 / 号码布都是人员唯一标识,重复时报出具体是哪个字段。 */
|
||||
/** 证件号是人员唯一标识、号码布是替补:重复时报出具体是哪个字段。 */
|
||||
private assertAthleteIdentityFree(input: AthleteInput, selfId: number | null): void {
|
||||
const idCard = input.idCard ?? ''
|
||||
if (idCard !== '' && this.athletes.some((athlete) => athlete.id !== selfId && athlete.idCard === idCard)) {
|
||||
@@ -604,7 +644,9 @@ export class MockBaseData {
|
||||
}
|
||||
|
||||
private async runImport(file: File, decisions: Map<string, ConflictAction>): Promise<EntryImportResult> {
|
||||
const empty = (errors: string[]): EntryImportResult => ({ created: 0, updated: 0, skipped: 0, errors, conflicts: [], sameNameNewPersons: [] })
|
||||
const empty = (errors: string[]): EntryImportResult => ({
|
||||
created: 0, updated: 0, skipped: 0, errors, notices: [], conflicts: [], sameNameNewPersons: [],
|
||||
})
|
||||
const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
|
||||
if (extension !== '.csv') return empty([XLSX_HINT])
|
||||
const rows = parseCsv(await file.text())
|
||||
@@ -615,8 +657,15 @@ export class MockBaseData {
|
||||
if (!index.has(column)) throw new Error(`报名表缺少列 ${column}`)
|
||||
}
|
||||
|
||||
const result: EntryImportResult = { created: 0, updated: 0, skipped: 0, errors: [], conflicts: [], sameNameNewPersons: [] }
|
||||
const seenBibs = new Set<string>()
|
||||
const result: EntryImportResult = {
|
||||
created: 0, updated: 0, skipped: 0, errors: [], notices: [], conflicts: [], sameNameNewPersons: [],
|
||||
}
|
||||
// 证件号是人员唯一标识:同一份表里重复出现 = 同一个人(多半是报多个组别),逐行落库并给一条提示;
|
||||
// 只有「同证件号 + 同一个小项同一个组别」才是真重复,进冲突面板。
|
||||
const idCardCounts = new Map<string, number>()
|
||||
const seenIdentity = new Set<string>()
|
||||
// 号码布不再是唯一标识,但两个不同的人共用同一个号码布仍然拦:号码布 -> 该行的证件号。
|
||||
const bibOwners = new Map<string, string>()
|
||||
rows.slice(1).forEach((row, offset) => {
|
||||
const line = offset + 2
|
||||
if (row.every((cell) => cell.trim() === '')) return
|
||||
@@ -675,17 +724,27 @@ export class MockBaseData {
|
||||
result.errors.push(`第 ${line} 行:年龄必须是 0–120 的整数`)
|
||||
return
|
||||
}
|
||||
// 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错;
|
||||
// 证件号只是可选档案信息,同一份表内重复不再拦截(与服务端口径一致)。
|
||||
if (bib !== '' && seenBibs.has(bib)) {
|
||||
result.skipped += 1
|
||||
result.errors.push(`第 ${line} 行:号码布 ${bib} 在文件中重复出现`)
|
||||
return
|
||||
// 同一个人报多个组别时可以共用号码布;不同的人共用同一个号码布仍然按行报错。
|
||||
if (bib !== '') {
|
||||
const owner = bibOwners.get(bib)
|
||||
if (owner !== undefined && (idCard === '' || owner !== idCard)) {
|
||||
result.skipped += 1
|
||||
result.errors.push(`第 ${line} 行:号码布 ${bib} 在文件中重复出现`)
|
||||
return
|
||||
}
|
||||
bibOwners.set(bib, idCard)
|
||||
}
|
||||
// 真重复判据与服务端一致:同一证件号 + 同一个小项同一个组别;组别留空时服务端取第一个组别。
|
||||
let duplicate = false
|
||||
if (idCard !== '') {
|
||||
idCardCounts.set(idCard, (idCardCounts.get(idCard) ?? 0) + 1)
|
||||
const identity = `${event.id}\u0000${categoryName !== '' ? categoryName : (categories[0]?.name ?? '')}`
|
||||
duplicate = seenIdentity.has(identity)
|
||||
seenIdentity.add(identity)
|
||||
}
|
||||
if (bib !== '') seenBibs.add(bib)
|
||||
const rawGender = cell('gender')
|
||||
const gender = rawGender === '男' || rawGender === '女' ? rawGender : null
|
||||
const match = this.matchAthlete({ line, name: athleteName, gender, age, idCard, bib, team }, decisions)
|
||||
const match = this.matchAthlete({ line, name: athleteName, gender, age, idCard, bib, team, duplicate }, decisions)
|
||||
if (match.kind === 'conflict') {
|
||||
result.conflicts.push(match.conflict)
|
||||
return
|
||||
@@ -695,21 +754,35 @@ export class MockBaseData {
|
||||
result.errors.push(`第 ${line} 行:${match.message}`)
|
||||
return
|
||||
}
|
||||
if (match.kind === 'skipped') {
|
||||
// 真重复行 + 用户选了「保留已有」:库里那条报名原样不动,本行不落库。
|
||||
result.skipped += 1
|
||||
return
|
||||
}
|
||||
if (match.kind === 'created' && match.sameNameNewPerson !== undefined) {
|
||||
result.sameNameNewPersons.push(match.sameNameNewPerson)
|
||||
}
|
||||
result.created += 1
|
||||
})
|
||||
// 同一证件号在本表里出现多次 = 同一个人报了多个组别:不报错,但要让现场看见(顺序按证件号排序)。
|
||||
for (const idCard of [...idCardCounts.keys()].sort()) {
|
||||
const count = idCardCounts.get(idCard) ?? 0
|
||||
if (count > 1) result.notices.push(`证件号 ${idCard} 在本表中出现 ${count} 次(同一人报名多个组别)`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 人员匹配:号码布精确命中 → 姓名 + 单位 → 新建,与真实服务端 repo_import.go 同序。
|
||||
* 人员匹配:证件号 → 号码布 → 姓名 + 单位 → 新建,与真实服务端 repo_import.go 同序。
|
||||
*
|
||||
* 证件号不参与匹配(只是可选档案信息),只在新建 / 回填 / keep-new 时写入;
|
||||
* 撞到别人的证件号仍然是行级报错,不允许静默改库或静默跳过。
|
||||
* 证件号是人员唯一标识,命中即认定同一个人;证件号为空、或库里那条还没填证件号时退到号码布;
|
||||
* 两者都没命中才按姓名 + 单位认人并回填证件号 / 号码布 / 年龄。
|
||||
*/
|
||||
private matchAthlete(row: ImportRow, decisions: Map<string, ConflictAction>): AthleteMatch {
|
||||
if (row.idCard !== '') {
|
||||
const byIDCard = this.athletes.find((athlete) => athlete.idCard === row.idCard)
|
||||
if (byIDCard !== undefined) return this.reuseOrConflict(byIDCard, row, decisions)
|
||||
}
|
||||
if (row.bib !== '') {
|
||||
const byBib = this.athletes.find((athlete) => athlete.bib === row.bib)
|
||||
if (byBib !== undefined) return this.reuseOrConflict(byBib, row, decisions)
|
||||
@@ -723,17 +796,21 @@ export class MockBaseData {
|
||||
// 同名同单位有多个人:不能替用户挑人,没决策就进面板(与服务端一样以第一条候选为代表)。
|
||||
// 字段全对得上时也拦:这里认不出「是哪一个人」,猜错就会改错档案。
|
||||
if (decisions.has(importDecisionKey(row))) return this.reuseOrConflict(first, row, decisions)
|
||||
if (row.duplicate) return { kind: 'conflict', conflict: this.duplicateConflict(first, row) }
|
||||
return { kind: 'conflict', conflict: this.buildConflict(first, row, this.changedFields(first, row)) }
|
||||
}
|
||||
// 表里也给了号码布、且与库里不同 → 同名的另一个人:新建并报告,不拦截导入。
|
||||
if (first.bib !== null && first.bib !== '' && row.bib !== '' && first.bib !== row.bib) {
|
||||
// 两边都给了身份字段、且对不上 → 同名的另一个人:新建并报告,不拦截导入。
|
||||
// 一边没给只是「未指定」,按同一人处理并回填。
|
||||
if (identityDiffers(first.idCard, row.idCard) || identityDiffers(first.bib, row.bib)) {
|
||||
const created = this.createImportedAthlete(row)
|
||||
if (created.kind === 'created') {
|
||||
created.sameNameNewPerson = {
|
||||
name: row.name,
|
||||
teamCode: row.team.code,
|
||||
newIdCard: row.idCard,
|
||||
existingIdCard: first.idCard ?? '',
|
||||
newBib: row.bib,
|
||||
existingBib: first.bib,
|
||||
existingBib: first.bib ?? '',
|
||||
}
|
||||
}
|
||||
return created
|
||||
@@ -743,12 +820,18 @@ export class MockBaseData {
|
||||
|
||||
/** 命中已有人员:无差异直接复用并回填空字段;有差异时按决策落库,没决策就进冲突面板。 */
|
||||
private reuseOrConflict(ex: Athlete, row: ImportRow, decisions: Map<string, ConflictAction>): AthleteMatch {
|
||||
const action = decisions.get(importDecisionKey(row))
|
||||
if (row.duplicate) {
|
||||
// 同一份表里同一证件号 + 同一个小项同一个组别出现两次 = 真重复:
|
||||
// 两行字段可能完全一致,但要不要覆盖已经落库的那条报名必须由用户决定。
|
||||
if (action === undefined) return { kind: 'conflict', conflict: this.duplicateConflict(ex, row) }
|
||||
if (action === 'keep-existing') return { kind: 'skipped' }
|
||||
}
|
||||
const conflict = this.conflictFor(ex, row)
|
||||
if (conflict === null) {
|
||||
const taken = this.backfillAthlete(ex, row)
|
||||
return taken === null ? { kind: 'reused' } : { kind: 'error', message: taken }
|
||||
}
|
||||
const action = decisions.get(importDecisionKey(conflict))
|
||||
if (action === undefined) return { kind: 'conflict', conflict }
|
||||
// keep-existing:人员字段保持库里的值;keep-new:用表里的字段覆盖(表里没填的保留)。
|
||||
if (action === 'keep-new') {
|
||||
@@ -795,7 +878,8 @@ export class MockBaseData {
|
||||
}
|
||||
|
||||
/**
|
||||
* 证件号不参与匹配,但仍一人一号:撞到别人时报出占用者是谁。
|
||||
* 证件号是人员唯一标识,正常路径上带库里已有证件号的行会先按证件号命中那个人(差异进冲突决策),
|
||||
* 所以这里主要兜住「姓名 + 单位命中另一个人、证件号却属于别人」的回填路径。
|
||||
* 文案与服务端 idCardTakenError 一致,现场在降级模式与真实服务端看到的是同一句话。
|
||||
*/
|
||||
private idCardTakenError(idCard: string, excludeId: number): string | null {
|
||||
@@ -812,7 +896,7 @@ export class MockBaseData {
|
||||
return changed.length === 0 ? null : this.buildConflict(athlete, row, changed)
|
||||
}
|
||||
|
||||
/** name / gender / age / teamCode / bib 与库里不一致才算差异;表里为空的字段视为未指定。 */
|
||||
/** name / gender / age / teamCode / idCard / bib 与库里不一致才算差异;表里为空的字段视为未指定。 */
|
||||
private changedFields(athlete: Athlete, row: ImportRow): ImportConflictField[] {
|
||||
const changed: ImportConflictField[] = []
|
||||
if (athlete.name !== row.name) changed.push('name')
|
||||
@@ -820,13 +904,17 @@ export class MockBaseData {
|
||||
// 年龄两侧都有值且不同才算差异;旧数据没有年龄时不能把所有人标成冲突。
|
||||
if (row.age !== null && athlete.age !== null && athlete.age !== row.age) changed.push('age')
|
||||
if (athlete.teamId !== row.team.id) changed.push('teamCode')
|
||||
// 号码布两侧都给了且不一致才算差异;表里没给视为不修改。
|
||||
if (row.bib !== '' && athlete.bib !== null && athlete.bib !== '' && athlete.bib !== row.bib) {
|
||||
changed.push('bib')
|
||||
}
|
||||
// 证件号 / 号码布两侧都给了且不一致才算差异;表里没给视为不修改。
|
||||
if (identityDiffers(athlete.idCard, row.idCard)) changed.push('idCard')
|
||||
if (identityDiffers(athlete.bib, row.bib)) changed.push('bib')
|
||||
return changed
|
||||
}
|
||||
|
||||
/** 真重复行的冲突:两行可能一个字段都不差,标上 duplicate 让面板说明为什么没有差异高亮。 */
|
||||
private duplicateConflict(athlete: Athlete, row: ImportRow): ImportConflict {
|
||||
return { ...this.buildConflict(athlete, row, this.changedFields(athlete, row)), duplicate: true }
|
||||
}
|
||||
|
||||
/** 冲突对象;changed 为空也算冲突(同名同单位认不出是哪一个,同样要进面板等决策)。 */
|
||||
private buildConflict(athlete: Athlete, row: ImportRow, changed: ImportConflictField[]): ImportConflict {
|
||||
return {
|
||||
@@ -834,12 +922,14 @@ export class MockBaseData {
|
||||
idCard: row.idCard,
|
||||
bib: row.bib,
|
||||
athleteId: athlete.id,
|
||||
duplicate: false,
|
||||
existing: {
|
||||
name: athlete.name,
|
||||
gender: athlete.gender,
|
||||
age: athlete.age,
|
||||
teamCode: this.teams.find((item) => item.id === athlete.teamId)?.code ?? '',
|
||||
employeeNo: athlete.employeeNo,
|
||||
idCard: athlete.idCard,
|
||||
bib: athlete.bib,
|
||||
},
|
||||
incoming: {
|
||||
@@ -848,6 +938,7 @@ export class MockBaseData {
|
||||
age: row.age,
|
||||
teamCode: row.team.code,
|
||||
employeeNo: null,
|
||||
idCard: row.idCard === '' ? null : row.idCard,
|
||||
bib: row.bib === '' ? null : row.bib,
|
||||
},
|
||||
changedFields: changed,
|
||||
|
||||
@@ -20,9 +20,10 @@ import type {
|
||||
TeamInput,
|
||||
} from '@/shared'
|
||||
import type { AthletePurgeResult } from '@/shared/api'
|
||||
import { adminApi, mockMode, mockReason } from '../../api/client'
|
||||
import { adminApi, mockMode, mockReason, sampleDataBlockedNotice } from '../../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useEventStore } from '../../stores/event'
|
||||
import { useToastStore } from '../../stores/toast'
|
||||
import { baseDataApi, baseDataFallback } from './baseDataApi'
|
||||
|
||||
/**
|
||||
@@ -317,6 +318,8 @@ export interface BaseDataController {
|
||||
isMock: ComputedRef<boolean>
|
||||
mockLabel: ComputedRef<string | null>
|
||||
canWrite: ComputedRef<boolean>
|
||||
/** 示例数据模式下拦下破坏性操作(统一判定 + 提示);返回 true 表示已拦下。 */
|
||||
blockDestructiveWrite: (action: string) => boolean
|
||||
/** 赛事设置(含榜单口径);GET /api/settings 仅管理员可见,只读访客拿不到时为 null。 */
|
||||
settings: ComputedRef<EventSettings | null>
|
||||
load: (force?: boolean) => Promise<void>
|
||||
@@ -354,6 +357,21 @@ export function useBaseData(): BaseDataController {
|
||||
const auth = useAuthStore()
|
||||
// 榜单口径是全局设置,唯一真值在 event store(bootstrap / 实时快照都写它)。
|
||||
const eventStore = useEventStore()
|
||||
const toast = useToastStore()
|
||||
const isMock = computed(() => baseDataFallback.value || mockMode.value)
|
||||
|
||||
/**
|
||||
* 示例数据模式下拦下破坏性操作并给出统一提示。
|
||||
*
|
||||
* 判定集中在这里(isMock 同时看全局降级与基础数据接口降级),页面不用各写一份;
|
||||
* 返回 true 表示已拦下,调用方直接 return 即可。
|
||||
*/
|
||||
function blockDestructiveWrite(action: string): boolean {
|
||||
if (!isMock.value) return false
|
||||
const notice = sampleDataBlockedNotice(action)
|
||||
toast.error(notice.title, notice.detail)
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
teams,
|
||||
@@ -365,7 +383,8 @@ export function useBaseData(): BaseDataController {
|
||||
loaded,
|
||||
error,
|
||||
selectedEventId,
|
||||
isMock: computed(() => baseDataFallback.value || mockMode.value),
|
||||
isMock,
|
||||
blockDestructiveWrite,
|
||||
mockLabel: computed(() =>
|
||||
baseDataFallback.value || mockMode.value ? `示例数据(${mockReason.value ?? '服务端未就绪'})` : null,
|
||||
),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type Event,
|
||||
type ResultState,
|
||||
type ResultStatus,
|
||||
type TeamRanksView,
|
||||
} from '@/shared'
|
||||
import MxIcon from '../components/MxIcon.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
@@ -28,6 +29,15 @@ import {
|
||||
type ResultCellHint,
|
||||
} from './entryGuards'
|
||||
import { entryProgress } from './entryProgress'
|
||||
import {
|
||||
hasTeamManualChannel,
|
||||
teamRankFlaggedTeams,
|
||||
teamRankRowsOf,
|
||||
teamRankWarnings,
|
||||
teamRanksDirty,
|
||||
teamRanksPayload,
|
||||
type TeamRankRow,
|
||||
} from './teamRanks'
|
||||
import { athleteCellName, inlineBibLabel } from './entryRowDisplay'
|
||||
import {
|
||||
entryBatchOutcome,
|
||||
@@ -168,6 +178,34 @@ const avgMode = ref<AvgModeState>(loadAvgMode(null, 'valid'))
|
||||
/** 只有配了「按队内平均」通道的小项才需要选平均口径。 */
|
||||
const showAvgMode = computed(() => hasAggregateChannel(channels.value))
|
||||
|
||||
/**
|
||||
* 「团体名次」区块(设计 17 §八第 3 条):只有该组别配了「团体 + 手动名次」通道时才出现。
|
||||
*
|
||||
* 这类通道读的是 channel_manual_ranks(按单位录),与成绩表的个人名次是两套数据,
|
||||
* 所以这里单开一块,而不是塞进成绩表格里。
|
||||
*/
|
||||
const showTeamRanks = computed(() => hasTeamManualChannel(channels.value))
|
||||
/**
|
||||
* 每个组别各自的编辑中行:组别间互不影响,切走再切回时未保存的输入还在
|
||||
* (成绩表也是这个口径——切换组别不会丢正在编辑的成绩)。
|
||||
*/
|
||||
const teamRanksByCategory = ref(new Map<number, TeamRankRow[]>())
|
||||
/** 录入接口随行下发的团体名次,按组别 id 缓存;0 号键放服务端没带 categoryId 的那一份。 */
|
||||
const embeddedTeamRanks = ref(new Map<number, TeamRanksView>())
|
||||
const teamRanksLoading = ref(false)
|
||||
const teamRanksSaving = ref(false)
|
||||
|
||||
/** 当前组别的团体名次行(表格直接绑它)。 */
|
||||
const teamRankRows = computed<TeamRankRow[]>(() => {
|
||||
const categoryId = selectedCategoryId.value
|
||||
return categoryId === null ? [] : (teamRanksByCategory.value.get(categoryId) ?? [])
|
||||
})
|
||||
const teamRankDirty = computed(() => teamRanksDirty(teamRankRows.value))
|
||||
/** 防呆提示(重复名次 / 名次超出单位数):只提示不拦截。 */
|
||||
const teamRankIssues = computed(() => teamRankWarnings(teamRankRows.value))
|
||||
/** 疑似笔误的行:给输入框描红,理由由 teamRankIssues 说清。 */
|
||||
const teamRankIssueTeams = computed(() => teamRankFlaggedTeams(teamRankRows.value))
|
||||
|
||||
function unitOf(type: Event['resultType']): string {
|
||||
if (type === 'time') return 's'
|
||||
if (type === 'distance') return 'm'
|
||||
@@ -354,10 +392,23 @@ const pendingSettlement = computed<SettlementGroup | null>(() => {
|
||||
) ?? null
|
||||
)
|
||||
})
|
||||
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
|
||||
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
|
||||
/**
|
||||
* dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。
|
||||
*
|
||||
* 团体名次的编辑也算在内:它是独立保存的(PUT team-ranks),
|
||||
* 不并入这里的话切页会把它静默丢掉,而它同样要重算名次与积分。
|
||||
*/
|
||||
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)) || teamRankDirty.value)
|
||||
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
|
||||
|
||||
/** 丢弃确认框的明细:成绩与团体名次分开说,别让人看到「0 行」以为没有改动。 */
|
||||
const discardDetails = computed(() => {
|
||||
const details = [`未保存的成绩行:${dirtyCount.value} 行`]
|
||||
if (teamRankDirty.value) details.push('还有未保存的团体名次(该组「团体名次」区块里)')
|
||||
details.push('如需保留,请先点「存草稿」(Ctrl+Enter),再切换项目或离开本页')
|
||||
return details
|
||||
})
|
||||
|
||||
/** 路由离开守卫:左侧菜单切页 / 浏览器前进后退都走这里,未保存的成绩不能静默丢失。 */
|
||||
const leaveGuard = createLeaveGuard(() => dirty.value)
|
||||
|
||||
@@ -678,6 +729,7 @@ function selectCategory(id: number): void {
|
||||
selectedCategoryId.value = id
|
||||
selectedHeat.value = 'all'
|
||||
selectedEntryIds.value = []
|
||||
void loadTeamRanks()
|
||||
}
|
||||
|
||||
/** 切换项目会丢弃当前编辑,先让操作员确认;确认框由 guardDirty 负责。 */
|
||||
@@ -692,21 +744,30 @@ async function loadEvent(id: number): Promise<void> {
|
||||
const seq = (requestSeq += 1)
|
||||
loading.value = true
|
||||
try {
|
||||
const [event, entries, records] = await Promise.all([
|
||||
// 用 entryBoard 而不是 entries:同一次请求就会带回团体手工名次(设计 17 §七),
|
||||
// 不必再多发一轮 GET team-ranks。
|
||||
const [event, board, records] = await Promise.all([
|
||||
adminApi.eventDetail(id),
|
||||
adminApi.entries(id),
|
||||
adminApi.entryBoard(id),
|
||||
adminApi.records(),
|
||||
])
|
||||
// 切换过快时旧响应可能后到,只有最新一次请求的结果才允许覆盖页面。
|
||||
if (seq !== requestSeq) return
|
||||
detail.value = event
|
||||
avgMode.value = loadAvgMode(id, event.avgMode)
|
||||
// 换项目等于换一份数据:团体名次的草稿与随行视图都先清掉,再归位新的一份。
|
||||
// 归位必须赶在下面改 selectedCategoryId 之前——组别 watch 会立刻按新组别取数据。
|
||||
teamRanksByCategory.value = new Map()
|
||||
collectEmbeddedTeamRanks(board.teamRanks)
|
||||
selectedCategoryId.value = event.categories?.[0]?.id ?? null
|
||||
selectedHeat.value = 'all'
|
||||
selectedEntryIds.value = []
|
||||
allRows.value = entries.map(toRow)
|
||||
allRows.value = board.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 }
|
||||
// 组别已定、随行视图已归位,这时候取团体名次:有随行数据就走缓存,否则发一次 GET。
|
||||
// 不用 watch(selectedCategoryId) 是为了不依赖「赋值后回调晚于归位」这种时序巧合。
|
||||
void loadTeamRanks()
|
||||
} catch (caught) {
|
||||
if (seq !== requestSeq) return
|
||||
toast.error(caught instanceof Error ? caught.message : '加载项目失败')
|
||||
@@ -769,6 +830,114 @@ async function reload(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 团体名次(按单位录入,只喂给团体 + 手动名次的通道) ────────────────────────
|
||||
|
||||
/** 把随行下发的视图按组别归位;服务端没带 categoryId 时收敛到 0 号键。 */
|
||||
function collectEmbeddedTeamRanks(views: readonly TeamRanksView[]): void {
|
||||
const next = new Map<number, TeamRanksView>()
|
||||
for (const view of views) next.set(view.categoryId ?? 0, view)
|
||||
embeddedTeamRanks.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 取某个组别的视图:优先用录入接口随行下发的那一份(省一轮请求)。
|
||||
*
|
||||
* 服务端不带 categoryId 时无从归位,只有一个组别的事件才敢直接用它——
|
||||
* 猜错组别会把别的组的名次显示成本组的名次,比多一次请求危险得多,宁可回退到 GET。
|
||||
*/
|
||||
function embeddedTeamRanksFor(categoryId: number): TeamRanksView | null {
|
||||
const exact = embeddedTeamRanks.value.get(categoryId)
|
||||
if (exact !== undefined) return exact
|
||||
return categories.value.length === 1 ? (embeddedTeamRanks.value.get(0) ?? null) : null
|
||||
}
|
||||
|
||||
/** 把最新视图写进缓存:无 categoryId 的旧视图可能就是这个组别的,一并清掉免得盖过新值。 */
|
||||
function cacheTeamRanksView(categoryId: number, view: TeamRanksView): void {
|
||||
const next = new Map(embeddedTeamRanks.value)
|
||||
next.delete(0)
|
||||
next.set(categoryId, view)
|
||||
embeddedTeamRanks.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 载入当前组别的团体名次。
|
||||
*
|
||||
* 已经有未保存输入的组别不重载——否则切走再切回会把操作员刚填的名次冲掉。
|
||||
*/
|
||||
async function loadTeamRanks(): Promise<void> {
|
||||
const eventId = selectedEventId.value
|
||||
const categoryId = selectedCategoryId.value
|
||||
if (eventId === null || categoryId === null || !showTeamRanks.value) {
|
||||
// 该组别没有团体手工通道:即使以前填过也不显示(服务端不会读它)。
|
||||
return
|
||||
}
|
||||
const draft = teamRanksByCategory.value.get(categoryId)
|
||||
if (draft !== undefined && teamRanksDirty(draft)) return
|
||||
const embedded = embeddedTeamRanksFor(categoryId)
|
||||
if (embedded !== null) {
|
||||
writeTeamRanks(categoryId, teamRankRowsOf(embedded, data.teams))
|
||||
return
|
||||
}
|
||||
const seq = requestSeq
|
||||
teamRanksLoading.value = true
|
||||
try {
|
||||
const view = await adminApi.teamRanks(eventId, categoryId)
|
||||
if (seq !== requestSeq || categoryId !== selectedCategoryId.value) return
|
||||
cacheTeamRanksView(categoryId, view)
|
||||
writeTeamRanks(categoryId, teamRankRowsOf(view, data.teams))
|
||||
} catch (caught) {
|
||||
if (seq !== requestSeq) return
|
||||
// 读不到就按空表处理:这是辅助区块,不能挡住成绩录入。
|
||||
writeTeamRanks(categoryId, [])
|
||||
toast.error('读取团体名次失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
if (seq === requestSeq) teamRanksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function writeTeamRanks(categoryId: number, rows: TeamRankRow[]): void {
|
||||
const next = new Map(teamRanksByCategory.value)
|
||||
next.set(categoryId, rows)
|
||||
teamRanksByCategory.value = next
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前组别的团体名次(PUT .../team-ranks,整组替换)。
|
||||
*
|
||||
* 保存会重算该组的名次与积分(成绩表的名次列、积分预览、榜单都会变),
|
||||
* 所以成功后按「发布」同一套流程整页刷新一次;服务端回的就是保存后的视图,直接回填。
|
||||
*/
|
||||
async function saveTeamRanks(): Promise<void> {
|
||||
const eventId = selectedEventId.value
|
||||
const categoryId = selectedCategoryId.value
|
||||
if (eventId === null || categoryId === null) return
|
||||
if (data.isMock) {
|
||||
// 与草稿 / 发布同一口径:示例数据模式下写接口没有后端可落库,提示成功等于谎报。
|
||||
toast.error('示例数据模式不能保存团体名次', '后端未就绪,改动不会写入真实赛事')
|
||||
return
|
||||
}
|
||||
if (!canWrite.value) {
|
||||
toast.error('当前不可写入', auth.readOnly ? '请先登录' : '服务端未连接')
|
||||
return
|
||||
}
|
||||
if (teamRankRows.value.length === 0) {
|
||||
toast.info('没有可录入的参赛单位')
|
||||
return
|
||||
}
|
||||
teamRanksSaving.value = true
|
||||
try {
|
||||
const view = await adminApi.saveTeamRanks(eventId, categoryId, teamRanksPayload(teamRankRows.value))
|
||||
cacheTeamRanksView(categoryId, view)
|
||||
writeTeamRanks(categoryId, teamRankRowsOf(view, data.teams))
|
||||
toast.success('团体名次已保存', '该组名次与积分榜会按新名次重算')
|
||||
await reloadAfterSettlementChange()
|
||||
} catch (caught) {
|
||||
toast.error('保存团体名次失败', caught instanceof Error ? caught.message : null)
|
||||
} finally {
|
||||
teamRanksSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildDraftPayload(): DraftResultInput[] {
|
||||
// 已发布行由服务端锁定,不能再存草稿(服务端会 409 STATE_INVALID);
|
||||
// 分批发布时本组会同时存在「已发布」与「未发布」的行,只提交未发布的那些。
|
||||
@@ -1087,8 +1256,9 @@ function canBatch(): boolean {
|
||||
return false
|
||||
}
|
||||
// 批量操作会重算并整表刷新,未保存的成绩修改会被覆盖,必须先落草稿。
|
||||
// 团体名次同样是「重算的输入」,也在这里挡住(dirty 已把它算进去)。
|
||||
if (dirty.value) {
|
||||
toast.error('有未保存的成绩修改', '批量操作会重算并刷新整表,请先「存草稿」再操作')
|
||||
toast.error('有未保存的修改', '批量操作会重算并刷新整表,请先「存草稿」/「保存团体名次」再操作')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -1793,6 +1963,72 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
团体名次:按单位录入,只喂给「团体 + 手动名次」的通道(设计 17 §八第 3 条)。
|
||||
与上面的成绩表是两套数据,所以单开一块,且不参与成绩表的行选择 / 批次筛选。
|
||||
-->
|
||||
<section v-if="showTeamRanks" class="mx-card team-ranks-card" :class="{ 'is-readonly': !canWrite }">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">团体名次</span>
|
||||
<div class="team-ranks-actions">
|
||||
<span v-if="teamRanksLoading" class="team-ranks-hint">读取中…</span>
|
||||
<span v-if="teamRankDirty" class="mx-badge mx-badge-warning">未保存</span>
|
||||
<button
|
||||
class="mx-btn mx-btn-primary mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!canWrite || teamRanksSaving || !teamRankDirty"
|
||||
:title="teamRankDirty ? '保存本组团体名次并重算名次与积分' : '没有改动'"
|
||||
@click="saveTeamRanks"
|
||||
>
|
||||
<MxIcon name="check" :size="14" /> {{ teamRanksSaving ? '保存中…' : '保存团体名次' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-card-body">
|
||||
<!--
|
||||
guest / 断线只读时整块置灰并说明原因:团体名次是写操作,
|
||||
读得到、改不了,必须让访客知道为什么输不进去。
|
||||
-->
|
||||
<div v-if="!canWrite" class="team-ranks-readonly-note">
|
||||
<MxIcon name="warning" :size="15" />
|
||||
<span>{{ auth.readOnly ? '当前为访客只读视图,团体名次只能查看,无法录入。' : '服务端未连接,暂时不能录入团体名次。' }}</span>
|
||||
</div>
|
||||
<p class="team-ranks-desc">
|
||||
按<strong>单位</strong>录入的团体名次:只影响本组别的团体通道(名次来源 = 手动录名次的团体通道),
|
||||
<strong>不影响个人名次</strong>;留空即清空该单位。名次重复允许并列,保存后本组名次与积分会重算。
|
||||
</p>
|
||||
<ul class="team-rank-list">
|
||||
<li v-for="row in teamRankRows" :key="row.teamId" class="team-rank-item">
|
||||
<span class="team-rank-code mono">{{ row.code }}</span>
|
||||
<span class="team-rank-name">{{ row.shortName || row.name }}</span>
|
||||
<input
|
||||
v-model="row.input"
|
||||
class="mx-cell-input rank-input mono"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="teamRankRows.length"
|
||||
:disabled="!canWrite"
|
||||
:class="{ 'is-invalid': teamRankIssueTeams.has(row.teamId) }"
|
||||
:placeholder="`1-${teamRankRows.length}`"
|
||||
:title="teamRankIssueTeams.has(row.teamId) ? '名次重复或超出参赛单位数,请确认' : ''"
|
||||
/>
|
||||
</li>
|
||||
<li v-if="teamRankRows.length === 0" class="team-rank-empty">
|
||||
没有可录入的参赛单位(单位列表为空或都已停用)
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="teamRankIssues.length > 0" class="issue-list team-rank-issues">
|
||||
<li v-for="(message, index) in teamRankIssues" :key="index" class="warn">
|
||||
<MxIcon name="bell" :size="14" />
|
||||
<span>{{ message }}(只提示,不阻止保存)</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="teamRankRows.length > 0" class="team-ranks-foot">
|
||||
<span class="team-ranks-hint">共 {{ teamRankRows.length }} 个参赛单位</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="issues.length > 0" class="mx-card issues-card">
|
||||
<div class="mx-card-head">
|
||||
<span class="mx-card-title">防呆校验</span>
|
||||
@@ -1882,10 +2118,7 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
tone="warning"
|
||||
:title="DISCARD_CONFIRM_TITLE"
|
||||
:message="DISCARD_CONFIRM_MESSAGE"
|
||||
:details="[
|
||||
`未保存的行:${dirtyCount} 行`,
|
||||
'如需保留,请先点「存草稿」(Ctrl+Enter),再切换项目或离开本页',
|
||||
]"
|
||||
:details="discardDetails"
|
||||
confirm-text="丢弃并继续"
|
||||
cancel-text="留在本页"
|
||||
@confirm="confirmDiscard"
|
||||
@@ -2175,6 +2408,70 @@ watch([selectedEventId, selectedCategoryId], () => void refreshSettlement(), { i
|
||||
.team-cell {
|
||||
font-weight: 600;
|
||||
}
|
||||
/*
|
||||
* 团体名次区块:与成绩表分开的一张卡片,横向铺开单位 + 名次输入框。
|
||||
* 只读(guest / 断线)时整块降透明度,配合卡片内的只读提示说明原因。
|
||||
*/
|
||||
.team-ranks-card.is-readonly {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.team-ranks-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.team-ranks-readonly-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
font-size: 12px;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: var(--mx-amber);
|
||||
}
|
||||
.team-ranks-desc {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.team-rank-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
}
|
||||
.team-rank-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--mx-r-sm);
|
||||
background: var(--mx-s2);
|
||||
font-size: 12px;
|
||||
color: var(--mx-t2);
|
||||
}
|
||||
.team-rank-code {
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.team-rank-name {
|
||||
min-width: 72px;
|
||||
}
|
||||
.team-rank-empty {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.team-rank-issues {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.team-ranks-foot {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.team-ranks-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mx-t3);
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { ScoreChannel, Team, TeamRanksInput, TeamRanksView } from '@/shared'
|
||||
|
||||
/**
|
||||
* 录入页「团体名次」区块的纯逻辑(设计 17 §八第 3 条)。
|
||||
*
|
||||
* 团体名次是**按单位**录入的,与成绩表(按报名项)不是一套数据:只有
|
||||
* `scope=team && basis=manual` 的通道读它,个人名次一行都不受影响。
|
||||
* 这里只管「该不该显示这个区块 → 显示哪些单位 → 输入怎么变成请求体 / 提示什么」,
|
||||
* 请求与刷新时机留在页面里(与 avgMode.ts 同一分工)。
|
||||
*/
|
||||
|
||||
export interface TeamRankRow {
|
||||
teamId: number
|
||||
code: string
|
||||
name: string
|
||||
shortName: string | null
|
||||
/** 输入框里的原文;空串 = 未填(保存时按 0 = 清空发给服务端)。 */
|
||||
input: string
|
||||
/** 载入时的原文:用来判断这一行有没有被改过。 */
|
||||
original: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 该组别是否需要「团体名次」区块。
|
||||
*
|
||||
* 只看通道配置,不看有没有报名:纯团体项目(篮球等)走团体报名项的 results.rank 兜底,
|
||||
* 不需要在这里录;只有配了「团体 + 手动名次」通道的小项才出现。
|
||||
*/
|
||||
export function hasTeamManualChannel(channels: readonly ScoreChannel[] | null | undefined): boolean {
|
||||
return (channels ?? []).some((channel) => channel.scope === 'team' && channel.basis === 'manual')
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入框原文 → 请求体里的名次。
|
||||
*
|
||||
* 空、0、负数、非整数一律归一为 0:服务端口径就是「rank 0 = 清空该单位」,
|
||||
* 页面上留空即清空,不需要再让操作员先填一个 0。
|
||||
*/
|
||||
export function parseTeamRankInput(raw: string): number {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed === '') return 0
|
||||
const value = Number(trimmed)
|
||||
if (!Number.isInteger(value) || value < 1) return 0
|
||||
return value
|
||||
}
|
||||
|
||||
/** 名次 → 输入框原文;0 / 负数视为未填。 */
|
||||
function inputOfRank(rank: number | undefined): string {
|
||||
return rank !== undefined && rank >= 1 ? String(rank) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装表格行。
|
||||
*
|
||||
* 单位名单优先用接口返回的 `teams`(服务端已经按「启用且未假删除」过滤过);
|
||||
* 接口没带单位时退回全局单位表,同样只列启用中的单位——假删除的单位不该出现在录入界面。
|
||||
*/
|
||||
export function teamRankRowsOf(view: TeamRanksView | null, teams: readonly Team[]): TeamRankRow[] {
|
||||
const ranks = new Map((view?.ranks ?? []).map((entry) => [entry.teamId, entry.rank]))
|
||||
const source =
|
||||
view !== null && view.teams.length > 0
|
||||
? view.teams.map((team) => ({
|
||||
teamId: team.teamId,
|
||||
code: team.code,
|
||||
name: team.name,
|
||||
shortName: team.shortName,
|
||||
}))
|
||||
: teams
|
||||
.filter((team) => team.active && (team.deletedAt === undefined || team.deletedAt === null))
|
||||
.map((team) => ({ teamId: team.id, code: team.code, name: team.name, shortName: team.shortName }))
|
||||
return source.map((team) => {
|
||||
const input = inputOfRank(ranks.get(team.teamId))
|
||||
return { ...team, input, original: input }
|
||||
})
|
||||
}
|
||||
|
||||
export function teamRanksDirty(rows: readonly TeamRankRow[]): boolean {
|
||||
return rows.some((row) => row.input !== row.original)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存载荷:整组回传。
|
||||
*
|
||||
* 服务端按 (小项, 组别, scope, basis) 整组替换,所以每个单位都要出现,
|
||||
* 没填的显式给 0(清空)——漏掉单位等于把它原来的名次留在库里,页面看着却像已经清掉了。
|
||||
*/
|
||||
export function teamRanksPayload(rows: readonly TeamRankRow[]): TeamRanksInput {
|
||||
return {
|
||||
scope: 'team',
|
||||
basis: 'manual',
|
||||
ranks: rows.map((row) => ({ teamId: row.teamId, rank: parseTeamRankInput(row.input) })),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 非阻塞提示(只提示不拦截,风格与成绩表的「名次重复」一致)。
|
||||
*
|
||||
* - 名次重复:**允许**,服务端按 tieMode 处理并列,这里只提醒操作员确认;
|
||||
* - 名次超出参赛单位数:一定是笔误,但改不改仍由操作员决定,所以同样不挡保存。
|
||||
*/
|
||||
export function teamRankWarnings(rows: readonly TeamRankRow[]): string[] {
|
||||
const counts = new Map<number, number>()
|
||||
for (const row of rows) {
|
||||
const rank = parseTeamRankInput(row.input)
|
||||
if (rank >= 1) counts.set(rank, (counts.get(rank) ?? 0) + 1)
|
||||
}
|
||||
const warnings: string[] = []
|
||||
for (const [rank, count] of [...counts.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
if (count > 1) warnings.push(`名次 ${rank} 重复 ${count} 次,允许并列,请确认是否为想要的结果`)
|
||||
}
|
||||
const max = rows.length
|
||||
if (max > 0) {
|
||||
for (const row of rows) {
|
||||
const rank = parseTeamRankInput(row.input)
|
||||
if (rank > max) {
|
||||
warnings.push(`${row.shortName ?? row.name}:名次 ${rank} 超出参赛单位数 ${max}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return warnings
|
||||
}
|
||||
|
||||
/** 有问题的单位(名次重复或超出参赛单位数):给输入框描红,理由由 teamRankWarnings 说。 */
|
||||
export function teamRankFlaggedTeams(rows: readonly TeamRankRow[]): Set<number> {
|
||||
const counts = new Map<number, number>()
|
||||
for (const row of rows) {
|
||||
const rank = parseTeamRankInput(row.input)
|
||||
if (rank >= 1) counts.set(rank, (counts.get(rank) ?? 0) + 1)
|
||||
}
|
||||
const max = rows.length
|
||||
const flagged = new Set<number>()
|
||||
for (const row of rows) {
|
||||
const rank = parseTeamRankInput(row.input)
|
||||
if (rank >= 1 && ((counts.get(rank) ?? 0) > 1 || (max > 0 && rank > max))) flagged.add(row.teamId)
|
||||
}
|
||||
return flagged
|
||||
}
|
||||
@@ -40,7 +40,7 @@ function respondWith(response: () => Response): void {
|
||||
reply = response
|
||||
}
|
||||
|
||||
const { ApiError, adminApi } = await import('../api/client')
|
||||
const { ApiError, adminApi, mockMode, SAMPLE_DATA_BLOCKED } = await import('../api/client')
|
||||
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||
|
||||
test('彻底删除走 DELETE /api/athletes/{id}?purge=1', async () => {
|
||||
@@ -164,22 +164,23 @@ test('remove 的 404 仍降级到示例数据(既有行为未受影响)', as
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
})
|
||||
|
||||
// 后端整体连不上时仍降级到示例数据;示例数据也返回完整的统计结构,页面不会拿到 undefined。
|
||||
// (示例数据里没有归属到人员的报名与成绩,所以各项是 0,用 id=1002 避开上一条已删掉的 1001。)
|
||||
test('后端不可用降级示例数据时,统计结构依然完整', async () => {
|
||||
// 后端整体连不上时不再降级示例数据:内存里「删掉」会让操作员以为人真被删了,
|
||||
// 恢复连接后这些人又会全部回来。必须给出可识别的「未提交」结果(SAMPLE_DATA_BLOCKED)。
|
||||
test('后端不可用时彻底删除被阻断:不降级示例数据、不返回假成功', async () => {
|
||||
baseDataFallback.value = false
|
||||
mockMode.value = false
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
|
||||
const result = await baseDataApi.athletes.purge(1002)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
deletedEntries: 0,
|
||||
deletedResults: 0,
|
||||
deletedLedger: 0,
|
||||
deletedBreaks: 0,
|
||||
recomputedEvents: [],
|
||||
})
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
await assert.rejects(
|
||||
() => baseDataApi.athletes.purge(1002),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
|
||||
assert.match(error.message, /示例数据/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
assert.equal(baseDataFallback.value, true, '写请求撞上后端不可达后,页面应切到示例数据标记')
|
||||
})
|
||||
@@ -46,7 +46,7 @@ function requestBody(): Record<string, unknown> {
|
||||
return parsed
|
||||
}
|
||||
|
||||
const { adminApi } = await import('../api/client')
|
||||
const { ApiError, SAMPLE_DATA_BLOCKED, adminApi } = await import('../api/client')
|
||||
|
||||
const input = {
|
||||
name: '男子甲组',
|
||||
@@ -95,7 +95,7 @@ test('服务端 409 的原文要能被页面拿到(提示里那句话是现场
|
||||
)
|
||||
})
|
||||
|
||||
test('服务端不可达时,组别就地更新与运动员批量都降级到示例数据而不是抛错', async () => {
|
||||
test('服务端不可达时,组别就地更新仍降级到示例数据,运动员批量则被阻断', async () => {
|
||||
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||
const { mockBaseData } = await import('../components/baseData/mockBaseData')
|
||||
// 断网:fetch 直接抛,客户端归成 NETWORK,baseDataApi 据此降级到示例数据。
|
||||
@@ -114,9 +114,15 @@ test('服务端不可达时,组别就地更新与运动员批量都降级到
|
||||
assert.equal(categories.find((item) => item.id === category.id)?.name, '降级改名')
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
|
||||
// 批量停用是破坏性操作:示例数据模式下必须拦下,不能假装停用成功(P1(17))。
|
||||
const athlete = mockBaseData.listAthletes()[0]
|
||||
assert.ok(athlete !== undefined)
|
||||
const result = await baseDataApi.athletes.batch('disable', [athlete.id])
|
||||
assert.equal(result.succeeded, 1)
|
||||
assert.equal(result.failed, 0)
|
||||
await assert.rejects(
|
||||
() => baseDataApi.athletes.batch('disable', [athlete.id]),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
// 计分通道页的两个奖牌开关(设计 17 §八第 2 条)与小项页的「该项启用奖牌结算」。
|
||||
// 项目没有组件测试工具,按仓库既有做法(avg-mode.test.ts)读源码断言接线。
|
||||
|
||||
const CHANNELS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const EVENTS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('通道行带两个奖牌开关,缺字段按 true 归一', () => {
|
||||
assert.match(CHANNELS_SOURCE, /countToMedals: normalizeCountToMedals\(channel\.countToMedals\)/)
|
||||
assert.match(CHANNELS_SOURCE, /countToTeamMedals: normalizeCountToTeamMedals\(channel\.countToTeamMedals\)/)
|
||||
assert.match(CHANNELS_SOURCE, /发奖牌/)
|
||||
assert.match(CHANNELS_SOURCE, /个人奖牌并入单位金牌榜/)
|
||||
})
|
||||
|
||||
test('两个开关一起序列化进 ScoreChannelInput,scope=team 的值不被清掉', () => {
|
||||
// buildInputs 必须带上两个字段:PUT 是整体替换,漏掉等于把它们重置回默认(发牌)。
|
||||
assert.match(CHANNELS_SOURCE, /countToMedals: row\.countToMedals/)
|
||||
assert.match(CHANNELS_SOURCE, /countToTeamMedals: row\.countToTeamMedals/)
|
||||
// 「个人奖牌并入单位金牌榜」只在 scope=athlete 的行显示;值本身原样回传。
|
||||
assert.match(CHANNELS_SOURCE, /v-if="row\.scope === 'athlete'"/)
|
||||
assert.match(CHANNELS_SOURCE, /切换 scope 时不会被清掉/)
|
||||
})
|
||||
|
||||
test('新通道默认发奖牌并并入单位榜(与库表默认值一致)', () => {
|
||||
assert.match(CHANNELS_SOURCE, /countToMedals: true,\s*\n\s*countToTeamMedals: true,/)
|
||||
})
|
||||
|
||||
test('小项编辑有「该项启用奖牌结算」开关,默认开并说清后果', () => {
|
||||
assert.match(EVENTS_SOURCE, /awardMedals: true,/)
|
||||
assert.match(EVENTS_SOURCE, /该项启用奖牌结算/)
|
||||
assert.match(EVENTS_SOURCE, /关闭后该小项只出积分不出奖牌|关闭「该项启用奖牌结算」后,该小项只出积分不出奖牌/)
|
||||
// 编辑已有小项时按当前值回填,不能每次都变成开。
|
||||
assert.match(EVENTS_SOURCE, /awardMedals: normalizeAwardMedals\(event\.awardMedals\)/)
|
||||
// 保存时带上该字段(PUT 整体替换)。
|
||||
assert.match(EVENTS_SOURCE, /awardMedals: eventForm\.value\.awardMedals/)
|
||||
})
|
||||
|
||||
test('小项页的通道摘要给「已关闭奖牌」的通道加可见徽标', () => {
|
||||
assert.match(EVENTS_SOURCE, /!channel\.countToMedals/)
|
||||
assert.match(EVENTS_SOURCE, /不发奖牌/)
|
||||
assert.match(EVENTS_SOURCE, /!channel\.countToTeamMedals/)
|
||||
assert.match(EVENTS_SOURCE, /个人奖牌不并入单位榜/)
|
||||
})
|
||||
@@ -37,10 +37,13 @@ test('选中「个人+团体」时提示双通道口径', () => {
|
||||
assert.match(SECTION_SOURCE, /计分通道/)
|
||||
})
|
||||
|
||||
test('示例数据里的「个人+团体」小项配了个人与团体两条通道', () => {
|
||||
test('示例数据里的「个人+团体」小项配了个人与团体通道(含按单位手工录名次)', () => {
|
||||
const event = mockDb.eventDetail(108)
|
||||
assert.equal(event.eventType, 'individual_team')
|
||||
|
||||
const scopes = (event.channels ?? []).map((channel) => channel.scope)
|
||||
assert.deepEqual(scopes, ['athlete', 'team'])
|
||||
// athlete=个人成绩、team+aggregate=队内平均、team+manual=按单位手工录名次
|
||||
// (设计 17 的「团体名次」区块就靠最后这条通道才会出现在录入页)。
|
||||
assert.deepEqual(scopes, ['athlete', 'team', 'team'])
|
||||
assert.ok((event.channels ?? []).some((channel) => channel.basis === 'manual'))
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { AthleteInput, ImportConflict } from '@/shared'
|
||||
import type { AthleteInput, ImportConflict, ImportConflictFields } from '@/shared'
|
||||
import { createApiClient } from '@/shared'
|
||||
import {
|
||||
REQUIRED_COLUMNS,
|
||||
@@ -15,13 +15,28 @@ import {
|
||||
} from '../components/baseData/entrySelection'
|
||||
import { MockBaseData } from '../components/baseData/mockBaseData'
|
||||
|
||||
/** 冲突两端可比的字段快照;证件号默认两侧相同(同一个人),号码布默认相同。 */
|
||||
function fields(overrides: Partial<ImportConflictFields> = {}): ImportConflictFields {
|
||||
return {
|
||||
name: '张三',
|
||||
gender: '男',
|
||||
age: 26,
|
||||
teamCode: 'F1',
|
||||
employeeNo: '1001',
|
||||
idCard: '110101199001010001',
|
||||
bib: '101',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function conflict(overrides: Partial<ImportConflict> & Pick<ImportConflict, 'idCard'>): ImportConflict {
|
||||
return {
|
||||
row: 12,
|
||||
bib: '',
|
||||
athleteId: 42,
|
||||
existing: { name: '张三', gender: '男', age: 26, teamCode: 'F1', employeeNo: '1001', bib: '101' },
|
||||
incoming: { name: '张三', gender: '男', age: 27, teamCode: 'F2', employeeNo: null, bib: '102' },
|
||||
duplicate: false,
|
||||
existing: fields(),
|
||||
incoming: fields({ age: 27, teamCode: 'F2', employeeNo: null, bib: '102' }),
|
||||
changedFields: ['age', 'teamCode', 'bib'],
|
||||
...overrides,
|
||||
}
|
||||
@@ -32,38 +47,71 @@ function csv(rows: string[][]): File {
|
||||
}
|
||||
|
||||
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / heat / status / member_names */
|
||||
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
|
||||
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', '', 'registered', '']
|
||||
function rowAt(
|
||||
eventCode: string,
|
||||
category: string,
|
||||
name: string,
|
||||
idCard: string,
|
||||
teamCode = 'F1',
|
||||
gender = '',
|
||||
bib = '',
|
||||
age = '',
|
||||
): string[] {
|
||||
return [eventCode, category, teamCode, name, idCard, gender, age, bib, '', '', 'registered', '']
|
||||
}
|
||||
|
||||
test('conflictDiffs 展开两侧字段并标出差异字段(含年龄 / 号码布)', () => {
|
||||
/** 默认小项与组别(M100 / 男子组)的一行。 */
|
||||
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
|
||||
return rowAt('M100', '男子组', name, idCard, teamCode, gender, bib, age)
|
||||
}
|
||||
|
||||
test('conflictDiffs 展开两侧字段并标出差异字段(含证件号 / 年龄 / 号码布)', () => {
|
||||
const diffs = conflictDiffs(conflict({ idCard: '110101199001011234' }))
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.field),
|
||||
['name', 'gender', 'age', 'teamCode', 'employeeNo', 'bib'],
|
||||
['name', 'gender', 'age', 'teamCode', 'employeeNo', 'idCard', 'bib'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.label),
|
||||
['姓名', '性别', '年龄', '单位', '工号 / 学号', '号码布'],
|
||||
['姓名', '性别', '年龄', '单位', '工号 / 学号', '证件号', '号码布'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.changed),
|
||||
[false, false, true, true, false, true],
|
||||
[false, false, true, true, false, false, true],
|
||||
'只高亮 changedFields 里的字段,employeeNo 差异不参与冲突判定',
|
||||
)
|
||||
assert.equal(diffs[2]?.existing, '26', 'number 类型的年龄转成文本展示')
|
||||
assert.equal(diffs[2]?.incoming, '27')
|
||||
assert.equal(diffs[4]?.incoming, '—', 'null 展示为占位符')
|
||||
assert.equal(diffs[5]?.existing, '101')
|
||||
assert.equal(diffs[5]?.incoming, '102')
|
||||
assert.equal(diffs[6]?.existing, '101')
|
||||
assert.equal(diffs[6]?.incoming, '102')
|
||||
})
|
||||
|
||||
// P1(16):服务端只差证件号时回的是 changedFields:["idCard"],前端必须有对应标签并高亮,
|
||||
// 否则冲突卡片上一个差异都不亮,用户无从判断该保留哪一侧。
|
||||
test('changedFields 含 idCard 时证件号一行有标签并高亮', () => {
|
||||
const diffs = conflictDiffs(
|
||||
conflict({
|
||||
idCard: 'C2',
|
||||
existing: fields({ idCard: 'C1' }),
|
||||
incoming: fields({ idCard: 'C2' }),
|
||||
changedFields: ['idCard'],
|
||||
}),
|
||||
)
|
||||
const idCard = diffs.find((diff) => diff.field === 'idCard')
|
||||
assert.deepEqual([idCard?.label, idCard?.existing, idCard?.incoming, idCard?.changed], ['证件号', 'C1', 'C2', true])
|
||||
assert.deepEqual(
|
||||
diffs.filter((diff) => diff.changed).map((diff) => diff.field),
|
||||
['idCard'],
|
||||
)
|
||||
})
|
||||
|
||||
test('conflictDiffs 年龄为空的一侧显示占位符,不算差异', () => {
|
||||
const diffs = conflictDiffs(
|
||||
conflict({
|
||||
idCard: '110101199001011234',
|
||||
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
|
||||
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
|
||||
existing: fields({ age: null }),
|
||||
incoming: fields({ age: null }),
|
||||
changedFields: [],
|
||||
}),
|
||||
)
|
||||
@@ -99,7 +147,7 @@ test('pruneDecisions / decisionsPayload / summarizeDecisions 按冲突列表收
|
||||
test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
|
||||
const conflicts = [conflict({ row: 2, idCard: '', bib: '101' }), conflict({ row: 3, idCard: '', bib: '102' })]
|
||||
|
||||
// 只勾选第一条:另一条必须仍是未决策,回退成按证件号作键时这里会被 '' 覆盖成空集。
|
||||
// 只勾选第一条:另一条必须仍是未决策,按证件号作键时两条都会被空串键带偏。
|
||||
const first = recordDecisions({}, conflicts, ['101'], 'keep-existing')
|
||||
assert.deepEqual(first, { 101: 'keep-existing' })
|
||||
assert.deepEqual(summarizeDecisions(first, conflicts), { keepExisting: 1, keepNew: 0, undecided: 1 })
|
||||
@@ -120,26 +168,33 @@ test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('决策键号码布优先:共用一个证件号的两个人不再共用一行决策', () => {
|
||||
// 决策键的优先级:证件号 → 号码布 → 姓名 + 单位,与服务端 athleteImportRow.decisionKey() 逐字一致。
|
||||
test('决策键证件号优先:同证件号的两行是同一个人,共用一条决策', () => {
|
||||
const shared = '110101199001010001'
|
||||
// 证件号不参与匹配后,同一份表里两个不同的人可以共用一个证件号;键回到号码布才不会串。
|
||||
// 同一证件号 = 同一个人(多半是报了多个组别),号码布不同也只做一次决策。
|
||||
const first = conflict({ row: 2, idCard: shared, bib: '101' })
|
||||
const second = conflict({ row: 3, idCard: shared, bib: '102' })
|
||||
assert.equal(decisionKeyOf(first), '101')
|
||||
assert.equal(decisionKeyOf(second), '102')
|
||||
assert.equal(decisionKeyOf(first), shared)
|
||||
assert.equal(decisionKeyOf(second), shared)
|
||||
|
||||
// 勾其中一行时另一行必须还是未决策(按证件号作键时两条会互相覆盖)。
|
||||
const decisions = recordDecisions({}, [first, second], [decisionKeyOf(first)], 'keep-new')
|
||||
assert.deepEqual(decisions, { 101: 'keep-new' })
|
||||
assert.deepEqual(summarizeDecisions(decisions, [first, second]), { keepExisting: 0, keepNew: 1, undecided: 1 })
|
||||
assert.deepEqual(decisionsPayload(decisions, [first, second]), [
|
||||
{ idCard: shared, bib: '101', action: 'keep-new' },
|
||||
])
|
||||
const decisions = recordDecisions({}, [first, second], [shared], 'keep-new')
|
||||
assert.deepEqual(decisions, { [shared]: 'keep-new' })
|
||||
assert.deepEqual(summarizeDecisions(decisions, [first, second]), { keepExisting: 0, keepNew: 2, undecided: 0 })
|
||||
assert.deepEqual(
|
||||
decisionsPayload(decisions, [first, second]),
|
||||
[
|
||||
{ idCard: shared, bib: '101', action: 'keep-new' },
|
||||
{ idCard: shared, bib: '102', action: 'keep-new' },
|
||||
],
|
||||
'同一个人的两条都带上证件号,服务端按证件号重算键后一次决策覆盖两行',
|
||||
)
|
||||
})
|
||||
|
||||
test('决策键回落:没有号码布用证件号,两样都没有用姓名 + 单位', () => {
|
||||
const idCard = '110101199001010002'
|
||||
assert.equal(decisionKeyOf(conflict({ idCard, bib: '' })), idCard)
|
||||
// 证件号优先于号码布:库里那条的号码布与表里一致、证件号不同时,键也必须取证件号。
|
||||
test('决策键证件号优先于号码布', () => {
|
||||
const idCard = '110101199001010003'
|
||||
assert.equal(decisionKeyOf(conflict({ idCard, bib: '101' })), idCard)
|
||||
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '101' })), '101', '没有证件号才退到号码布')
|
||||
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '' })), '张三·F2', '两样都没有时仍要有一行唯一的键')
|
||||
})
|
||||
|
||||
@@ -161,25 +216,62 @@ test('导入:新建人员带证件号,姓名 + 单位命中且证件号为
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010002')
|
||||
})
|
||||
|
||||
test('导入:有姓名的行缺证件号 / 号码布都按行报错;重复证件号不再拦截', async () => {
|
||||
test('导入:有姓名的行缺证件号 / 号码布按行报错', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const missing = await mock.importEntries(csv([row('无证人员', '')]))
|
||||
assert.equal(missing.skipped, 1)
|
||||
assert.match(missing.errors[0] ?? '', /必须填证件号或号码布/)
|
||||
|
||||
// 同一份表里两行共用证件号不再报错:证件号只是可选档案信息,人员按号码布 / 姓名 + 单位认,
|
||||
// 两行都落在同一个人身上(第一行按号码布命中并回填证件号,第二行按姓名 + 单位命中)。
|
||||
const before = mock.listAthletes().length
|
||||
const duplicated = await mock.importEntries(
|
||||
csv([row('刘洋', '110101199001010003', 'F1', '男', '201'), row('刘洋', '110101199001010003', 'F1', '男')]),
|
||||
)
|
||||
assert.deepEqual(duplicated.errors, [], '重复证件号不再拦截')
|
||||
assert.equal(duplicated.created, 2, '两行都落库')
|
||||
assert.equal(mock.listAthletes().length, before, '两行都命中已有人员,不新增档案')
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010003')
|
||||
})
|
||||
|
||||
test('导入:证件号撞别人按行报错,复用同一个人时不算差异', async () => {
|
||||
test('导入:同一证件号报多个组别不报错,逐行落库并给出提示', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const idCard = '110101199001010003'
|
||||
const before = mock.listAthletes().length
|
||||
// 同一个人报两个小项:号码布也可以照抄(号码布不再是唯一标识)。
|
||||
const result = await mock.importEntries(
|
||||
csv([
|
||||
rowAt('M100', '男子组', '多组别甲', idCard, 'F1', '男', '9901'),
|
||||
rowAt('M800', '男子组', '多组别甲', idCard, 'F1', '男', '9901'),
|
||||
]),
|
||||
)
|
||||
assert.deepEqual(result.errors, [], '同一证件号重复出现不再拦截')
|
||||
assert.equal(result.created, 2, '同一人的两条报名项都落库')
|
||||
assert.deepEqual(result.notices, [`证件号 ${idCard} 在本表中出现 2 次(同一人报名多个组别)`])
|
||||
assert.deepEqual(result.conflicts, [], '不同小项不是真重复')
|
||||
assert.equal(mock.listAthletes().length, before + 1, '同一个人只建一份档案')
|
||||
})
|
||||
|
||||
test('导入:同证件号 + 同一个小项同一个组别才是真重复,等用户决策', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const idCard = '110101199001010004'
|
||||
const body = [row('重复甲', idCard, 'F1', '男', '9401'), row('重复甲', idCard, 'F1', '男', '9402')]
|
||||
|
||||
const conflicted = await mock.importEntries(csv(body))
|
||||
assert.equal(conflicted.created, 1, '首行落库')
|
||||
assert.equal(conflicted.skipped, 0, '真重复行不进「跳过」,要等决策')
|
||||
assert.equal(conflicted.conflicts.length, 1)
|
||||
const [conflict] = conflicted.conflicts
|
||||
assert.equal(conflict?.duplicate, true, '标记为真重复行,面板才能说明为什么没有差异高亮')
|
||||
assert.equal(conflict?.row, 3)
|
||||
assert.deepEqual(conflict?.changedFields, ['bib'])
|
||||
assert.deepEqual(conflicted.notices, [`证件号 ${idCard} 在本表中出现 2 次(同一人报名多个组别)`])
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9401', '未决策前保持首行的值')
|
||||
|
||||
// 保留已有:真重复行不落库,同一个人一个字段都不动。
|
||||
const keptExisting = await mock.resolveImportConflicts(csv(body), [
|
||||
{ idCard, bib: '9402', action: 'keep-existing' },
|
||||
])
|
||||
assert.equal(keptExisting.skipped, 1)
|
||||
assert.deepEqual(keptExisting.conflicts, [])
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9401')
|
||||
|
||||
// 使用新数据:用真重复行的字段覆盖同一个人。
|
||||
const keptNew = await mock.resolveImportConflicts(csv(body), [{ idCard, bib: '9402', action: 'keep-new' }])
|
||||
assert.deepEqual(keptNew.conflicts, [])
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9402')
|
||||
})
|
||||
|
||||
test('导入:证件号优先命中,复用同一个人时不算差异', async () => {
|
||||
const idCard = '110101199001010004'
|
||||
const mock = new MockBaseData()
|
||||
// 库里没有「张三」:第一行按新人员建,证件号写进档案。
|
||||
@@ -188,21 +280,17 @@ test('导入:证件号撞别人按行报错,复用同一个人时不算差
|
||||
assert.deepEqual(first.conflicts, [])
|
||||
assert.ok(mock.listAthletes().some((athlete) => athlete.idCard === idCard))
|
||||
|
||||
// 同名同单位再来一行:姓名 + 单位命中同一个人,证件号相同不是差异。
|
||||
const again = await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
|
||||
// 同一证件号换一个小项再来一行:按证件号命中同一个人,不算冲突。
|
||||
const again = await mock.importEntries(csv([rowAt('M800', '男子组', '张三', idCard, 'F1', '男')]))
|
||||
assert.equal(again.created, 1)
|
||||
assert.deepEqual(again.conflicts, [])
|
||||
|
||||
// 同名但单位不同:姓名 + 单位找不到人,只能新建,而证件号已被 F1 的张三占用 → 行级报错。
|
||||
const taken = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
|
||||
assert.equal(taken.created, 0)
|
||||
assert.equal(taken.skipped, 1)
|
||||
assert.deepEqual(taken.conflicts, [])
|
||||
assert.equal(
|
||||
taken.errors[0],
|
||||
`第 2 行:证件号 ${idCard} 已被另一个人占用(姓名 张三,号码布 未填)`,
|
||||
'与服务端 idCardTakenError 同一句中文文案',
|
||||
)
|
||||
// 别的人拿着这个证件号来:按证件号命中张三,姓名 / 单位对不上 → 冲突面板,不是行级报错。
|
||||
const taken = await mock.importEntries(csv([row('李四', idCard, 'F2', '男')]))
|
||||
assert.equal(taken.skipped, 0)
|
||||
assert.deepEqual(taken.errors, [], '证件号命中那个人就不再报「已被占用」')
|
||||
assert.equal(taken.conflicts.length, 1)
|
||||
assert.deepEqual(taken.conflicts[0]?.changedFields, ['name', 'teamCode'])
|
||||
assert.equal(mock.listAthletes().filter((athlete) => athlete.name === '张三').length, 1)
|
||||
})
|
||||
|
||||
@@ -319,7 +407,14 @@ test('导入:号码布不同 → 按「同名的另一个人」新建并报告
|
||||
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
|
||||
assert.deepEqual(result.conflicts, [], '号码布不同不是冲突')
|
||||
assert.deepEqual(result.sameNameNewPersons, [
|
||||
{ name: '张伟', teamCode: 'JG', newBib: '999', existingBib: '101' },
|
||||
{
|
||||
name: '张伟',
|
||||
teamCode: 'JG',
|
||||
newIdCard: '110101199001010007',
|
||||
existingIdCard: '',
|
||||
newBib: '999',
|
||||
existingBib: '101',
|
||||
},
|
||||
])
|
||||
assert.equal(mock.listAthletes().length, before + 1)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.idCard, '110101199001010007')
|
||||
@@ -328,6 +423,49 @@ test('导入:号码布不同 → 按「同名的另一个人」新建并报告
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId, 1)
|
||||
})
|
||||
|
||||
// 证件号是唯一标识:号码布写错不影响认人,只把差异交给冲突决策。
|
||||
test('导入:证件号优先命中,号码布对不上也只是冲突', async () => {
|
||||
const mock = new MockBaseData()
|
||||
// 种子数据里 JG 的「张伟」号码布为 101;先给他登记证件号。
|
||||
const zhangWei = mock.listAthletes().find((athlete) => athlete.bib === '101')
|
||||
assert.ok(zhangWei !== undefined)
|
||||
const { id: athleteId, ...base } = zhangWei
|
||||
mock.updateAthlete(athleteId, { ...base, idCard: 'C30003' })
|
||||
|
||||
const before = mock.listAthletes().length
|
||||
const result = await mock.importEntries(csv([row('张伟', 'C30003', 'JG', '男', '7777')]))
|
||||
assert.equal(result.created, 0, '冲突行不落库')
|
||||
assert.equal(mock.listAthletes().length, before, '证件号命中那个人,不新建档案')
|
||||
assert.equal(result.conflicts.length, 1)
|
||||
assert.equal(result.conflicts[0]?.athleteId, athleteId)
|
||||
assert.deepEqual(result.conflicts[0]?.changedFields, ['bib'])
|
||||
})
|
||||
|
||||
// 姓名 + 单位相同、证件号不同 → 同名的另一个人:新建并报告(与号码布不同同一条路径)。
|
||||
test('导入:姓名 + 单位相同但证件号不同 → 按同名的另一个人新建并报告', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const first = await mock.importEntries(csv([rowAt('M100', '男子组', '同名甲', 'C10001', 'F1', '男')]))
|
||||
assert.equal(first.created, 1)
|
||||
assert.deepEqual(first.sameNameNewPersons, [])
|
||||
|
||||
const before = mock.listAthletes().length
|
||||
const result = await mock.importEntries(csv([rowAt('M800', '男子组', '同名甲', 'C20002', 'F1', '男')]))
|
||||
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
|
||||
assert.deepEqual(result.conflicts, [], '证件号不同不是冲突')
|
||||
assert.deepEqual(result.sameNameNewPersons, [
|
||||
{
|
||||
name: '同名甲',
|
||||
teamCode: 'F1',
|
||||
newIdCard: 'C20002',
|
||||
existingIdCard: 'C10001',
|
||||
newBib: '',
|
||||
existingBib: '',
|
||||
},
|
||||
])
|
||||
assert.equal(mock.listAthletes().length, before + 1)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === 'C10001')?.idCard, 'C10001', '原档案证件号不动')
|
||||
})
|
||||
|
||||
test('导入:同名同单位有多个人时一律进冲突面板,以第一条候选为代表', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const firstId = mock.listAthletes().find((athlete) => athlete.bib === '101')?.id
|
||||
|
||||
@@ -50,6 +50,7 @@ test('示例数据的组别就地更新:重名与不存在给与服务端一
|
||||
compare: seed.compare,
|
||||
recordEligible: seed.recordEligible,
|
||||
scored: seed.scored,
|
||||
awardMedals: seed.awardMedals,
|
||||
multiplier: seed.multiplier,
|
||||
status: seed.status,
|
||||
scheduledAt: null,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
/**
|
||||
* 示例数据模式(后端不可达 / mock 生效)下破坏性写操作的统一拦截(P1(17))。
|
||||
*
|
||||
* 以前「批量停用 / 批量彻底删除」会照常走 mock 分支:内存里把人删掉、还提示
|
||||
* 「已彻底删除 N 人」,操作员以为真删了。这里钉住三件事——
|
||||
* 示例数据模式下不发写请求、返回可识别的「未提交」结果;连上服务端时行为不变;
|
||||
* 拦截文案存在且页面入口会触发它。
|
||||
*/
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/** 记录每一次真实发出的请求;破坏性操作在示例数据模式下应当一条都不产生。 */
|
||||
let calls: string[] = []
|
||||
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
|
||||
|
||||
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? 'GET'} ${String(input)}`)
|
||||
return reply()
|
||||
}) as typeof fetch
|
||||
|
||||
const {
|
||||
ApiError,
|
||||
SAMPLE_DATA_BLOCKED,
|
||||
isSampleData,
|
||||
mockMode,
|
||||
mockReason,
|
||||
sampleDataBlockedError,
|
||||
sampleDataBlockedNotice,
|
||||
} = await import('../api/client')
|
||||
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||
|
||||
function blocked(error: unknown): boolean {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 切到「后端不可达 / mock 生效」这一态:全局降级为真。 */
|
||||
function enterSampleData(): void {
|
||||
mockMode.value = true
|
||||
mockReason.value = '测试:后端未就绪'
|
||||
baseDataFallback.value = false
|
||||
calls = []
|
||||
}
|
||||
|
||||
/** 切回「连得上服务端」这一态。 */
|
||||
function leaveSampleData(): void {
|
||||
mockMode.value = false
|
||||
mockReason.value = null
|
||||
baseDataFallback.value = false
|
||||
calls = []
|
||||
}
|
||||
|
||||
test('示例数据模式下批量停用被阻断:不发写请求,返回可识别的「未提交」结果', async () => {
|
||||
enterSampleData()
|
||||
|
||||
await assert.rejects(() => baseDataApi.athletes.batch('disable', [1, 2]), blocked)
|
||||
assert.deepEqual(calls, [], '示例数据模式下不应发出任何写请求')
|
||||
})
|
||||
|
||||
test('示例数据模式下批量彻底删除(预览与提交)都被阻断且不发请求', async () => {
|
||||
enterSampleData()
|
||||
|
||||
// dryRun 预览与真删是同一流程的两步,示例数据里连预览都不该发。
|
||||
await assert.rejects(() => baseDataApi.athletes.batch('purge', [1], true), blocked)
|
||||
await assert.rejects(() => baseDataApi.athletes.batch('purge', [1]), blocked)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('示例数据模式下单条彻底删除(人员 / 单位)也被阻断', async () => {
|
||||
enterSampleData()
|
||||
|
||||
await assert.rejects(() => baseDataApi.athletes.purge(1), blocked)
|
||||
await assert.rejects(() => baseDataApi.teams.purge(1), blocked)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('基础数据接口降级(仅页面级)同样算示例数据模式', async () => {
|
||||
// 服务端整体正常、只有基础数据接口未实现:mockMode 为 false,页面却仍在用示例数据。
|
||||
mockMode.value = false
|
||||
baseDataFallback.value = true
|
||||
calls = []
|
||||
|
||||
assert.equal(isSampleData(), true, '统一判定要同时看全局与页面级降级')
|
||||
await assert.rejects(() => baseDataApi.athletes.batch('disable', [1]), blocked)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('连上服务端时行为不变:真发请求并原样带回服务端结果', async () => {
|
||||
leaveSampleData()
|
||||
reply = () => jsonResponse({ ok: true, data: { action: 'purge', items: [{ id: 1, ok: true }] } })
|
||||
|
||||
const result = await baseDataApi.athletes.batch('purge', [1])
|
||||
|
||||
assert.equal(result.succeeded, 1)
|
||||
assert.deepEqual(calls, ['POST /api/athletes/batch'])
|
||||
assert.equal(mockMode.value, false)
|
||||
})
|
||||
|
||||
test('拦截错误与提示文案可识别:说清是示例数据、未连接服务端', () => {
|
||||
assert.equal(SAMPLE_DATA_BLOCKED, 'SAMPLE_DATA_BLOCKED')
|
||||
|
||||
const error = sampleDataBlockedError('批量彻底删除')
|
||||
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
|
||||
assert.match(error.message, /示例数据/)
|
||||
assert.match(error.message, /未连接服务端/)
|
||||
assert.match(error.message, /批量彻底删除/)
|
||||
|
||||
const notice = sampleDataBlockedNotice('批量彻底删除')
|
||||
assert.equal(notice.title, '示例数据模式不能批量彻底删除')
|
||||
assert.match(notice.detail, /未连接服务端/)
|
||||
})
|
||||
|
||||
// 项目没有组件测试工具(无 @vue/test-utils / jsdom),界面部分按仓库既有做法读源码断言。
|
||||
const ATHLETES_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/AthletesSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const TEAMS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/TeamsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const SPORTS_EVENTS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('运动员页的批量停用 / 批量彻底删除 / 单条彻底删除都先过统一拦截', () => {
|
||||
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('批量停用'\)/)
|
||||
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('批量彻底删除'\)/)
|
||||
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('彻底删除'\)/)
|
||||
})
|
||||
|
||||
test('单位与大小项页的破坏性入口同样先过统一拦截', () => {
|
||||
assert.match(TEAMS_SOURCE, /blockDestructiveWrite\('彻底删除'\)/)
|
||||
assert.match(SPORTS_EVENTS_SOURCE, /blockDestructiveWrite\('删除大项'\)/)
|
||||
assert.match(SPORTS_EVENTS_SOURCE, /blockDestructiveWrite\('删除小项'\)/)
|
||||
})
|
||||
@@ -18,6 +18,7 @@ function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
compare: 'asc',
|
||||
recordEligible: false,
|
||||
scored: true,
|
||||
awardMedals: true,
|
||||
multiplier: 1,
|
||||
status: 'pending',
|
||||
scheduledAt: null,
|
||||
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user