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:
AskaEth committed 2026-09-11 21:47:07 +08:00
1 parent 53a0cb225c
commit 45edfbeb71
115 files changed
+10104 -755

No files matched your search

+1 -1
View File
@@ -1 +1 @@
dev_0.1.7
dev_0.1.8
+1 -1
View File
@@ -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
View File
@@ -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) }
+190 -7
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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. 表里**不要合并单元格**、不要加标题行、不要插图;
+17
View File
@@ -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)。赛前导出一份留底,现场改乱了可以导回对照。
+26
View File
@@ -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 {
+55
View File
@@ -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)
}
})
}
}
+4 -1
View File
@@ -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,
}
}
+9 -7
View File
@@ -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)
}
}
+31 -9
View File
@@ -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,由调用方退回全局口径。
+109
View File
@@ -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)
}
}
+112 -1
View File
@@ -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 {
+2 -3
View File
@@ -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)},
+23 -4
View File
@@ -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) {
+20 -7
View File
@@ -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
+26
View File
@@ -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
+36 -4
View File
@@ -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 {
+7 -1
View File
@@ -371,7 +371,13 @@ type BaseDataRow struct {
// Line 是该行在工作表里的真实行号(1 起,含表头上方的标题行),行级报错直接用它。
Line int
// Cells 是「字段名 → 单元格原文(已去空白)」;Has 标记该字段的列有没有出现在表头里。
// 两者分开:列不存在 = 不改动(更新时沿用库里的值),列存在但为空 = 明确写空。
//
// 这里的 Has / 空格之分是留给上层的原料,具体怎么解释按列的性质定(解析只负责如实报告):
// - 文本列 / 年龄 / 布尔 / 权重 / 排序:列缺席 = 不动,列在而格子空 = 按该列口径处理
// (文本与年龄清空、布尔取「是」、权重取 1、排序保持原值)——与三处用户文档一致;
// - 枚举列(小项的 类型/结果模式/结果类型/比较/状态/平均口径、组别的 性别):
// 列缺席与格子空**都** = 不动,只有显式填值才覆盖。留空曾按「取系统默认」处理,
// 一次误清格就会把已发布小项打回待录入、把团体项目类型改成个人 —— 见 planEvents。
Cells map[string]string
Has map[string]bool
}
+1
View File
@@ -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
+24
View File
@@ -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
+73 -27
View File
@@ -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)
+199
View File
@@ -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) {
+67 -10
View File
@@ -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,
+55
View File
@@ -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)
+1
View File
@@ -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 {
+17
View File
@@ -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)
+27 -19
View File
@@ -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 {
+31 -41
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
}
+38 -23
View File
@@ -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)
}
}
+98 -24
View File
@@ -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
}
+695 -36
View File
@@ -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)
}
}
+10
View File
@@ -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 ""
}
+87
View File
@@ -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)
}
}
+59 -2
View File
@@ -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)
}
+108 -18
View File
@@ -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, &note, &e.AvgMode)
&scheduledAt, &venue, &e.SortOrder, &note, &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)
}
+196 -42
View File
@@ -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)
}
+72 -23
View File
@@ -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)
}
+6
View File
@@ -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)
}
}
+169
View File
@@ -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)
}
}
}
+174 -51
View File
@@ -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)
+4
View File
@@ -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))
+360
View File
@@ -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
}
+681
View File
@@ -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
}
+1 -1
View File
@@ -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("恢复后报名不该消失")
}
+19
View File
@@ -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 {
+5 -2
View File
@@ -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)))
})
}
}
+44
View File
@@ -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
}
+42 -6
View File
@@ -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
View File
@@ -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
}
+493 -2
View File
@@ -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)
}
+30 -21
View File
@@ -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()
+49 -10
View File
@@ -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 {
+21 -6
View File
@@ -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
+7 -1
View File
@@ -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
}
+6
View File
@@ -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
}
+402
View File
@@ -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)
}
})
}
+50 -36
View File
@@ -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 {
+19 -6
View File
@@ -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,结构保留。
+1
View File
@@ -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
View File
@@ -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);
+263
View File
@@ -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, &notNull, &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])
}
}
}
+62 -1
View File
@@ -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 再决定是否调用。
+83 -3
View File
@@ -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
}
/** 决策键 → 决策,作为面板上的暂存状态,点「应用决策」时才提交。 */
+125 -34
View File
@@ -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,
),
+307 -10
View File
@@ -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;
}
+137
View File
@@ -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
}
+15 -14
View File
@@ -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'))
})
+192 -54
View File
@@ -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\('删除小项'\)/)
})
+1
View File
@@ -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