From 631c8dd8e160c9cc0c3a89121b0a2d2a2f5688bd Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 11 Sep 2026 17:14:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=208=20=E6=9D=A1=E5=8A=9F=E8=83=BD=20+=20?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E5=8F=A3=E5=BE=84=E6=94=B9=E4=B8=BA=E5=8F=B7?= =?UTF-8?q?=E7=A0=81=E5=B8=83=E4=B8=BA=E5=87=86=EF=BC=88=E5=90=AB=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E9=80=80=E8=B5=9B/=E7=A7=BB=E5=87=BA=E3=80=81?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=85=A5=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E3=80=81=E7=99=BB=E5=BD=95=E6=9C=89=E6=95=88=E6=9C=9F?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证: ① 录入页姓名后直接显示号码布(原「号码」列保留) ② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用 entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条") ⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触) ③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩, 名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提: 奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现 「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单 ④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/ 改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对); 权重真的变了才触发该组重算(容差 1e-9) ④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报, dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解 新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、 跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是 ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0); 退赛同时清 results.rank(否则出现"0 分还挂着金牌") ⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致) ⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、 行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用) ⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 + Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie) 身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告); 同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留) 其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位); 大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块; 删除报名项/退赛/权重等改动都会重算并推榜单。 测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。 文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。 版本号 dev_0.1.5 → dev_0.1.6。 --- VERSION | 2 +- docs/使用手册.md | 118 +- docs/常见问题.md | 72 +- docs/报名名单导入规范.md | 38 +- docs/部署指南.md | 18 +- server/internal/auth/auth.go | 220 +++- server/internal/auth/session_ttl_test.go | 343 ++++++ server/internal/dto/settings.go | 25 + server/internal/export/basedata.go | 571 +++++++++ server/internal/export/basedata_test.go | 291 +++++ server/internal/export/entries.go | 217 ++++ server/internal/export/entries_test.go | 199 +++ server/internal/export/event.go | 34 +- server/internal/export/load.go | 16 +- server/internal/export/load_test.go | 125 ++ server/internal/httpapi/athlete_batch_test.go | 241 ++++ .../internal/httpapi/category_update_test.go | 368 ++++++ .../httpapi/deferred_settlement_test.go | 466 +++++++ server/internal/httpapi/dto.go | 3 + server/internal/httpapi/entries_batch.go | 370 ++++++ server/internal/httpapi/entries_batch_test.go | 411 +++++++ .../internal/httpapi/export_basedata_test.go | 527 ++++++++ .../internal/httpapi/export_entries_test.go | 106 ++ server/internal/httpapi/export_handlers.go | 1090 +++++++++++++++++ server/internal/httpapi/handlers_config.go | 85 +- .../internal/httpapi/handlers_config_test.go | 276 ++++- server/internal/httpapi/handlers_results.go | 21 + server/internal/httpapi/payload.go | 27 + server/internal/httpapi/repo.go | 318 +++++ server/internal/httpapi/repo_events.go | 169 ++- server/internal/httpapi/repo_import.go | 240 +++- server/internal/httpapi/repo_misc.go | 105 ++ server/internal/httpapi/scoring.go | 314 ++++- server/internal/httpapi/server.go | 15 + server/internal/httpapi/session_ttl_test.go | 330 +++++ .../httpapi/settlement_internal_test.go | 149 +++ server/internal/httpapi/system_test.go | 2 +- .../components/baseData/AthletesSection.vue | 296 ++++- .../components/baseData/CategoriesSection.vue | 97 +- .../admin/components/baseData/EntryPicker.vue | 2 +- .../components/baseData/ImportSection.vue | 26 +- .../components/baseData/ProfilesSection.vue | 70 ++ .../baseData/SportsEventsSection.vue | 13 +- .../admin/components/baseData/baseDataApi.ts | 24 +- .../components/baseData/entrySelection.ts | 16 +- .../components/baseData/eventRowActions.ts | 31 + .../admin/components/baseData/mockBaseData.ts | 336 +++-- .../admin/components/baseData/useBaseData.ts | 20 + .../admin/components/system/SessionPanel.vue | 208 ++++ .../components/system/sessionSettings.ts | 69 ++ web/src/admin/components/system/systemApi.ts | 110 ++ web/src/admin/layouts/AdminLayout.vue | 28 +- web/src/admin/pages/EntryPage.vue | 487 +++++++- web/src/admin/pages/ExportPage.vue | 398 +++++- web/src/admin/pages/ReviewPage.vue | 34 +- web/src/admin/pages/SystemPage.vue | 10 +- web/src/admin/pages/entryBatch.ts | 276 +++++ web/src/admin/pages/entryProgress.ts | 97 ++ web/src/admin/pages/entryRowDisplay.ts | 28 + web/src/admin/stores/auth.ts | 36 +- web/src/admin/tests/athlete-batch-api.test.ts | 135 ++ .../admin/tests/basedata-import-api.test.ts | 202 +++ .../admin/tests/category-update-api.test.ts | 122 ++ web/src/admin/tests/entry-batch-api.test.ts | 149 +++ web/src/admin/tests/entry-batch.test.ts | 117 ++ web/src/admin/tests/entry-progress.test.ts | 122 ++ web/src/admin/tests/entry-row-bib.test.ts | 40 + web/src/admin/tests/event-dblclick.test.ts | 61 + web/src/admin/tests/import-conflicts.test.ts | 163 ++- .../tests/mock-batch-and-category.test.ts | 169 +++ web/src/admin/tests/session-settings.test.ts | 167 +++ web/src/admin/tests/settlement.test.ts | 171 +++ web/src/display/components/pages/LivePage.vue | 105 +- web/src/display/fitFont.ts | 77 +- web/src/display/live.ts | 38 + web/src/display/tests/fitFont.test.ts | 85 +- web/src/display/tests/live-page.test.ts | 84 ++ web/src/shared/api.ts | 88 ++ web/src/shared/basedata.ts | 240 ++++ web/src/shared/types.ts | 18 +- 80 files changed, 12468 insertions(+), 519 deletions(-) create mode 100644 server/internal/auth/session_ttl_test.go create mode 100644 server/internal/export/basedata.go create mode 100644 server/internal/export/basedata_test.go create mode 100644 server/internal/export/entries.go create mode 100644 server/internal/export/entries_test.go create mode 100644 server/internal/httpapi/athlete_batch_test.go create mode 100644 server/internal/httpapi/category_update_test.go create mode 100644 server/internal/httpapi/deferred_settlement_test.go create mode 100644 server/internal/httpapi/entries_batch.go create mode 100644 server/internal/httpapi/entries_batch_test.go create mode 100644 server/internal/httpapi/export_basedata_test.go create mode 100644 server/internal/httpapi/export_entries_test.go create mode 100644 server/internal/httpapi/session_ttl_test.go create mode 100644 server/internal/httpapi/settlement_internal_test.go create mode 100644 web/src/admin/components/baseData/eventRowActions.ts create mode 100644 web/src/admin/components/system/SessionPanel.vue create mode 100644 web/src/admin/components/system/sessionSettings.ts create mode 100644 web/src/admin/pages/entryBatch.ts create mode 100644 web/src/admin/pages/entryProgress.ts create mode 100644 web/src/admin/pages/entryRowDisplay.ts create mode 100644 web/src/admin/tests/athlete-batch-api.test.ts create mode 100644 web/src/admin/tests/basedata-import-api.test.ts create mode 100644 web/src/admin/tests/category-update-api.test.ts create mode 100644 web/src/admin/tests/entry-batch-api.test.ts create mode 100644 web/src/admin/tests/entry-batch.test.ts create mode 100644 web/src/admin/tests/entry-progress.test.ts create mode 100644 web/src/admin/tests/entry-row-bib.test.ts create mode 100644 web/src/admin/tests/event-dblclick.test.ts create mode 100644 web/src/admin/tests/mock-batch-and-category.test.ts create mode 100644 web/src/admin/tests/session-settings.test.ts create mode 100644 web/src/admin/tests/settlement.test.ts create mode 100644 web/src/shared/basedata.ts diff --git a/VERSION b/VERSION index 6f2059d..cbeafc9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.1.5 +dev_0.1.6 diff --git a/docs/使用手册.md b/docs/使用手册.md index 66e3cf9..37e2b2a 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -72,6 +72,8 @@ 4. 组别页签旁边有**批次下拉**(`全部` / `第 1 批` / `第 2 批` / … / `未分批`),默认 `全部`(`未分批` 只在确实还有未分批的行时才出现)。选某一批后表格只显示那一批的人,方便录入员**只在自己这一批里找名字**,不用在一大堆人里翻; 5. 配了「按队内平均」通道的小项(例如「个人+团体」项目的团体通道),工具栏下方会出现 **「平均值口径」单选**:`只算有效成绩`(没有有效成绩的人不进分母)或 `全部报名人员都计入`(没成绩的按 0 计入平均)。点选后立即保存到该小项,不需要另外点保存;这是小项级设置,该小项的所有组别共用。 +**批次录入进度条**:工具栏正下方有一条横跨整行的进度条,显示当前筛选范围的录入进度,例如 `第 2 批 已录 5/8 · 3 条有错`(有错的行既不算「已录」,也会在发布时被防呆拦下,所以要单独报出来);**全部录完时文字变成「已录完 8/8」、进度条变绿**。分母就是当前筛选范围的行数——选了某一批就只数这一批。它和发布防呆、行上的状态徽标用的是**同一套判定**,不会出现「进度条说录完了、发布却说缺人」。 + **批次是什么**:层级是 **大项 → 小项 → 组别 → 批次**。批次只是「把一条组别里的人分成几组,方便分批录入 / 分批查找」的**录入与展示分组**——**不参与算分**:名次与积分仍然按「小项 + 组别」整体计算,分批与否不会让分数、名次发生任何变化(评审口径不会变成「按批排名」)。批次筛选也只影响这张表格显示哪些行,不影响发布、积分榜与大屏。 **怎么分批**(三种方式,可混用): @@ -88,7 +90,7 @@ ### 4.2 录入成绩 -表格列依次为:号码、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注。 +表格列依次为:号码、单位、运动员 / 队、成绩输入、名次、状态、积分(自动)、备注。**「运动员 / 队」这一列在姓名后面直接跟着该人的号码布**(灰色小字,例如 `张伟 101`):号码列仍然保留,这一处只是省得在长表格里左右横跳找号。 | 列 | 操作 | | --- | --- | @@ -153,7 +155,7 @@ 1. **存草稿**:保存当前组别的成绩,草稿**不会上屏**,也不计入积分榜;有未保存修改时页面会显示「有未保存修改」徽标。 - **未保存的成绩不会被静默丢掉**:有未保存修改时,点左侧菜单切到别的页面(以及浏览器前进 / 后退)会先弹出确认框,确认丢弃才离开;关闭或刷新浏览器时浏览器也会原生提示;切换项目本来就会先确认。选「取消」留在本页,改动都还在。 -2. **发布**:点击后弹出确认框,说明本次将发布多少人、还有多少人没成绩,以及影响的单位数与最大分差;确认后这批成绩立即上屏并重算积分榜。 +2. **发布**:点击后弹出确认框,说明本次将发布多少人、还有多少人没成绩,以及影响的单位数与最大分差;确认后这批成绩立即上屏并重算积分榜(若管理员开了**延迟结算**,则只上屏成绩,名次与榜单等该组录完再更新,见 4.6、10.5)。 - 发布前系统会自动把当前内容整组保存为草稿再发布,无需手动先存; - 示例数据模式下「发布」按钮不可用,避免把未落库的改动当成发布成功。 @@ -163,13 +165,19 @@ 1. 这一批跑完,把已出的成绩填上(其他还没跑的行保持空白即可); 2. 点「发布」(该组已有发布过的成绩、且还有没发的已填行时,按钮显示为「**发布本批**」); -3. 确认框会写明:`本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可。本次影响 … 个单位,最大分差变化 … 分。发布后立即上屏并重算积分榜。`——**看完再确认**,系统只发布已填的那 M 人,不会把没成绩的人当成 0 分发出去; -4. 确认后这批成绩立即上屏,**名次与积分先按已发布的这部分算**,大屏立刻有内容; +3. 确认框会写明:`本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可。本次影响 … 个单位,最大分差变化 … 分。发布后立即上屏并重算积分榜。`——**看完再确认**,系统只发布已填的那 M 人,不会把没成绩的人当成 0 分发出去(末句在延迟结算下会换成「名次与榜单等该组全部录完后再更新」,见下面); +4. 确认后这批成绩立即上屏;**实时结算(默认)下,名次与积分先按已发布的这部分算**,大屏立刻有内容(延迟结算下先不动,见下); 5. 下一批跑完,回到本页补录成绩,再点一次「发布本批」——系统会把两批放在一起重算名次与积分,先发的部分自动跟着更新; 6. 全部发完后按钮不再显示「本批」(并变为不可用),此时该组视为已全部发布,要改就得走「撤销发布 → 修改 → 重新发布」(见 4.7)。 > 只有「会被算错」的写法才会挡住发布(见 4.4);空格子只是「还没录」,属于提醒,不影响发布。审核页(第 5 节)也是同一套口径:只有写错或缺名次才挡「发布整组」,空成绩只是本次跳过。 +**如果管理员关掉了「分批发布时实时更新榜单」**(见 10.5),行为会变成这样:发布后**成绩照常上屏,但名次、积分与奖牌榜都不动**——要等**该项目该组所有人成绩都录完**(退赛 / 失格的人不算在等待里)才一次性结算并更新榜单。未结算期间: + +- 录入页在进度条下方显示一条提示:`延迟结算:本组 X/Y 人已出成绩,名次与榜单等该组全部录完后一起更新`(本页预览的名次仍是实时的),旁边就是「立即结算」按钮; +- 该组在**分项成绩 / 最新成绩里的名次显示 `—`**(成绩照常显示)——这是有意的,免得出现「有金牌但 0 分」这种自相矛盾的画面; +- 有人永远录不出成绩时(例如已退赛但没改状态),在录入页点 **「立即结算」**,系统按当前已发布成绩先结算一次榜单和奖牌;之后补录完还会再自动结算。 + ### 4.7 撤销发布 已发布的组别会出现「撤销发布」按钮: @@ -178,6 +186,20 @@ 2. 系统作废该组已产生的积分流水,大屏回退到发布前状态; 3. 修改后重新发布即可。全程留痕,可在「变更历史」查看。 +### 4.8 勾选与批量操作(退赛 / 恢复 / 移出报名) + +表格每行最左边有勾选框,表头那个勾选框是**全选当前筛选范围内的行**——它只勾当前组别、当前批次筛选下看得见的行,被筛掉的行不会被选上。勾选后工具栏里出现「已选 N 行」与三个按钮: + +| 按钮 | 作用 | 可恢复吗 | +| --- | --- | --- | +| **批量退赛** | 把选中的正常参赛者标记为**退赛(弃权)**:0 分、不占名次 | **可以**,用「批量恢复」撤回 | +| **批量恢复** | 把选中的退赛者改回「正常参赛」 | —— | +| **批量移出报名** | 把选中的报名项**物理删除**,连同他的成绩与积分流水一起删掉 | **不可以**,需二次确认 | + +- **批量退赛 / 恢复**会按新名次重算积分并推到大屏(若开了延迟结算,同样等该组录完才结算,见 4.6、10.5)。已经是「取消资格(dq)」「未出场(dns)」的行会被**跳过、不覆盖原判定**(跳过的人会在确认框里逐条列出原因,例如「已被判罚取消资格(dq),跳过」)。 +- **批量移出报名**的确认框会列出**将被删除的人**(姓名|号码布|单位,超过 20 人只列前 20 并报剩余数量)以及**将重算的小项与组别**——看完再确认,删了不能恢复。 +- 一次最多处理 **500 条**报名项,超出请分批提交;有未保存的成绩修改时,请先「存草稿」再批量操作;示例数据模式下不能批量操作。 + --- ## 5. 成绩审核 @@ -189,7 +211,7 @@ 3. 用「整组对比 / 逐条对比」切换,查看发布后各单位积分的变化; 4. 「发布影响」列出受影响单位与分值变化; 5. 操作: - - **发布整组**:确认框会写明本组还有多少条没有成绩、本次将发布已填的多少条;确认后**只发布已填的那部分**并重算积分榜,没录的保持未发布,补录后再发一次即可(分批发布,见 4.6); + - **发布整组**:确认框会写明本组还有多少条没有成绩、本次将发布已填的多少条;确认后**只发布已填的那部分**并重算积分榜(延迟结算下同样等该组录完才更新名次与榜单),没录的保持未发布,补录后再发一次即可(分批发布,见 4.6); - **退回**:当前版本服务端没有「退回」接口,此操作**仅在管理端本地标记**,不会改动成绩数据;刷新页面后标记可能消失。如需真正退回,请通知录入员到「成绩录入」修改后重新保存。 > 草稿态永远不会上屏;发布以「小项 + 组别」为一组整体生效,但**没录成绩的条目会被跳过**——它既不挡发布,也不会被当成 0 分(防呆口径与录入页一致,见 4.4)。 @@ -423,12 +445,20 @@ 3. 保存会整体替换该模板的分值明细,请核对后再保存; 4. 页面下方的「榜单口径(全局)」卡片设置**个人项目得分折算系数**(默认 1,步进 0.1,必须大于 0 且不超过 100):`单位总分 = Σ 只记单位的流水 + 系数 × Σ 记到个人的流水`。它是**全局**设置(键名 `standings.individual_to_team_weight`,不随某一张模板保存,所有模板与所有计分通道共用;导入规则包时,规则包里的同名配置会覆盖它),只有管理员能改,点「保存榜单口径」后榜单按新系数立即变化。「个人+团体」项目的个人通道已经用「计入单位总分」关掉了折算,这里的系数对它没有影响。 +**同一张卡片里的「分批发布时实时更新榜单」开关(默认开)** + +- **开(默认)**:每发布一批成绩就立即重算名次、各单位总分与奖牌榜,即 4.6 描述的行为; +- **关** = **延迟结算**:分批发布时**只把成绩上屏,不动积分与名次**;等**该项目该组所有批次都录完**(退赛 / 失格的人不算在等待里)才一次性结算,更新榜单与奖牌。未结算的组在分项成绩 / 最新成绩里的**名次显示 `—`**(成绩照常显示)——这是有意的,否则会出现「有金牌但 0 分」的自相矛盾; +- 有人永远录不出成绩时,到**成绩录入页**点 **「立即结算」** 兜底:按当前已发布成绩先结算一次,补录完还会再自动结算; +- 勾选 / 取消后点「保存结算方式」生效;只有管理员能改。 + ### 10.6 运动员 1. 点「新增运动员」,填写姓名 *、性别、年龄、所属单位 *、工号 / 学号、证件号、号码布等(年龄可留空,填了必须是 0–120 的整数); -2. **证件号(如身份证号)与号码布二者取其一**即可唯一标识一个人,用于区分重名;两个人员不能共用同一个证件号,也不能共用同一个号码布,重复会被拒绝;没有身份证信息的人可以只填号码布。已有历史数据两项可以都为空; +2. **号码布是单人的唯一标识**,同名同姓的人靠它区分,两个人的号码布不能重复;**证件号(如身份证号)是可选的档案信息**,报名导入**不按证件号认人**,但系统里**一个证件号也只能属于一个人**,与别人重复同样会被拒绝。没有身份证信息的人只填号码布就行;两项都不填也能保存档案,但这样的人员**不能在「报名导入 → 个人报名」里勾选**(系统认不出是谁)。已有历史数据两项可以都为空; 3. 每行有三个动作:**编辑**、**停用**(停用后按钮变成「启用」)、**彻底删除**; -4. 个人项目的报名项会关联到运动员,团体项目可以留空。 +4. 个人项目的报名项会关联到运动员,团体项目可以留空; +5. **多选与批量操作**:列表每行有勾选框,表头是「全选当前筛选结果」(只勾当前筛出来的人)。选中后顶部出现「已选 N 人」与 **批量停用** / **批量彻底删除** 两个按钮,整批只提交一个请求,不用一个个点;**批量彻底删除**会先预览将删掉谁、重算哪些小项,确认后才执行,规则与单个「彻底删除」完全一致(见下)。 **停用与彻底删除的区别** @@ -467,7 +497,7 @@ **个人报名**(一人一条报名) 1. 选择单位,按姓名 / 工号搜索并在运动员列表里勾选:已报名的运动员显示「已报名」且不可重复勾选;证件号与号码布都没有的运动员显示「缺证件号/号码布」、不能勾选,请先到「运动员」分区补齐其中一项; -2. 点「加入报名」,结果同样显示**新增 / 更新 / 跳过**数量;报名会自动带上该运动员的证件号(优先)或号码布。 +2. 点「加入报名」,结果同样显示**新增 / 更新 / 跳过**数量;报名会自动带上该运动员的证件号与号码布。 **团体报名**(一个单位一条报名) @@ -483,26 +513,29 @@ 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 的整数;同一份文件里同一个证件号或同一个号码布重复出现时,重复的行会报错; +3. **只要有姓名的行,`id_card` 与 `bib` 必须至少填一个**,否则该行报错并跳过;`gender` 只认「男」「女」,其他值忽略;`age` 可留空,填了必须是 0–120 的整数;**同一份文件里同一个号码布重复出现会报错并跳过**(号码布是唯一标识);同一个证件号重复出现**不再报错**,但证件号撞上系统里**另一个人**时会报「证件号 X 已被另一个人占用(姓名 Y,号码布 Z)」并跳过该行; 4. **`status` 列很宽容**:大小写不敏感(`DNS`、`Registered`、`registered` 都认),也认中文别名—— - `正常` / `正常参赛` / `参赛` → 正常参赛; - `退赛` / `弃赛` / `请假` → 退赛; - `取消资格` / `犯规` → 取消资格; - `未出场` / `未到` / `缺席` → 未出场。 留空即「正常参赛」。真正认不出的写法会给出中文行级错误、该行跳过,例如「第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空)」; -5. 人员匹配顺序: - 1. 先按 `id_card` 匹配已有人员; - 2. 没匹配到,再按 `bib`(号码布)匹配; - 3. 还没匹配到,按「姓名 + 单位」匹配且该人员证件号为空,视为同一人,把表里给的证件号 / 号码布 / 年龄补进去(不算冲突);如果该人员已有号码布且与表里不同,则算冲突; - 4. 都没匹配到则新建人员(姓名、性别、年龄、单位、证件号、号码布)——被「彻底删除」的人(见 10.6)档案已经不在库里,匹配不到他,再次导入会当作**新人员**重新建档,他以前的报名与成绩不会回来; -6. `bib` 列会同时写入人员档案与报名记录;从已有名单勾选生成的报名也会带上人员的证件号(优先)或号码布; +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. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。 **人员信息冲突的处理** -导入时如果某个证件号或号码布在系统里已经存在,但表里的姓名、性别、年龄、单位或号码布与已有人员不一致,这个人会被列为**冲突**,本次不写入(该人的报名也不会创建)。处理办法: +导入时如果按号码布(或「姓名 + 单位」唯一命中)找到的人在系统里已有记录,但表里的姓名、性别、年龄、单位或号码布与他的档案不一致,这个人会被列为**冲突**,本次不写入(该人的报名也不会创建)。处理办法: 1. 导入结果里会单独列出冲突人员,每条显示已有数据、新数据,以及不一致的字段(含年龄与号码布); 2. 用左侧复选框选中一条或多条(也可以全选),再点「保留已有数据」或「使用新数据」: @@ -512,13 +545,17 @@ > 「从已有名单勾选」走的是同一个导入通道,人员匹配与冲突处理规则完全一致。 +**「同名的另一个人」提醒(不是冲突)** + +如果表里某人的号码布与库里「同名 + 同单位」的人不同,系统按**同名的另一个人**给他**新建档案**,并在导入结果里单独列出一块:`以下 N 人按同名的另一个人新建了档案(号码布不同)`,逐条显示 `姓名(单位):原有号码布 XXX → 新号码布 XXX`。这些行**已经导入成功、不会拦截**,只是提醒你核对号码布有没有写错;若确认是本人写错了号,到「基础数据 → 运动员」把档案里的号码布改回来、并删掉多出来的那条档案即可。 + 种子等字段无法在勾选界面填写,仍需通过表格导入;号码布取自人员档案,勾选时会自动写入报名。团体报名登记的队员名单只存姓名,不会带动运动员档案。 --- ## 11. 导出 -页面分三个页签。 +页面分四个页签:**结果导出** / **基础数据** / **赛事项目包** / **规则包**。 ### 11.1 结果导出(xlsx) @@ -529,10 +566,12 @@ | 全部(单页) | 所有单位、所有项目、所有成绩**平铺在一张表**里(一个工作簿只有一张表),适合筛选与二次统计 | `赛事成绩总表(单页).xlsx` | | 分项成绩表 | 选择一个小项导出成绩与积分 | `分项成绩表.xlsx` | | 单位积分明细 | 选择一个单位,逐项列出得分来源 | `单位积分明细.xlsx` | +| 报名名单 | **本页下方的「报名名单」卡片**:按小项 / 组别 / 单位 / 批次筛选后导出报名数据(不填 = 全部)。13 列:小项编码、小项名、组别、单位编码、单位名、姓名、证件号、**号码布**、**批次**、性别、年龄、状态、备注;号码布按文本导出,前导零不会丢 | `报名名单.xlsx` | | 团体奖牌明细 | **在「积分榜 → 团体奖牌榜」页操作**:逐枚奖牌列出单位 / 大项 / 小项 / 组别 / 名次 / 奖牌 / 运动员、队,每个有奖牌的单位一张分表(末尾小计) | `团体奖牌明细.xlsx` | | 个人积分总明细 | **在「积分榜 → 个人积分榜」页操作**:逐条列出单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源,每个有得分的单位一张分表 | `个人积分总明细.xlsx` | -- 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位; +- 「分项成绩表」需先选择小项,「单位积分明细」需先选择单位;「报名名单」的四个筛选(小项 / 组别 / 单位 / 批次)**都可以留空**,留空就是不筛这一维度(全导); +- **录了但还没发布的成绩也会占一行**(「分项成绩表」「全部」「全部(单页)」都是):这类行的成绩列写 **`分数未录入`**、名次与积分列写 `—`、状态列写「草稿」,人不会从表上消失;**完全没有录成绩的报名行**(连草稿都没有)保持原样——各列留空、状态显示「未录入」; - **「全部」与「全部(单页)」的区别**:两者内容同源,覆盖的都是全部小项的全部报名 / 成绩;「全部」把每个小项放成一张分表(多 sheet 工作簿),「全部(单页)」把所有行平铺进同一张表,每行前面补「大项 / 小项 / 组别」三列以便脱离表名也能看懂。单页表的列是:大项、小项、组别、名次、号码、单位、运动员/队、成绩、积分、状态、备注; - **「团体奖牌明细」与「个人积分总明细」不在本页**,在「积分榜」页对应榜单的卡片头点「导出奖牌明细」「导出个人明细」(见 8.3);个人积分总明细里的「得分」与个人积分榜一致,不受榜单口径折算系数影响; - 导出文件由浏览器直接下载,表头、合并单元格与字体已按打印模板预设。 @@ -573,6 +612,28 @@ > 规则导入会立即改变积分榜与大屏,且**不能一键撤销**,纠错需重新导入旧规则包。 +### 11.4 基础数据(单位 / 大项 / 小项 / 组别) + +「基础数据」页签有**导出**与**导入**两张卡片,用于批量维护基础数据(单位 / 大项 / 小项 / 组别)。 + +**导出基础数据包**:点「导出 xlsx」导出一个工作簿,含**四张工作表:单位 / 大项 / 小项 / 组别**(组别表带所属小项编码)。列名是中文表头;导出的文件可以直接原样导回来,识别到的差异为 0。 + +**导入基础数据包**(**仅管理员**): + +1. 选择 `.xlsx` 文件(只认 xlsx:四张表装不进一个 CSV); +2. 点「**预览差异**」:先看到 `新增 N 条 / 更新 M 条 / 无变化 K 条 / 错误 K 条` 与**逐行变化**(哪个字段从什么变成什么),这一步**不写库**; +3. 确认无误后点「**确认写入**」才真正写库;**有错的行会被跳过,合法的行照常写入**(与报名导入一致);行级错误是中文并带**真实 Excel 行号**,例如 `单位 第 3 行:单位编码必填`。 + +几条现场要记住的规则: + +- **按编码匹配**:单位 / 大项按编码,小项按编码,组别按「小项编码 + 组别名」;**存在就更新、不存在就新建**; +- **绝不删除**:文件里没有的行(表里没有、或整张工作表没放)**不会被删掉**,也不会被停用; +- **列不在表里 = 不动该字段**;**列在表里但格子留空 = 按该列的默认口径处理**:简称 / 颜色 / 场地 / 备注等文本列与年龄上下限会**清空**,权重留空按 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`;布尔列(启用 / 计分 / 计破纪录)填 **是 / 否**;组别表的「性别」填 **男 / 女**(留空 = 不限); +- **最稳的用法**:先「导出基础数据包」,在导出件上直接改,再导回来——表头与取值天然对得上;手工整理的表格请照导出件的表头写; +- 被「彻底删除」的单位不在导出件里;导入**不会**把它翻出来(已有的隐藏记录只更新字段,仍是隐藏状态)。 + --- ## 12. 变更历史 @@ -602,7 +663,7 @@ ## 14. 系统设置 -页面分三个页签,均需要管理员权限。 +页面分四个页签:**用户管理** / **登录有效期** / **备份** / **赛事信息**。前三个需要管理员权限,非管理员只看到「赛事信息」(其余页签不显示)。 ### 14.1 用户管理 @@ -663,6 +724,21 @@ > 修改大屏标题、主题色、字体等会立即影响所有在线大屏,请避开颁奖或播报时段。 +### 14.4 登录有效期 + +「登录有效期」页签(**仅管理员**)设置**这台机器上所有人的登录能待多久**。它是机器级配置(存在系统库 `system.db` 里),不随赛事项目包走。 + +| 项 | 说明 | +| --- | --- | +| 登录有效期(小时) | 可填 **1–720** 的整数(最长 30 天),默认 **24 小时**;原先固定 12 小时的做法已取消 | +| 当前会话将于 X 过期 | 页面顶部与右上角用户菜单里都会显示当前登录的到期时间 | + +- **滑动续期**:只要在有效期内继续操作(两次操作的间隔不超过设定时长),到期时间会自动往后顺延——**不会因为「登录满 N 小时」被踢**。因此比赛期间可以从早用到晚不重新登录,例如把有效期设成 **72 小时**(三天); +- **改有效期只对之后的新登录生效**:已经登录的会话保持各自的到期时间,不会被当场踢下线; +- 页面开着不动时,管理端每 10 分钟会自动续期一次,所以只要页面还开着就不会掉线; +- ⚠️ **不是「永不掉线」**:如果超过**设定的有效期**(默认 24 小时)一直没有操作(例如浏览器关了、机器关机过夜),再打开时仍会要求重新登录。 +- 忘记密码时用 `scoreboard.exe passwd` 重置(见常见问题「忘记密码怎么办」)。 + --- ## 15. 修改密码 @@ -772,10 +848,10 @@ 结果导出和赛事包由浏览器下载到默认下载目录;服务端自身也会在数据目录下的 `exports\` 里生成临时包(导出完成后自动删除)。数据目录默认是 `%LOCALAPPDATA%\ScoreboardConsole\data`,详见部署指南第 3.4 节。 **Q:登录多久会过期?** -会话有效期 12 小时。过期后自动回到只读访客视图,重新登录即可。 +默认 **24 小时**,可由管理员在「系统设置 → 登录有效期」里改成 **1–720 小时**(最长 30 天);比赛期间建议设成 72 小时。会话是**滑动续期**的:有效期内继续操作会自动顺延到期时间,不会因为「登录满 N 小时」被踢;但超过设定的有效期一直没操作(比如浏览器关了、机器关机过夜),就需要重新登录。当前到期时间在右上角用户菜单与「系统设置 → 登录有效期」里都能看到。 **Q:为什么登录失败次数多了会被暂时拒绝?** -登录有频率限制(每个 IP 每分钟 5 次),写操作也有频率限制(每个用户每分钟 30 次)。稍等一分钟再试即可。 +登录有频率限制(每个 IP 每分钟 5 次),写操作也有频率限制(每个用户每分钟 120 次)。稍等一分钟再试即可。 **Q:为什么有些页面 / 按钮看不到或不可用?** 权限按角色控制:访客只能只读;变更历史、日志、系统设置、备份、用户管理、规则包与赛事项目包导入需要相应角色。请用对应账号登录。 diff --git a/docs/常见问题.md b/docs/常见问题.md index 83e8cd4..b00c2be 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -18,7 +18,7 @@ 访客只能只读查看;录入员可以录入、存草稿、发布、撤销发布、确认破纪录、做积分调整、控制大屏;管理员在此基础上还能管理用户、备份、看审计日志、导入规则包和赛事包。 **Q4:为什么登录后还是只读?** -可能未登录成功、会话已过期(有效期 12 小时),或页面处于示例数据模式。看右上角是否显示账号名与角色。 +可能未登录成功、会话已过期(登录有效期默认 24 小时、可配 1–720 小时,见 Q89),或页面处于示例数据模式。看右上角是否显示账号名与角色。 **Q5:可以建「访客账号」吗?** 不可以。访客是未登录时的默认身份,不需要也不允许建账号;可落库的角色只有管理员和录入员。 @@ -219,13 +219,13 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 ## 导出与导入 **Q43:能导出哪些文件?** -结果导出页有五类 Excel:**总表**(`总积分榜.xlsx`)、**全部**(`赛事成绩总表.xlsx`,多 sheet)、**全部(单页)**(`赛事成绩总表(单页).xlsx`,单张表平铺)、**分项成绩表**(`分项成绩表.xlsx`)、**单位积分明细**(`单位积分明细.xlsx`)。「积分榜」页另有四项:单位总分榜卡片头的**导出总明细**(`单位积分总明细.xlsx`)、团体奖牌榜卡片头的**导出奖牌明细**(`团体奖牌明细.xlsx`)、个人积分榜卡片头的**导出个人明细**(`个人积分总明细.xlsx`),以及展开单位后明细卡头的**导出该单位明细**(`单位积分明细.xlsx`)。另外可导出赛事项目包(zip)与规则包(YAML)。 +「导出 → 结果导出」页有六类 Excel:**总表**(`总积分榜.xlsx`)、**全部**(`赛事成绩总表.xlsx`,多 sheet)、**全部(单页)**(`赛事成绩总表(单页).xlsx`,单张表平铺)、**分项成绩表**(`分项成绩表.xlsx`)、**单位积分明细**(`单位积分明细.xlsx`),以及可筛选的**报名名单**(`报名名单.xlsx`,见 Q87)。「积分榜」页另有四项:单位总分榜卡片头的**导出总明细**(`单位积分总明细.xlsx`)、团体奖牌榜卡片头的**导出奖牌明细**(`团体奖牌明细.xlsx`)、个人积分榜卡片头的**导出个人明细**(`个人积分总明细.xlsx`),以及展开单位后明细卡头的**导出该单位明细**(`单位积分明细.xlsx`)。另外可导出赛事项目包(zip)、**基础数据包**(xlsx,见 Q88)与规则包(YAML)。 **Q44:导出文件保存在哪?** 浏览器默认下载目录。服务端生成的临时包位于 `data\exports\`,下载完成后自动删除。 -**Q45:谁能导入规则包 / 赛事项目包?** -只有管理员。两者都会整体替换数据:规则包导入后全量重算积分且不能一键撤销;赛事包覆盖同名项目前会二次确认。 +**Q45:谁能导入规则包 / 赛事项目包 / 基础数据包?** +三者都只有管理员能导入。规则包导入后全量重算积分且不能一键撤销;赛事包覆盖同名项目前会二次确认;**基础数据包**按编码「存在就更新、不存在就新建」,**绝不删除**(见 Q88)。 **Q46:导入赛事包后为什么看不到数据?** 因为导入只把包解到数据目录,**不会自动切换当前赛事**。服务端启动时只打开一个赛事项目(启动参数 `-project` 指定的那个;没带参数时用上次切换记下的,从未切换过则是 `default`),页面上看到的是它的数据——通常是导入前那个空赛事,所以看着像「没导进去」。 @@ -237,20 +237,27 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 **Q47:导入规则包后积分变了,能撤销吗?** 不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。 -**Q48:导入时提示证件号重复怎么办?** -证件号与号码布是人员的两项唯一标识,系统不允许两个人员共用同一个证件号,用来区分重名。 +**Q48:报名表里的证件号重复了怎么办?** +先分清两种「重复」,处理方式完全不同: -- **在「运动员」里新增 / 编辑时提示重复**:说明该证件号已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查证件号是否填错。已有历史数据可以没有证件号。 -- **表格导入时出现重复**:分两种情况。同一份文件里同一个证件号重复出现,重复的行会报错并跳过,改好后重新上传;证件号在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后逐条或多选决定「保留已有数据」还是「使用新数据」,没有做决定的人不会被写入。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。 +- **同一份表里同一个证件号出现多次**:**不再报错**,照常导入(同一个人报多项时,证件号本来就会重复)。证件号只是**档案信息,不参与认人**。 +- **证件号已经登记在系统里另一个人名下**(姓名、号码布都对不上):这一行会报 `第 N 行:证件号 X 已被另一个人占用(姓名 Y,号码布 Z)` 并**跳过该行**。系统里一个证件号只能属于一个人(避免把两个人的身份弄混)。处理办法:先核对证件号是不是抄错了——不是这个人的,把这一格留空即可(只填号码布也能导入);确实是他的,先到「基础数据 → 运动员」把另一条档案里的证件号清掉或改对,再重新上传。 +- **在「运动员」里新增 / 编辑时提示证件号重复**:同上,说明该证件号已属于另一个人。是同一个人的话直接用已有记录;证件号填错就改对。已有历史数据可以没有证件号。 + +详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。 **Q49:人员没有身份证,只有号码布,能报名吗?** -可以。证件号与号码布二者取其一即可作为人员唯一标识:在「运动员」里只填号码布也能保存;报名表里有姓名的行,`id_card` 与 `bib` 至少填一个就能导入;从已有名单勾选报名时,两项里有一项就能勾选。号码布会同时写入人员档案与报名记录。 +可以,这正是**号码布作为唯一标识**的用法:在「运动员」里只填号码布也能保存;报名表里有姓名的行,`id_card` 与 `bib` 至少填一个就能导入;从已有名单勾选报名时,两项里有一项就能勾选。号码布会同时写入人员档案与报名记录。反过来,证件号只是**可选的档案信息**——只填证件号、没有号码布也能导入,但系统是**按号码布认人**的(号码布为空时按「姓名 + 单位」认),所以能编号码布就编上,重名重姓时才分得清。 **Q50:号码布重复了怎么办?** -号码布和证件号一样是人员唯一标识,系统不允许两个人员共用同一个号码布。 +号码布是**单人的唯一标识**,系统不允许两个人员共用同一个号码布。 - **在「运动员」里新增 / 编辑时提示重复**:说明该号码布已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查号码布是否填错。 -- **表格导入时出现重复**:同一份文件里同一个号码布重复出现,重复的行会报错并跳过,改好后重新上传;号码布在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后决定「保留已有数据」还是「使用新数据」。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。 +- **同一份报名表里号码布重复出现**:重复的行会报错并跳过,改好后重新上传。 +- **号码布在系统里已有、但表里这个人跟他对不上**(姓名 / 性别 / 年龄 / 单位不同):会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后决定「保留已有数据」还是「使用新数据」。 +- **表里某人的号码布与库里「同名 + 同单位」的人不同**:系统按「**同名的另一个人**」给他**新建档案**(不报错),并在导入结果里单独列出 `姓名(单位):原有号码布 XXX → 新号码布 XXX` 提醒核对。多半是号码布写错了:到「基础数据 → 运动员」把档案里的号改回来、删掉多出来的那条档案即可。 + +详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。 **Q51:导入 CSV 时中文变成乱码,或者提示「报名表缺少列 event_code」怎么办?** **不用转码,直接传原文件就行。** 服务端会自己识别编码:UTF-8、带 BOM 的 UTF-8(Excel 的「CSV UTF-8」)、GBK / GB18030(中文 Windows 上 Excel「另存为 CSV」的默认编码)、UTF-16(PowerShell `Export-Csv` 的默认编码)都能正常导入。 @@ -272,7 +279,7 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 > 注意区分:顶部若只是显示「连接中断」,说明实时推送断了(数据可能不是最新),但写操作仍会照常提交给服务端并做校验。 **Q54:为什么登录 / 写操作偶尔被拒绝?** -系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。 +系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 **120 次**。稍等一分钟再试。 **Q55:数据目录在哪?** 默认 `%LOCALAPPDATA%\ScoreboardConsole\data`(当前用户的本地应用数据目录,升级 / 覆盖安装 / 卸载都不会删)。里面是 `system.db`(账号、会话)、`event_projs\<项目名>\`(每个赛事的 `event.db` 与上传资源 `uploads\`,图片与自定义字体都在里面,字体在 `uploads\fonts\`)、`backups\`、`exports\`。 @@ -516,3 +523,44 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 - 对话框不会超出屏幕,按钮与分页的点击区域也放大了,触屏点得中。 录入、发布、看榜这些日常操作手机上都能做;赛事配置这类条目较多的操作仍建议在电脑上完成。如果某台手机上仍显示不全,请把**机型、浏览器与屏幕宽度**(或截图)告诉维护人员——目前适配覆盖到 390px 宽的窄屏,桌面端布局不受影响。 + +**Q86:号码布和证件号,哪个才是系统认人的依据?** +**号码布。** 报名导入时的匹配顺序是「号码布 → 姓名 + 单位」;**证件号不参与认人**,只作为可选的档案信息写入 / 更新。不过系统里**一个证件号仍然只能属于一个人**,撞上别人的证件号会明确报错并指出占用者(见 Q48)。完整匹配规则见[报名名单导入规范](./报名名单导入规范.md)。 + +**Q87:怎么把报名名单导出来?** +到「导出 → 结果导出」页,往下找**「报名名单」**卡片:先按**小项 / 组别 / 单位 / 批次**筛选(都可以留空 = 不筛,即导全部),再点「导出报名名单」,得到 `报名名单.xlsx`。表里有 13 列:小项编码、小项名、组别、单位编码、单位名、姓名、证件号、号码布、批次、性别、年龄、状态(中文:正常参赛 / 已退赛 / 取消资格 / 未出场)、备注;号码布按文本导出,前导零不会丢。访客也能导出(只读)。 + +**Q88:基础数据(单位 / 大项 / 小项 / 组别)能批量导入导出吗?** +能。到「导出 → 基础数据」页: + +- **导出**:一个工作簿四张工作表(单位 / 大项 / 小项 / 组别,组别表带所属小项编码); +- **导入**(**仅管理员**,只认 `.xlsx`):按编码匹配,**存在就更新、不存在就新建,绝不删除**(表里没有的行不会被删、也不会被停用)。先点「预览差异」看「新增 / 更新 / 无变化 / 错误」与逐行变化,确认后再点「**确认写入**」;**有错的行会跳过、合法的行照常写入**,行级错误是中文并带真实 Excel 行号(如 `单位 第 3 行:单位编码必填`)。 + +要点:**列不在表里 = 不动该字段**;**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列(启用 / 计分等)按「是」,**排序留空保持原值**。表头行在第 4 行(导出件)或第 1 行(手工表)都能识别;枚举列要填英文规范值、布尔列填「是 / 否」。**最稳的做法是从系统导出、改完再导回**。详见使用手册 11.4。 + +**Q89:登录会不会用着用着被踢下线?「登录有效期」怎么设?** +默认有效期 **24 小时**,管理员可在「系统设置 → 登录有效期」里改成 **1–720 小时**(最长 30 天);比赛期间建议设成 **72 小时**。会话是**滑动续期**的:只要在有效期内继续操作(页面开着也会定期自动续期),到期时间就往后顺延——**不会因为「登录满 N 小时」被踢**。当前到期时间在右上角用户菜单与系统设置页都写着「当前会话将于 X 过期」。 + +⚠️ 但它不是「永不掉线」:超过设定的有效期一直没操作(浏览器关掉、机器关机过夜),再打开仍要重新登录。改设置**只对之后的新登录生效**,已经登录的会话不会被当场踢下线。 + +**Q90:发布后成绩上屏了,为什么积分榜没变、名次还是「—」?** +多半是管理员把「榜单口径」里的**「分批发布时实时更新榜单」关掉了**(默认是开的),也就是**延迟结算**:分批发布时只把成绩上屏,**积分与名次等该项目该组所有批次都录完才一次性结算**(退赛 / 失格的人不算在等待里)。未结算组的**名次显示 `—`**(成绩照常显示)是有意的,免得出现「有金牌但 0 分」这种自相矛盾的画面。 + +想让榜单立刻跟上:等该组录完会自动结算;确实有人永远录不出成绩时,在**成绩录入页**点 **「立即结算」**,按当前已发布成绩先结算一次。想改回实时结算:管理员到「基础数据 → 积分模板 → 榜单口径」,把「分批发布时实时更新榜单」勾上并点「保存结算方式」(见使用手册 10.5)。 + +**Q91:能不能一次把多个人退赛 / 恢复 / 移出报名?** +能,在「成绩录入」页操作:表格每行最左边勾选(表头是全选,**只作用于当前筛选范围**),选中后工具栏出现 **批量退赛** / **批量恢复** / **批量移出报名**。 + +- **批量退赛**:标记弃权(0 分、不占名次),**随时可以批量恢复**;已经是「取消资格 / 未出场」的行会被跳过、不覆盖原判定; +- **批量移出报名**:**物理删除**报名项连同他的成绩与积分流水,**不可恢复**,确认框会列出将被删的人与将重算的小项 / 组别; +- 一次最多 500 条;有未保存的成绩修改时请先「存草稿」再操作。 + +运动员列表也有类似的多选与**批量停用 / 批量彻底删除**(见使用手册 10.6)。 + +**Q92:新建单位 / 大项 / 小项时没填「启用」「计分」这类开关,会不会被静默关掉?** +不会。**漏传这些开关时会取默认值,而不是按「关」处理**: + +- **单位 / 大项**:漏传「启用」= **默认启用**(以前会静默建成「停用」,在界面上等于不存在);**更新**时漏传则沿用原来的状态,不会顺手把它停掉; +- **小项**:漏传「计分」「计破纪录」= **两个都默认打开**(以前会被静默建成「只排名不计分、不能破纪录」);更新时漏传同样沿用原值。 + +用**基础数据包**导入时同理:「启用 / 计分 / 计破纪录」列留空 = 「是」,这些列**不在表里 = 不改动**该字段(见 Q88)。 diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md index 43e2946..8ba2235 100644 --- a/docs/报名名单导入规范.md +++ b/docs/报名名单导入规范.md @@ -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` | @@ -60,11 +60,14 @@ - **团体项目**:**这一列留空**,表示"这是某个单位的一条团体报名",队员写在 `member_names` 里。 - 姓名为空的行,系统只建一条单位报名,不会去建运动员档案。 -### `id_card` 证件号 / `bib` 号码布 —— 二选一,至少填一个 -只要有姓名,**证件号与号码布至少要填一个**,否则无法区分同名同姓的两个人,这一行会被跳过,提示「填写了 athlete_name 就必须填 id_card 或 bib」。 +### `id_card` 证件号 / `bib` 号码布 —— 有姓名就至少填一个 +只要有姓名,**证件号与号码布至少要填一个**,否则无法确认是哪一个人,这一行会被跳过,提示「填写了 athlete_name 就必须填 id_card 或 bib」。 -- 优先认证件号;没有证件号就认号码布(比如没有身份证的、外聘人员)。 -- **同一份表里,同一个证件号或同一个号码布只能出现一次**,重复的会被跳过并报错。 +- **号码布是单人的唯一标识,也是系统认人的依据**:先按号码布找人,号码布为空或没找到时,再按「姓名 + 单位」找(完整顺序见本节的「系统怎么认人」)。 +- **证件号是可选的档案信息,不参与认人**:它只写进 / 更新人员档案,用来登记身份;没有身份证、外聘人员只填号码布就行。 +- **但一个证件号在系统里只能属于一个人**:如果表里给的证件号已经登记在**另一个人**名下(姓名、号码布都对不上),这一行会报 `证件号 X 已被另一个人占用(姓名 Y,号码布 Z)` 并跳过——证件号不会被抢过来,那个人也不会被改成表里的人。 +- **同一个证件号在同一份表里重复出现不再报错**(同一个人报多项时本来就会重复),照常导入。 +- **号码布不能在一份表里重复出现**:重号说明要么是填错、要么是把号码布给了两个人,重复的行会被跳过并报错。 - 号码布会同时写进人员档案和报名记录。 ### `gender` 性别 @@ -114,6 +117,16 @@ - 团体项目**可以只报单位、不写队员**(这一列留空即可),队员名单以后还能补。 - 系统界面里会把这条名单显示成「张三、李四、王五」。 +### 系统怎么认人(号码布 → 姓名 + 单位) +有姓名的行,系统按下面的顺序认人。**号码布是唯一标识,证件号只当档案信息,不参与这一步**: + +| 情况 | 系统怎么处理 | +|---|---| +| 表里的号码布在系统里已有 | 认作**同一个人**;若表里的姓名 / 性别 / 年龄 / 单位 / 号码布与他的档案对不上,会进「冲突」列表等你逐条决定(保留哪一侧),该行本轮不写入 | +| 号码布为空,或按号码布没找到 | 按「**姓名 + 单位**」找:
· 找不到 → 新建人员档案;
· 恰好一人、且他**还没有号码布** → 认定是同一人,此时若表里给了号码布,会**回填**到他的档案;
· 恰好一人、且他**已有号码布** → 仍认定是同一人(重传不带号码布的名单不会把每个人复制一份);
· 恰好一人、但他已有号码布**且与表里不同** → 按「**同名的另一个人**」**新建档案**,并在导入结果里单独列出(姓名 / 单位 / 原号码布 / 新号码布)提醒你核对,**不拦截、不报错**;
· 多人 → 分不清是谁,进「冲突」列表等逐条决定 | + +> 「同名的另一个人」多半是号码布写错了。收到这条提醒时,先核对新号码布是不是本人:是别人(真的同名),确认无误即可;是本人写错了号,到「基础数据 → 运动员」把档案里的号码布改回来,并把多出来的那条新档案删掉。 + --- ## 四、三个完整例子 @@ -157,7 +170,8 @@ - [ ] `event_code`、`team_code` 都是从系统里抄的编码,没有空格 - [ ] `category` 与系统里的组别名一字不差 - [ ] 个人行都有姓名,且都有证件号或号码布 -- [ ] 一份表里没有重复的证件号、没有重复的号码布 +- [ ] 一份表里**没有重复的号码布**(号码布是唯一标识,重号会报错) +- [ ] 证件号可以重复(同一个人报多项很正常),但每个证件号在系统里只属于一个人,别和别人的档案重号 - [ ] `status` 只用那 4 个英文词(大小写都行)或第三节列的中文别名,不写别的词;拿不准就留空 - [ ] `heat` 要么留空,要么是 1–999 的整数(不参与算分,只是录入分组) - [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的) @@ -179,10 +193,11 @@ | 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) | | 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 | | 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项唯一标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 | -| 第 N 行:证件号 / 号码布 … 在报名表内重复出现 | 同一份表里同一个人出现了两次 | 删掉重复行(同一个人报多个项目时,`event_code` 必须不同) | +| 第 N 行:号码布 XX 在报名表内重复出现 | 同一份表里同一个号码布出现了两次 | 删掉重复行(同一个人报多个项目时,`event_code` 必须不同;同一个号码布也不能给两个人) | +| 第 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 只读第一个工作表 | 把要导入的数据放在第一个工作表里 | @@ -194,10 +209,11 @@ - **新增**:这次新建的报名; - **更新**:这个人本来就有这个项目的报名,用表里的信息覆盖了(**不会产生重复报名**); - **跳过**:没能导入的行,会带着**行号**列出来,改好那几行重新上传即可。 + - 另有一份**「按同名的另一个人新建了档案(号码布不同)」**清单,单独列出姓名 / 单位 / 原号码布 / 新号码布。它不是错误:这些行**已经导入成功**,只是提醒你核对号码布有没有写错(见第三节的「系统怎么认人」)。 2. **重复上传同一份表是安全的**,不会把名单变成两份。改完再传一遍就行。 - ⚠️ 但要注意「更新」是**整条覆盖**:表里空着的格会把系统里原来的值清掉(队员名单、道次、号码布、**批次**都会被清空),`status` 空着会被重置成「正常参赛」。如果之前手工调整过某些人的状态或批次,**重传前请在表里把 `status`、`heat` 写清楚**。 3. 名单导入后,到「成绩录入」里录成绩、发布,积分榜就会自动更新。 -4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个证件号或号码布,系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。 +4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个号码布(或同一「姓名 + 单位」),系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。 5. **被「彻底删除」的单位则相反,导入时会「复活」**:管理员彻底删除某个单位,只是把它从单位列表里移除,它的报名与成绩一条都没删;表里再出现这个 `team_code`,系统按编码复用原来那条单位记录,清掉删除标记并让它重新出现在列表里,**原有的报名、成绩与积分都还在**,也不会新建第二个同名单位。 6. **别一口气连传几十次**:写操作限 **120 次/分钟/用户**,超了会返回「操作过于频繁,请稍后再试」。正常一次导入只算一次;真被限了就等一分钟再传,不要连点。 @@ -208,7 +224,7 @@ > 1. 用系统里下载的模板,**第一行不要动**; > 2. **一个人一行**;一个人报几项就写几行; > 3. `event_code`(小项)、`team_code`(单位)、`category`(组别)照抄系统里配好的,不自己编; -> 4. 每人要有**证件号或号码布**,至少一个; +> 4. 每人要有**证件号或号码布**,至少一个;**号码布是系统认人的唯一标识**(优先填、别重号),证件号只是可选档案信息、也不能和别人重号; > 5. 团体项目:姓名列留空,队员写进 `member_names`,多人用 `/` 隔开; > 6. 拿不准的列**留空**,不要写「无」「/」「—」这类符号; > 7. 表里**不要合并单元格**、不要加标题行、不要插图; diff --git a/docs/部署指南.md b/docs/部署指南.md index 585b76f..89ad472 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -469,7 +469,7 @@ http://<主机IP>:19540/display - `uploads/`:**全部上传资源**——赛事会标、大屏会标、大屏背景图、各参赛单位 logo,以及**上传的字体**(放在 `uploads/fonts/` 下,见使用手册 14.3)。导入时会连文件一起落到新项目目录的 `uploads/`,切换过去后图片直接显示、字体直接可用,不用重新上传; - `rules.yaml`:当前规则包的一份 YAML 副本,便于单独复用到别的赛事;规则本身存在赛事库里,导入后即刻生效,这份只是副本。 -导出界面同一页的「结果导出」标签可以把最终结果导成 xlsx:**总表**(总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿)、**全部**(总表 + 全部分项)、**全部(单页)**(所有成绩平铺在一张表)、**分项成绩表**(按小项)、**单位积分明细**(按单位)各自可单独导出打印;「积分榜」页三个榜单的卡片头还各有明细导出(**导出总明细** / **导出奖牌明细** / **导出个人明细**,见使用手册 8.3)。 +导出界面同一页的「结果导出」标签可以把最终结果导成 xlsx:**总表**(总积分榜 + 团体奖牌榜 + 个人积分奖牌榜,一个工作簿)、**全部**(总表 + 全部分项)、**全部(单页)**(所有成绩平铺在一张表)、**分项成绩表**(按小项)、**单位积分明细**(按单位)各自可单独导出打印,另有可筛选(小项 / 组别 / 单位 / 批次)的**报名名单**;「积分榜」页三个榜单的卡片头还各有明细导出(**导出总明细** / **导出奖牌明细** / **导出个人明细**,见使用手册 8.3)。基础数据(单位 / 大项 / 小项 / 组别)另有单独的基础数据包可导出 / 导入,见 8.7。 导入与切换: @@ -498,6 +498,22 @@ http://<主机IP>:19540/display > 只拷 `backups\` 里的 `.db` 快照也能救回成绩数据,但上传的背景图/会标/单位 logo **与自定义字体**在 `event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`),不拷这个目录会丢这些文件,大屏上表现为图片不显示、字体回落成默认字体。 +### 8.7 基础数据包导出 / 导入(单位 / 大项 / 小项 / 组别) + +赛前批量维护基础数据,或换一台机器时把基础数据整体搬过去,用「导出 → 基础数据」页签: + +| 操作 | 位置 | 说明 | +| --- | --- | --- | +| 导出 | 「导出 → 基础数据 → 导出 xlsx」 | 一个工作簿四张工作表:单位 / 大项 / 小项 / 组别(组别表带所属小项编码) | +| 导入 | 「导出 → 基础数据 → 选择 .xlsx → 预览差异 → 确认写入」 | **仅管理员**;只认 `.xlsx`(四张表装不进一个 CSV) | + +- **按编码匹配,存在就更新、不存在就新建;绝不删除**:文件里没有的行不会被删,也不会被停用; +- **两步走**:先「预览差异」(新增 / 更新 / 无变化 / 错误 + 逐行变化,这一步不写库),确认后再点「**确认写入**」;**有错的行会跳过,合法的行照常写入**,行级错误带**真实 Excel 行号**(如 `单位 第 3 行:单位编码必填`); +- **列不在表里 = 不动该字段**;**列在表里但格子空**:文本列与年龄上下限清空,权重按 1、布尔列按「是」,**排序留空保持原值**; +- 表头行在第 4 行(系统导出件,上面是标题与副标题)或第 1 行(手工整理的表)都能识别;枚举列要填英文规范值(如 `individual` / `time` / `published`),布尔列填「是 / 否」; +- 被「彻底删除」的单位不在导出件里,导入也**不会**把它翻出来(仍是隐藏状态); +- **这个包只含基础数据**,不含成绩、报名与规则;整场赛事的搬迁仍用赛事项目包(见 8.5)。赛前导出一份留底,现场改乱了可以导回对照。 + --- ## 9. 故障排查 diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go index 81f87ba..71f0bdc 100644 --- a/server/internal/auth/auth.go +++ b/server/internal/auth/auth.go @@ -12,7 +12,9 @@ import ( "fmt" "log/slog" "net/http" + "strconv" "strings" + "sync/atomic" "time" "golang.org/x/crypto/bcrypt" @@ -23,8 +25,33 @@ const ( CookieSession = "scoreboard_session" CookieCSRF = "csrf_token" - // DefaultSessionTTL 是会话有效期:12h。 - DefaultSessionTTL = 12 * time.Hour + // DefaultSessionTTL 是登录有效期的默认值:24h。 + // 原来是 12h:现场前一晚 20:00 登录,次日早上 8:00 整被踢出登录, + // 而那正是比赛进行中的时间——这正是本项需求要修的。 + DefaultSessionTTL = 24 * time.Hour + // DefaultSessionHours 与 DefaultSessionTTL 同值,供以「小时」为单位的配置读写共用。 + DefaultSessionHours = 24 + // MinSessionHours / MaxSessionHours 是面板可配的登录有效期范围:1 小时 ~ 720 小时(30 天)。 + MinSessionHours = 1 + MaxSessionHours = 720 + // SessionHoursKey 是系统库 settings 里存登录有效期的键(机器级配置,与赛事级 settings 不共用)。 + SessionHoursKey = "security.session_hours" + // SessionHoursSourceKey 记录 SessionHoursKey 是谁写的:面板写过的值一定是操作员的明确选择, + // 不能被下面的历史占位值规则改写。 + SessionHoursSourceKey = "security.session_hours_source" + // SessionHoursSourcePanel 是面板写入时登记的来源标记。 + SessionHoursSourcePanel = "panel" + // legacySessionHoursSeed 是 0005 迁移种下的历史占位值。 + // 在本项改动之前,没有任何代码读过 security.session_hours,所以库里还留着 12 + // 只可能是「没人配过」;它按「未配置」处理,用新的默认 24,否则新装的库会永远停在 12。 + legacySessionHoursSeed = "12" + // renewThreshold 是滑动续期的写入节流窗口:剩余寿命不足「有效期 - 1 分钟」时才回写 expires_at。 + // 每个请求都续期会让 sessions 变成热点写表(现场几十个客户端轮询), + // 而少顺延这 1 分钟对「会不会掉线」没有任何影响。 + renewThreshold = time.Minute + // touchInterval 是 last_seen_at 的回写节流窗口:没有续期时,活动时间最快每分钟写一次。 + // 一次页面加载会打若干个请求,逐个回写纯属放大写压力;last_seen_at 只用于排障,分钟级足够。 + touchInterval = time.Minute // DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。 DefaultBcryptCost = 12 // DefaultLoginPerMinute 登录限流:5 次/分钟/IP。防爆破用,别跟着写限流一起放大。 @@ -73,7 +100,10 @@ type Session struct { // Options 是认证服务的可调参数;零值即文档默认。 type Options struct { - Logger *slog.Logger + Logger *slog.Logger + // SessionTTL 显式指定登录有效期(测试 / 嵌入用)。优先级最低的那一档默认值: + // 只要系统库 settings 里有有效的 security.session_hours(面板写的就是它),就以面板为准; + // 这里只在系统库里没有配置时兜底(例如测试用的空库)。 SessionTTL time.Duration BcryptCost int LoginPerMinute int @@ -88,12 +118,14 @@ type Options struct { type Service struct { db *sql.DB log *slog.Logger - sessionTTL time.Duration bcryptCost int secureCookies bool now func() time.Time - loginLimiter *Limiter - writeLimiter *Limiter + // ttl 是当前生效的登录有效期;面板可以随时改(SetSessionTTL), + // 所以每个请求都会读它,必须用原子量而不是普通字段。 + ttl atomic.Int64 + loginLimiter *Limiter + writeLimiter *Limiter } // New 构建认证服务;db 为系统库(data/system.db)。 @@ -102,9 +134,14 @@ func New(db *sql.DB, opts Options) *Service { if logger == nil { logger = slog.Default() } - ttl := opts.SessionTTL - if ttl <= 0 { - ttl = DefaultSessionTTL + // 优先级:面板(系统库设置)> 启动参数 / 配置文件 > 内置默认。 + // 面板排在最前是为了不出现「改了面板没反应」:只要系统库里有一个有效值, + // 它就是这个进程的有效期,别处的默认值一律让位。 + ttl, source := DefaultSessionTTL, "内置默认" + if dbTTL, ok := sessionTTLFromSettings(db); ok { + ttl, source = dbTTL, "系统设置" + } else if opts.SessionTTL > 0 { + ttl, source = opts.SessionTTL, "启动参数" } cost := opts.BcryptCost if cost <= 0 { @@ -122,20 +159,86 @@ func New(db *sql.DB, opts Options) *Service { if writeLimit == 0 { writeLimit = DefaultWritePerMinute } - return &Service{ + logger.Info("登录有效期", "hours", ttl.Hours(), "source", source, + "滑动续期", true, "节流窗口", renewThreshold.String()) + s := &Service{ db: db, log: logger, - sessionTTL: ttl, bcryptCost: cost, secureCookies: opts.Secure, now: now, loginLimiter: NewLimiter(loginLimit, time.Minute, now), writeLimiter: NewLimiter(writeLimit, time.Minute, now), } + s.ttl.Store(int64(ttl)) + return s } -// SessionTTL 返回会话有效期。 -func (s *Service) SessionTTL() time.Duration { return s.sessionTTL } +// SessionTTL 返回当前生效的登录有效期。 +func (s *Service) SessionTTL() time.Duration { return time.Duration(s.ttl.Load()) } + +// SetSessionTTL 更新登录有效期。只对之后创建的会话与之后的续期生效: +// 已存在会话的 expires_at 一律不动——改设置不该把正在录入的人踢下线。 +func (s *Service) SetSessionTTL(d time.Duration) { + if d <= 0 { + return + } + s.ttl.Store(int64(d)) +} + +// ParseSessionHours 解析登录有效期(小时);非法或超出 1~720 一律判为无效。 +func ParseSessionHours(raw string) (int, bool) { + hours, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || hours < MinSessionHours || hours > MaxSessionHours { + return 0, false + } + return hours, true +} + +// EffectiveSessionHours 算出实际生效的小时数。 +// 规则:面板写过的值原样生效(含操作员明确选的 12);迁移种下的历史占位值 12 +// 因为从来没有代码读过,按「未配置」处理,回落到新默认 24;缺键或非法同样是默认值。 +func EffectiveSessionHours(values map[string]string) int { + raw := strings.TrimSpace(values[SessionHoursKey]) + if hours, ok := ParseSessionHours(raw); ok { + if strings.TrimSpace(values[SessionHoursSourceKey]) != "" || raw != legacySessionHoursSeed { + return hours + } + } + return DefaultSessionHours +} + +// sessionTTLFromSettings 从系统库 settings 读登录有效期;没有有效配置(缺键或非法)时 ok=false, +// 由调用方回落到启动参数 / 内置默认。数据库不可用不当作 fatal:认证要能带着默认值起来。 +func sessionTTLFromSettings(db *sql.DB) (time.Duration, bool) { + if db == nil { + return 0, false + } + values := make(map[string]string, 2) + rows, err := db.Query(`SELECT key, value FROM settings WHERE key IN (?, ?)`, + SessionHoursKey, SessionHoursSourceKey) + if err != nil { + slog.Warn("读取登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours) + return 0, false + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + slog.Warn("解析登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours) + return 0, false + } + values[k] = v + } + if err := rows.Err(); err != nil { + slog.Warn("读取登录有效期失败,本次用默认值", "err", err, "hours", DefaultSessionHours) + return 0, false + } + if _, ok := values[SessionHoursKey]; !ok { + return 0, false + } + return time.Duration(EffectiveSessionHours(values)) * time.Hour, true +} // AllowLogin 按 IP 限流登录。 func (s *Service) AllowLogin(ip string) bool { return s.loginLimiter.Allow(ip) } @@ -301,20 +404,22 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er hash := hashToken(token) var ( - u User - sess Session - expiresAt string - role string + u User + sess Session + expiresAt string + lastSeenRaw string + role string ) err := s.db.QueryRowContext(ctx, `SELECT s.id, s.user_id, s.expires_at, COALESCE(s.ip, ''), COALESCE(s.user_agent, ''), + COALESCE(s.last_seen_at, ''), u.id, u.username, u.display_name, u.role, u.active, u.must_change_password, COALESCE(u.last_login_at, '') FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.expires_at > ? AND u.active = 1`, hash, s.now().UTC().Format(time.DateTime)). - Scan(&sess.ID, &sess.UserID, &expiresAt, &sess.IP, &sess.UserAgent, + Scan(&sess.ID, &sess.UserID, &expiresAt, &sess.IP, &sess.UserAgent, &lastSeenRaw, &u.ID, &u.Username, &u.DisplayName, &role, &u.Active, &u.MustChangePassword, &u.LastLoginAt) switch { case errors.Is(err, sql.ErrNoRows): @@ -323,6 +428,7 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er return nil, nil, fmt.Errorf("查询会话: %w", err) } + now := s.now().UTC() exp, err := parseDBTime(expiresAt) if err != nil { return nil, nil, err @@ -330,8 +436,26 @@ func (s *Service) Lookup(ctx context.Context, token string) (*User, *Session, er sess.ExpiresAt = exp u.Role = ParseRole(role) - _, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, - s.now().UTC().Format(time.DateTime), sess.ID) + // 滑动续期:会话只要还在有效期内被使用,就把到期时间顺延到 now+有效期, + // 而不是「登录满 N 小时必死」——现场前一晚登录、比赛当天一直在用的人不该掉线。 + // + // 写入节流:只有剩余寿命已不足「有效期 - renewThreshold(1 分钟)」时才回写 expires_at。 + // 否则每点一次按钮、每次轮询都要写一遍 sessions,把单连接 sqlite 变成热点写表; + // 少顺延这 1 分钟不会让任何人提前掉线(前一次续期至少还剩一个有效期)。 + if ttl := s.SessionTTL(); ttl > renewThreshold && exp.Sub(now) < ttl-renewThreshold { + renewed := now.Add(ttl) + _, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?`, + now.Format(time.DateTime), renewed.Format(time.DateTime), sess.ID) + sess.ExpiresAt = renewed + return &u, &sess, nil + } + // 没到续期点时也不是每个请求都写:last_seen_at 最快每 touchInterval 回写一次。 + // 一个页面加载会打七八个请求,逐个写一遍纯属放大写压力,而「上次活动时间」精确到分钟就够用。 + lastSeen, err := parseDBTime(lastSeenRaw) + if err != nil || now.Sub(lastSeen) >= touchInterval { + _, _ = s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id = ?`, + now.Format(time.DateTime), sess.ID) + } return &u, &sess, nil } @@ -362,7 +486,7 @@ func (s *Service) createSession(ctx context.Context, userID int64, ip, userAgent if err != nil { return "", nil, err } - expiresAt := s.now().UTC().Add(s.sessionTTL) + expiresAt := s.now().UTC().Add(s.SessionTTL()) _, err = s.db.ExecContext(ctx, `INSERT INTO sessions (id, user_id, token_hash, expires_at, ip, user_agent, last_seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, @@ -377,54 +501,64 @@ func (s *Service) createSession(ctx context.Context, userID int64, ip, userAgent // SetSessionCookie 写入 HttpOnly + SameSite=Lax 的会话 Cookie。 func (s *Service) SetSessionCookie(w http.ResponseWriter, token string) { + setSessionCookie(w, s.secureCookies, token, int(s.SessionTTL().Seconds())) +} + +// RefreshSessionCookies 按会话到期时间重发会话与 CSRF Cookie。 +// +// 滑动续期之后必须重发:Cookie 的 Max-Age 是浏览器端的硬期限, +// 不跟着续期走的话,浏览器会在「登录时刻 + 有效期」处把 Cookie 丢掉, +// 服务端续得再久也留不住人。CSRF 沿用原值(不轮换),避免和正在飞行的写请求打架。 +// 到期时间已过时不重发——调用方不该给一个已经失效的会话续 Cookie。 +func (s *Service) RefreshSessionCookies(w http.ResponseWriter, sessionToken, csrfToken string, expiresAt time.Time) { + life := expiresAt.Sub(s.now()) + if life <= 0 || sessionToken == "" { + return + } + seconds := int(life.Seconds()) + setSessionCookie(w, s.secureCookies, sessionToken, seconds) + if csrfToken != "" { + setCSRFCookie(w, s.secureCookies, csrfToken, seconds) + } +} + +func setSessionCookie(w http.ResponseWriter, secure bool, token string, maxAge int) { http.SetCookie(w, &http.Cookie{ Name: CookieSession, Value: token, Path: "/", - MaxAge: int(s.sessionTTL.Seconds()), + MaxAge: maxAge, HttpOnly: true, - Secure: s.secureCookies, + Secure: secure, SameSite: http.SameSiteLaxMode, }) } // ClearSessionCookie 删除会话 Cookie。 func (s *Service) ClearSessionCookie(w http.ResponseWriter) { - http.SetCookie(w, &http.Cookie{ - Name: CookieSession, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - Secure: s.secureCookies, - SameSite: http.SameSiteLaxMode, - }) + setSessionCookie(w, s.secureCookies, "", -1) } // SetCSRFCookie 写入可被前端读取的 CSRF Cookie(双提交 Cookie 方案)。 func (s *Service) SetCSRFCookie(w http.ResponseWriter, token string) { + setCSRFCookie(w, s.secureCookies, token, int(s.SessionTTL().Seconds())) +} + +func setCSRFCookie(w http.ResponseWriter, secure bool, token string, maxAge int) { http.SetCookie(w, &http.Cookie{ Name: CookieCSRF, Value: token, Path: "/", - MaxAge: int(s.sessionTTL.Seconds()), + MaxAge: maxAge, HttpOnly: false, - Secure: s.secureCookies, + Secure: secure, SameSite: http.SameSiteLaxMode, }) } // ClearCSRFCookie 删除 CSRF Cookie。 func (s *Service) ClearCSRFCookie(w http.ResponseWriter) { - http.SetCookie(w, &http.Cookie{ - Name: CookieCSRF, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: false, - Secure: s.secureCookies, - SameSite: http.SameSiteLaxMode, - }) + setCSRFCookie(w, s.secureCookies, "", -1) } // NewCSRFToken 生成新的 CSRF Token。 diff --git a/server/internal/auth/session_ttl_test.go b/server/internal/auth/session_ttl_test.go new file mode 100644 index 0000000..907a7a4 --- /dev/null +++ b/server/internal/auth/session_ttl_test.go @@ -0,0 +1,343 @@ +package auth + +import ( + "context" + "database/sql" + "io" + "log/slog" + "path/filepath" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +// 登录有效期的解析与新默认值:迁移种下的历史占位值 12 从未被任何代码读过, +// 按「未配置」处理,否则新装的库会永远停在 12(现场前一晚登录、次日比赛当天掉线)。 +func TestEffectiveSessionHours(t *testing.T) { + for _, tc := range []struct { + name string + values map[string]string + want int + }{ + {"历史占位值 12(无面板标记)按未配置处理", map[string]string{SessionHoursKey: "12"}, 24}, + {"面板明确选过的 12 原样生效", map[string]string{SessionHoursKey: "12", SessionHoursSourceKey: "panel"}, 12}, + {"面板写的 72 生效", map[string]string{SessionHoursKey: "72", SessionHoursSourceKey: "panel"}, 72}, + {"未配置回默认 24", map[string]string{}, 24}, + {"上界 720", map[string]string{SessionHoursKey: "720"}, 720}, + {"下界 1", map[string]string{SessionHoursKey: "1"}, 1}, + {"0 非法回默认", map[string]string{SessionHoursKey: "0"}, 24}, + {"721 非法回默认", map[string]string{SessionHoursKey: "721"}, 24}, + {"非数字非法回默认", map[string]string{SessionHoursKey: "十二"}, 24}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := EffectiveSessionHours(tc.values); got != tc.want { + t.Fatalf("EffectiveSessionHours(%v) = %d,期望 %d", tc.values, got, tc.want) + } + }) + } +} + +func TestParseSessionHours(t *testing.T) { + for raw, want := range map[string]int{"1": 1, "24": 24, "720": 720, " 48 ": 48} { + got, ok := ParseSessionHours(raw) + if !ok || got != want { + t.Fatalf("ParseSessionHours(%q) = %d,%v,期望 %d,true", raw, got, ok, want) + } + } + for _, raw := range []string{"", "0", "-1", "721", "24.5", "abc"} { + if _, ok := ParseSessionHours(raw); ok { + t.Fatalf("ParseSessionHours(%q) 应判为非法", raw) + } + } +} + +// 系统库里的登录有效期要真正被认成会话寿命;显式传入的 Options.SessionTTL 优先(测试 / 嵌入用)。 +func TestSessionTTLFromSystemSettings(t *testing.T) { + for _, tc := range []struct { + name string + seed map[string]string + optsTTL time.Duration + want time.Duration + }{ + {"历史占位值 12 视为未配置 → 24h", map[string]string{SessionHoursKey: "12"}, 0, 24 * time.Hour}, + {"面板写的 1 → 1h", map[string]string{SessionHoursKey: "1", SessionHoursSourceKey: "panel"}, 0, time.Hour}, + {"面板里的值优先于启动参数", map[string]string{SessionHoursKey: "1", SessionHoursSourceKey: "panel"}, 90 * time.Minute, time.Hour}, + {"系统库没有配置时用启动参数兜底", nil, 90 * time.Minute, 90 * time.Minute}, + } { + t.Run(tc.name, func(t *testing.T) { + db := newSettingsDB(t, tc.seed) + svc := New(db, Options{Logger: quietLogger(), SessionTTL: tc.optsTTL}) + if got := svc.SessionTTL(); got != tc.want { + t.Fatalf("SessionTTL() = %s,期望 %s", got, tc.want) + } + }) + } +} + +// 滑动续期:剩余寿命少于「有效期 - 节流窗口」时才回写,且回写后到期时间顺延到 now+有效期。 +func TestLookupSlidesExpiryWithThrottle(t *testing.T) { + db := newSettingsDB(t, nil) + now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) + svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour, + Now: func() time.Time { return now }}) + userID := insertUser(t, db, "admin") + token := insertSession(t, db, userID, now.Add(24*time.Hour)) + + // 刚创建的会话:剩余 = 有效期,不写 expires_at(首次请求不该产生额外写)。 + if _, sess, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("查询会话: %v", err) + } else if !sess.ExpiresAt.Equal(now.Add(24 * time.Hour)) { + t.Fatalf("新鲜会话的到期时间不该变,得到 %s", sess.ExpiresAt) + } + if got := dbExpiry(t, db); got != now.Add(24*time.Hour).Format(time.DateTime) { + t.Fatalf("新鲜会话不应回写 expires_at,库里是 %s", got) + } + + // 剩余 23h58m(不足 24h-1m):下一次请求应把到期时间顺延到 now+24h。 + before := now.Add(24*time.Hour - 2*time.Minute) + setExpiry(t, db, before) + if _, sess, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("查询会话: %v", err) + } else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) { + t.Fatalf("应顺延到 %s,得到 %s", want, sess.ExpiresAt) + } + if got, want := dbExpiry(t, db), now.Add(24*time.Hour).Format(time.DateTime); got != want { + t.Fatalf("库里到期时间应为 %s,得到 %s", want, got) + } + + // 节流:紧接着的多次请求既不写 expires_at,也不写 last_seen_at—— + // 现场一次页面加载会打七八个请求,逐个写库纯属放大写压力。 + renewed := dbExpiry(t, db) + seen := dbLastSeen(t, db) + for i := 0; i < 5; i++ { + now = now.Add(time.Second) + if _, _, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("第 %d 次查询会话: %v", i, err) + } + if got := dbExpiry(t, db); got != renewed { + t.Fatalf("节流窗口内不该回写 expires_at:%s → %s", renewed, got) + } + if got := dbLastSeen(t, db); got != seen { + t.Fatalf("节流窗口内不该回写 last_seen_at:%s → %s", seen, got) + } + } + + // 越过节流窗口(1 分钟)后再次顺延:活跃会话大约每分钟才写一次库。 + now = now.Add(5 * time.Minute) + if _, sess, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("查询会话: %v", err) + } else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) { + t.Fatalf("越过节流窗口后应顺延到 %s,得到 %s", want, sess.ExpiresAt) + } + if got, want := dbLastSeen(t, db), now.Format(time.DateTime); got != want { + t.Fatalf("续期时活动时间应一起回写为 %s,得到 %s", want, got) + } +} + +// 活动时间的节流不会影响续期:会话剩余寿命超过当前有效期(例如有效期被调短)时, +// 只回写 last_seen_at,expires_at 保持原值。 +func TestLookupTouchThrottleKeepsLongLivedSession(t *testing.T) { + db := newSettingsDB(t, nil) + now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) + svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour, + Now: func() time.Time { return now }}) + userID := insertUser(t, db, "admin") + token := insertSession(t, db, userID, now.Add(20*time.Hour)) + setLastSeen(t, db, now.Add(-time.Hour)) + stored := dbExpiry(t, db) + + if _, _, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("查询会话: %v", err) + } + if got, want := dbLastSeen(t, db), now.Format(time.DateTime); got != want { + t.Fatalf("活动时间应回写为 %s,得到 %s", want, got) + } + if got := dbExpiry(t, db); got != stored { + t.Fatalf("已登录会话的到期时间不该被改设置的续期逻辑改写:%s → %s", stored, got) + } +} + +// 越过节流窗口后再次顺延:单独跑一遍,避免上面的用例把它和活动时间混在一起。 +func TestLookupRenewsAfterThrottleWindow(t *testing.T) { + db := newSettingsDB(t, nil) + now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) + svc := New(db, Options{Logger: quietLogger(), SessionTTL: 24 * time.Hour, + Now: func() time.Time { return now }}) + userID := insertUser(t, db, "admin") + token := insertSession(t, db, userID, now.Add(24*time.Hour-2*time.Minute)) + + if _, _, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("查询会话: %v", err) + } + now = now.Add(2 * time.Minute) + if _, sess, err := svc.Lookup(context.Background(), token); err != nil { + t.Fatalf("查询会话: %v", err) + } else if want := now.Add(24 * time.Hour); !sess.ExpiresAt.Equal(want) { + t.Fatalf("越过节流窗口后应顺延到 %s,得到 %s", want, sess.ExpiresAt) + } +} + +// 到期校验不能被续期改坏:已过期的会话仍然查不到(调用方据此回 401)。 +func TestLookupRejectsExpiredSession(t *testing.T) { + db := newSettingsDB(t, nil) + now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) + svc := New(db, Options{Logger: quietLogger(), SessionTTL: 12 * time.Hour, + Now: func() time.Time { return now }}) + userID := insertUser(t, db, "admin") + token := insertSession(t, db, userID, now.Add(-time.Minute)) + + if _, _, err := svc.Lookup(context.Background(), token); err != ErrSessionNotFound { + t.Fatalf("过期会话应返回 ErrSessionNotFound,得到 %v", err) + } +} + +// 改有效期不动已存在的会话:只换进程内的 TTL,库里的 expires_at 原样保留。 +func TestSetSessionTTLKeepsExistingExpiry(t *testing.T) { + db := newSettingsDB(t, nil) + now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) + svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour, + Now: func() time.Time { return now }}) + userID := insertUser(t, db, "admin") + token := insertSession(t, db, userID, now.Add(time.Hour)) + stored := dbExpiry(t, db) + + svc.SetSessionTTL(72 * time.Hour) + + if got := svc.SessionTTL(); got != 72*time.Hour { + t.Fatalf("SessionTTL() = %s,期望 72h", got) + } + if got := dbExpiry(t, db); got != stored { + t.Fatalf("改有效期不该动已存在会话的 expires_at:%s → %s", stored, got) + } + // 旧会话下一次被使用时按新的、更长的有效期顺延(是延长,不是踢下线)。 + _, sess, err := svc.Lookup(context.Background(), token) + if err != nil { + t.Fatalf("旧会话不该被踢下线: %v", err) + } + if want := now.Add(72 * time.Hour); !sess.ExpiresAt.Equal(want) { + t.Fatalf("旧会话应顺延到 %s,得到 %s", want, sess.ExpiresAt) + } +} + +// 新会话按改后的有效期创建。 +func TestCreateSessionUsesCurrentTTL(t *testing.T) { + db := newSettingsDB(t, nil) + now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC) + svc := New(db, Options{Logger: quietLogger(), SessionTTL: 12 * time.Hour, + Now: func() time.Time { return now }}) + userID := insertUser(t, db, "admin") + + svc.SetSessionTTL(720 * time.Hour) + if _, sess, err := svc.createSession(context.Background(), userID, "127.0.0.1", "test"); err != nil { + t.Fatalf("创建会话: %v", err) + } else if want := now.Add(720 * time.Hour); !sess.ExpiresAt.Equal(want) { + t.Fatalf("新会话应在 %s 到期,得到 %s", want, sess.ExpiresAt) + } +} + +// ---------- 测试脚手架 ---------- + +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +const testSchema = ` +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'recorder', + active INTEGER NOT NULL DEFAULT 1, + must_change_password INTEGER NOT NULL DEFAULT 0, + last_login_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + ip TEXT, + user_agent TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_seen_at TEXT +); +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +);` + +func newSettingsDB(t *testing.T, seed map[string]string) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", "file:"+filepath.Join(t.TempDir(), "system.db")) + if err != nil { + t.Fatalf("打开测试库: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + if _, err := db.Exec(testSchema); err != nil { + t.Fatalf("建表: %v", err) + } + for k, v := range seed { + if _, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)`, k, v); err != nil { + t.Fatalf("写入设置 %s: %v", k, err) + } + } + return db +} + +func insertUser(t *testing.T, db *sql.DB, username string) int64 { + t.Helper() + res, err := db.Exec(`INSERT INTO users (username, role) VALUES (?, 'admin')`, username) + if err != nil { + t.Fatalf("建用户: %v", err) + } + id, _ := res.LastInsertId() + return id +} + +func insertSession(t *testing.T, db *sql.DB, userID int64, expiresAt time.Time) string { + t.Helper() + token := "token-" + expiresAt.Format(time.RFC3339Nano) + if _, err := db.Exec( + `INSERT INTO sessions (id, user_id, token_hash, expires_at, last_seen_at) VALUES (?, ?, ?, ?, ?)`, + "sid", userID, hashToken(token), expiresAt.Format(time.DateTime), expiresAt.Format(time.DateTime)); err != nil { + t.Fatalf("建会话: %v", err) + } + return token +} + +func setExpiry(t *testing.T, db *sql.DB, expiresAt time.Time) { + t.Helper() + if _, err := db.Exec(`UPDATE sessions SET expires_at = ?`, expiresAt.Format(time.DateTime)); err != nil { + t.Fatalf("改到期时间: %v", err) + } +} + +func setLastSeen(t *testing.T, db *sql.DB, at time.Time) { + t.Helper() + if _, err := db.Exec(`UPDATE sessions SET last_seen_at = ?`, at.Format(time.DateTime)); err != nil { + t.Fatalf("改活动时间: %v", err) + } +} + +func dbExpiry(t *testing.T, db *sql.DB) string { + t.Helper() + var v string + if err := db.QueryRow(`SELECT expires_at FROM sessions`).Scan(&v); err != nil { + t.Fatalf("读 expires_at: %v", err) + } + return v +} + +func dbLastSeen(t *testing.T, db *sql.DB) string { + t.Helper() + var v string + if err := db.QueryRow(`SELECT last_seen_at FROM sessions`).Scan(&v); err != nil { + t.Fatalf("读 last_seen_at: %v", err) + } + return v +} diff --git a/server/internal/dto/settings.go b/server/internal/dto/settings.go index b7ebc0d..f7584f1 100644 --- a/server/internal/dto/settings.go +++ b/server/internal/dto/settings.go @@ -55,6 +55,10 @@ const ( // SettingKeyLiveEvents / SettingKeyLiveSource 是 settings 表里的点号键。 SettingKeyLiveEvents = "display.live_events" SettingKeyLiveSource = "display.live_source" + // SettingKeyLiveOnPublish 决定发布成绩后是否立即结算:写积分流水并刷新各单位总分与奖牌榜。 + // 关闭后按「分批延迟结算」处理:每次发布只落成绩,等该 (小项, 组别) 所有批次都录完才结算一次。 + // 缺键 / 空值一律等于开启,保证历史赛事的现场行为不变。 + SettingKeyLiveOnPublish = "standings.live_on_publish" // MaxLiveEvents 是人工指定「当前项目」的上限:现场同时进行的比赛不超过两场。 MaxLiveEvents = 2 // LiveSourceAuto 按排期与最近发布自动判定;LiveSourceManual 只认人工指定。 @@ -116,6 +120,8 @@ type EventSettingsStandings struct { RankingMode string `json:"rankingMode"` ShowIndividualBoard bool `json:"showIndividualBoard"` ShowIndividualMedals bool `json:"showIndividualMedals"` + // LiveOnPublish 为 false 表示分批延迟结算:发布只落成绩,等该 (小项, 组别) 全部录完才更新榜单。 + LiveOnPublish bool `json:"liveOnPublish"` } // WithoutAdminFields 返回非 admin 会话可见的配置子集。 @@ -132,10 +138,27 @@ func (s EventSettings) WithoutAdminFields() EventSettings { RankingMode: s.Standings.RankingMode, ShowIndividualBoard: s.Standings.ShowIndividualBoard, ShowIndividualMedals: s.Standings.ShowIndividualMedals, + // 结算方式不是系统机密,而是录入员必须知道的现场行为: + // 关掉后「发布这批成绩」不会立刻改榜单,录入页要据此提示「等全组录完」。 + LiveOnPublish: s.Standings.LiveOnPublish, }, } } +// SessionSettings 是 GET/PUT /api/system/session 的线上结构:登录有效期(小时)。 +// +// 它是**系统级**配置,存在系统库 system.db 的 settings 里,与赛事级的 EventSettings +// (event.db,随赛事包走)不共用——会话寿命是这台机器的属性,不该因为切换赛事而变。 +type SessionSettings struct { + SessionHours int `json:"sessionHours"` + // DefaultHours / MinHours / MaxHours 由服务端下发,前端不再各写一份范围提示。 + DefaultHours int `json:"defaultSessionHours"` + MinHours int `json:"minHours"` + MaxHours int `json:"maxHours"` + // ExpiresAt 是当前会话的到期时间(RFC3339 UTC);未登录为 null。 + ExpiresAt *string `json:"expiresAt"` +} + // RuleSwitches 是 GET /api/rules/switches 的线上结构:只暴露规则包里的布尔开关, // 不包含任何规则内容、模板明细或文件路径,供录入员判断相关功能是否启用。 type RuleSwitches struct { @@ -188,6 +211,8 @@ func ParseEventSettings(values map[string]string) EventSettings { out.Standings.ShowIndividualBoard = boolDefault( firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true) out.Standings.ShowIndividualMedals = boolDefault(values["standings.show_individual_medals"], true) + // 缺键 = 实时结算:老赛事升级后行为必须与升级前完全一致。 + out.Standings.LiveOnPublish = boolDefault(values[SettingKeyLiveOnPublish], true) out.RequireApproval = boolDefault(values["results.review_required"], false) return out diff --git a/server/internal/export/basedata.go b/server/internal/export/basedata.go new file mode 100644 index 0000000..45bfcf5 --- /dev/null +++ b/server/internal/export/basedata.go @@ -0,0 +1,571 @@ +package export + +import ( + "database/sql" + "fmt" + "io" + "strconv" + "strings" + + "github.com/xuri/excelize/v2" +) + +// 基础数据包的四个工作表名。导入按这几个中文名认表:缺哪张就整张跳过, +// 一张都没有才报错——「没写的表」代表不改,绝不代表删除。 +const ( + BaseDataSheetTeams = "单位" + BaseDataSheetSports = "大项" + BaseDataSheetEvents = "小项" + BaseDataSheetCategories = "组别" +) + +// BaseDataHeader 是一列表头与字段的对应关系。 +// Header 是导出写进单元格的中文表头,Field 是导入按它取值的规范字段名。 +// 导出与导入共用这一份定义:表头文案只有一处,往返(导出→原样导入)天然对得上。 +type BaseDataHeader struct { + Header string + Field string + Required bool +} + +// BaseDataSheetSpec 是一张基础数据表的导入契约:表名 + 表头集合。 +type BaseDataSheetSpec struct { + Sheet string + Headers []BaseDataHeader +} + +// baseDataColumn 在表头之外带上版式(列宽 / 对齐),仅供导出用。 +type baseDataColumn struct { + BaseDataHeader + width float64 + kind cellKind +} + +type baseDataSheet struct { + sheet string + columns []baseDataColumn +} + +// baseDataSheets 是基础数据包的全部内容定义,顺序即导入顺序: +// 单位 → 大项 → 小项 → 组别,后者依赖前者的编码(组别依赖小项,小项依赖大项)。 +var baseDataSheets = []baseDataSheet{ + { + sheet: BaseDataSheetTeams, + columns: []baseDataColumn{ + {BaseDataHeader{"单位编码", "code", true}, 14, kindCenter}, + {BaseDataHeader{"单位名称", "name", true}, 24, kindText}, + {BaseDataHeader{"简称", "shortName", false}, 12, kindText}, + {BaseDataHeader{"颜色", "color", false}, 12, kindCenter}, + {BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt}, + {BaseDataHeader{"启用", "active", false}, 8, kindCenter}, + }, + }, + { + sheet: BaseDataSheetSports, + columns: []baseDataColumn{ + {BaseDataHeader{"大项编码", "code", true}, 14, kindCenter}, + {BaseDataHeader{"大项名称", "name", true}, 24, kindText}, + {BaseDataHeader{"简称", "shortName", false}, 12, kindText}, + {BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt}, + {BaseDataHeader{"启用", "active", false}, 8, kindCenter}, + }, + }, + { + sheet: BaseDataSheetEvents, + columns: []baseDataColumn{ + {BaseDataHeader{"大项编码", "sportCode", true}, 14, kindCenter}, + {BaseDataHeader{"小项编码", "code", true}, 14, kindCenter}, + {BaseDataHeader{"小项名称", "name", true}, 22, kindText}, + {BaseDataHeader{"类型", "eventType", false}, 14, kindCenter}, + {BaseDataHeader{"结果模式", "resultMode", false}, 10, kindCenter}, + {BaseDataHeader{"结果类型", "resultType", false}, 10, kindCenter}, + {BaseDataHeader{"比较", "compare", false}, 8, kindCenter}, + {BaseDataHeader{"计分", "scored", false}, 8, kindCenter}, + {BaseDataHeader{"计破纪录", "recordEligible", false}, 10, kindCenter}, + {BaseDataHeader{"权重", "multiplier", false}, 8, kindNumber}, + {BaseDataHeader{"状态", "status", false}, 10, kindCenter}, + {BaseDataHeader{"平均口径", "avgMode", false}, 10, kindCenter}, + {BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt}, + {BaseDataHeader{"场地", "venue", false}, 16, kindText}, + {BaseDataHeader{"备注", "remark", false}, 20, kindText}, + }, + }, + { + sheet: BaseDataSheetCategories, + columns: []baseDataColumn{ + // 组别必须带所属小项编码,否则导回来时对不上是哪个体项目的组别。 + {BaseDataHeader{"小项编码", "eventCode", true}, 14, kindCenter}, + {BaseDataHeader{"组别名", "name", true}, 16, kindText}, + {BaseDataHeader{"权重", "multiplier", false}, 8, kindNumber}, + {BaseDataHeader{"性别", "gender", false}, 8, kindCenter}, + {BaseDataHeader{"年龄下限", "ageMin", false}, 10, kindInt}, + {BaseDataHeader{"年龄上限", "ageMax", false}, 10, kindInt}, + {BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt}, + }, + }, +} + +// BaseDataSpecs 返回基础数据包的导入契约(表名与表头),顺序即导入顺序。 +// 调用方(httpapi)拿它做表头识别与行级校验,不再自己写一份中文表头。 +func BaseDataSpecs() []BaseDataSheetSpec { + out := make([]BaseDataSheetSpec, 0, len(baseDataSheets)) + for _, s := range baseDataSheets { + spec := BaseDataSheetSpec{Sheet: s.sheet, Headers: make([]BaseDataHeader, 0, len(s.columns))} + for _, c := range s.columns { + spec.Headers = append(spec.Headers, c.BaseDataHeader) + } + out = append(out, spec) + } + return out +} + +// BaseDataRecord 是一行待写出的基础数据:字段名 → 单元格值。 +type BaseDataRecord map[string]any + +// BaseDataWorkbook 是基础数据包的内容,一张表一份记录列表。 +type BaseDataWorkbook struct { + Meta Meta + Teams []BaseDataRecord + Sports []BaseDataRecord + Events []BaseDataRecord + Categories []BaseDataRecord +} + +// records 按工作表名取记录,顺序与 baseDataSheets 一致。 +func (wb BaseDataWorkbook) records(sheet string) []BaseDataRecord { + switch sheet { + case BaseDataSheetTeams: + return wb.Teams + case BaseDataSheetSports: + return wb.Sports + case BaseDataSheetEvents: + return wb.Events + case BaseDataSheetCategories: + return wb.Categories + default: + return nil + } +} + +// ExportBaseDataXLSX 导出基础数据包到 dst。 +func ExportBaseDataXLSX(db *sql.DB, dst string, opts Options) error { + wb, err := LoadBaseDataWorkbook(db, opts) + if err != nil { + return err + } + f, err := baseDataFile(wb) + if err != nil { + return err + } + return saveWorkbook(f, dst) +} + +// WriteBaseDataXLSX 把基础数据包写成 xlsx 流,供 HTTP 直接响应下载。 +func WriteBaseDataXLSX(w io.Writer, wb BaseDataWorkbook) error { + f, err := baseDataFile(wb) + if err != nil { + return err + } + return f.Write(w) +} + +func baseDataFile(wb BaseDataWorkbook) (*excelize.File, error) { + return buildWorkbook(BaseDataSheetTeams, func(f *excelize.File, st *styleSet) error { + meta := wb.Meta.normalized() + namer := &sheetNamer{} + for _, s := range baseDataSheets { + name := namer.add(s.sheet) + if err := addSheet(f, name); err != nil { + return err + } + cols := make([]column, 0, len(s.columns)) + for _, c := range s.columns { + cols = append(cols, column{c.Header, c.width, c.kind}) + } + rows := baseDataRows(s, wb.records(s.sheet)) + if err := writeSheet(f, st, name, meta, sheetTitle(meta, s.sheet), cols, rows); err != nil { + return err + } + } + return nil + }) +} + +// baseDataRows 按列定义把记录摊平成行;记录里缺的字段写空,不写 "0" / "false" 这类噪声。 +func baseDataRows(s baseDataSheet, records []BaseDataRecord) [][]any { + out := make([][]any, 0, len(records)) + for _, rec := range records { + row := make([]any, 0, len(s.columns)) + for _, c := range s.columns { + v, ok := rec[c.Field] + if !ok || v == nil { + v = "" + } + row = append(row, v) + } + out = append(out, row) + } + return out +} + +// boolCell 把布尔值写成「是 / 否」:中文表头配中文取值,现场改表时不用查英文枚举。 +func boolCell(v bool) string { + if v { + return "是" + } + return "否" +} + +// LoadBaseDataWorkbook 读取全部基础数据(单位 / 大项 / 小项 / 组别)。 +// +// 单位排除「彻底删除」(deleted_at 非空)的行:它们对界面不可见,也不该被重新印进导出件; +// 停用(active=0)的单位照常导出,启用列写「否」,导入回来才不会把它误改回启用。 +func LoadBaseDataWorkbook(db *sql.DB, opts Options) (BaseDataWorkbook, error) { + if db == nil { + return BaseDataWorkbook{}, ErrNoDatabase + } + meta, _, err := resolveMeta(db, opts.Meta) + if err != nil { + return BaseDataWorkbook{}, err + } + + teams, err := loadBaseDataTeams(db) + if err != nil { + return BaseDataWorkbook{}, err + } + sports, err := loadBaseDataSports(db) + if err != nil { + return BaseDataWorkbook{}, err + } + events, err := loadBaseDataEvents(db) + if err != nil { + return BaseDataWorkbook{}, err + } + categories, err := loadBaseDataCategories(db) + if err != nil { + return BaseDataWorkbook{}, err + } + return BaseDataWorkbook{Meta: meta, Teams: teams, Sports: sports, Events: events, Categories: categories}, nil +} + +func loadBaseDataTeams(db *sql.DB) ([]BaseDataRecord, error) { + rows, err := db.Query(` + SELECT code, name, COALESCE(short_name, ''), COALESCE(color, ''), sort_order, active + FROM teams WHERE deleted_at IS NULL ORDER BY sort_order, code`) + if err != nil { + return nil, fmt.Errorf("export: 读取单位: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []BaseDataRecord + for rows.Next() { + var ( + code, name, shortName, color string + sortOrder, active int + ) + if err := rows.Scan(&code, &name, &shortName, &color, &sortOrder, &active); err != nil { + return nil, fmt.Errorf("export: 扫描单位: %w", err) + } + out = append(out, BaseDataRecord{ + "code": code, "name": name, "shortName": shortName, "color": color, + "sortOrder": sortOrder, "active": boolCell(active != 0), + }) + } + return out, rows.Err() +} + +func loadBaseDataSports(db *sql.DB) ([]BaseDataRecord, error) { + rows, err := db.Query(` + SELECT code, name, COALESCE(short_name, ''), sort_order, active + FROM sports ORDER BY sort_order, code`) + if err != nil { + return nil, fmt.Errorf("export: 读取大项: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []BaseDataRecord + for rows.Next() { + var ( + code, name, shortName string + sortOrder, active int + ) + if err := rows.Scan(&code, &name, &shortName, &sortOrder, &active); err != nil { + return nil, fmt.Errorf("export: 扫描大项: %w", err) + } + out = append(out, BaseDataRecord{ + "code": code, "name": name, "shortName": shortName, + "sortOrder": sortOrder, "active": boolCell(active != 0), + }) + } + return out, rows.Err() +} + +func loadBaseDataEvents(db *sql.DB) ([]BaseDataRecord, error) { + rows, err := db.Query(` + SELECT COALESCE(s.code, ''), e.code, e.name, e.event_type, e.result_mode, e.result_type, + e.compare, e.scored, e.record_eligible, e.multiplier, e.status, e.avg_mode, + e.sort_order, COALESCE(e.venue, ''), COALESCE(e.remark, '') + FROM events e + LEFT JOIN sports s ON s.id = e.sport_id + ORDER BY COALESCE(s.sort_order, 0), e.sort_order, e.code`) + if err != nil { + return nil, fmt.Errorf("export: 读取小项: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []BaseDataRecord + for rows.Next() { + var ( + sportCode, code, name string + eventType, resultMode, resultType, cmpare string + status, avgMode, venue, remark string + scored, recordEligible, sortOrder int + multiplier float64 + ) + if err := rows.Scan(&sportCode, &code, &name, &eventType, &resultMode, &resultType, + &cmpare, &scored, &recordEligible, &multiplier, &status, &avgMode, + &sortOrder, &venue, &remark); err != nil { + return nil, fmt.Errorf("export: 扫描小项: %w", err) + } + out = append(out, BaseDataRecord{ + "sportCode": sportCode, "code": code, "name": name, + "eventType": eventType, "resultMode": resultMode, "resultType": resultType, + "compare": cmpare, "scored": boolCell(scored != 0), "recordEligible": boolCell(recordEligible != 0), + "multiplier": multiplier, "status": status, "avgMode": avgMode, + "sortOrder": sortOrder, "venue": venue, "remark": remark, + }) + } + return out, rows.Err() +} + +func loadBaseDataCategories(db *sql.DB) ([]BaseDataRecord, error) { + rows, err := db.Query(` + SELECT COALESCE(e.code, ''), c.name, c.multiplier, COALESCE(c.gender, ''), + c.age_min, c.age_max, c.sort_order + FROM event_categories c + LEFT JOIN events e ON e.id = c.event_id + ORDER BY COALESCE(e.sort_order, 0), e.code, c.sort_order, c.id`) + if err != nil { + return nil, fmt.Errorf("export: 读取组别: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []BaseDataRecord + for rows.Next() { + var ( + eventCode, name, gender string + multiplier float64 + ageMin, ageMax sql.NullInt64 + sortOrder int + ) + if err := rows.Scan(&eventCode, &name, &multiplier, &gender, &ageMin, &ageMax, &sortOrder); err != nil { + return nil, fmt.Errorf("export: 扫描组别: %w", err) + } + rec := BaseDataRecord{ + "eventCode": eventCode, "name": name, "multiplier": multiplier, + "gender": gender, "sortOrder": sortOrder, + } + rec["ageMin"], rec["ageMax"] = "", "" + if ageMin.Valid { + rec["ageMin"] = int(ageMin.Int64) + } + if ageMax.Valid { + rec["ageMax"] = int(ageMax.Int64) + } + out = append(out, rec) + } + return out, rows.Err() +} + +// ---------- 导入侧:读取工作簿 ---------- + +// BaseDataRow 是基础数据表里的一行数据。 +type BaseDataRow struct { + // Line 是该行在工作表里的真实行号(1 起,含表头上方的标题行),行级报错直接用它。 + Line int + // Cells 是「字段名 → 单元格原文(已去空白)」;Has 标记该字段的列有没有出现在表头里。 + // 两者分开:列不存在 = 不改动(更新时沿用库里的值),列存在但为空 = 明确写空。 + Cells map[string]string + Has map[string]bool +} + +// BaseDataTable 是从工作簿里解析出的一张表。 +type BaseDataTable struct { + Sheet string + HeaderLine int + Rows []BaseDataRow +} + +// maxHeaderScanRows 是表头检测的扫描范围。 +// 本系统的导出件在表头上方有标题与副标题(表头在第 4 行),手工整理的表通常表头在第 1 行, +// 再往上加两三行说明也有可能;扫前 20 行足够,又不会被中间某行数据误判成表头。 +const maxHeaderScanRows = 20 + +// ReadBaseDataPackage 解析基础数据包工作簿,按工作表返回表头位置与数据行。 +// 只认 baseDataSheets 里的四个中文表名,别的工作表一律忽略;一张都没找到时返回错误。 +func ReadBaseDataPackage(r io.Reader) ([]BaseDataTable, error) { + f, err := excelize.OpenReader(r) + if err != nil { + return nil, fmt.Errorf("export: 打开基础数据包: %w", err) + } + defer func() { _ = f.Close() }() + + sheets := f.GetSheetList() + present := make(map[string]bool, len(sheets)) + for _, name := range sheets { + present[strings.TrimSpace(name)] = true + } + + tables := make([]BaseDataTable, 0, len(baseDataSheets)) + for _, spec := range BaseDataSpecs() { + if !present[spec.Sheet] { + continue + } + rows, err := f.GetRows(spec.Sheet) + if err != nil { + return nil, fmt.Errorf("export: 读取工作表 %s: %w", spec.Sheet, err) + } + table, err := parseBaseDataSheet(spec, rows) + if err != nil { + return nil, err + } + tables = append(tables, table) + } + if len(tables) == 0 { + return nil, fmt.Errorf("export: 基础数据包缺少可识别的工作表(应为 %s)", baseDataSheetNames()) + } + return tables, nil +} + +func baseDataSheetNames() string { + names := make([]string, 0, len(baseDataSheets)) + for _, s := range baseDataSheets { + names = append(names, s.sheet) + } + return strings.Join(names, " / ") +} + +// parseBaseDataSheet 在表头行定位成功后,把其后的数据行逐行摊成「字段 → 原文」。 +// 整行为空的行(用户在表尾留的空行)直接跳过,不产生「必填」误报。 +func parseBaseDataSheet(spec BaseDataSheetSpec, rows [][]string) (BaseDataTable, error) { + headerLine, index := findBaseDataHeader(spec, rows) + if headerLine < 0 { + return BaseDataTable{}, fmt.Errorf("export: 工作表 %s 找不到表头行:应有一行同时包含 %s(本系统导出的包把表头放在标题行下方)", + spec.Sheet, strings.Join(requiredHeaders(spec), "、")) + } + + table := BaseDataTable{Sheet: spec.Sheet, HeaderLine: headerLine + 1} + for i := headerLine + 1; i < len(rows); i++ { + row := rows[i] + cells := make(map[string]string, len(index)) + has := make(map[string]bool, len(index)) + blank := true + for _, h := range spec.Headers { + col, ok := index[h.Field] + if !ok { + continue + } + has[h.Field] = true + v := "" + if col < len(row) { + v = strings.TrimSpace(row[col]) + } + cells[h.Field] = v + if v != "" { + blank = false + } + } + if blank { + continue + } + table.Rows = append(table.Rows, BaseDataRow{Line: i + 1, Cells: cells, Has: has}) + } + return table, nil +} + +// findBaseDataHeader 找出表头行:返回行下标(0 起)与「字段 → 列下标」。 +// 判据是该行必须同时出现全部必填表头,避免把标题行或数据行认成表头。 +func findBaseDataHeader(spec BaseDataSheetSpec, rows [][]string) (int, map[string]int) { + limit := len(rows) + if limit > maxHeaderScanRows { + limit = maxHeaderScanRows + } + for i := 0; i < limit; i++ { + byHeader := make(map[string]int, len(rows[i])) + for j, cell := range rows[i] { + text := strings.TrimSpace(cell) + if _, dup := byHeader[text]; !dup { + byHeader[text] = j + } + } + index := make(map[string]int, len(spec.Headers)) + missing := false + for _, h := range spec.Headers { + col, ok := byHeader[h.Header] + if !ok { + if h.Required { + missing = true + break + } + continue + } + index[h.Field] = col + } + if !missing { + return i, index + } + } + return -1, nil +} + +func requiredHeaders(spec BaseDataSheetSpec) []string { + out := make([]string, 0, len(spec.Headers)) + for _, h := range spec.Headers { + if h.Required { + out = append(out, h.Header) + } + } + return out +} + +// ---------- 导入侧用的小工具 ---------- + +// ParseBaseDataBool 解析「是 / 否」型单元格:空值取 fallback,认不出的写法返回 false。 +func ParseBaseDataBool(raw string, fallback bool) (bool, bool) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "": + return fallback, true + case "是", "1", "true", "yes", "y", "启用", "on": + return true, true + case "否", "0", "false", "no", "n", "停用", "off": + return false, true + default: + return false, false + } +} + +// ParseBaseDataFloat 解析数值列;空值取 fallback。 +func ParseBaseDataFloat(raw string, fallback float64) (float64, bool) { + text := strings.TrimSpace(raw) + if text == "" { + return fallback, true + } + v, err := strconv.ParseFloat(text, 64) + if err != nil { + return 0, false + } + return v, true +} + +// ParseBaseDataInt 解析整数列;空值返回 nil。返回的第二个值表示是否合法。 +func ParseBaseDataInt(raw string) (*int, bool) { + text := strings.TrimSpace(raw) + if text == "" { + return nil, true + } + v, err := strconv.Atoi(text) + if err != nil { + return nil, false + } + return &v, true +} diff --git a/server/internal/export/basedata_test.go b/server/internal/export/basedata_test.go new file mode 100644 index 0000000..de884c0 --- /dev/null +++ b/server/internal/export/basedata_test.go @@ -0,0 +1,291 @@ +package export + +import ( + "bytes" + "database/sql" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +// seedBaseData 造一份字段尽量填满的基础数据:两个单位、两个大项、三个小项、四个组别。 +// 含简称 / 颜色 / 权重 / 性别 / 年龄区间 / 场地 / 备注与一个停用的大项, +// 让导出→导入的往返能覆盖到「启用 = 否」这类容易被静默改掉的列。 +func seedBaseData(t *testing.T, db *sql.DB) { + t.Helper() + stmts := []string{ + `INSERT INTO teams(code, name, short_name, color, sort_order, active) + VALUES ('T1', '第一分公司', '一公司', '#3482FF', 1, 1)`, + `INSERT INTO teams(code, name, sort_order, active) VALUES ('T2', '第二分公司', 2, 0)`, + `INSERT INTO sports(code, name, short_name, sort_order, active) VALUES ('SP1', '田径', '田', 1, 1)`, + `INSERT INTO sports(code, name, sort_order, active) VALUES ('SP2', '游泳', 2, 0)`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, + record_eligible, scored, multiplier, status, sort_order, venue, remark) + VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 2, 'ongoing', 1, '主体育场', '决赛')`, + `INSERT INTO events(sport_id, code, name) VALUES (1, 'E2', '4×100米接力')`, + `INSERT INTO events(sport_id, code, name, event_type) VALUES (2, 'E3', '100米自由泳', 'team')`, + `INSERT INTO event_categories(event_id, name, multiplier, gender, age_min, age_max, sort_order) + VALUES (1, '男子青年组', 1.5, '男', 18, 35, 1)`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子组', 2)`, + `INSERT INTO event_categories(event_id, name, gender, sort_order) VALUES (2, '团体组', '男', 1)`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (3, '男子', 1)`, + `UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } +} + +// findRecord 按某字段的取值找一行;顺序不是本测试要验证的东西,按内容找更稳。 +func findRecord(t *testing.T, rows []BaseDataRow, field, value string) BaseDataRow { + t.Helper() + for _, row := range rows { + if row.Cells[field] == value { + return row + } + } + t.Fatalf("找不到 %s = %q 的行", field, value) + return BaseDataRow{} +} + +// TestBaseDataPackageRoundTrip 是往返硬要求:导出的包原样读回来,四张表的表名、表头、 +// 行数与每行的关键取值都要对得上——表头对不上,导回来就是「找不到表头行」。 +func TestBaseDataPackageRoundTrip(t *testing.T) { + db := openEventDB(t) + seedBaseData(t, db) + + wb, err := LoadBaseDataWorkbook(db, Options{}) + if err != nil { + t.Fatalf("读取基础数据: %v", err) + } + var buf bytes.Buffer + if err := WriteBaseDataXLSX(&buf, wb); err != nil { + t.Fatalf("写出基础数据包: %v", err) + } + + tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("读回基础数据包: %v", err) + } + if len(tables) != 4 { + t.Fatalf("工作表数 = %d,期望 4", len(tables)) + } + wantSheets := []string{BaseDataSheetTeams, BaseDataSheetSports, BaseDataSheetEvents, BaseDataSheetCategories} + wantRows := []int{2, 2, 3, 4} + for i, table := range tables { + if table.Sheet != wantSheets[i] { + t.Fatalf("第 %d 张表 = %q,期望 %q", i+1, table.Sheet, wantSheets[i]) + } + if table.HeaderLine != rowHeader { + t.Errorf("%s 表头行 = %d,期望 %d", table.Sheet, table.HeaderLine, rowHeader) + } + if len(table.Rows) != wantRows[i] { + t.Errorf("%s 行数 = %d,期望 %d", table.Sheet, len(table.Rows), wantRows[i]) + } + // 表头必须与导出契约逐字一致,否则导入端认不出列。 + spec := BaseDataSpecs()[i] + for _, h := range spec.Headers { + found := false + for _, row := range table.Rows { + if row.Has[h.Field] { + found = true + break + } + } + if !found && len(table.Rows) > 0 { + t.Errorf("%s 缺少列 %q(字段 %s)", table.Sheet, h.Header, h.Field) + } + } + } + + teams := tables[0] + if got := findRecord(t, teams.Rows, "code", "T1").Cells["code"]; got != "T1" { + t.Errorf("单位编码 = %q,期望 T1", got) + } + t1 := findRecord(t, teams.Rows, "code", "T1") + if got := t1.Cells["active"]; got != "是" { + t.Errorf("T1 启用 = %q,期望 是", got) + } + if got := t1.Cells["color"]; got != "#3482FF" { + t.Errorf("T1 颜色 = %q", got) + } + if got := findRecord(t, teams.Rows, "code", "T2").Cells["active"]; got != "否" { + t.Errorf("停用的 T2 启用 = %q,期望 否(往返不能把停用改回启用)", got) + } + + events := tables[2] + e1 := findRecord(t, events.Rows, "code", "E1") + if got := e1.Cells["sportCode"]; got != "SP1" { + t.Errorf("小项大项编码 = %q,期望 SP1", got) + } + if got := e1.Cells["multiplier"]; got != "2" { + t.Errorf("E1 权重 = %q,期望 2", got) + } + if got := e1.Cells["venue"]; got != "主体育场" { + t.Errorf("E1 场地 = %q", got) + } + if got := e1.Cells["resultType"]; got != "time" { + t.Errorf("E1 结果类型 = %q,期望 time", got) + } + if got := findRecord(t, events.Rows, "code", "E3").Cells["sportCode"]; got != "SP2" { + t.Errorf("E3 大项编码 = %q,期望 SP2", got) + } + + cats := tables[3] + findRecord(t, cats.Rows, "eventCode", "E1") + c1 := findRecord(t, cats.Rows, "name", "男子青年组") + if got := c1.Cells["eventCode"]; got != "E1" { + t.Errorf("组别所属小项编码 = %q,期望 E1(组别必须带小项编码)", got) + } + if got := c1.Cells["ageMin"]; got != "18" { + t.Errorf("年龄下限 = %q,期望 18", got) + } + if got := c1.Cells["gender"]; got != "男" { + t.Errorf("性别 = %q,期望 男", got) + } + if got := c1.Cells["multiplier"]; got != "1.5" { + t.Errorf("组别权重 = %q,期望 1.5", got) + } + + // 再读一遍原始表头,确认表头文案与契约逐字一致(含「组别 sheet 带小项编码」这条)。 + raw, err := excelize.OpenReader(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("打开基础数据包: %v", err) + } + defer func() { _ = raw.Close() }() + headerRow, err := raw.GetRows(BaseDataSheetCategories) + if err != nil { + t.Fatalf("读取组别表: %v", err) + } + for i, h := range []string{"小项编码", "组别名", "权重", "性别", "年龄下限", "年龄上限", "排序"} { + if got := cell(headerRow, rowHeader-1, i); got != h { + t.Errorf("组别表头第 %d 列 = %q,期望 %q", i+1, got, h) + } + } +} + +// 手工整理的表通常把表头放在第 1 行,也必须认得出来,且行号要报真实行号。 +func TestReadBaseDataPackageHeaderAtFirstRow(t *testing.T) { + f := excelize.NewFile() + defer func() { _ = f.Close() }() + if err := f.SetSheetName("Sheet1", "单位"); err != nil { + t.Fatalf("重命名工作表: %v", err) + } + rows := [][]string{ + {"单位编码", "单位名称", "启用"}, + {"T9", "第九分公司", "是"}, + {"T8", "第八分公司", "否"}, + } + for r, row := range rows { + for c, v := range row { + name, _ := excelize.CoordinatesToCellName(c+1, r+1) + if err := f.SetCellValue("单位", name, v); err != nil { + t.Fatalf("写单元格: %v", err) + } + } + } + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + t.Fatalf("写出测试工作簿: %v", err) + } + + tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("读回: %v", err) + } + if len(tables) != 1 || tables[0].Sheet != "单位" { + t.Fatalf("只应认出单位表,得到 %+v", tables) + } + if tables[0].HeaderLine != 1 { + t.Errorf("表头行 = %d,期望 1", tables[0].HeaderLine) + } + if len(tables[0].Rows) != 2 { + t.Fatalf("行数 = %d,期望 2", len(tables[0].Rows)) + } + if got := tables[0].Rows[0].Line; got != 2 { + t.Errorf("首行行号 = %d,期望 2", got) + } + if got := tables[0].Rows[1].Line; got != 3 { + t.Errorf("次行行号 = %d,期望 3", got) + } + // 文件里没有「简称」列:Has 必须是 false,导入端才知道「不改」而不是「写空」。 + if tables[0].Rows[0].Has["shortName"] { + t.Errorf("文件里没有简称列,Has[shortName] 不该为 true") + } + if !tables[0].Rows[0].Has["active"] { + t.Errorf("文件里有启用列,Has[active] 应为 true") + } + if tables[0].Rows[0].Cells["name"] != "第九分公司" { + t.Errorf("单位名 = %q", tables[0].Rows[0].Cells["name"]) + } +} + +func TestReadBaseDataPackageErrors(t *testing.T) { + // 表名认得出来但表头找不到:必须点名缺哪一列,而不是笼统地说文件坏了。 + f := excelize.NewFile() + defer func() { _ = f.Close() }() + if err := f.SetSheetName("Sheet1", "单位"); err != nil { + t.Fatalf("重命名工作表: %v", err) + } + if err := f.SetCellValue("单位", "A1", "随便写点什么"); err != nil { + t.Fatalf("写单元格: %v", err) + } + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + t.Fatalf("写出测试工作簿: %v", err) + } + if _, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes())); err == nil { + t.Fatal("找不到表头应报错") + } else if !strings.Contains(err.Error(), "单位编码") { + t.Errorf("错误信息应点名缺的表头,得到 %q", err.Error()) + } + + // 一张可识别的表都没有。 + f2 := excelize.NewFile() + defer func() { _ = f2.Close() }() + if err := f2.SetCellValue("Sheet1", "A1", "无关内容"); err != nil { + t.Fatalf("写单元格: %v", err) + } + var buf2 bytes.Buffer + if err := f2.Write(&buf2); err != nil { + t.Fatalf("写出测试工作簿: %v", err) + } + if _, err := ReadBaseDataPackage(bytes.NewReader(buf2.Bytes())); err == nil { + t.Fatal("没有任何可识别工作表应报错") + } +} + +func TestParseBaseDataHelpers(t *testing.T) { + for _, raw := range []string{"是", "1", "true", "启用", "YES", "on"} { + if v, ok := ParseBaseDataBool(raw, false); !ok || !v { + t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 true,true", raw, v, ok) + } + } + for _, raw := range []string{"否", "0", "false", "停用", "NO", "off"} { + if v, ok := ParseBaseDataBool(raw, true); !ok || v { + t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 false,true", raw, v, ok) + } + } + if v, ok := ParseBaseDataBool("", true); !ok || !v { + t.Errorf("空值应取 fallback,得到 %v,%v", v, ok) + } + if _, ok := ParseBaseDataBool("也许", true); ok { + t.Error("认不出的写法应报不合法") + } + + if v, ok := ParseBaseDataFloat("", 1); !ok || v != 1 { + t.Errorf("空权重应取 fallback 1,得到 %v,%v", v, ok) + } + if _, ok := ParseBaseDataFloat("两倍", 1); ok { + t.Error("非数字权重应报不合法") + } + if v, ok := ParseBaseDataInt(""); !ok || v != nil { + t.Errorf("空整数应为 nil,true,得到 %v,%v", v, ok) + } + if _, ok := ParseBaseDataInt("三"); ok { + t.Error("非整数应报不合法") + } +} diff --git a/server/internal/export/entries.go b/server/internal/export/entries.go new file mode 100644 index 0000000..ee1985b --- /dev/null +++ b/server/internal/export/entries.go @@ -0,0 +1,217 @@ +package export + +import ( + "database/sql" + "fmt" + "io" + "strconv" + "strings" + + "github.com/xuri/excelize/v2" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// entriesSheet 是「报名名单」唯一那张表的表名。 +const entriesSheet = "报名名单" + +// EntriesFilter 是报名名单的筛选条件;某一项为零值即该维度不筛选(全部)。 +// heat 的合法值从 1 起,所以 0 可以安全地表示「不按批次筛选」。 +type EntriesFilter struct { + EventID int64 + CategoryID int64 + TeamID int64 + Heat int64 +} + +// EntryExportRow 是报名名单的一行。 +// 批次(heat)属于报名数据,是用户点名要看到的列:未分批时留空,也要占着一列。 +type EntryExportRow struct { + EventCode string + EventName string + Category string + TeamCode string + TeamName string + Name string + IDCard string + Bib string + Heat string + Gender string + Age any + Status string + Remark string +} + +// EntriesWorkbook 是「报名名单」的内容:一张平铺表。 +type EntriesWorkbook struct { + Meta Meta + Rows []EntryExportRow +} + +// entriesColumns 的列序与用户点名要的口径一致:批次紧跟在号码布之后。 +// 号码布用 kindText:它是带前导零的字符串("007"),任何数字格式都会把它吃掉。 +var entriesColumns = []column{ + {"小项编码", 12, kindCenter}, + {"小项名", 18, kindText}, + {"组别", 14, kindText}, + {"单位编码", 12, kindCenter}, + {"单位名", 22, kindText}, + {"姓名", 16, kindText}, + {"证件号", 22, kindCenter}, + {"号码布", 10, kindText}, + {"批次", 8, kindCenter}, + {"性别", 8, kindCenter}, + {"年龄", 8, kindInt}, + {"状态", 12, kindCenter}, + {"备注", 20, kindText}, +} + +// ExportEntriesXLSX 导出报名名单到 dst。 +func ExportEntriesXLSX(db *sql.DB, dst string, filter EntriesFilter, opts Options) error { + wb, err := LoadEntriesWorkbook(db, filter, opts) + if err != nil { + return err + } + f, err := entriesFile(wb) + if err != nil { + return err + } + return saveWorkbook(f, dst) +} + +// WriteEntriesXLSX 把报名名单写成 xlsx 流,供 HTTP 直接响应下载。 +func WriteEntriesXLSX(w io.Writer, wb EntriesWorkbook) error { + f, err := entriesFile(wb) + if err != nil { + return err + } + return f.Write(w) +} + +func entriesFile(wb EntriesWorkbook) (*excelize.File, error) { + return buildWorkbook(entriesSheet, func(f *excelize.File, st *styleSet) error { + meta := wb.Meta.normalized() + name := sanitizeSheetName(entriesSheet) + if err := addSheet(f, name); err != nil { + return err + } + return writeSheet(f, st, name, meta, sheetTitle(meta, entriesSheet), entriesColumns, entriesDataRows(wb.Rows)) + }) +} + +func entriesDataRows(rows []EntryExportRow) [][]any { + out := make([][]any, 0, len(rows)) + for _, r := range rows { + out = append(out, []any{ + r.EventCode, r.EventName, r.Category, r.TeamCode, r.TeamName, + r.Name, r.IDCard, r.Bib, r.Heat, r.Gender, r.Age, r.Status, r.Remark, + }) + } + return out +} + +// LoadEntriesWorkbook 读取报名名单;筛选条件为空表示导出全部。 +// +// 读的是报名表本身(entries),不是成绩表:批次、号码布、状态都是报名侧的数据。 +// 姓名取运动员姓名,团体行(athlete_id 为空)回落到成员名单,与分项成绩表同口径。 +func LoadEntriesWorkbook(db *sql.DB, filter EntriesFilter, opts Options) (EntriesWorkbook, error) { + if db == nil { + return EntriesWorkbook{}, ErrNoDatabase + } + meta, _, err := resolveMeta(db, opts.Meta) + if err != nil { + return EntriesWorkbook{}, err + } + + query := ` + SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''), COALESCE(c.name, ''), + COALESCE(t.code, ''), COALESCE(t.name, ''), + COALESCE(a.name, ''), COALESCE(en.member_names, ''), + COALESCE(a.id_card, ''), COALESCE(en.bib, ''), en.heat, + COALESCE(a.gender, ''), a.age, en.status, COALESCE(en.remark, '') + FROM entries en + JOIN events ev ON ev.id = en.event_id + JOIN teams t ON t.id = en.team_id + LEFT JOIN event_categories c ON c.id = en.category_id + LEFT JOIN athletes a ON a.id = en.athlete_id` + var ( + conds []string + args []any + ) + for _, f := range []struct { + column string + value int64 + }{ + {"en.event_id", filter.EventID}, + {"en.category_id", filter.CategoryID}, + {"en.team_id", filter.TeamID}, + {"en.heat", filter.Heat}, + } { + if f.value != 0 { + conds = append(conds, f.column+" = ?") + args = append(args, f.value) + } + } + if len(conds) > 0 { + query += " WHERE " + strings.Join(conds, " AND ") + } + // 排序与录入页一致:按大项 / 小项 / 组别,组内先未分批再按批次,最后按单位。 + // 未分批(heat 为 NULL)的排最前,与「空值最小」的直觉一致。 + query += ` + ORDER BY ev.sort_order, ev.code, c.sort_order, c.id, + en.heat IS NOT NULL, en.heat, t.sort_order, t.code, en.id` + + rows, err := db.Query(query, args...) + if err != nil { + return EntriesWorkbook{}, fmt.Errorf("export: 读取报名名单: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []EntryExportRow + for rows.Next() { + var ( + row EntryExportRow + memberJSON string + heat sql.NullInt64 + age sql.NullInt64 + athleteName string + ) + if err := rows.Scan(&row.EventCode, &row.EventName, &row.Category, &row.TeamCode, &row.TeamName, + &athleteName, &memberJSON, &row.IDCard, &row.Bib, &heat, + &row.Gender, &age, &row.Status, &row.Remark); err != nil { + return EntriesWorkbook{}, fmt.Errorf("export: 扫描报名名单: %w", err) + } + row.Name = participantName(athleteName, memberJSON) + if heat.Valid { + row.Heat = strconv.FormatInt(heat.Int64, 10) + } + if age.Valid { + row.Age = int(age.Int64) + } else { + row.Age = "" + } + row.Status = entryExportStatusLabel(row.Status) + out = append(out, row) + } + if err := rows.Err(); err != nil { + return EntriesWorkbook{}, fmt.Errorf("export: 遍历报名名单: %w", err) + } + return EntriesWorkbook{Meta: meta, Rows: out}, nil +} + +// entryExportStatusLabel 与录入页报名状态徽标同口径(registered 正常参赛 / scratched 已退赛 / +// dq 取消资格 / dns 未出场),导出件上的字与现场在界面上看到的一致。 +func entryExportStatusLabel(status string) string { + switch domain.EntryStatus(status) { + case domain.EntryStatusRegistered, "": + return "正常参赛" + case domain.EntryStatusScratched: + return "已退赛" + case domain.EntryStatusDQ: + return "取消资格" + case domain.EntryStatusDNS: + return "未出场" + default: + return status + } +} diff --git a/server/internal/export/entries_test.go b/server/internal/export/entries_test.go new file mode 100644 index 0000000..00b1bc9 --- /dev/null +++ b/server/internal/export/entries_test.go @@ -0,0 +1,199 @@ +package export + +import ( + "bytes" + "database/sql" + "path/filepath" + "testing" + + "github.com/xuri/excelize/v2" +) + +// seedEntries 造一份覆盖筛选维度的报名名单:两个单位、两个小项、两个组别, +// 含批次(有无各一)、中文姓名、带前导零的号码布,以及一条团体报名(无运动员)。 +func seedEntries(t *testing.T, db *sql.DB) { + t.Helper() + stmts := []string{ + `INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T1', '第一分公司', '一公司', 1, 1)`, + `INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T2', '第二分公司', '二公司', 2, 1)`, + `INSERT INTO athletes(name, gender, team_id, id_card, age) VALUES ('张伟', '男', 1, '110101199001011234', 35)`, + `INSERT INTO athletes(name, gender, team_id, id_card, age) VALUES ('李强', '男', 2, '110101199202022345', 33)`, + `INSERT INTO sports(code, name, sort_order, active) VALUES ('SP1', '田径', 1, 1)`, + `INSERT INTO sports(code, name, sort_order, active) VALUES ('SP2', '游泳', 2, 1)`, + `INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E1', '100米跑', 'ongoing', 1)`, + `INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (1, 'E2', '4×100米接力', 'ongoing', 2)`, + `INSERT INTO events(sport_id, code, name, status, sort_order) VALUES (2, 'E3', '100米自由泳', 'pending', 3)`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子', 2)`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (3, '男子', 1)`, + // 批次 2、号码布带前导零。 + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, heat, status, remark) + VALUES (1, 1, 1, 1, '007', 2, 'registered', '卫冕冠军')`, + // 未分批,号码布 0012。 + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib, status) + VALUES (1, 1, 2, 2, '0012', 'scratched')`, + // 团体报名:没有运动员,姓名回落成员名单。 + `INSERT INTO entries(event_id, category_id, team_id, member_names, heat, status) + VALUES (2, 3, 1, '["王磊","赵雷"]', 1, 'registered')`, + `UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } +} + +func TestExportEntriesXLSXColumnsAndRows(t *testing.T) { + db := openEventDB(t) + seedEntries(t, db) + + dst := filepath.Join(t.TempDir(), "报名名单.xlsx") + if err := ExportEntriesXLSX(db, dst, EntriesFilter{}, Options{}); err != nil { + t.Fatalf("导出报名名单: %v", err) + } + f, err := excelize.OpenFile(dst) + if err != nil { + t.Fatalf("重新打开 xlsx: %v", err) + } + defer func() { _ = f.Close() }() + + wantSheet := []string{"报名名单"} + if got := f.GetSheetList(); !equalStrings(got, wantSheet) { + t.Fatalf("sheet 列表 = %v,期望 %v", got, wantSheet) + } + rows, err := f.GetRows("报名名单") + if err != nil { + t.Fatalf("读取报名名单: %v", err) + } + // 4 行版式 + 3 条报名。 + if len(rows) != 7 { + t.Fatalf("行数 = %d,期望 7(4 行版式 + 3 条报名)", len(rows)) + } + + want := []string{"小项编码", "小项名", "组别", "单位编码", "单位名", "姓名", "证件号", + "号码布", "批次", "性别", "年龄", "状态", "备注"} + for i, header := range want { + if got := cell(rows, 3, i); got != header { + t.Errorf("表头第 %d 列 = %q,期望 %q", i+1, got, header) + } + } + + // 排序:未分批的排最前(heat IS NOT NULL 升序 = false < true)。 + if got := cell(rows, 4, 5); got != "李强" { + t.Fatalf("第一行姓名 = %q,期望 李强(未分批排最前)", got) + } + if got := cell(rows, 4, 7); got != "0012" { + t.Errorf("未分批行号码布 = %q,期望 0012(前导零不能丢)", got) + } + if got := cell(rows, 4, 8); got != "" { + t.Errorf("未分批行批次 = %q,期望留空", got) + } + if got := cell(rows, 4, 11); got != "已退赛" { + t.Errorf("scratched 状态 = %q,期望 已退赛", got) + } + if got := cell(rows, 4, 2); got != "男子" { + t.Errorf("组别 = %q,期望 男子", got) + } + if got := cell(rows, 4, 10); got != "33" { + t.Errorf("年龄 = %q,期望 33", got) + } + + if got := cell(rows, 5, 5); got != "张伟" { + t.Fatalf("第二行姓名 = %q,期望 张伟", got) + } + if got := cell(rows, 5, 7); got != "007" { + t.Errorf("号码布 = %q,期望 007", got) + } + if got := cell(rows, 5, 8); got != "2" { + t.Errorf("批次 = %q,期望 2", got) + } + if got := cell(rows, 5, 11); got != "正常参赛" { + t.Errorf("registered 状态 = %q,期望 正常参赛", got) + } + if got := cell(rows, 5, 12); got != "卫冕冠军" { + t.Errorf("备注 = %q,期望 卫冕冠军", got) + } + + // 团体行:姓名取成员名单,批次 1。 + if got := cell(rows, 6, 5); got != "王磊、赵雷" { + t.Errorf("团体行姓名 = %q,期望 王磊、赵雷", got) + } + if got := cell(rows, 6, 1); got != "4×100米接力" { + t.Errorf("团体行小项名 = %q,期望 4×100米接力", got) + } + + // 号码布必须是文本单元格:写成数字会把 007 变成 7。 + if typ, err := f.GetCellType("报名名单", "H5"); err != nil || typ != excelize.CellTypeSharedString { + t.Errorf("号码布单元格类型 = %v (err=%v),期望共享字符串(保住前导零)", typ, err) + } +} + +func TestExportEntriesXLSXFilters(t *testing.T) { + db := openEventDB(t) + seedEntries(t, db) + + cases := []struct { + name string + filter EntriesFilter + want int + first string + }{ + {"全部", EntriesFilter{}, 3, "李强"}, + {"按批次", EntriesFilter{Heat: 2}, 1, "张伟"}, + {"按单位", EntriesFilter{TeamID: 1}, 2, "张伟"}, + {"按小项", EntriesFilter{EventID: 1}, 2, "李强"}, + {"按组别", EntriesFilter{CategoryID: 3}, 1, "王磊、赵雷"}, + {"组合筛选", EntriesFilter{EventID: 1, TeamID: 1, Heat: 2}, 1, "张伟"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wb, err := LoadEntriesWorkbook(db, tc.filter, Options{}) + if err != nil { + t.Fatalf("读取报名名单: %v", err) + } + if len(wb.Rows) != tc.want { + t.Fatalf("行数 = %d,期望 %d", len(wb.Rows), tc.want) + } + if got := wb.Rows[0].Name; got != tc.first { + t.Errorf("首行姓名 = %q,期望 %q", got, tc.first) + } + }) + } + + // 不存在的批次号不是错误,只是没有数据。 + wb, err := LoadEntriesWorkbook(db, EntriesFilter{Heat: 99}, Options{}) + if err != nil { + t.Fatalf("读取空结果: %v", err) + } + if len(wb.Rows) != 0 { + t.Errorf("批次 99 应为空,得到 %d 行", len(wb.Rows)) + } +} + +func TestWriteEntriesXLSXStream(t *testing.T) { + db := openEventDB(t) + seedEntries(t, db) + + wb, err := LoadEntriesWorkbook(db, EntriesFilter{}, Options{}) + if err != nil { + t.Fatalf("读取报名名单: %v", err) + } + var buf bytes.Buffer + if err := WriteEntriesXLSX(&buf, wb); err != nil { + t.Fatalf("流式导出: %v", err) + } + f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes())) + if err != nil { + t.Fatalf("打开流式导出的 xlsx: %v", err) + } + defer func() { _ = f.Close() }() + rows, err := f.GetRows("报名名单") + if err != nil { + t.Fatalf("读取报名名单: %v", err) + } + if len(rows) != 7 { + t.Fatalf("行数 = %d,期望 7", len(rows)) + } +} diff --git a/server/internal/export/event.go b/server/internal/export/event.go index bab1902..8bb1bc9 100644 --- a/server/internal/export/event.go +++ b/server/internal/export/event.go @@ -20,6 +20,12 @@ type EventResultRow struct { Points float64 Status string Remark string + // Unpublished 表示这行有草稿成绩但还没发布:成绩值不写出来, + // 名次与积分对未发布成绩没有意义,一并换成占位符,见 eventRows。 + Unpublished bool + // NoResult 表示这行连成绩行都还没有(还没录):三个成绩格子一律占位符。 + // 与 Unpublished 互斥——有成绩行才会有发布状态。 + NoResult bool } // EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。 @@ -117,12 +123,36 @@ func eventSheetTitle(meta Meta, sh EventSheet) string { return sheetTitle(meta, base) } +// eventCellBlank 是「这一格还没有数值」的占位符。留着空格子会让整行读起来像缺数据, +// 占位符才读得出「这是一条完整记录,只是还没发布 / 还没录」。 +const eventCellBlank = "—" + +// eventCellUnpublished 是未发布成绩行的成绩列文案(用户指定的字面文案)。 +const eventCellUnpublished = "分数未录入" + +// eventRows 把行铺成单元格。单页版与多 sheet 版都走这里,两种切法逐格一致。 +// +// 还没数值的格子统一写占位符:名次、成绩、积分在未发布 / 未录入时都没有可比的值, +// 半空半 0 反而像填错。已发布行原样写出(未发布与未录入这两种行都不带 r.Points 的 0)。 func eventRows(rows []EventResultRow) [][]any { out := make([][]any, 0, len(rows)) for _, r := range rows { + var ( + rank any = r.Rank + result any = displayResult(r.ResultText, r.Unit) + points any = r.Points + ) + switch { + case r.NoResult: + // 连成绩行都没有:三格都没值,状态列才是「未录入」。 + rank, result, points = eventCellBlank, eventCellBlank, eventCellBlank + case r.Unpublished: + // 有草稿没发布:成绩值不对外,名次与积分对未发布成绩也没有意义。 + rank, result, points = eventCellBlank, eventCellUnpublished, eventCellBlank + } out = append(out, []any{ - r.Rank, r.Bib, r.TeamName, r.Participant, - displayResult(r.ResultText, r.Unit), r.Points, r.Status, r.Remark, + rank, r.Bib, r.TeamName, r.Participant, + result, points, r.Status, r.Remark, }) } return out diff --git a/server/internal/export/load.go b/server/internal/export/load.go index f221994..e0d5edf 100644 --- a/server/internal/export/load.go +++ b/server/internal/export/load.go @@ -30,7 +30,8 @@ func (m Meta) normalized() Meta { // Options 是导出选项;零值即「正式成绩」口径。 type Options struct { Meta Meta - // IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩也导出。 + // IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩连值一起导出; + // 为 false(默认)时草稿行照样占一行,但成绩值不外露,按未发布呈现(见 EventResultRow.Unpublished)。 // 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。 IncludeDrafts bool } @@ -441,6 +442,8 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) { // loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。 // 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。 +// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished); +// 连成绩行都没有的报名项同样占位(NoResult)。 func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) { query := ` SELECT COALESCE(r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''), @@ -459,9 +462,9 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([ query += ` AND en.category_id = ?` args = append(args, categoryID) } - if !includeDrafts { - query += ` AND (r.state IS NULL OR r.state = 'published')` - } + // 只看已发布成绩时也不把草稿行滤掉:名单来自报名表,成绩没发布只是这一行的状态。 + // 整行消失会被当成「这个人没报名」(现场实测就是这么报的),所以草稿行照样占一行, + // 只是成绩值按未发布处理(见下面的 Unpublished 与 eventRows)。 query += ` ORDER BY CASE WHEN r.rank IS NULL THEN 1 ELSE 0 END, r.rank, t.sort_order, t.code, en.id` rows, err := db.Query(query, args...) @@ -489,6 +492,11 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([ row.Rank = rankLabel(rank, isTie != 0) row.Status = statusLabel(resultStat, resultState, entryStat) row.Remark = joinRemark(entryRemark, resultNote) + // results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。 + // 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。 + row.NoResult = !resultState.Valid + row.Unpublished = !includeDrafts && resultState.Valid && + domain.ResultState(resultState.String) == domain.ResultStateDraft out = append(out, row) } if err := rows.Err(); err != nil { diff --git a/server/internal/export/load_test.go b/server/internal/export/load_test.go index 5c884f7..4c211a3 100644 --- a/server/internal/export/load_test.go +++ b/server/internal/export/load_test.go @@ -2,6 +2,7 @@ package export import ( "context" + "database/sql" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" @@ -293,3 +294,127 @@ func TestLoadStandingsSkipsPersonalOnlyLedger(t *testing.T) { } } } + +// seedDraftAndBlank 在 seedEvent 之上补两类「还没发布」的报名: +// 王五有一条草稿成绩(录了没发布),赵六一条成绩都没有。种子里的两条是已发布成绩。 +func seedDraftAndBlank(t *testing.T, db *sql.DB) { + t.Helper() + stmts := []string{ + `INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '男', 1)`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 3, '103')`, + // rank 留空:草稿还没参与排名,也让行序稳定落在已发布成绩之后。 + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (1, 3, NULL, '12.10', 12.10, 's', 'draft')`, + `INSERT INTO athletes(name, gender, team_id) VALUES ('赵六', '男', 2)`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 2, 4, '104')`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } +} + +// 草稿行不能从表上消失,但要按「未发布」呈现:成绩列写「分数未录入」,名次与积分填「—」; +// 连成绩行都没有的行三格一律占位;已发布的格子一个都不许变。 +func TestEventRowsShowUnpublishedDraftWithoutDroppingIt(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + // 先取一份只有已发布成绩的导出,等下用它证明已发布行没被新行挤动。 + before, err := LoadEventWorkbook(db, 1, Options{}) + if err != nil { + t.Fatalf("导出已发布成绩: %v", err) + } + if len(before.Sheets) != 1 { + t.Fatalf("组别数 = %d,期望 1", len(before.Sheets)) + } + publishedRows := eventRows(before.Sheets[0].Rows) + + seedDraftAndBlank(t, db) + after, err := LoadEventWorkbook(db, 1, Options{}) + if err != nil { + t.Fatalf("导出含草稿的成绩: %v", err) + } + rows := eventRows(after.Sheets[0].Rows) + if len(rows) != 4 { + t.Fatalf("行数 = %d,期望 4(已发布 2 + 草稿 1 + 没成绩 1): %v", len(rows), rows) + } + + // 已发布的两行:每个格子与「没有那两条未发布报名时」完全一致。 + for i, want := range publishedRows { + if !equalCells(rows[i], want) { + t.Errorf("第 %d 行被改动了:得到 %v,期望 %v", i+1, rows[i], want) + } + } + // 再钉一遍字面值:这两行是现场打印件上的样子,不能因为新增占位逻辑而漂移。 + wantPublished := [][]any{ + {"1", "101", "第一分公司", "张三", "11.58 秒", 10.0, "有效", ""}, + {"2", "102", "第二分公司", "李四", "11.90 秒", 8.0, "有效", ""}, + } + for i, want := range wantPublished { + if !equalCells(rows[i], want) { + t.Errorf("已发布第 %d 行 = %v,期望 %v", i+1, rows[i], want) + } + } + + // 草稿行(王五):占一行,成绩列是「分数未录入」,名次与积分是「—」。 + if got := rows[2]; !equalCells(got, []any{ + "—", "103", "第一分公司", "王五", "分数未录入", "—", "草稿", "", + }) { + t.Errorf("草稿行 = %v,期望名次 / 积分占位、成绩写「分数未录入」", got) + } + + // 完全没成绩的行(赵六):三个成绩格子都不留空白也不写 0,状态仍是未录入。 + if got := rows[3]; !equalCells(got, []any{ + "—", "104", "第二分公司", "赵六", "—", "—", "未录入", "", + }) { + t.Errorf("没成绩的行 = %v,期望名次 / 成绩 / 积分都是「—」、状态未录入", got) + } + // 逐列再钉一次:这三格的列序是 名次(0) / 成绩(4) / 积分(5),改列序时这里会先炸。 + for _, col := range []int{0, 4, 5} { + if got := rows[3][col]; got != "—" { + t.Errorf("没成绩的行第 %d 列 = %v,期望「—」", col, got) + } + } +} + +// includeDrafts 打开时(草稿也导出)草稿行的真实值照常写出; +// 连成绩行都没有的行仍然三格占位——它本来就没有值可写。 +func TestEventRowsIncludeDraftsKeepsDraftValues(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + seedDraftAndBlank(t, db) + + wb, err := LoadEventWorkbook(db, 1, Options{IncludeDrafts: true}) + if err != nil { + t.Fatalf("带草稿导出: %v", err) + } + rows := eventRows(wb.Sheets[0].Rows) + if len(rows) != 4 { + t.Fatalf("行数 = %d,期望 4", len(rows)) + } + if got := rows[2]; !equalCells(got, []any{ + "", "103", "第一分公司", "王五", "12.10 秒", 0.0, "草稿", "", + }) { + t.Errorf("带草稿导出时草稿行 = %v,期望写出草稿成绩值", got) + } + if got := rows[3]; !equalCells(got, []any{ + "—", "104", "第二分公司", "赵六", "—", "—", "未录入", "", + }) { + t.Errorf("带草稿导出时没成绩的行 = %v,期望三格仍是「—」", got) + } +} + +// equalCells 逐格比较两行;float64 与 string 都按值比。 +func equalCells(got, want []any) bool { + if len(got) != len(want) { + return false + } + for i := range want { + if got[i] != want[i] { + return false + } + } + return true +} diff --git a/server/internal/httpapi/athlete_batch_test.go b/server/internal/httpapi/athlete_batch_test.go new file mode 100644 index 0000000..8f94e2e --- /dev/null +++ b/server/internal/httpapi/athlete_batch_test.go @@ -0,0 +1,241 @@ +package httpapi_test + +import ( + "net/http" + "testing" + "time" +) + +// batchData 解一次批量动作的响应体。 +type batchItemView struct { + ID int64 `json:"id"` + OK bool `json:"ok"` + Code string `json:"code"` + Message string `json:"message"` +} + +type batchResultView struct { + Action string `json:"action"` + DryRun bool `json:"dryRun"` + Requested int `json:"requested"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Items []batchItemView `json:"items"` + Purge *struct { + DeletedEntries int `json:"deletedEntries"` + DeletedResults int `json:"deletedResults"` + DeletedLedger int `json:"deletedLedger"` + DeletedBreaks int `json:"deletedBreaks"` + RecomputedEvent []int64 `json:"recomputedEvents"` + } `json:"purge"` +} + +func doBatch(t *testing.T, env *testEnv, client *http.Client, body map[string]any) batchResultView { + t.Helper() + status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", body, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("批量动作应 200,得到 %d %+v", status, env2.Error) + } + return decodeData[batchResultView](t, env2) +} + +// auditCount 数某个 action 的审计条数;审计在响应之后落库,给一点重试窗口。 +func auditCount(t *testing.T, env *testEnv, action string) int { + t.Helper() + for i := 0; i < 100; i++ { + if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action); n > 0 { + return n + } + time.Sleep(10 * time.Millisecond) + } + return 0 +} + +// 一次请求停用 5 人:库内 5 行 active = 0,响应逐条成功,且只产生一条审计。 +func TestBatchDisableAthletesOneRequest(t *testing.T) { + env, ids, client := recorderWithSeed(t) + targets := []int64{ids.athleteA, ids.athB} + for _, name := range []string{"赵一", "钱二", "孙三"} { + targets = append(targets, env.insert(t, + `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, name, ids.teamA)) + } + + result := doBatch(t, env, client, map[string]any{"action": "disable", "ids": targets}) + if result.Action != "disable" || result.Requested != 5 || result.Succeeded != 5 || result.Failed != 0 { + t.Fatalf("响应应为 5 成功 0 失败,得到 %+v", result) + } + for _, item := range result.Items { + if !item.OK { + t.Fatalf("每条都该成功,得到 %+v", item) + } + } + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != 5 { + t.Fatalf("应有 5 人处于停用,得到 %d", n) + } + // 停用是软删除:报名与成绩一条不动。 + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 { + t.Fatalf("停用不该动报名,得到 %d 条", n) + } + if n := auditCount(t, env, "athlete.batch.disable"); n != 1 { + t.Fatalf("整批只应写 1 条审计,得到 %d 条", n) + } +} + +// 混入不存在的 id:逐条回报,整批仍然成功,重复 id 只算一次。 +func TestBatchDisableReportsMissingIDs(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + result := doBatch(t, env, client, map[string]any{ + "action": "disable", "ids": []int64{ids.athleteA, ids.athleteA, 999999}, + }) + if result.Requested != 2 || result.Succeeded != 1 || result.Failed != 1 { + t.Fatalf("重复 id 应去重、缺失 id 应逐条失败,得到 %+v", result) + } + if len(result.Items) != 2 { + t.Fatalf("应逐条回报 2 条,得到 %+v", result.Items) + } + missing := result.Items[1] + if missing.ID != 999999 || missing.OK || missing.Code != "NOT_FOUND" || missing.Message != "运动员不存在" { + t.Fatalf("缺失 id 应回报 运动员不存在,得到 %+v", missing) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 1 { + t.Fatalf("存在的那个人应被停用,得到 %d", n) + } +} + +// 批量彻底删除:删 2 人、重算一次受影响组别,第三个人升到第一并拿到 10 分; +// 缺失的 id 不影响其余两人被删掉。 +func TestBatchPurgeRecomputesAndReportsPerItem(t *testing.T) { + env, ids, client := recorderWithSeed(t) + athC := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA) + entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, athC) + publishResults(t, env, client, ids.event, "batch-purge", + 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"}, + map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"}, + ) + // 前置:A 第一 10 分,C 第三 6 分。 + if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) { + t.Fatalf("前置条件:C 应是 6 分,得到 %v", got) + } + + result := doBatch(t, env, client, map[string]any{ + "action": "purge", "ids": []int64{ids.athleteA, ids.athB, 999999}, + }) + if result.Succeeded != 2 || result.Failed != 1 { + t.Fatalf("应 2 成功 1 失败,得到 %+v", result) + } + if result.Purge == nil { + t.Fatalf("purge 应带回删除规模,得到 %+v", result) + } + if result.Purge.DeletedEntries != 2 || result.Purge.DeletedResults != 2 || result.Purge.DeletedLedger != 2 { + t.Fatalf("删除规模应为 报名2 / 成绩2 / 流水2,得到 %+v", result.Purge) + } + if len(result.Purge.RecomputedEvent) != 1 || result.Purge.RecomputedEvent[0] != ids.event { + t.Fatalf("应重算 1 个受影响小项,得到 %+v", result.Purge.RecomputedEvent) + } + + // 被删的两人痕迹全无,第三人原样保留。 + for _, id := range []int64{ids.athleteA, ids.athB} { + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, id); n != 0 { + t.Fatalf("运动员 %d 应被删掉,得到 %d", id, n) + } + } + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, athC); n != 1 { + t.Fatalf("第三个人的报名不该被删,得到 %d", n) + } + // 别人的名次被重算:C 从第三升到第一,得分 6 → 10,单位总分跟着变。 + if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{10}) { + t.Fatalf("C 应随新名次重算成 10 分,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamB); got != 0 { + t.Fatalf("B 单位删空后总分应归零,得到 %v", got) + } + // 榜单也重算了:C 所在的 A 单位仍是 10 分(A 的 10 分换成了 C 的 10 分)。 + if got := teamPoints(t, env, client, ids.teamA); got != 10 { + t.Fatalf("A 单位应保持 10 分,得到 %v", got) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n == 0 { + t.Fatalf("重算应把旧流水作废留下来") + } + assertNoForeignKeyViolation(t, env) + if n := auditCount(t, env, "athlete.batch.purge"); n != 1 { + t.Fatalf("整批只应写 1 条审计,得到 %d 条", n) + } +} + +// dryRun 只统计不改库:预览数字与真删同源,但一行都不能动,也不该留审计。 +func TestBatchPurgeDryRunChangesNothing(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "batch-dry", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + ) + before := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) + + tableDump(t, env, `SELECT * FROM entries ORDER BY id`) + + tableDump(t, env, `SELECT * FROM results ORDER BY id`) + + tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`) + + result := doBatch(t, env, client, map[string]any{ + "action": "purge", "ids": []int64{ids.athleteA}, "dryRun": true, + }) + if !result.DryRun || result.Succeeded != 1 { + t.Fatalf("预览应逐条成功并标记 dryRun,得到 %+v", result) + } + if result.Purge == nil || result.Purge.DeletedEntries != 1 || result.Purge.DeletedResults != 1 { + t.Fatalf("预览应报出会删掉什么,得到 %+v", result.Purge) + } + if len(result.Purge.RecomputedEvent) != 1 { + t.Fatalf("预览应报出会重算哪个小项,得到 %+v", result.Purge.RecomputedEvent) + } + + after := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) + + tableDump(t, env, `SELECT * FROM entries ORDER BY id`) + + tableDump(t, env, `SELECT * FROM results ORDER BY id`) + + tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`) + if before != after { + t.Fatalf("预览不该改动任何数据:\n%s\n---\n%s", before, after) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action LIKE 'athlete.batch%'`); n != 0 { + t.Fatalf("预览不该写审计,得到 %d 条", n) + } +} + +// 非法入参:action 只认 disable / purge,ids 去重后不能为空。 +func TestBatchRejectsBadInput(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + for _, tc := range []struct { + name string + body map[string]any + want string + }{ + {"action 非法", map[string]any{"action": "delete", "ids": []int64{ids.athleteA}}, "action 只能是 disable / purge"}, + {"ids 为空", map[string]any{"action": "disable", "ids": []int64{}}, "ids 不能为空"}, + {"ids 全非法", map[string]any{"action": "purge", "ids": []int64{0, -3}}, "ids 不能为空"}, + } { + status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", tc.body, true) + if status != http.StatusBadRequest { + t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error) + } + if env2.Error == nil || env2.Error.Message != tc.want { + t.Fatalf("%s 文案应为 %q,得到 %+v", tc.name, tc.want, env2.Error) + } + } +} + +// 未登录的写操作一律 401,且不改动任何数据。 +func TestBatchRequiresLogin(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + client, _ := env.client() + + status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", + map[string]any{"action": "disable", "ids": []int64{ids.athleteA}}, false) + if status != http.StatusUnauthorized { + t.Fatalf("未登录应 401,得到 %d %+v", status, env2.Error) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 0 { + t.Fatalf("被拒后不该改动数据,得到 %d", n) + } +} diff --git a/server/internal/httpapi/category_update_test.go b/server/internal/httpapi/category_update_test.go new file mode 100644 index 0000000..79f656d --- /dev/null +++ b/server/internal/httpapi/category_update_test.go @@ -0,0 +1,368 @@ +package httpapi_test + +import ( + "database/sql" + "fmt" + "net/http" + "strings" + "testing" +) + +// rawData 发一次请求并把 data 原样作为字符串返回。 +// 逐字节比较需要的是服务端的原始字节,不是 decode 再 encode 的结果。 +func rawData(t *testing.T, env *testEnv, client *http.Client, path string) string { + t.Helper() + status, resp := env.do(client, http.MethodGet, path, nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("GET %s 应 200,得到 %d %+v", path, status, resp.Error) + } + return string(resp.Data) +} + +// tableDump 把查询结果按原始列顺序 dump 成文本。 +// 逐字节比较要的是存储层的事实,而不是经过 join 加工后的展示字段。 +func tableDump(t *testing.T, env *testEnv, query string) string { + t.Helper() + rows, err := env.ev.Query(query) + if err != nil { + t.Fatalf("dump %s: %v", query, err) + } + defer func() { _ = rows.Close() }() + cols, err := rows.Columns() + if err != nil { + t.Fatalf("读列名: %v", err) + } + var b strings.Builder + for rows.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + t.Fatalf("扫描 %s: %v", query, err) + } + for i, v := range vals { + if raw, ok := v.([]byte); ok { + v = string(raw) + } + fmt.Fprintf(&b, "%s=%v ", cols[i], v) + } + b.WriteString("\n") + } + if err := rows.Err(); err != nil { + t.Fatalf("遍历 %s: %v", query, err) + } + return b.String() +} + +// categorySnapshot 抓下「报名 / 成绩 / 流水 / 成绩接口 / 积分榜」五份原始数据。 +// 改个组别名不该让它们有任何变化,连一个字节都不行。 +// +// 不含 GET /events/{id}/entries:它 join 了组别名,categoryName 跟着变是应该的, +// 这条由下面的断言单独检查。 +func categorySnapshot(t *testing.T, env *testEnv, client *http.Client, eventID int64) string { + t.Helper() + return strings.Join([]string{ + tableDump(t, env, fmt.Sprintf(`SELECT * FROM entries WHERE event_id = %d ORDER BY id`, eventID)), + tableDump(t, env, fmt.Sprintf(`SELECT * FROM results WHERE event_id = %d ORDER BY id`, eventID)), + tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`), + rawData(t, env, client, fmt.Sprintf("/api/results?eventId=%d&size=500", eventID)), + rawData(t, env, client, "/api/standings"), + }, "\n") +} + +// 有报名数据的小项必须能改组别名,且报名 / 成绩 / 榜单逐字节不变——这是本次修复的核心诉求。 +func TestUpdateCategoryRenamesWithEntriesKeepingData(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "cat-rename", + 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 := categorySnapshot(t, env, client, ids.event) + + status, env2 := env.do(client, http.MethodPut, + fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category), + map[string]any{ + "id": ids.category, "eventId": ids.event, "name": "男子甲组", + "multiplier": 1, "gender": "男", "ageMin": 18, "ageMax": 35, "sortOrder": 0, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error) + } + + // 响应回的是该小项最新的组别列表,前端据此刷新。 + cats := decodeData[[]map[string]any](t, env2) + found := false + for _, c := range cats { + if c["id"] == float64(ids.category) { + found = true + if c["name"] != "男子甲组" { + t.Fatalf("响应里的组别名应为 男子甲组,得到 %+v", c["name"]) + } + } + } + if !found { + t.Fatalf("响应里应有组别 %d,得到 %+v", ids.category, cats) + } + + var ( + name string + multiplier float64 + gender string + ageMin, ageMax int + ) + if err := env.ev.QueryRow(`SELECT name, multiplier, gender, age_min, age_max FROM event_categories WHERE id = ?`, + ids.category).Scan(&name, &multiplier, &gender, &ageMin, &ageMax); err != nil { + t.Fatalf("读组别: %v", err) + } + if name != "男子甲组" || multiplier != 1 || gender != "男" || ageMin != 18 || ageMax != 35 { + t.Fatalf("组别字段应就地更新,得到 name=%s mult=%v gender=%s age=%d-%d", name, multiplier, gender, ageMin, ageMax) + } + + // 组别 id 没变(整表替换会换 id,报名就跟着断链)。 + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE category_id = ?`, ids.category); n != 2 { + t.Fatalf("两条报名应仍挂在原组别上,得到 %d 条", n) + } + + after := categorySnapshot(t, env, client, ids.event) + if before != after { + t.Fatalf("改名不该动报名 / 成绩 / 流水 / 榜单:\n--- before ---\n%s\n--- after ---\n%s", before, after) + } + // 改名不参与算分:一条流水都不该被作废或重建。 + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 0 { + t.Fatalf("改名不该触发重算,得到 %d 条作废流水", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger`); n != 2 { + t.Fatalf("流水条数不该变,得到 %d 条", n) + } + if audit := waitAudit(t, env, "event.category.update", ids.category); !strings.Contains(audit, `"recomputedLedger":false`) { + t.Fatalf("审计应标出没有重算,得到 %s", audit) + } + + // 展示层该跟着变:报名接口里的 categoryName 是新名字(这就是用户想看到的结果)。 + entries := rawData(t, env, client, fmt.Sprintf("/api/events/%d/entries", ids.event)) + if !strings.Contains(entries, `"categoryName":"男子甲组"`) || strings.Contains(entries, `"categoryName":"男子"`) { + t.Fatalf("报名列表应显示新组别名,得到 %s", entries) + } +} + +// 改权重同样是就地更新:不重算已有流水,榜单在改名前后必须逐字节一致。 +// 改权重要立刻重算该组已发布成绩:权重进算分公式,不重算就会让积分与榜单静默不一致。 +func TestUpdateCategoryWeightChangeRecomputesLedger(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "cat-mult", + 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 第一 10 分、B 第二 8 分,单位总分 10 / 8。 + if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) { + t.Fatalf("前置条件:A 应是 10 分,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamA); got != 10 { + t.Fatalf("前置条件:A 单位应是 10 分,得到 %v", got) + } + before := rawData(t, env, client, "/api/standings") + + status, env2 := env.do(client, http.MethodPut, + fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category), + map[string]any{"name": "男子", "multiplier": 2, "sortOrder": 0}, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("改权重应 200,得到 %d %+v", status, env2.Error) + } + + // 旧流水被作废保留痕迹,新流水按 ×2 重建:10 → 20、8 → 16。 + if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{20}) { + t.Fatalf("A 应重算成 20 分,得到 %v", got) + } + if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{16}) { + t.Fatalf("B 应重算成 16 分,得到 %v", got) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 2 { + t.Fatalf("两条旧流水都应被作废,得到 %d 条", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger`); n != 4 { + t.Fatalf("应是「2 条作废 + 2 条有效」,得到 %d 条", n) + } + // 名次不变(权重不改排序),但分数与榜单必须跟着变。 + if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE rank = 1`); n != 1 { + t.Fatalf("名次不该被改乱,得到 %d 个第一", n) + } + if after := rawData(t, env, client, "/api/standings"); before == after { + t.Fatalf("榜单必须随新权重重算,但仍与改前一致:%s", after) + } + if got := teamPoints(t, env, client, ids.teamA); got != 20 { + t.Fatalf("改权重后 A 单位应是 20 分,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamB); got != 16 { + t.Fatalf("改权重后 B 单位应是 16 分,得到 %v", got) + } + + // 审计要能看出这次动了权重并触发重算。 + after := waitAudit(t, env, "event.category.update", ids.category) + if !strings.Contains(after, `"recomputedLedger":true`) { + t.Fatalf("审计应标出触发了重算,得到 %s", after) + } +} + +// 权重原样提交(浮点比较的容差)不该白重算一次全组积分。 +func TestUpdateCategorySameMultiplierDoesNotRecompute(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "cat-same", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + ) + var logID int64 + if err := env.ev.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM score_ledger`).Scan(&logID); err != nil { + t.Fatalf("读流水: %v", err) + } + + status, env2 := env.do(client, http.MethodPut, + fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category), + // 1 与 1+1e-12 在容差内视为同一个权重。 + map[string]any{"name": "男子甲组", "multiplier": 1.000000000001, "sortOrder": 0}, 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 id > ?`, logID); n != 0 { + t.Fatalf("权重没变不该产生新流水,得到 %d 条", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 0 { + t.Fatalf("权重没变不该作废流水,得到 %d 条", n) + } + if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) { + t.Fatalf("分值不该变,得到 %v", got) + } + after := waitAudit(t, env, "event.category.update", ids.category) + if !strings.Contains(after, `"recomputedLedger":false`) { + t.Fatalf("审计应标出没有重算,得到 %s", after) + } +} + +// 同小项内重名必须 409,且文案点名是哪一个名字。 +func TestUpdateCategoryRejectsDuplicateName(t *testing.T) { + env, ids, client := recorderWithSeed(t) + cat2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) + VALUES (?, '女子', 1, 1)`, ids.event) + + status, env2 := env.do(client, http.MethodPut, + fmt.Sprintf("/api/events/%d/categories/%d", ids.event, cat2), + map[string]any{"name": "男子", "multiplier": 1, "sortOrder": 1}, true) + if status != http.StatusConflict { + t.Fatalf("重名应 409,得到 %d %+v", status, env2.Error) + } + if env2.Error == nil || !strings.Contains(env2.Error.Message, "组别名称重复:男子") { + t.Fatalf("重名文案应含「组别名称重复:男子」,得到 %+v", env2.Error) + } + + // 原行没被改坏。 + var name string + if err := env.ev.QueryRow(`SELECT name FROM event_categories WHERE id = ?`, cat2).Scan(&name); err != nil { + t.Fatalf("读组别: %v", err) + } + if name != "女子" { + t.Fatalf("冲突时不该改动原组别,得到 %s", name) + } +} + +// 名称必填、性别枚举、年龄范围都按新接口的口径给 400,而不是让数据库约束冒成 500。 +func TestUpdateCategoryValidatesFields(t *testing.T) { + env, ids, client := recorderWithSeed(t) + path := fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category) + + for _, tc := range []struct { + name string + body map[string]any + want string + }{ + {"名称必填", map[string]any{"name": " ", "multiplier": 1}, "组别 name 必填"}, + {"性别枚举", map[string]any{"name": "男子", "multiplier": 1, "gender": "其他"}, "组别 gender 只能是 男 / 女"}, + {"年龄范围", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 130}, "组别年龄需要在 0-120 之间"}, + {"年龄区间", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 30, "ageMax": 20}, "组别年龄下限不能大于上限"}, + } { + status, env2 := env.do(client, http.MethodPut, path, tc.body, true) + if status != http.StatusBadRequest { + t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error) + } + if env2.Error == nil || env2.Error.Message != tc.want { + t.Fatalf("%s 文案应为 %q,得到 %+v", tc.name, tc.want, env2.Error) + } + } +} + +// 组别不存在、或 categoryId 属于别的小项,都按 404「组别不存在」处理,不给跨小项改名。 +func TestUpdateCategoryMissingReturns404(t *testing.T) { + env, ids, client := recorderWithSeed(t) + otherEvent := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport) + otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) + VALUES (?, '女子', 1, 0)`, otherEvent) + + for _, tc := range []struct { + name string + path string + }{ + {"组别不存在", fmt.Sprintf("/api/events/%d/categories/999999", ids.event)}, + {"组别属于别的小项", fmt.Sprintf("/api/events/%d/categories/%d", ids.event, otherCat)}, + } { + status, env2 := env.do(client, http.MethodPut, tc.path, map[string]any{"name": "男子", "multiplier": 1}, true) + if status != http.StatusNotFound || env2.Error == nil || env2.Error.Message != "组别不存在" { + t.Fatalf("%s 应 404 组别不存在,得到 %d %+v", tc.name, status, env2.Error) + } + } + // 别的小项的组别没被改动。 + var name string + if err := env.ev.QueryRow(`SELECT name FROM event_categories WHERE id = ?`, otherCat).Scan(&name); err != nil { + t.Fatalf("读组别: %v", err) + } + if name != "女子" { + t.Fatalf("跨小项改名必须被拒,得到 %s", name) + } +} + +// 老路径(整表替换)不能被改坏:有报名数据时仍然拒绝,但文案要说清是「不能增删」。 +func TestReplaceCategoriesStillRejectsWithEntries(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + status, env2 := env.do(client, http.MethodPut, + fmt.Sprintf("/api/events/%d/categories", ids.event), + map[string]any{"categories": []map[string]any{{"name": "男子", "multiplier": 1}}}, true) + if status != http.StatusConflict { + t.Fatalf("有报名数据时整表替换应 409,得到 %d %+v", status, env2.Error) + } + if env2.Error == nil || !strings.Contains(env2.Error.Message, "不能新增或删除组别") { + t.Fatalf("文案应说清是「不能增删组别」,得到 %+v", env2.Error) + } + // 拒绝时不许留下半截改动。 + if n := countRows(t, env, `SELECT COUNT(*) FROM event_categories WHERE event_id = ?`, ids.event); n != 1 { + t.Fatalf("拒绝后组别数不该变,得到 %d", n) + } +} + +// 就地更新也要推大屏、写审计。 +func TestUpdateCategoryWritesChangeLogAndAudit(t *testing.T) { + env, ids, client := recorderWithSeed(t) + before := changeLogCount(t, env, "events", "upsert") + + status, env2 := env.do(client, http.MethodPut, + fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category), + map[string]any{"name": "男子甲组", "multiplier": 1, "sortOrder": 0}, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error) + } + if got := changeLogCount(t, env, "events", "upsert"); got != before+1 { + t.Fatalf("改名应写 1 条 events change_log,得到 %d(之前 %d)", got, before) + } + after := waitAudit(t, env, "event.category.update", ids.category) + if !strings.Contains(after, `"男子甲组"`) { + t.Fatalf("审计的 after 应含新名字,得到 %s", after) + } + var beforeJSON sql.NullString + if err := env.ev.QueryRow( + `SELECT before_json FROM audit_logs WHERE action = 'event.category.update' AND entity_id = ?`, + ids.category).Scan(&beforeJSON); err != nil { + t.Fatalf("读审计 before: %v", err) + } + if !beforeJSON.Valid || !strings.Contains(beforeJSON.String, `"男子"`) { + t.Fatalf("审计的 before 应含旧名字,得到 %+v", beforeJSON) + } +} diff --git a/server/internal/httpapi/deferred_settlement_test.go b/server/internal/httpapi/deferred_settlement_test.go new file mode 100644 index 0000000..b33f00a --- /dev/null +++ b/server/internal/httpapi/deferred_settlement_test.go @@ -0,0 +1,466 @@ +package httpapi_test + +import ( + "database/sql" + "fmt" + "net/http" + "reflect" + "testing" +) + +// setLiveOnPublish 直接改赛事库设置,等价于管理端 PUT /api/settings。 +// 走库而不是接口是为了让用例只关心结算行为,设置写入由 TestLiveOnPublishSwitchPersists 单独覆盖。 +func setLiveOnPublish(t *testing.T, env *testEnv, value bool) { + t.Helper() + env.exec(t, `INSERT INTO settings (key, value, updated_at) VALUES ('standings.live_on_publish', ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, map[bool]string{true: "true", false: "false"}[value]) +} + +type settlementStatus struct { + LiveOnPublish bool `json:"liveOnPublish"` + Groups []struct { + EventID int64 `json:"eventId"` + CategoryID int64 `json:"categoryId"` + CategoryName string `json:"categoryName"` + Required int `json:"required"` + Published int `json:"published"` + } `json:"groups"` +} + +func readSettlement(t *testing.T, env *testEnv, client *http.Client, eventID, categoryID int64) settlementStatus { + t.Helper() + status, resp := env.do(client, http.MethodGet, + fmt.Sprintf("/api/results/settlement?eventId=%d&categoryId=%d", eventID, categoryID), nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("读结算状态应 200,得到 %d %+v", status, resp.Error) + } + return decodeData[settlementStatus](t, resp) +} + +func activeLedgerRows(t *testing.T, env *testEnv, eventID int64) int { + t.Helper() + var n int + if err := env.ev.QueryRow( + `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, eventID).Scan(&n); err != nil { + t.Fatalf("统计流水: %v", err) + } + return n +} + +func standingsTotal(t *testing.T, env *testEnv, client *http.Client) float64 { + t.Helper() + _, resp := env.do(client, http.MethodGet, "/api/standings", nil, false) + rows := decodeData[[]map[string]any](t, resp) + var total float64 + for _, row := range rows { + total += row["totalPoints"].(float64) + } + return total +} + +// rankOrZero 读名次:未发布的成绩没有名次(NULL),这里折算成 0,方便分批快照比较。 +func rankOrZero(t *testing.T, env *testEnv, entryID int64) int { + t.Helper() + var rank sql.NullInt64 + if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil { + t.Fatalf("读取名次: %v", err) + } + if !rank.Valid { + return 0 + } + return int(rank.Int64) +} + +func changeLogCountOf(t *testing.T, env *testEnv, entityType string) int { + t.Helper() + var n int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM change_log WHERE entity_type = ?`, entityType).Scan(&n); err != nil { + t.Fatalf("统计 change_log: %v", err) + } + return n +} + +// 默认(设置键不存在)必须是历史行为:分批发一批,流水与榜当场就变。 +func TestLiveOnPublishDefaultIsRealTime(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + entries := seedBatch(t, env, ids, 8) + 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) + status, resp := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error) + } + + // 4 条已发布 = 4 条流水;前四名 10+8+6+3 = 27 分上榜。 + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 4, ids.event) + if got := standingsTotal(t, env, client); got != 27 { + t.Fatalf("默认设置下发布一批后榜单总分 = %v,期望 27", got) + } + if got := changeLogCountOf(t, env, "standings"); got != 1 { + t.Fatalf("默认设置下发布应推 1 次榜单,得到 %d", got) + } + if got := rankOfEntry(t, env, entries[0]); got != 1 { + t.Fatalf("第一名应第 1 名,得到 %d", got) + } +} + +// 开关默认(键不存在)与显式 true 必须逐字节一致: +// 这是「加开关不能悄悄改变现场既有行为」的证明。 +func TestLiveOnPublishDefaultMatchesExplicitTrue(t *testing.T) { + // snapshot 是一次发布之后的可比状态:名次、流水、榜单原始字节、榜单推送次数。 + type snapshot struct { + ranks []int + ledger []string + standings string + pushes int + } + run := func(t *testing.T, value *bool) (afterFirst, afterSecond snapshot) { + t.Helper() + env := newTestEnv(t) + ids := env.seed(t) + entries := seedBatch(t, env, ids, 8) + if value != nil { + setLiveOnPublish(t, env, *value) + } + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + read := func() snapshot { + t.Helper() + var snap snapshot + for _, id := range entries { + snap.ranks = append(snap.ranks, rankOrZero(t, env, id)) + } + rows, err := env.ev.Query( + `SELECT entry_id, team_id, points FROM score_ledger WHERE event_id = ? AND voided = 0 ORDER BY entry_id, team_id`, ids.event) + if err != nil { + t.Fatalf("读流水: %v", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var entryID, teamID int64 + var points float64 + if err := rows.Scan(&entryID, &teamID, &points); err != nil { + t.Fatalf("扫描流水: %v", err) + } + snap.ledger = append(snap.ledger, fmt.Sprintf("%d/%d/%v", entryID, teamID, points)) + } + if err := rows.Err(); err != nil { + t.Fatalf("遍历流水: %v", err) + } + snap.standings = rawData(t, env, client, "/api/standings") + snap.pushes = changeLogCountOf(t, env, "standings") + return snap + } + + slow := make([]float64, 8) + fast := make([]float64, 8) + for i := range slow { + slow[i] = 12.0 + float64(i)*0.1 + fast[i] = 10.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, slow), + }, true) + env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, + }, true) + afterFirst = read() + + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 8, 4, fast), + }, true) + env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, + }, true) + afterSecond = read() + return afterFirst, afterSecond + } + + assertSame := func(t *testing.T, label string, absent, explicit snapshot) { + t.Helper() + if !reflect.DeepEqual(absent.ranks, explicit.ranks) { + t.Fatalf("%s:名次不一致:默认 %v vs 显式 true %v", label, absent.ranks, explicit.ranks) + } + if !reflect.DeepEqual(absent.ledger, explicit.ledger) { + t.Fatalf("%s:流水不一致:默认 %v vs 显式 true %v", label, absent.ledger, explicit.ledger) + } + if absent.standings != explicit.standings { + t.Fatalf("%s:榜单字节不一致:\n默认 %s\n显式 %s", label, absent.standings, explicit.standings) + } + if absent.pushes != explicit.pushes { + t.Fatalf("%s:榜单推送次数不一致:默认 %d vs 显式 true %d", label, absent.pushes, explicit.pushes) + } + } + + absentFirst, absentSecond := run(t, nil) + explicit := true + trueFirst, trueSecond := run(t, &explicit) + assertSame(t, "第一批", absentFirst, trueFirst) + assertSame(t, "第二批", absentSecond, trueSecond) + + // 反向证明这份快照确实受开关控制,否则上面的「一致」可能只是两条路径都坏了。 + // 关掉开关后第一批只发 4/8:名次照写(延迟结算只省流水与榜单),流水必须为空、榜单必须不动。 + off := false + offFirst, _ := run(t, &off) + if len(offFirst.ledger) != 0 { + t.Fatalf("关闭开关后第一批不该写流水,得到 %v", offFirst.ledger) + } + if offFirst.pushes != 0 { + t.Fatalf("关闭开关后第一批不该推榜单,得到 %d 次", offFirst.pushes) + } + if offFirst.standings == absentFirst.standings { + t.Fatalf("关闭开关后榜单应与默认不同,得到同一份:%s", offFirst.standings) + } +} + +// 延迟结算:第一批只落成绩,名次 / 流水 / 榜单都不动; +// 第二批录完自动结算,整组重算并把名次与榜单补上。 +func TestDeferredSettlementWaitsForWholeGroup(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") + + slow := make([]float64, 8) + fast := make([]float64, 8) + for i := range slow { + slow[i] = 12.0 + float64(i)*0.1 + fast[i] = 10.0 + float64(i)*0.1 + } + // 发布之前的榜单原始字节:延迟结算下这批发布不允许改动它(含奖牌列)。 + beforeStandings := rawData(t, env, client, "/api/standings") + + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, slow), + }, true) + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, + }, true); status != http.StatusOK || !e.OK { + t.Fatalf("第一批发布应 200,得到 %d %+v", status, e.Error) + } + + // 成绩要上屏(可以看到时间),但名次 / 流水 / 榜单都要等结算。 + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 4, ids.event) + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event) + assertCount(t, env, `SELECT COUNT(*) FROM change_log WHERE entity_type = 'standings'`, 0) + // 名次不写:奖牌榜统计读的就是 results.rank,分批写名次会让奖牌列跟总分对不上。 + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, 0, ids.event) + if got := rawData(t, env, client, "/api/standings"); got != beforeStandings { + t.Fatalf("延迟结算下榜单不该动:\n发布前 %s\n发布后 %s", beforeStandings, got) + } + + // 未结算清单要能查到这一组。 + first := readSettlement(t, env, client, ids.event, ids.category) + if first.LiveOnPublish { + t.Fatalf("设置已关闭,接口应回报 liveOnPublish=false: %+v", first) + } + if len(first.Groups) != 1 || first.Groups[0].Required != 8 || first.Groups[0].Published != 4 { + t.Fatalf("未结算清单应含 4/8 的该组,得到 %+v", first.Groups) + } + + // 第二批录完 → 自动结算:整组重算,流水齐全,榜单正确。 + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 8, 4, fast), + }, true) + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, + }, true); status != http.StatusOK || !e.OK { + t.Fatalf("第二批发布应 200,得到 %d %+v", status, e.Error) + } + + assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 8, ids.event) + // 前六名得分 10+8+6+3+2+1 = 30,共 6 条流水。 + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event) + if got := standingsTotal(t, env, client); got != 30 { + t.Fatalf("结算后榜单总分 = %v,期望 30", got) + } + if got := changeLogCountOf(t, env, "standings"); got != 1 { + t.Fatalf("结算应推 1 次榜单,得到 %d", got) + } + // 整组重算的证明:第二批更快,第一批第一名退到第 5 名。 + if got := rankOfEntry(t, env, entries[4]); got != 1 { + t.Fatalf("第二批最快者应第 1 名,得到 %d", got) + } + if got := rankOfEntry(t, env, entries[0]); got != 5 { + t.Fatalf("第一批第一名应退到第 5 名,得到 %d", got) + } + if got := readSettlement(t, env, client, ids.event, ids.category); len(got.Groups) != 0 { + t.Fatalf("结算后未结算清单应为空,得到 %+v", got.Groups) + } +} + +// 有人弃权(scratched)时不该再等他:7 人录完 + 1 人退赛也算全组录完,自动结算。 +func TestDeferredSettlementTreatsScratchedAsFinished(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") + + // 第 8 人退赛(与前端「批量退赛」同一条接口)。 + if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ + "action": "scratch", "ids": []int64{entries[7]}, + }, true); status != http.StatusOK || !e.OK { + t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error) + } + + 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, 7, 0, times), + }, true) + if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, + }, true); status != http.StatusOK || !e.OK { + t.Fatalf("发布应 200,得到 %d %+v", status, e.Error) + } + + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event) + if got := standingsTotal(t, env, client); got != 30 { + t.Fatalf("退赛者不该阻塞结算,榜单总分 = %v,期望 30", got) + } + if got := readSettlement(t, env, client, ids.event, ids.category); len(got.Groups) != 0 { + t.Fatalf("全组(含退赛)录完后清单应为空,得到 %+v", got.Groups) + } +} + +// 发布之后才标退赛:整组因此录完,退赛动作本身也要把这组结算掉,不能停在空流水上。 +func TestScratchAfterDeferredPublishSettlesGroup(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, 7, 0, times), + }, true) + env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, + }, true) + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event) + + if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ + "action": "scratch", "ids": []int64{entries[7]}, + }, true); status != http.StatusOK || !e.OK { + t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error) + } + + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event) + if got := standingsTotal(t, env, client); got != 30 { + t.Fatalf("退赛补全整组后应结算,榜单总分 = %v,期望 30", got) + } +} + +// 立即结算是延迟结算的人工兜底:按当前已发布成绩结算,并写审计。 +func TestManualSettleUsesPublishedResults(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) + + if status, e := env.do(client, http.MethodPost, "/api/results/settle", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, "requestId": "settle-1", + }, true); status != http.StatusOK || !e.OK { + t.Fatalf("立即结算应 200,得到 %d %+v", status, e.Error) + } + + // 只有 4 条已发布 → 前四名 10+8+6+3 = 27 分。 + assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 4, ids.event) + if got := standingsTotal(t, env, client); got != 27 { + t.Fatalf("立即结算后榜单总分 = %v,期望 27", got) + } + waitAudit(t, env, "result.settle", ids.event) + + // 未登录不能结算。 + guest, _ := env.client() + if status, _ := env.do(guest, http.MethodPost, "/api/results/settle", map[string]any{ + "eventId": ids.event, "categoryId": ids.category, + }, false); status != http.StatusUnauthorized { + t.Fatalf("未登录结算应 401,得到 %d", status) + } +} + +// 开关键写入 / 读出:PUT /api/settings 落库后 GET 要能读回,且缺键默认 true。 +func TestLiveOnPublishSwitchPersists(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + read := func() bool { + t.Helper() + _, resp := env.do(client, http.MethodGet, "/api/settings", nil, false) + settings := decodeData[map[string]any](t, resp) + return settings["standings"].(map[string]any)["liveOnPublish"].(bool) + } + if !read() { + t.Fatalf("缺键时 liveOnPublish 应默认 true") + } + + if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{ + "standings": map[string]any{"liveOnPublish": false}, + }, true); status != http.StatusOK || !e.OK { + t.Fatalf("关闭开关应 200,得到 %d %+v", status, e.Error) + } + if read() { + t.Fatalf("关闭后 liveOnPublish 应读回 false") + } + // 只改一个字段不能把榜单口径的其余取值抹掉。 + _, resp := env.do(client, http.MethodGet, "/api/settings", nil, false) + standings := decodeData[map[string]any](t, resp)["standings"].(map[string]any) + if standings["individualToTeamWeight"].(float64) != 1 || standings["rankingMode"] != "points" { + t.Fatalf("其余榜单口径不应被改动: %+v", standings) + } + + if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{ + "standings": map[string]any{"liveOnPublish": true}, + }, true); status != http.StatusOK || !e.OK { + t.Fatalf("重新打开应 200,得到 %d %+v", status, e.Error) + } + if !read() { + t.Fatalf("重新打开后 liveOnPublish 应读回 true") + } +} diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index d88d6fb..c9ecc86 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -248,6 +248,9 @@ type currentUserDTO struct { Role string `json:"role"` LastLoginAt *string `json:"lastLoginAt"` MustChangePassword bool `json:"mustChangePassword"` + // SessionExpiresAt 是当前会话的到期时间(RFC3339 UTC),供界面显示 + // 「当前会话将于 … 过期」;未登录时为 null。 + SessionExpiresAt *string `json:"sessionExpiresAt"` } type backupResultDTO struct { diff --git a/server/internal/httpapi/entries_batch.go b/server/internal/httpapi/entries_batch.go new file mode 100644 index 0000000..3663ccb --- /dev/null +++ b/server/internal/httpapi/entries_batch.go @@ -0,0 +1,370 @@ +package httpapi + +import ( + "context" + "database/sql" + "fmt" + "net/http" + "slices" + "strings" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" +) + +// maxEntryBatch 是单次批量操作的上限:前端一次勾选几十条是常态, +// 500 足够覆盖「一个组别全选」,再大说明是脚本误用,要求分批提交。 +const maxEntryBatch = 500 + +// entryBatchItemDTO 是逐条回报:ids 里每一个 id 都必有一条, +// 不存在 / 被跳过都要写清原因,不能整批失败,也不能静默略过。 +type entryBatchItemDTO struct { + ID int64 `json:"id"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Skipped bool `json:"skipped,omitempty"` + Note string `json:"note,omitempty"` +} + +// entryBatchSummaryDTO 是整批汇总:ok 含跳过的行,changed 才是真正改动的行数; +// failed 只统计 id 不存在这类逐条失败(整批 SQL 出错会直接 5xx / 409,不进这里)。 +type entryBatchSummaryDTO struct { + OK int `json:"ok"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Changed int `json:"changed"` + RemovedEntries int `json:"removedEntries"` + RemovedResults int `json:"removedResults"` + RemovedLedger int `json:"removedLedger"` + RemovedBreaks int `json:"removedBreaks"` + RecomputedEvent []int64 `json:"recomputedEvents"` +} + +type entryBatchResultDTO struct { + Items []entryBatchItemDTO `json:"items"` + Summary entryBatchSummaryDTO `json:"summary"` +} + +// entryBatchTarget 是勾选行在库里的落点:换状态要看当前 status,重算要按 (小项, 组别) 分组。 +type entryBatchTarget struct { + id int64 + eventID int64 + categoryID int64 + status string +} + +// batchEntriesHandler 是 POST /api/entries/batch: +// scratch(批量退赛,可恢复)/ restore(批量恢复)/ remove(批量移出报名,物理删除)。 +func (s *server) batchEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in entryBatchPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + action := strings.TrimSpace(in.Action) + switch action { + case "scratch", "restore", "remove": + default: + return nil, badRequest("action 只能是 scratch / restore / remove", in.Action) + } + // 重复 id 去重(前端全选后二次勾选很容易重复),非法 id 直接丢掉。 + ids := dedupeIDs(in.IDs) + if len(ids) == 0 { + return nil, badRequest("ids 不能为空", nil) + } + if len(ids) > maxEntryBatch { + return nil, badRequest(fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", maxEntryBatch), nil) + } + + result, err := s.batchEntries(r.Context(), action, ids) + if err != nil { + return nil, err + } + s.audit(r.Context(), "entry."+action, "entry", 0, nil, map[string]any{ + "ids": ids, + "reason": strings.TrimSpace(in.Reason), + "summary": result.Summary, + }) + return result, nil +} + +// entrySkipNote 解释某一行为什么没被改动:跳过必须带出现状, +// 否则操作员只看到「没退赛」会以为是系统漏了。 +func entrySkipNote(action, status string) string { + switch status { + case "scratched": + return "已是退赛状态,跳过" + case "registered": + return "当前正常参赛,无需恢复" + case "dq": + return "已被判罚取消资格(dq),跳过,不覆盖原判定" + case "dns": + return "已标记未出场(dns),跳过,不覆盖原判定" + } + return "当前状态为「" + status + "」,跳过" +} + +// recomputeReason 是流水作废原因,写进 score_ledger.void_reason,事后能对上「榜为什么变了」。 +func recomputeReason(action string) string { + switch action { + case "scratch": + return "批量退赛后重算" + case "restore": + return "批量恢复后重算" + } + return "批量移出报名后重算" +} + +// loadEntryBatchTargets 读回请求里存在的报名项;不存在的由调用方逐条回报。 +func loadEntryBatchTargets(ctx context.Context, tx *sql.Tx, ids []int64) (map[int64]entryBatchTarget, error) { + args := idsToArgs(ids) + rows, err := tx.QueryContext(ctx, + `SELECT id, event_id, category_id, status FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make(map[int64]entryBatchTarget, len(ids)) + for rows.Next() { + var t entryBatchTarget + if err := rows.Scan(&t.id, &t.eventID, &t.categoryID, &t.status); err != nil { + return nil, internal(err) + } + out[t.id] = t + } + if err := rows.Err(); err != nil { + return nil, internal(err) + } + return out, nil +} + +// entryRemoveStats 是移出报名的物理删除规模,随响应汇总返回。 +type entryRemoveStats struct { + entries int + results int + ledger int + breaks int +} + +// removeEntriesTx 物理删除报名项及其成绩痕迹。 +// +// 顺序按外键方向来(与 repo.go 的 purgeAthlete 同一套解法): +// score_ledger 对 entries / results 都是 ON DELETE SET NULL,直接删 entries 只会把 +// entry_id 置空——人没了分还留在榜上,所以必须按 entry_id 显式清掉(result_id 一并兜住); +// record_breaks 是 CASCADE、results 对 entries 没有 ON DELETE,都要显式先删。 +func (s *server) removeEntriesTx(ctx context.Context, tx *sql.Tx, ids []int64) (entryRemoveStats, error) { + stats := entryRemoveStats{} + if len(ids) == 0 { + return stats, nil + } + ph := placeholders(len(ids)) + idArgs := idsToArgs(ids) + steps := []struct { + query string + args []any + count *int + }{ + {`DELETE FROM score_ledger + WHERE entry_id IN (` + ph + `) + OR result_id IN (SELECT id FROM results WHERE entry_id IN (` + ph + `))`, + append(append([]any{}, idArgs...), idArgs...), &stats.ledger}, + {`DELETE FROM record_breaks WHERE entry_id IN (` + ph + `)`, idArgs, &stats.breaks}, + {`DELETE FROM results WHERE entry_id IN (` + ph + `)`, idArgs, &stats.results}, + {`DELETE FROM entries WHERE id IN (` + ph + `)`, idArgs, &stats.entries}, + } + for _, step := range steps { + res, err := tx.ExecContext(ctx, step.query, step.args...) + if err != nil { + return entryRemoveStats{}, s.entryBatchBlocked(ctx, tx, err) + } + n, err := res.RowsAffected() + if err != nil { + return entryRemoveStats{}, internal(err) + } + *step.count = int(n) + } + return stats, nil +} + +// entryBatchBlocked 兜底:撞上外键约束时回滚并给可读的 409,不让它冒成 500。 +// 连接池只有一条连接,先把事务交还再返回,避免后续查询自锁。 +func (s *server) entryBatchBlocked(ctx context.Context, tx *sql.Tx, cause error) error { + if !isForeignKeyViolation(cause) { + return internal(cause) + } + s.logger.WarnContext(ctx, "批量移出报名撞上外键约束", "err", cause) + _ = tx.Rollback() + return conflict("报名项仍被其它数据引用,本批已回滚,报名与成绩都没有改动。请刷新后重试。") +} + +// batchEntries 在一个事务里完成整批状态变更或删除,并按 (小项, 组别) 分组重算积分。 +// +// 重算口径照 repo.go 的 purgeAthlete:删完成绩(或改完报名状态)后必须重算流水、 +// 把新名次写回 results,再推小项与榜单,否则大屏还挂着旧分数、别人的名次也不会变。 +func (s *server) batchEntries(ctx context.Context, action string, ids []int64) (entryBatchResultDTO, error) { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return entryBatchResultDTO{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + targets, err := loadEntryBatchTargets(ctx, tx, ids) + if err != nil { + return entryBatchResultDTO{}, err + } + + out := entryBatchResultDTO{Items: make([]entryBatchItemDTO, 0, len(ids))} + affected := make([]entryBatchTarget, 0, len(ids)) + for _, id := range ids { + target, ok := targets[id] + if !ok { + out.Items = append(out.Items, entryBatchItemDTO{ID: id, OK: false, Error: "报名项不存在"}) + out.Summary.Failed++ + continue + } + // 退赛只认 registered、恢复只认 scratched;dq/dns 是别人的判定,一律不覆盖。 + want := "" + switch action { + case "scratch": + want = "registered" + case "restore": + want = "scratched" + } + if want != "" && target.status != want { + out.Items = append(out.Items, entryBatchItemDTO{ + ID: id, OK: true, Skipped: true, Note: entrySkipNote(action, target.status), + }) + out.Summary.OK++ + out.Summary.Skipped++ + continue + } + affected = append(affected, target) + out.Items = append(out.Items, entryBatchItemDTO{ID: id, OK: true}) + out.Summary.OK++ + out.Summary.Changed++ + } + + changedIDs := targetIDs(affected) + switch action { + case "scratch", "restore": + if len(changedIDs) > 0 { + status := "scratched" + if action == "restore" { + status = "registered" + } + if _, err := tx.ExecContext(ctx, + `UPDATE entries SET status = ? WHERE id IN (`+placeholders(len(changedIDs))+`)`, + append([]any{status}, idsToArgs(changedIDs)...)...); err != nil { + return entryBatchResultDTO{}, internal(err) + } + } + case "remove": + stats, err := s.removeEntriesTx(ctx, tx, changedIDs) + if err != nil { + return entryBatchResultDTO{}, err + } + out.Summary.RemovedEntries = stats.entries + out.Summary.RemovedResults = stats.results + out.Summary.RemovedLedger = stats.ledger + out.Summary.RemovedBreaks = stats.breaks + } + + // 按 (小项, 组别) 分组重算:跨小项混选是正常的「按人名勾选」场景,但重算必须逐个范围来。 + 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 + } + } + 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 + } + 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 entryBatchResultDTO{}, err + } + } + // 延迟结算且没有一组录完时不推榜单:榜单该保持上一次结算的状态。 + if len(eventIDs) > 0 && settled { + if err := s.bumpStandings(ctx, tx, actorID); err != nil { + return entryBatchResultDTO{}, err + } + } + out.Summary.RecomputedEvent = eventIDs + + if err := tx.Commit(); err != nil { + return entryBatchResultDTO{}, s.entryBatchBlocked(ctx, tx, err) + } + return out, nil +} + +func targetIDs(targets []entryBatchTarget) []int64 { + out := make([]int64, 0, len(targets)) + for _, t := range targets { + out = append(out, t.id) + } + return out +} + +// entryScope 是重算的最小单位:积分与名次都按「小项 + 组别」算。 +type entryScope struct{ eventID, categoryID int64 } + +// distinctScopes 按出现顺序去重,保证同一批请求的重算次序稳定、可复现。 +func distinctScopes(targets []entryBatchTarget) []entryScope { + out := make([]entryScope, 0, len(targets)) + for _, t := range targets { + scope := entryScope{t.eventID, t.categoryID} + if !slices.Contains(out, scope) { + out = append(out, scope) + } + } + return out +} + +func idsToArgs(ids []int64) []any { + out := make([]any, 0, len(ids)) + for _, id := range ids { + out = append(out, id) + } + return out +} diff --git a/server/internal/httpapi/entries_batch_test.go b/server/internal/httpapi/entries_batch_test.go new file mode 100644 index 0000000..44b84b0 --- /dev/null +++ b/server/internal/httpapi/entries_batch_test.go @@ -0,0 +1,411 @@ +package httpapi_test + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" +) + +// batchItem / batchSummary / batchResult 是 POST /api/entries/batch 的响应契约, +// 用例直接按前端会收到的 JSON 字段来断言,避免服务端字段改名后测试还绿着。 +type batchItem struct { + ID int64 `json:"id"` + OK bool `json:"ok"` + Error string `json:"error"` + Skipped bool `json:"skipped"` + Note string `json:"note"` +} + +type batchSummary struct { + OK int `json:"ok"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + Changed int `json:"changed"` + RemovedEntries int `json:"removedEntries"` + RemovedResults int `json:"removedResults"` + RemovedLedger int `json:"removedLedger"` + RemovedBreaks int `json:"removedBreaks"` + RecomputedEvent []int64 `json:"recomputedEvents"` +} + +type batchResult struct { + Items []batchItem `json:"items"` + Summary batchSummary `json:"summary"` + Raw map[string]any `json:"-"` +} + +// entryStatus 读报名项当前状态。 +func entryStatus(t *testing.T, env *testEnv, entryID int64) string { + t.Helper() + var status string + if err := env.ev.QueryRow(`SELECT status FROM entries WHERE id = ?`, entryID).Scan(&status); err != nil { + t.Fatalf("读报名项状态: %v", err) + } + return status +} + +// addTeamAthleteEntry 在既有小项 / 组别下补一个单位 + 运动员 + 报名项。 +func addTeamAthleteEntry(t *testing.T, env *testEnv, ids seedIDs, code, name string, athleteName string) (int64, int64) { + t.Helper() + teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES (?, ?, ?, 9, 1)`, + code, name, name) + athleteID := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, + athleteName, teamID) + entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, teamID, athleteID) + return teamID, entryID +} + +// 批量退赛后别人被重算、被退赛的人不再占名次;恢复后榜单回到原状。 +func TestEntryBatchScratchAndRestore(t *testing.T) { + env, ids, client := recorderWithSeed(t) + teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超") + publishResults(t, env, client, ids.event, "batch", + 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"}, + map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"}, + ) + + // 前置:10 / 8 / 6 分。 + if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) { + t.Fatalf("前置条件:B 应是 8 分,得到 %v", got) + } + if got := teamPoints(t, env, client, teamC); got != 6 { + t.Fatalf("前置条件:C 单位总分应是 6,得到 %v", got) + } + + status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ + "action": "scratch", "ids": []int64{ids.entryB, entryC}, "reason": "临时请假", + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[batchResult](t, env2) + if len(result.Items) != 2 || !result.Items[0].OK || !result.Items[1].OK { + t.Fatalf("应有 2 条逐条成功,得到 %+v", result.Items) + } + if result.Summary.OK != 2 || result.Summary.Failed != 0 || result.Summary.Changed != 2 { + t.Fatalf("汇总应是 2 成功 / 0 失败,得到 %+v", result.Summary) + } + + if got := entryStatus(t, env, ids.entryB); got != "scratched" { + t.Fatalf("B 应已退赛,得到 %s", got) + } + if got := entryStatus(t, env, entryC); got != "scratched" { + t.Fatalf("C 应已退赛,得到 %s", got) + } + // 退赛的行不再有有效流水,旧流水保留为「已作废」的证据。 + if got := activeLedgerPoints(t, env, ids.entryB); len(got) != 0 { + t.Fatalf("B 退赛后不应再有有效流水,得到 %v", got) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryB); n != 1 { + t.Fatalf("B 的旧流水应被作废,得到 %d 条", n) + } + // 别人被重算:A 仍是第一 10 分,C 退赛不占名次。 + if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) { + t.Fatalf("A 应保持 10 分,得到 %v", got) + } + if got := teamPoints(t, env, client, teamC); got != 0 { + t.Fatalf("C 单位总分应归零,得到 %v", got) + } + // 退赛不占名次:已发布成绩的 rank 必须清掉,否则奖牌统计还会给他记一块金牌。 + for _, id := range []int64{ids.entryB, entryC} { + var rank sql.NullInt64 + if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank); err != nil { + t.Fatalf("读退赛行的名次: %v", err) + } + if rank.Valid { + t.Fatalf("退赛的报名项不应再占名次,entry %d 的 rank=%d", id, rank.Int64) + } + } + if n := countRows(t, env, + `SELECT COUNT(*) FROM results r JOIN entries e ON e.id = r.entry_id + WHERE r.state = 'published' AND r.rank IS NOT NULL AND e.status <> 'registered'`); n != 0 { + t.Fatalf("非正常参赛的已发布成绩不该带名次,得到 %d 条", n) + } + + // 恢复:同一端点 restore,只有 scratched 会被改回 registered。 + status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ + "action": "restore", "ids": []int64{ids.entryB, entryC}, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("批量恢复应 200,得到 %d %+v", status, env2.Error) + } + if got := entryStatus(t, env, ids.entryB); got != "registered" { + t.Fatalf("B 应恢复为 registered,得到 %s", got) + } + if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) { + t.Fatalf("B 恢复后应重算回 8 分,得到 %v", got) + } + if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) { + t.Fatalf("C 恢复后应重算回 6 分,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamB); got != 8 { + t.Fatalf("B 单位总分应恢复为 8,得到 %v", got) + } + if got := teamPoints(t, env, client, teamC); got != 6 { + t.Fatalf("C 单位总分应恢复为 6,得到 %v", got) + } + // 恢复后名次写回:B 回到第 2 名(A 第一、C 第三)。 + var restoredRank sql.NullInt64 + if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&restoredRank); err != nil { + t.Fatalf("读恢复后的名次: %v", err) + } + if !restoredRank.Valid || restoredRank.Int64 != 2 { + t.Fatalf("B 恢复后应重算回第 2 名,得到 %+v", restoredRank) + } + assertNoForeignKeyViolation(t, env) +} + +// 已是 dq 的行不能被退赛覆盖;不存在的 id 逐条报错;整批不失败。 +func TestEntryBatchScratchSkipsDQAndReportsMissing(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE entries SET status = 'dq' WHERE id = ?`, ids.entryB) + + status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ + "action": "scratch", "ids": []int64{ids.entryA, ids.entryB, int64(999999)}, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("混选批量退赛应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[batchResult](t, env2) + if len(result.Items) != 3 { + t.Fatalf("3 个 id 应有 3 条逐条结果,得到 %+v", result.Items) + } + if !result.Items[0].OK || result.Items[0].Skipped { + t.Fatalf("A 应正常退赛,得到 %+v", result.Items[0]) + } + if !result.Items[1].OK || !result.Items[1].Skipped || !strings.Contains(result.Items[1].Note, "dq") { + t.Fatalf("B 应被跳过并说明是 dq,得到 %+v", result.Items[1]) + } + if result.Items[2].OK || result.Items[2].Error != "报名项不存在" { + t.Fatalf("不存在的 id 应逐条报错,得到 %+v", result.Items[2]) + } + if result.Summary.OK != 2 || result.Summary.Failed != 1 || result.Summary.Skipped != 1 || result.Summary.Changed != 1 { + t.Fatalf("汇总应是 2 成功(含 1 跳过)/ 1 失败,得到 %+v", result.Summary) + } + if got := entryStatus(t, env, ids.entryA); got != "scratched" { + t.Fatalf("A 应已退赛,得到 %s", got) + } + if got := entryStatus(t, env, ids.entryB); got != "dq" { + t.Fatalf("B 的 dq 判定不应被覆盖,得到 %s", got) + } + assertNoForeignKeyViolation(t, env) +} + +// 移出报名:报名、成绩、流水全部按 entry_id 清干净(不留 entry_id 置空的空壳流水), +// 留下的人按新名次重算。 +func TestEntryBatchRemovePurgesLedgerByEntryID(t *testing.T) { + env, ids, client := recorderWithSeed(t) + teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超") + publishResults(t, env, client, ids.event, "remove", + 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"}, + map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"}, + ) + // 破纪录登记也要跟着消失,否则会留下一条挂不上报名项的记录。 + env.exec(t, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, bonus_points, state) + VALUES (?, ?, 'meet', 11.92, 5, 'pending')`, ids.event, ids.entryB) + + status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ + "action": "remove", "ids": []int64{ids.entryB}, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[batchResult](t, env2) + if result.Summary.RemovedEntries != 1 || result.Summary.RemovedResults != 1 || + result.Summary.RemovedLedger != 1 || result.Summary.RemovedBreaks != 1 { + t.Fatalf("汇总应带回删除规模,得到 %+v", result.Summary) + } + + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 0 { + t.Fatalf("报名项应被物理删除,得到 %d 条", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryB); n != 0 { + t.Fatalf("成绩应被删除,得到 %d 条", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`, ids.entryB); n != 0 { + t.Fatalf("流水应按 entry_id 清干净,得到 %d 条", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM record_breaks WHERE entry_id = ?`, ids.entryB); n != 0 { + t.Fatalf("破纪录登记应被删除,得到 %d 条", n) + } + // 关键证据:删完之后不能留下 entry_id 被 SET NULL 置空的成绩流水。 + if n := countRows(t, env, + `SELECT COUNT(*) FROM score_ledger WHERE entry_id IS NULL AND result_id IS NULL AND source_type = 'result'`); n != 0 { + t.Fatalf("不应留下 entry_id 被置空的空壳流水,得到 %d 条", n) + } + // 留下的人被重算:C 从第三 6 分升到第二 8 分。 + if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{8}) { + t.Fatalf("C 应随新名次重算成 8 分,得到 %v", got) + } + if got := teamPoints(t, env, client, teamC); got != 8 { + t.Fatalf("C 单位总分应重算成 8,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamB); got != 0 { + t.Fatalf("被移出的单位总分应归零,得到 %v", got) + } + assertNoForeignKeyViolation(t, env) +} + +// 跨小项混选允许,但要按 (小项, 组别) 分组各算各的。 +func TestEntryBatchScratchAcrossEvents(t *testing.T) { + env, ids, client := recorderWithSeed(t) + event2 := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, + compare, scored, multiplier, status) + VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport) + category2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) + VALUES (?, '男子', 1, 0)`, event2) + var profileID int64 + if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil { + t.Fatalf("读取积分模板: %v", err) + } + env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, 'athlete', 'result', ?, 0)`, event2, profileID) + entry2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, event2, category2, ids.teamA, ids.athleteA) + publishResults(t, env, client, ids.event, "cross1", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}) + publishResults(t, env, client, event2, "cross2", + map[string]any{"entryId": entry2, "resultValue": 23.10, "resultText": "23.10", "unit": "s", "status": "ok"}) + + status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ + "action": "scratch", "ids": []int64{ids.entryA, entry2}, + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("跨小项批量退赛应 200,得到 %d %+v", status, env2.Error) + } + result := decodeData[batchResult](t, env2) + if len(result.Summary.RecomputedEvent) != 2 || + result.Summary.RecomputedEvent[0] != ids.event || result.Summary.RecomputedEvent[1] != event2 { + t.Fatalf("两个小项都应重算,得到 %+v", result.Summary.RecomputedEvent) + } + if got := entryStatus(t, env, ids.entryA); got != "scratched" { + t.Fatalf("小项一的报名应已退赛,得到 %s", got) + } + if got := entryStatus(t, env, entry2); got != "scratched" { + t.Fatalf("小项二的报名应已退赛,得到 %s", got) + } +} + +// 边界与权限:空 ids、重复 ids、超上限、非法 action、guest 写。 +func TestEntryBatchValidation(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", + map[string]any{"action": "scratch", "ids": []int64{}}, true) + if status != http.StatusBadRequest || env2.Error == nil { + t.Fatalf("空 ids 应 400,得到 %d %+v", status, env2.Error) + } + + status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", + map[string]any{"action": "nuke", "ids": []int64{ids.entryA}}, true) + if status != http.StatusBadRequest { + t.Fatalf("非法 action 应 400,得到 %d %+v", status, env2.Error) + } + + // 重复 id 去重后只回报一条。 + status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", + map[string]any{"action": "scratch", "ids": []int64{ids.entryA, ids.entryA}}, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("带重复 id 的请求应 200,得到 %d %+v", status, env2.Error) + } + dup := decodeData[batchResult](t, env2) + if len(dup.Items) != 1 || dup.Summary.Changed != 1 { + t.Fatalf("重复 id 应去重成一条,得到 %+v", dup) + } + + // 超过上限要在动手之前拒绝。 + big := make([]int64, 501) + for i := range big { + big[i] = int64(i + 1) + } + status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", + map[string]any{"action": "remove", "ids": big}, true) + if status != http.StatusBadRequest || env2.Error == nil || !strings.Contains(env2.Error.Message, "分批") { + t.Fatalf("超过上限应 400 并要求分批,得到 %d %+v", status, env2.Error) + } + + // guest(未登录)不能写。 + guest, _ := env.client() + status, env2 = env.do(guest, http.MethodPost, "/api/entries/batch", + map[string]any{"action": "remove", "ids": []int64{ids.entryA}}, false) + if status != http.StatusUnauthorized { + t.Fatalf("未登录批量操作应 401,得到 %d %+v", status, env2.Error) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 1 { + t.Fatalf("guest 不应删掉任何报名项,得到 %d 条", n) + } +} + +// waitBatchAudit 取批量操作的审计 after_json:批量动作没有单个实体 id +// (entity_id 落库为 NULL),所以按 action 找,和 waitAudit 的单实体口径区分开。 +func waitBatchAudit(t *testing.T, env *testEnv, action string) string { + t.Helper() + for i := 0; i < 100; i++ { + var after sql.NullString + err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = ? AND entity_id IS NULL`, + action).Scan(&after) + switch { + case err == nil: + return after.String + case errors.Is(err, sql.ErrNoRows): + time.Sleep(10 * time.Millisecond) + default: + t.Fatalf("查询审计: %v", err) + } + } + t.Fatalf("等不到 %s 的审计记录", action) + return "" +} + +// 审计要带 ids 与 summary,三个动作各一条。 +func TestEntryBatchAudit(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + for _, action := range []string{"scratch", "restore", "remove"} { + status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ + "action": action, "ids": []int64{ids.entryA}, "reason": "实机验证", + }, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("%s 应 200,得到 %d %+v", action, status, env2.Error) + } + after := waitBatchAudit(t, env, "entry."+action) + for _, want := range []string{fmt.Sprintf(`"ids":[%d]`, ids.entryA), `"summary"`, `"实机验证"`} { + if !strings.Contains(after, want) { + t.Fatalf("%s 审计应含 %s,得到 %s", action, want, after) + } + } + } +} + +// 保障:remove 之后 results 表里的 rank 不能留下指向已删报名项的悬挂行。 +func TestEntryBatchRemoveKeepsOtherResultsIntact(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "keep", + 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 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{ + "action": "remove", "ids": []int64{ids.entryA}, + }, true); status != http.StatusOK || !env2.OK { + t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error) + } + + var ( + rank sql.NullInt64 + state string + ) + if err := env.ev.QueryRow(`SELECT rank, state FROM results WHERE entry_id = ?`, ids.entryB). + Scan(&rank, &state); err != nil { + t.Fatalf("读 B 的成绩: %v", err) + } + if !rank.Valid || rank.Int64 != 1 || state != "published" { + t.Fatalf("B 的名次应重算为 1 且保持发布态,得到 rank=%+v state=%s", rank, state) + } +} diff --git a/server/internal/httpapi/export_basedata_test.go b/server/internal/httpapi/export_basedata_test.go new file mode 100644 index 0000000..74baa24 --- /dev/null +++ b/server/internal/httpapi/export_basedata_test.go @@ -0,0 +1,527 @@ +package httpapi_test + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "strings" + "testing" + + "github.com/xuri/excelize/v2" +) + +// cellAt 取单元格文本,越界返回空串(表格行可能比表头短)。 +func cellAt(rows [][]string, r, c int) string { + if r < 0 || r >= len(rows) || c < 0 || c >= len(rows[r]) { + return "" + } + return rows[r][c] +} + +type testSheet struct { + name string + rows [][]string +} + +// buildTestXLSX 手工拼一份上传用的工作簿(表头在第 1 行),模拟现场自己整理的表。 +func buildTestXLSX(t *testing.T, sheets []testSheet) []byte { + t.Helper() + f := excelize.NewFile() + defer func() { _ = f.Close() }() + for i, sh := range sheets { + if i == 0 { + if err := f.SetSheetName("Sheet1", sh.name); err != nil { + t.Fatalf("重命名工作表: %v", err) + } + } else if _, err := f.NewSheet(sh.name); err != nil { + t.Fatalf("新建工作表: %v", err) + } + for r, row := range sh.rows { + for c, v := range row { + name, err := excelize.CoordinatesToCellName(c+1, r+1) + if err != nil { + t.Fatalf("算单元格坐标: %v", err) + } + if err := f.SetCellValue(sh.name, name, v); err != nil { + t.Fatalf("写单元格: %v", err) + } + } + } + } + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + t.Fatalf("写出测试工作簿: %v", err) + } + return buf.Bytes() +} + +// editWorkbookCell 在导出的工作簿里找到「A 列等于 key」的那一行,把另一列改成新值。 +// 行号不写死:导出的版式(标题 / 副标题 / 空行 / 表头)以后变了也不会让测试失效。 +func editWorkbookCell(t *testing.T, f *excelize.File, sheet, keyCol, key, targetCol, value string) { + t.Helper() + rows, err := f.GetRows(sheet) + if err != nil { + t.Fatalf("读取工作表 %s: %v", sheet, err) + } + for r := range rows { + if cellAt(rows, r, columnIndex(keyCol)) != key { + continue + } + cell, err := excelize.CoordinatesToCellName(columnIndex(targetCol)+1, r+1) + if err != nil { + t.Fatalf("算单元格坐标: %v", err) + } + if err := f.SetCellValue(sheet, cell, value); err != nil { + t.Fatalf("改单元格: %v", err) + } + return + } + t.Fatalf("工作表 %s 里找不到 %s=%s 的行", sheet, keyCol, key) +} + +func columnIndex(letter string) int { + n, _ := excelize.ColumnNameToNumber(letter) + return n - 1 +} + +// uploadBaseData 以 multipart 上传基础数据包,返回状态码与原始响应体。 +func (e *testEnv) uploadBaseData(t *testing.T, client *http.Client, filename string, data []byte, fields map[string]string) (int, []byte) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("写表单字段: %v", err) + } + } + fw, err := w.CreateFormFile("file", filename) + if err != nil { + t.Fatalf("创建上传字段: %v", err) + } + if _, err := fw.Write(data); err != nil { + t.Fatalf("写工作簿: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("关闭 multipart: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/export/basedata/import", &buf) + if err != nil { + t.Fatalf("构造请求: %v", err) + } + req.Header.Set("Content-Type", w.FormDataContentType()) + req.Header.Set("X-CSRF-Token", e.csrfToken(client)) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("上传基础数据包: %v", err) + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + return resp.StatusCode, raw +} + +// baseDataResult 是导入响应里我们关心的部分(与后端 baseDataImportResult 同构)。 +type baseDataResult struct { + DryRun bool `json:"dryRun"` + Added int `json:"added"` + Updated int `json:"updated"` + Unchanged int `json:"unchanged"` + ErrorCount int `json:"errorCount"` + Errors []string `json:"errors"` + Changes []struct { + Sheet string `json:"sheet"` + Line int `json:"line"` + Action string `json:"action"` + Key string `json:"key"` + Fields []struct { + Field string `json:"field"` + From string `json:"from"` + To string `json:"to"` + } `json:"fields"` + } `json:"changes"` +} + +func decodeBaseDataResult(t *testing.T, raw []byte) baseDataResult { + t.Helper() + var env struct { + OK bool `json:"ok"` + Data baseDataResult `json:"data"` + Error *apiErrorBody `json:"error"` + } + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatalf("解析导入响应: %v (%s)", err, raw) + } + if !env.OK { + t.Fatalf("导入失败: %+v", env.Error) + } + return env.Data +} + +// 往返硬要求:刚导出的基础数据包原样导入,差异必须是 0/0/0。 +func TestBaseDataPackageRoundTripIsNoop(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + status, header, body := env.getRaw(t, client, "/api/export/basedata.xlsx") + if status != http.StatusOK { + t.Fatalf("导出基础数据包应 200,得到 %d %s", status, body) + } + assertContentDisposition(t, header.Get("Content-Disposition"), "basedata.xlsx", "基础数据包.xlsx") + + f := openWorkbook(t, body) + defer func() { _ = f.Close() }() + if got := f.GetSheetList(); !sameStrings(got, []string{"单位", "大项", "小项", "组别"}) { + t.Fatalf("工作表列表 = %v,期望 单位 / 大项 / 小项 / 组别", got) + } + + status, raw := env.uploadBaseData(t, client, "basedata.xlsx", body, map[string]string{"dryRun": "1"}) + if status != http.StatusOK { + t.Fatalf("预览应 200,得到 %d %s", status, raw) + } + res := decodeBaseDataResult(t, raw) + if !res.DryRun { + t.Errorf("dryRun 应为 true") + } + if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 { + t.Fatalf("原样导入应无差异,得到 新增 %d / 更新 %d / 错误 %d:%v", + res.Added, res.Updated, res.ErrorCount, res.Errors) + } + if len(res.Changes) != 0 { + t.Errorf("无差异时不该有逐行差异,得到 %+v", res.Changes) + } +} + +// 改掉一个单位名与一个小项名再导入:差异应正好是这 2 条更新,其余 0。 +func TestBaseDataImportReportsUpdates(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + _, _, body := env.getRaw(t, client, "/api/export/basedata.xlsx") + + // 在导出的工作簿上改两处:一个单位名、一个小项名。 + f := openWorkbook(t, body) + defer func() { _ = f.Close() }() + editWorkbookCell(t, f, "单位", "B", "第一分公司", "B", "第一分公司(改)") + editWorkbookCell(t, f, "小项", "B", "M100", "C", "百米飞人大战") + var buf bytes.Buffer + if err := f.Write(&buf); err != nil { + t.Fatalf("写回工作簿: %v", err) + } + + status, raw := env.uploadBaseData(t, client, "basedata.xlsx", buf.Bytes(), map[string]string{"dryRun": "1"}) + if status != http.StatusOK { + t.Fatalf("预览应 200,得到 %d %s", status, raw) + } + res := decodeBaseDataResult(t, raw) + if res.Added != 0 || res.ErrorCount != 0 { + t.Fatalf("应只有更新,得到 新增 %d / 错误 %d:%v", res.Added, res.ErrorCount, res.Errors) + } + if res.Updated != 2 { + t.Fatalf("更新条数 = %d,期望 2", res.Updated) + } + seen := map[string]string{} + for _, ch := range res.Changes { + for _, f := range ch.Fields { + seen[ch.Sheet+"."+f.Field] = f.From + "→" + f.To + } + } + if got := seen["单位.单位名称"]; got != "第一分公司→第一分公司(改)" { + t.Errorf("单位名差异 = %q,期望 第一分公司→第一分公司(改)", got) + } + if got := seen["小项.小项名称"]; got != "100米跑→百米飞人大战" { + t.Errorf("小项名差异 = %q,期望 100米跑→百米飞人大战", got) + } + + // dryRun 绝不落库:库里的名字还是原来的。 + var name string + if err := env.ev.QueryRow(`SELECT name FROM teams WHERE id = ?`, ids.teamA).Scan(&name); err != nil { + t.Fatalf("查询单位: %v", err) + } + if name != "第一分公司" { + t.Fatalf("dryRun 不该写库,单位名变成了 %q", name) + } + var eventName string + if err := env.ev.QueryRow(`SELECT name FROM events WHERE id = ?`, ids.event).Scan(&eventName); err != nil { + t.Fatalf("查询小项: %v", err) + } + if eventName != "100米跑" { + t.Fatalf("dryRun 不该写库,小项名变成了 %q", eventName) + } +} + +// 绝不删除:只含 1 个新单位的表导进去,其余单位一条不少。 +func TestBaseDataImportNeverDeletes(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + before := tableCount(t, env, "teams") + beforeSports := tableCount(t, env, "sports") + beforeEvents := tableCount(t, env, "events") + + pack := buildTestXLSX(t, []testSheet{{ + name: "单位", + rows: [][]string{ + {"单位编码", "单位名称", "启用"}, + {"C", "第三分公司", "是"}, + }, + }}) + status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil) + if status != http.StatusOK { + t.Fatalf("导入应 200,得到 %d %s", status, raw) + } + res := decodeBaseDataResult(t, raw) + if res.Added != 1 || res.Updated != 0 || res.ErrorCount != 0 { + t.Fatalf("应新增 1 条,得到 新增 %d / 更新 %d / 错误 %d %v", res.Added, res.Updated, res.ErrorCount, res.Errors) + } + + if got := tableCount(t, env, "teams"); got != before+1 { + t.Fatalf("单位数 = %d,期望 %d(只多 1 条,其余一条不少)", got, before+1) + } + for _, code := range []string{"A", "B", "C"} { + var n int + if err := env.ev.QueryRow(`SELECT count(*) FROM teams WHERE code = ?`, code).Scan(&n); err != nil { + t.Fatalf("查询单位 %s: %v", code, err) + } + if n != 1 { + t.Errorf("单位 %s 的记录数 = %d,期望 1", code, n) + } + } + if got := tableCount(t, env, "sports"); got != beforeSports { + t.Errorf("大项数 = %d,期望 %d(表里没写的不动)", got, beforeSports) + } + if got := tableCount(t, env, "events"); got != beforeEvents { + t.Errorf("小项数 = %d,期望 %d(表里没写的不动)", got, beforeEvents) + } +} + +// 新建的单位 / 大项不能被建成停用:缺省 = 启用。 +func TestBaseDataImportCreatesEnabledRows(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + pack := buildTestXLSX(t, []testSheet{ + {name: "单位", rows: [][]string{ + {"单位编码", "单位名称"}, + {"C", "第三分公司"}, + }}, + {name: "大项", rows: [][]string{ + {"大项编码", "大项名称"}, + {"SWIM", "游泳"}, + }}, + {name: "小项", rows: [][]string{ + {"大项编码", "小项编码", "小项名称", "类型", "结果类型"}, + {"SWIM", "S100", "100米自由泳", "individual", "time"}, + }}, + {name: "组别", rows: [][]string{ + {"小项编码", "组别名", "性别", "年龄下限", "年龄上限"}, + {"S100", "男子", "男", "18", "35"}, + }}, + }) + status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil) + if status != http.StatusOK { + t.Fatalf("导入应 200,得到 %d %s", status, raw) + } + res := decodeBaseDataResult(t, raw) + if res.Added != 4 || res.ErrorCount != 0 { + t.Fatalf("应新增 4 条,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors) + } + + var active int + if err := env.ev.QueryRow(`SELECT active FROM teams WHERE code = 'C'`).Scan(&active); err != nil { + t.Fatalf("查询新单位: %v", err) + } + if active != 1 { + t.Fatalf("新单位应启用(active=1),得到 %d", active) + } + if err := env.ev.QueryRow(`SELECT active FROM sports WHERE code = 'SWIM'`).Scan(&active); err != nil { + t.Fatalf("查询新大项: %v", err) + } + if active != 1 { + t.Fatalf("新大项应启用(active=1),得到 %d", active) + } + + // 小项挂到了新建的大项下,组别挂到了新建的小项下,枚举值也按原样落库。 + var ( + sportID int64 + evType string + rType string + ) + if err := env.ev.QueryRow(`SELECT sport_id, event_type, result_type FROM events WHERE code = 'S100'`). + Scan(&sportID, &evType, &rType); err != nil { + t.Fatalf("查询新小项: %v", err) + } + var wantSport int64 + if err := env.ev.QueryRow(`SELECT id FROM sports WHERE code = 'SWIM'`).Scan(&wantSport); err != nil { + t.Fatalf("查询新大项 id: %v", err) + } + if sportID != wantSport { + t.Errorf("新小项的大项 id = %d,期望 %d", sportID, wantSport) + } + if evType != "individual" || rType != "time" { + t.Errorf("枚举值 = %s / %s,期望 individual / time", evType, rType) + } + + var gender string + var ageMin, ageMax int + if err := env.ev.QueryRow(`SELECT gender, age_min, age_max FROM event_categories WHERE name = '男子' AND event_id = ?`, + func() int64 { + var id int64 + _ = env.ev.QueryRow(`SELECT id FROM events WHERE code = 'S100'`).Scan(&id) + return id + }()). + Scan(&gender, &ageMin, &ageMax); err != nil { + t.Fatalf("查询新组别: %v", err) + } + if gender != "男" || ageMin != 18 || ageMax != 35 { + t.Errorf("组别 = %s %d-%d,期望 男 18-35", gender, ageMin, ageMax) + } +} + +// 行级错误:中文、带行号、带工作表名,且 dryRun 下库一点没动。 +func TestBaseDataImportRowErrors(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + before := countAllTables(t, env) + + pack := buildTestXLSX(t, []testSheet{ + {name: "小项", rows: [][]string{ + {"大项编码", "小项编码", "小项名称", "类型"}, + {"NOPE", "X1", "幽灵小项", "individual"}, // 第 2 行:大项不存在 + {"TRACK", "X2", "坏类型", "乱七八糟"}, // 第 3 行:枚举非法 + }}, + {name: "组别", rows: [][]string{ + {"小项编码", "组别名"}, + {"NOPE", "甲组"}, // 第 2 行:小项不存在 + }}, + }) + status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, map[string]string{"dryRun": "1"}) + if status != http.StatusOK { + t.Fatalf("预览应 200,得到 %d %s", status, raw) + } + res := decodeBaseDataResult(t, raw) + if res.ErrorCount != 3 { + t.Fatalf("错误条数 = %d,期望 3:%v", res.ErrorCount, res.Errors) + } + if res.Added != 0 || res.Updated != 0 { + t.Errorf("全错时不该有变更,得到 新增 %d / 更新 %d", res.Added, res.Updated) + } + wantParts := [][]string{ + {"小项", "第 2 行", "大项编码 NOPE 不存在"}, + {"小项", "第 3 行", "eventType 只能是 individual / team / match / individual_team"}, + {"组别", "第 2 行", "小项编码 NOPE 不存在"}, + } + for i, parts := range wantParts { + if i >= len(res.Errors) { + t.Fatalf("缺少第 %d 条错误", i+1) + } + for _, p := range parts { + if !strings.Contains(res.Errors[i], p) { + t.Errorf("错误 %d = %q,应包含 %q", i+1, res.Errors[i], p) + } + } + } + + // dryRun 不写库:四张表计数一条不变。 + if got := countAllTables(t, env); !sameCounts(got, before) { + t.Fatalf("dryRun 不该写库,导入前后计数不同:%v → %v", before, got) + } +} + +// 导入是批量写,只有 admin 能做。 +func TestBaseDataImportAdminOnly(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + pack := buildTestXLSX(t, []testSheet{{name: "单位", rows: [][]string{ + {"单位编码", "单位名称"}, + {"C", "第三分公司"}, + }}}) + status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil) + if status != http.StatusForbidden { + t.Fatalf("recorder 导入应 403,得到 %d %s", status, raw) + } + if got := tableCount(t, env, "teams"); got != 2 { + t.Fatalf("403 不该写库,单位数 = %d", got) + } +} + +func TestBaseDataImportRejectsNonXlsx(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + status, raw := env.uploadBaseData(t, client, "basedata.csv", []byte("单位编码,单位名称\nC,第三分公司\n"), nil) + if status != http.StatusBadRequest { + t.Fatalf("CSV 应 400,得到 %d %s", status, raw) + } + if !strings.Contains(string(raw), "xlsx") { + t.Errorf("错误信息应说明必须是 xlsx,得到 %s", raw) + } +} + +// ---------- 小工具 ---------- + +func tableCount(t *testing.T, env *testEnv, table string) int { + t.Helper() + var n int + if err := env.ev.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil { + t.Fatalf("统计 %s: %v", table, err) + } + return n +} + +func countAllTables(t *testing.T, env *testEnv) map[string]int { + t.Helper() + out := make(map[string]int, 4) + for _, table := range []string{"teams", "sports", "events", "event_categories"} { + out[table] = tableCount(t, env, table) + } + return out +} + +func sameCounts(a, b map[string]int) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +func sameStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/server/internal/httpapi/export_entries_test.go b/server/internal/httpapi/export_entries_test.go new file mode 100644 index 0000000..c30aa60 --- /dev/null +++ b/server/internal/httpapi/export_entries_test.go @@ -0,0 +1,106 @@ +package httpapi_test + +import ( + "net/http" + "testing" +) + +// 报名名单导出:guest 可用、表头含批次、前导零不丢、筛选参数生效。 +func TestExportEntriesXLSX(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + // 号码布带前导零,entryA 分到批次 2,entryB 未分批。 + env.exec(t, `UPDATE entries SET bib = '007', heat = 2, remark = '卫冕冠军' WHERE id = ?`, ids.entryA) + env.exec(t, `UPDATE entries SET bib = '0012' WHERE id = ?`, ids.entryB) + + client, _ := env.client() + status, header, body := env.getRaw(t, client, "/api/export/entries.xlsx") + if status != http.StatusOK { + t.Fatalf("导出报名名单应 200,得到 %d %s", status, body) + } + assertContentDisposition(t, header.Get("Content-Disposition"), "entries.xlsx", "报名名单.xlsx") + + f := openWorkbook(t, body) + defer func() { _ = f.Close() }() + rows, err := f.GetRows("报名名单") + if err != nil { + t.Fatalf("读取报名名单: %v", err) + } + // 4 行版式 + 2 条报名。 + if len(rows) != 6 { + t.Fatalf("行数 = %d,期望 6", len(rows)) + } + wantHeaders := []string{"小项编码", "小项名", "组别", "单位编码", "单位名", "姓名", + "证件号", "号码布", "批次", "性别", "年龄", "状态", "备注"} + for i, want := range wantHeaders { + if got := cellAt(rows, 3, i); got != want { + t.Errorf("表头第 %d 列 = %q,期望 %q", i+1, got, want) + } + } + // 未分批(heat 为空)的排最前。 + if got := cellAt(rows, 4, 5); got != "李强" { + t.Fatalf("第一行姓名 = %q,期望 李强", got) + } + if got := cellAt(rows, 4, 7); got != "0012" { + t.Errorf("号码布 = %q,期望 0012(前导零不能丢)", got) + } + if got := cellAt(rows, 4, 8); got != "" { + t.Errorf("未分批行批次 = %q,期望留空", got) + } + if got := cellAt(rows, 5, 5); got != "张伟" { + t.Fatalf("第二行姓名 = %q,期望 张伟", got) + } + if got := cellAt(rows, 5, 8); got != "2" { + t.Errorf("批次 = %q,期望 2", got) + } + if got := cellAt(rows, 5, 11); got != "正常参赛" { + t.Errorf("状态 = %q,期望 正常参赛", got) + } + + // 按批次筛选:只剩批次 2 的那条。 + status, _, filtered := env.getRaw(t, client, "/api/export/entries.xlsx?heat=2") + if status != http.StatusOK { + t.Fatalf("按批次筛选应 200,得到 %d", status) + } + f2 := openWorkbook(t, filtered) + defer func() { _ = f2.Close() }() + fRows, err := f2.GetRows("报名名单") + if err != nil { + t.Fatalf("读取筛选结果: %v", err) + } + if len(fRows) != 5 { + t.Fatalf("按批次筛选后行数 = %d,期望 5", len(fRows)) + } + if got := cellAt(fRows, 4, 8); got != "2" { + t.Errorf("筛选后批次 = %q,期望 2", got) + } + + // 按单位 + 小项 + 组别组合筛选。 + path := "/api/export/entries.xlsx?teamId=1&eventId=1&categoryId=1" + status, _, filtered = env.getRaw(t, client, path) + if status != http.StatusOK { + t.Fatalf("组合筛选应 200,得到 %d", status) + } + f3 := openWorkbook(t, filtered) + defer func() { _ = f3.Close() }() + cRows, _ := f3.GetRows("报名名单") + if len(cRows) != 5 { + t.Fatalf("组合筛选后行数 = %d,期望 5", len(cRows)) + } +} + +// 筛选参数填错必须报错:静默当成「不筛」会让现场拿到全量名单还以为筛过了。 +func TestExportEntriesXLSXRejectsBadFilter(t *testing.T) { + env := newTestEnv(t) + env.seed(t) + client, _ := env.client() + + status, _, body := env.getRaw(t, client, "/api/export/entries.xlsx?heat=abc") + if status != http.StatusBadRequest { + t.Fatalf("非法批次筛选应 400,得到 %d %s", status, body) + } + status, _, body = env.getRaw(t, client, "/api/export/entries.xlsx?teamId=0") + if status != http.StatusBadRequest { + t.Fatalf("0 不是合法单位 id,应 400,得到 %d %s", status, body) + } +} diff --git a/server/internal/httpapi/export_handlers.go b/server/internal/httpapi/export_handlers.go index 20e5b8e..f4e8d68 100644 --- a/server/internal/httpapi/export_handlers.go +++ b/server/internal/httpapi/export_handlers.go @@ -3,6 +3,7 @@ package httpapi import ( "context" "crypto/rand" + "database/sql" "encoding/hex" "errors" "fmt" @@ -11,11 +12,13 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" "git.yeij.top/AskaEth/Scoreboard/server/internal/export" + "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" "git.yeij.top/AskaEth/Scoreboard/server/internal/rules" ) @@ -37,6 +40,13 @@ func (s *server) exportRoutes(mux *http.ServeMux) { // teams.xlsx 与 teams/{id} 段数不同,ServeMux 不会冲突。 mux.HandleFunc("GET /api/export/teams.xlsx", s.read(auth.RoleGuest, s.exportAllTeamsXLSX)) mux.HandleFunc("GET /api/export/teams/{id}", s.read(auth.RoleGuest, s.exportTeamXLSX)) + // 报名名单:按小项 / 组别 / 单位 / 批次筛选,四者可单传也可全不传(= 全部)。 + mux.HandleFunc("GET /api/export/entries.xlsx", s.read(auth.RoleGuest, s.exportEntriesXLSX)) + // 基础数据包(单位 / 大项 / 小项 / 组别):导出为多 sheet 工作簿,导入按编码 upsert。 + // 导出与其它导出同档(基础数据本身在 /api/teams、/api/sports 等接口就是公开可读的); + // 导入是批量写、影响面比录入大,权限收到 admin。 + mux.HandleFunc("GET /api/export/basedata.xlsx", s.read(auth.RoleGuest, s.exportBaseDataXLSX)) + mux.HandleFunc("POST /api/export/basedata/import", s.write(auth.RoleAdmin, s.importBaseDataXLSX)) mux.HandleFunc("GET /api/export/project.zip", s.read(auth.RoleAdmin, s.exportProjectZip)) mux.HandleFunc("POST /api/export/project/import", s.write(auth.RoleAdmin, s.importProjectZip)) @@ -166,6 +176,1086 @@ func (s *server) exportTeamXLSX(w http.ResponseWriter, r *http.Request) (any, er }) } +// exportEntriesXLSX 导出报名名单(xlsx)。筛选参数都可省,省 = 不筛。 +// 批次是用户点名要的一列,它属于报名数据,所以这里读的是 entries 而不是成绩表。 +func (s *server) exportEntriesXLSX(w http.ResponseWriter, r *http.Request) (any, error) { + filter := export.EntriesFilter{} + for _, f := range []struct { + key string + dst *int64 + what string + }{ + {"eventId", &filter.EventID, "小项"}, + {"categoryId", &filter.CategoryID, "组别"}, + {"teamId", &filter.TeamID, "单位"}, + {"heat", &filter.Heat, "批次"}, + } { + id, err := filterIDQuery(r, f.key, f.what) + if err != nil { + return nil, err + } + *f.dst = id + } + wb, err := export.LoadEntriesWorkbook(s.ev, filter, export.Options{}) + if err != nil { + return nil, internal(err) + } + return s.streamDownload(w, "entries.xlsx", "报名名单.xlsx", xlsxMIME, func(out io.Writer) error { + return export.WriteEntriesXLSX(out, wb) + }) +} + +// ---------- 基础数据包(单位 / 大项 / 小项 / 组别) ---------- + +// maxBaseDataImportBytes 是基础数据包上传的硬上限(16MB 文件 + multipart 开销)。 +const maxBaseDataImportBytes = 32 << 20 + +func (s *server) exportBaseDataXLSX(w http.ResponseWriter, r *http.Request) (any, error) { + wb, err := export.LoadBaseDataWorkbook(s.ev, export.Options{}) + if err != nil { + return nil, internal(err) + } + return s.streamDownload(w, "basedata.xlsx", "基础数据包.xlsx", xlsxMIME, func(out io.Writer) error { + return export.WriteBaseDataXLSX(out, wb) + }) +} + +// baseDataFieldDiff 是一条字段级差异:哪个字段、从什么变成什么。 +type baseDataFieldDiff struct { + Field string `json:"field"` + From string `json:"from"` + To string `json:"to"` +} + +// baseDataChange 是一行的差异;Action 为 added / updated。 +type baseDataChange struct { + Sheet string `json:"sheet"` + Line int `json:"line"` + Action string `json:"action"` + Key string `json:"key"` + Fields []baseDataFieldDiff `json:"fields"` +} + +// baseDataImportResult 是差异预览 / 导入落地的结果。 +// 三类计数互不重叠:added + updated + unchanged = 表里有效数据行数; +// errorCount 是另计的被拦下的行(它们不写库)。 +type baseDataImportResult struct { + DryRun bool `json:"dryRun"` + Added int `json:"added"` + Updated int `json:"updated"` + Unchanged int `json:"unchanged"` + ErrorCount int `json:"errorCount"` + Errors []string `json:"errors"` + Changes []baseDataChange `json:"changes"` +} + +// importBaseDataXLSX 导入基础数据包:一个端点 + dryRun 标志。 +// +// 与 rules/preview + rules/import 那种双端点相比,这里干的是同一件事、同一份文件、 +// 同一份解析结果,只差「写不写库」——用标志位表达,避免预览与实际落地各解析一遍而分叉。 +// 两段式由调用方保证:先 ?dryRun=1 预览,用户确认后再不带标志提交。 +// +// dryRun 下绝不写库;落地时按编码 upsert(存在就更新、不存在就新建), +// 表里没有的行一律不动——导入永远不做删除。 +func (s *server) importBaseDataXLSX(w http.ResponseWriter, r *http.Request) (any, error) { + r.Body = http.MaxBytesReader(w, r.Body, maxBaseDataImportBytes) + if err := r.ParseMultipartForm(16 << 20); err != nil { + return nil, badRequest("上传文件解析失败", err.Error()) + } + file, header, err := r.FormFile("file") + if err != nil { + return nil, badRequest("缺少 file 字段", err.Error()) + } + defer func() { _ = file.Close() }() + if ext := strings.ToLower(filepath.Ext(header.Filename)); ext != ".xlsx" { + return nil, badRequest("基础数据包必须是 .xlsx 文件:单位 / 大项 / 小项 / 组别分在四张工作表里,CSV 装不下", ext) + } + dryRun := isTruthy(r.FormValue("dryRun")) + + tables, err := export.ReadBaseDataPackage(file) + if err != nil { + return nil, badRequest(err.Error(), nil) + } + plan, err := s.planBaseDataImport(r.Context(), tables) + if err != nil { + return nil, err + } + plan.result.DryRun = dryRun + if dryRun { + return plan.result, nil + } + if err := s.applyBaseDataPlan(r.Context(), plan); err != nil { + return nil, err + } + s.audit(r.Context(), "basedata.import", "basedata", 0, nil, map[string]any{ + "added": plan.result.Added, + "updated": plan.result.Updated, + "errorCount": plan.result.ErrorCount, + }) + return plan.result, nil +} + +// isTruthy 解析开关型表单/查询值:只认 1 / true,写错的值不会误开开关。 +func isTruthy(raw string) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true": + return true + } + return false +} + +// ---------- 导入:先算差异(dryRun 与落地共用这一段) ---------- + +// 现值快照类型:更新时以它们为基准做「列不存在就不改」的合并与逐字段比对。 +type baseDataExistingTeam struct { + id int64 + name string + shortName *string + color *string + sortOrder int + active bool +} + +type baseDataExistingSport struct { + id int64 + name string + shortName *string + sortOrder int + active bool +} + +type baseDataExistingEvent struct { + id int64 + sportCode string + name string + eventType string + resultMode string + resultType string + compare string + scored bool + recordEligible bool + multiplier float64 + status string + avgMode string + sortOrder int + venue *string + remark *string +} + +type baseDataExistingCategory struct { + id int64 + eventID int64 + name string + gender *string + ageMin *int + ageMax *int + sortOrder int + multiplier float64 +} + +// 待写计划:落地阶段只认这些值,不再回头解析表。 +type baseDataTeamPlan struct { + line int + action string + code string + name string + shortName *string + color *string + sortOrder int + active bool +} + +type baseDataSportPlan struct { + line int + action string + code string + name string + shortName *string + sortOrder int + active bool +} + +type baseDataEventPlan struct { + line int + action string + code string + sportCode string + name string + eventType string + resultMode string + resultType string + compare string + scored bool + recordEligible bool + multiplier float64 + status string + avgMode string + sortOrder int + venue *string + remark *string +} + +type baseDataCategoryPlan struct { + line int + action string + eventCode string + name string + multiplier float64 + gender *string + ageMin *int + ageMax *int + sortOrder int +} + +// baseDataPlan 是一次导入的全部待写内容与差异报告。 +type baseDataPlan struct { + teams []baseDataTeamPlan + sports []baseDataSportPlan + events []baseDataEventPlan + categories []baseDataCategoryPlan + result baseDataImportResult +} + +// baseDataPlanner 在解析期间维护「库里现值 + 本次会新建的编码」两张视图: +// 后一张表引用前一张表刚建的编码时,同一次导入里也要认得出来(哪怕还在 dryRun)。 +type baseDataPlanner struct { + s *server + ctx context.Context + specs map[string]export.BaseDataSheetSpec + teams map[string]baseDataExistingTeam + sports map[string]baseDataExistingSport + events map[string]baseDataExistingEvent + cats map[string]baseDataExistingCategory + plan baseDataPlan +} + +func (p *baseDataPlanner) rowError(sheet string, line int, format string, args ...any) { + // 行号之外再带上工作表名:四张表各有一条「第 3 行」,只说行号现场认不出是哪张表。 + p.plan.result.Errors = append(p.plan.result.Errors, sheet+" "+p.s.interceptRow(line, format, args...)) + p.plan.result.ErrorCount++ +} + +func (p *baseDataPlanner) record(action, sheet string, line int, key string, fields []baseDataFieldDiff) { + switch action { + case "added": + p.plan.result.Added++ + case "updated": + p.plan.result.Updated++ + default: + p.plan.result.Unchanged++ + return + } + p.plan.result.Changes = append(p.plan.result.Changes, baseDataChange{ + Sheet: sheet, Line: line, Action: action, Key: key, Fields: fields, + }) +} + +// planBaseDataImport 读库取现值,逐表逐行算差异;行级错误只拦该行,其余行照常进计划。 +func (s *server) planBaseDataImport(ctx context.Context, tables []export.BaseDataTable) (baseDataPlan, error) { + p := &baseDataPlanner{ + s: s, + ctx: ctx, + specs: make(map[string]export.BaseDataSheetSpec, 4), + teams: make(map[string]baseDataExistingTeam), + sports: make(map[string]baseDataExistingSport), + events: make(map[string]baseDataExistingEvent), + cats: make(map[string]baseDataExistingCategory), + plan: baseDataPlan{result: baseDataImportResult{Errors: []string{}, Changes: []baseDataChange{}}}, + } + for _, spec := range export.BaseDataSpecs() { + p.specs[spec.Sheet] = spec + } + if err := p.loadExisting(); err != nil { + return baseDataPlan{}, err + } + + rowsBySheet := make(map[string][]export.BaseDataRow, len(tables)) + for _, t := range tables { + rowsBySheet[t.Sheet] = t.Rows + } + // 固定按 单位 → 大项 → 小项 → 组别 处理:后者引用前者的编码,顺序反了会误报「不存在」。 + p.planTeams(rowsBySheet[export.BaseDataSheetTeams]) + p.planSports(rowsBySheet[export.BaseDataSheetSports]) + p.planEvents(rowsBySheet[export.BaseDataSheetEvents]) + p.planCategories(rowsBySheet[export.BaseDataSheetCategories]) + return p.plan, nil +} + +func (p *baseDataPlanner) loadExisting() error { + // 单位连「彻底删除」(deleted_at 非空)的一起读:编码有唯一索引,不认它就会在 INSERT 时撞索引。 + // 更新不会碰 deleted_at,它继续是隐藏状态——导入不该顺手把藏起来的条目翻出来。 + teamRows, err := p.s.ev.QueryContext(p.ctx, ` + SELECT id, code, name, short_name, color, sort_order, active + FROM teams ORDER BY sort_order, code`) + if err != nil { + return internal(err) + } + defer func() { _ = teamRows.Close() }() + for teamRows.Next() { + var ( + t baseDataExistingTeam + code string + shortName, color sql.NullString + ) + var active int + if err := teamRows.Scan(&t.id, &code, &t.name, &shortName, &color, &t.sortOrder, &active); err != nil { + return internal(err) + } + t.shortName, t.color, t.active = strPtr(shortName), strPtr(color), active != 0 + p.teams[code] = t + } + if err := teamRows.Err(); err != nil { + return internal(err) + } + + sportRows, err := p.s.ev.QueryContext(p.ctx, ` + SELECT id, code, name, short_name, sort_order, active FROM sports ORDER BY sort_order, code`) + if err != nil { + return internal(err) + } + defer func() { _ = sportRows.Close() }() + for sportRows.Next() { + var ( + sp baseDataExistingSport + code string + short sql.NullString + active int + ) + if err := sportRows.Scan(&sp.id, &code, &sp.name, &short, &sp.sortOrder, &active); err != nil { + return internal(err) + } + sp.shortName, sp.active = strPtr(short), active != 0 + p.sports[code] = sp + } + if err := sportRows.Err(); err != nil { + return internal(err) + } + + eventRows, err := p.s.ev.QueryContext(p.ctx, ` + SELECT e.id, COALESCE(s.code, ''), e.code, e.name, e.event_type, e.result_mode, e.result_type, + e.compare, e.scored, e.record_eligible, e.multiplier, e.status, e.avg_mode, + e.sort_order, e.venue, e.remark + FROM events e LEFT JOIN sports s ON s.id = e.sport_id`) + if err != nil { + return internal(err) + } + defer func() { _ = eventRows.Close() }() + for eventRows.Next() { + var ( + e baseDataExistingEvent + code string + scored, recordEligible int + venue, remark sql.NullString + ) + if err := eventRows.Scan(&e.id, &e.sportCode, &code, &e.name, &e.eventType, &e.resultMode, + &e.resultType, &e.compare, &scored, &recordEligible, &e.multiplier, &e.status, + &e.avgMode, &e.sortOrder, &venue, &remark); err != nil { + return internal(err) + } + e.scored, e.recordEligible = scored != 0, recordEligible != 0 + e.venue, e.remark = strPtr(venue), strPtr(remark) + p.events[code] = e + } + if err := eventRows.Err(); err != nil { + return internal(err) + } + + catRows, err := p.s.ev.QueryContext(p.ctx, ` + SELECT c.id, c.event_id, COALESCE(e.code, ''), c.name, c.multiplier, + c.gender, c.age_min, c.age_max, c.sort_order + FROM event_categories c LEFT JOIN events e ON e.id = c.event_id`) + if err != nil { + return internal(err) + } + defer func() { _ = catRows.Close() }() + for catRows.Next() { + var ( + c baseDataExistingCategory + eventCode string + gender sql.NullString + ageMin, ageMax sql.NullInt64 + ) + if err := catRows.Scan(&c.id, &c.eventID, &eventCode, &c.name, &c.multiplier, + &gender, &ageMin, &ageMax, &c.sortOrder); err != nil { + return internal(err) + } + c.gender = strPtr(gender) + c.ageMin, c.ageMax = intPtr(ageMin), intPtr(ageMax) + p.cats[baseDataCategoryKey(eventCode, c.name)] = c + } + return catRows.Err() +} + +// baseDataCategoryKey 用 NUL 拼小项编码与组别名:两者都不含控制字符,拼出来不会撞键。 +func baseDataCategoryKey(eventCode, name string) string { + return eventCode + "\x00" + name +} + +func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) { + spec := p.specs[export.BaseDataSheetTeams] + for _, row := range rows { + sheet, line := export.BaseDataSheetTeams, row.Line + code := row.Cells["code"] + if code == "" { + p.rowError(sheet, line, "单位编码必填") + continue + } + name := row.Cells["name"] + if name == "" { + p.rowError(sheet, line, "单位名称必填") + continue + } + plan := baseDataTeamPlan{line: line, code: code, name: name, active: true} + if row.Has["active"] { + active, ok := export.ParseBaseDataBool(row.Cells["active"], true) + if !ok { + p.rowError(sheet, line, "启用只能填 是 / 否") + continue + } + plan.active = active + } + if row.Has["sortOrder"] { + n, ok := export.ParseBaseDataInt(row.Cells["sortOrder"]) + if !ok { + p.rowError(sheet, line, "排序需要是整数") + continue + } + if n != nil { + plan.sortOrder = *n + } + } + existing, ok := p.teams[code] + if !ok { + plan.action = "added" + if row.Has["shortName"] { + plan.shortName = baseDataStrPtr(row.Cells["shortName"]) + } + if row.Has["color"] { + plan.color = baseDataStrPtr(row.Cells["color"]) + } + p.plan.teams = append(p.plan.teams, plan) + p.teams[code] = baseDataExistingTeam{ + name: name, shortName: plan.shortName, color: plan.color, + sortOrder: plan.sortOrder, active: plan.active, + } + p.record("added", sheet, line, code, addedFields(spec, row)) + continue + } + if row.Has["shortName"] { + plan.shortName = baseDataStrPtr(row.Cells["shortName"]) + } else { + plan.shortName = existing.shortName + } + if row.Has["color"] { + plan.color = baseDataStrPtr(row.Cells["color"]) + } else { + plan.color = existing.color + } + diffs := make([]baseDataFieldDiff, 0, 6) + addDiff(&diffs, "单位名称", existing.name, plan.name) + addDiff(&diffs, "简称", ptrText(existing.shortName), ptrText(plan.shortName)) + addDiff(&diffs, "颜色", ptrText(existing.color), ptrText(plan.color)) + addDiff(&diffs, "排序", strconv.Itoa(existing.sortOrder), strconv.Itoa(plan.sortOrder)) + addBoolDiff(&diffs, "启用", existing.active, plan.active) + if len(diffs) == 0 { + p.record("unchanged", sheet, line, code, nil) + continue + } + plan.action = "updated" + p.plan.teams = append(p.plan.teams, plan) + p.teams[code] = baseDataExistingTeam{ + id: existing.id, name: name, shortName: plan.shortName, color: plan.color, + sortOrder: plan.sortOrder, active: plan.active, + } + p.record("updated", sheet, line, code, diffs) + } +} + +func (p *baseDataPlanner) planSports(rows []export.BaseDataRow) { + spec := p.specs[export.BaseDataSheetSports] + for _, row := range rows { + sheet, line := export.BaseDataSheetSports, row.Line + code := row.Cells["code"] + if code == "" { + p.rowError(sheet, line, "大项编码必填") + continue + } + name := row.Cells["name"] + if name == "" { + p.rowError(sheet, line, "大项名称必填") + continue + } + plan := baseDataSportPlan{line: line, code: code, name: name, active: true} + if row.Has["active"] { + active, ok := export.ParseBaseDataBool(row.Cells["active"], true) + if !ok { + p.rowError(sheet, line, "启用只能填 是 / 否") + continue + } + plan.active = active + } + if row.Has["sortOrder"] { + n, ok := export.ParseBaseDataInt(row.Cells["sortOrder"]) + if !ok { + p.rowError(sheet, line, "排序需要是整数") + continue + } + if n != nil { + plan.sortOrder = *n + } + } + existing, ok := p.sports[code] + if !ok { + plan.action = "added" + if row.Has["shortName"] { + plan.shortName = baseDataStrPtr(row.Cells["shortName"]) + } + p.plan.sports = append(p.plan.sports, plan) + p.sports[code] = baseDataExistingSport{ + name: name, shortName: plan.shortName, + sortOrder: plan.sortOrder, active: plan.active, + } + p.record("added", sheet, line, code, addedFields(spec, row)) + continue + } + if row.Has["shortName"] { + plan.shortName = baseDataStrPtr(row.Cells["shortName"]) + } else { + plan.shortName = existing.shortName + } + diffs := make([]baseDataFieldDiff, 0, 3) + addDiff(&diffs, "大项名称", existing.name, plan.name) + addDiff(&diffs, "简称", ptrText(existing.shortName), ptrText(plan.shortName)) + addDiff(&diffs, "排序", strconv.Itoa(existing.sortOrder), strconv.Itoa(plan.sortOrder)) + addBoolDiff(&diffs, "启用", existing.active, plan.active) + if len(diffs) == 0 { + p.record("unchanged", sheet, line, code, nil) + continue + } + plan.action = "updated" + p.plan.sports = append(p.plan.sports, plan) + p.sports[code] = baseDataExistingSport{ + id: existing.id, name: name, shortName: plan.shortName, + sortOrder: plan.sortOrder, active: plan.active, + } + p.record("updated", sheet, line, code, diffs) + } +} + +func (p *baseDataPlanner) planEvents(rows []export.BaseDataRow) { + spec := p.specs[export.BaseDataSheetEvents] + for _, row := range rows { + sheet, line := export.BaseDataSheetEvents, row.Line + sportCode := row.Cells["sportCode"] + code := row.Cells["code"] + name := row.Cells["name"] + if sportCode == "" { + p.rowError(sheet, line, "大项编码必填") + continue + } + if code == "" { + p.rowError(sheet, line, "小项编码必填") + continue + } + if name == "" { + p.rowError(sheet, line, "小项名称必填") + continue + } + if _, ok := p.sports[sportCode]; !ok { + p.rowError(sheet, line, "大项编码 %s 不存在", sportCode) + continue + } + existing, exists := p.events[code] + if !exists { + // 新建时的起手值:与建表默认值 / 创建接口的空值口径一致。 + existing = baseDataExistingEvent{ + eventType: "individual", resultMode: "auto", resultType: "none", + compare: "none", status: "pending", avgMode: "valid", + scored: true, recordEligible: true, multiplier: 1, + } + } + plan := baseDataEventPlan{ + line: line, code: code, sportCode: sportCode, name: name, + eventType: existing.eventType, resultMode: existing.resultMode, + resultType: existing.resultType, compare: existing.compare, + scored: existing.scored, recordEligible: existing.recordEligible, + multiplier: existing.multiplier, status: existing.status, + avgMode: existing.avgMode, sortOrder: existing.sortOrder, + venue: existing.venue, remark: existing.remark, + } + if row.Has["eventType"] { + v, err := normalizeEventType(row.Cells["eventType"]) + if err != nil { + p.rowError(sheet, line, "%s", rowMessage(err)) + continue + } + plan.eventType = v + } + if row.Has["resultMode"] { + v, err := normalizeResultMode(row.Cells["resultMode"]) + if err != nil { + p.rowError(sheet, line, "%s", rowMessage(err)) + continue + } + plan.resultMode = v + } + if row.Has["resultType"] { + v, err := normalizeResultType(row.Cells["resultType"]) + if err != nil { + p.rowError(sheet, line, "%s", rowMessage(err)) + continue + } + plan.resultType = v + } + if row.Has["compare"] { + v, err := normalizeCompare(row.Cells["compare"]) + if err != nil { + p.rowError(sheet, line, "%s", rowMessage(err)) + continue + } + plan.compare = v + } + if row.Has["status"] { + v, err := normalizeEventStatus(row.Cells["status"]) + if err != nil { + p.rowError(sheet, line, "%s", rowMessage(err)) + continue + } + plan.status = v + } + if row.Has["avgMode"] { + v, err := normalizeAvgMode(row.Cells["avgMode"]) + if err != nil { + p.rowError(sheet, line, "%s", rowMessage(err)) + continue + } + plan.avgMode = v + } + if row.Has["scored"] { + v, ok := export.ParseBaseDataBool(row.Cells["scored"], true) + if !ok { + p.rowError(sheet, line, "计分只能填 是 / 否") + continue + } + plan.scored = v + } + if row.Has["recordEligible"] { + v, ok := export.ParseBaseDataBool(row.Cells["recordEligible"], true) + if !ok { + p.rowError(sheet, line, "计破纪录只能填 是 / 否") + continue + } + plan.recordEligible = v + } + if row.Has["multiplier"] { + v, ok := export.ParseBaseDataFloat(row.Cells["multiplier"], 1) + if !ok || v < 0 { + p.rowError(sheet, line, "权重需要是不小于 0 的数字") + continue + } + plan.multiplier = v + } + if row.Has["sortOrder"] { + n, ok := export.ParseBaseDataInt(row.Cells["sortOrder"]) + if !ok { + p.rowError(sheet, line, "排序需要是整数") + continue + } + if n != nil { + plan.sortOrder = *n + } + } + if row.Has["venue"] { + plan.venue = baseDataStrPtr(row.Cells["venue"]) + } + if row.Has["remark"] { + plan.remark = baseDataStrPtr(row.Cells["remark"]) + } + + if !exists { + plan.action = "added" + p.plan.events = append(p.plan.events, plan) + p.events[code] = baseDataExistingEvent{ + sportCode: sportCode, name: name, eventType: plan.eventType, + resultMode: plan.resultMode, resultType: plan.resultType, compare: plan.compare, + scored: plan.scored, recordEligible: plan.recordEligible, multiplier: plan.multiplier, + status: plan.status, avgMode: plan.avgMode, sortOrder: plan.sortOrder, + venue: plan.venue, remark: plan.remark, + } + p.record("added", sheet, line, code, addedFields(spec, row)) + continue + } + diffs := make([]baseDataFieldDiff, 0, 16) + addDiff(&diffs, "大项编码", existing.sportCode, plan.sportCode) + addDiff(&diffs, "小项名称", existing.name, plan.name) + addDiff(&diffs, "类型", existing.eventType, plan.eventType) + addDiff(&diffs, "结果模式", existing.resultMode, plan.resultMode) + addDiff(&diffs, "结果类型", existing.resultType, plan.resultType) + addDiff(&diffs, "比较", existing.compare, plan.compare) + addBoolDiff(&diffs, "计分", existing.scored, plan.scored) + addBoolDiff(&diffs, "计破纪录", existing.recordEligible, plan.recordEligible) + addDiff(&diffs, "权重", formatFloat(existing.multiplier), formatFloat(plan.multiplier)) + addDiff(&diffs, "状态", existing.status, plan.status) + addDiff(&diffs, "平均口径", existing.avgMode, plan.avgMode) + addDiff(&diffs, "排序", strconv.Itoa(existing.sortOrder), strconv.Itoa(plan.sortOrder)) + addDiff(&diffs, "场地", ptrText(existing.venue), ptrText(plan.venue)) + addDiff(&diffs, "备注", ptrText(existing.remark), ptrText(plan.remark)) + if len(diffs) == 0 { + p.record("unchanged", sheet, line, code, nil) + continue + } + plan.action = "updated" + p.plan.events = append(p.plan.events, plan) + existing.sportCode, existing.name = plan.sportCode, plan.name + existing.eventType, existing.resultMode, existing.resultType, existing.compare = plan.eventType, plan.resultMode, plan.resultType, plan.compare + existing.scored, existing.recordEligible = plan.scored, plan.recordEligible + existing.multiplier, existing.status, existing.avgMode = plan.multiplier, plan.status, plan.avgMode + existing.sortOrder, existing.venue, existing.remark = plan.sortOrder, plan.venue, plan.remark + p.events[code] = existing + p.record("updated", sheet, line, code, diffs) + } +} + +func (p *baseDataPlanner) planCategories(rows []export.BaseDataRow) { + spec := p.specs[export.BaseDataSheetCategories] + for _, row := range rows { + sheet, line := export.BaseDataSheetCategories, row.Line + eventCode := row.Cells["eventCode"] + name := row.Cells["name"] + if eventCode == "" { + p.rowError(sheet, line, "小项编码必填") + continue + } + if name == "" { + p.rowError(sheet, line, "组别名必填") + continue + } + if _, ok := p.events[eventCode]; !ok { + p.rowError(sheet, line, "小项编码 %s 不存在", eventCode) + continue + } + key := baseDataCategoryKey(eventCode, name) + existing, exists := p.cats[key] + if !exists { + existing = baseDataExistingCategory{name: name, multiplier: 1} + } + plan := baseDataCategoryPlan{ + line: line, eventCode: eventCode, name: name, + multiplier: existing.multiplier, gender: existing.gender, + ageMin: existing.ageMin, ageMax: existing.ageMax, sortOrder: existing.sortOrder, + } + if row.Has["multiplier"] { + v, ok := export.ParseBaseDataFloat(row.Cells["multiplier"], 1) + if !ok || v < 0 { + p.rowError(sheet, line, "权重需要是不小于 0 的数字") + continue + } + plan.multiplier = v + } + if row.Has["gender"] { + gender := strings.TrimSpace(row.Cells["gender"]) + if gender != "" && gender != "男" && gender != "女" { + p.rowError(sheet, line, "性别只能填 男 / 女(留空表示不限)") + continue + } + plan.gender = baseDataStrPtr(gender) + } + if row.Has["ageMin"] { + n, ok := export.ParseBaseDataInt(row.Cells["ageMin"]) + if !ok { + p.rowError(sheet, line, "年龄下限需要是整数(留空表示不限)") + continue + } + plan.ageMin = n + } + if row.Has["ageMax"] { + n, ok := export.ParseBaseDataInt(row.Cells["ageMax"]) + if !ok { + p.rowError(sheet, line, "年龄上限需要是整数(留空表示不限)") + continue + } + plan.ageMax = n + } + if err := validateCategoryAges(plan.ageMin, plan.ageMax); err != nil { + p.rowError(sheet, line, "%s", rowMessage(err)) + continue + } + if row.Has["sortOrder"] { + n, ok := export.ParseBaseDataInt(row.Cells["sortOrder"]) + if !ok { + p.rowError(sheet, line, "排序需要是整数") + continue + } + if n != nil { + plan.sortOrder = *n + } + } + + if !exists { + plan.action = "added" + p.plan.categories = append(p.plan.categories, plan) + p.cats[key] = baseDataExistingCategory{ + name: name, multiplier: plan.multiplier, gender: plan.gender, + ageMin: plan.ageMin, ageMax: plan.ageMax, sortOrder: plan.sortOrder, + } + p.record("added", sheet, line, eventCode+" / "+name, addedFields(spec, row)) + continue + } + diffs := make([]baseDataFieldDiff, 0, 6) + addDiff(&diffs, "权重", formatFloat(existing.multiplier), formatFloat(plan.multiplier)) + addDiff(&diffs, "性别", ptrText(existing.gender), ptrText(plan.gender)) + addDiff(&diffs, "年龄下限", intText(existing.ageMin), intText(plan.ageMin)) + addDiff(&diffs, "年龄上限", intText(existing.ageMax), intText(plan.ageMax)) + addDiff(&diffs, "排序", strconv.Itoa(existing.sortOrder), strconv.Itoa(plan.sortOrder)) + if len(diffs) == 0 { + p.record("unchanged", sheet, line, eventCode+" / "+name, nil) + continue + } + plan.action = "updated" + p.plan.categories = append(p.plan.categories, plan) + existing.multiplier, existing.gender = plan.multiplier, plan.gender + existing.ageMin, existing.ageMax, existing.sortOrder = plan.ageMin, plan.ageMax, plan.sortOrder + p.cats[key] = existing + p.record("updated", sheet, line, eventCode+" / "+name, diffs) + } +} + +// ---------- 导入:落地 ---------- + +// applyBaseDataPlan 在一个事务里把计划写完:中途任何一步失败都不会留下半份数据。 +func (s *server) applyBaseDataPlan(ctx context.Context, plan baseDataPlan) error { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return internal(err) + } + defer func() { _ = tx.Rollback() }() + + actorID := middleware.User(ctx).ID + for _, t := range plan.teams { + var id int64 + if t.action == "added" { + res, err := tx.ExecContext(ctx, + `INSERT INTO teams (code, name, short_name, color, sort_order, active) VALUES (?, ?, ?, ?, ?, ?)`, + t.code, t.name, nullStr(t.shortName), nullStr(t.color), t.sortOrder, t.active) + if err != nil { + return baseDataWriteError("单位", t.code, err) + } + if id, err = lastInsertID(res); err != nil { + return internal(err) + } + } else { + if _, err := tx.ExecContext(ctx, + `UPDATE teams SET name = ?, short_name = ?, color = ?, sort_order = ?, active = ? WHERE code = ?`, + t.name, nullStr(t.shortName), nullStr(t.color), t.sortOrder, t.active, t.code); err != nil { + return baseDataWriteError("单位", t.code, err) + } + if id, err = lookupCodeID(ctx, tx, `SELECT id FROM teams WHERE code = ?`, t.code); err != nil { + return err + } + } + // 与 POST /api/teams 同口径地推一次变更:单位改名要能传到大屏。 + team, err := teamByIDQ(ctx, tx, id) + if err != nil { + return err + } + if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, actorID); err != nil { + return err + } + } + + for _, sp := range plan.sports { + if sp.action == "added" { + if _, err := tx.ExecContext(ctx, + `INSERT INTO sports (code, name, short_name, sort_order, active) VALUES (?, ?, ?, ?, ?)`, + sp.code, sp.name, nullStr(sp.shortName), sp.sortOrder, sp.active); err != nil { + return baseDataWriteError("大项", sp.code, err) + } + continue + } + // 大项没有独立的变更推送(与 POST /api/sports 现状一致),只更新名字与启用状态。 + if _, err := tx.ExecContext(ctx, + `UPDATE sports SET name = ?, short_name = ?, sort_order = ?, active = ? WHERE code = ?`, + sp.name, nullStr(sp.shortName), sp.sortOrder, sp.active, sp.code); err != nil { + return baseDataWriteError("大项", sp.code, err) + } + } + + for _, e := range plan.events { + sportID, err := lookupCodeID(ctx, tx, `SELECT id FROM sports WHERE code = ?`, e.sportCode) + if err != nil { + return err + } + var id int64 + if e.action == "added" { + res, err := tx.ExecContext(ctx, + `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, + record_eligible, scored, multiplier, status, sort_order, venue, remark, avg_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + sportID, e.code, e.name, e.eventType, e.resultMode, e.resultType, e.compare, + e.recordEligible, e.scored, e.multiplier, e.status, e.sortOrder, + nullStr(e.venue), nullStr(e.remark), e.avgMode) + if err != nil { + return baseDataWriteError("小项", e.code, err) + } + if id, err = lastInsertID(res); err != nil { + return internal(err) + } + } else { + if _, err := tx.ExecContext(ctx, + `UPDATE events SET sport_id = ?, name = ?, event_type = ?, result_mode = ?, result_type = ?, + compare = ?, record_eligible = ?, scored = ?, multiplier = ?, status = ?, + sort_order = ?, venue = ?, remark = ?, avg_mode = ? + WHERE code = ?`, + sportID, e.name, e.eventType, e.resultMode, e.resultType, e.compare, + e.recordEligible, e.scored, e.multiplier, e.status, e.sortOrder, + nullStr(e.venue), nullStr(e.remark), e.avgMode, e.code); err != nil { + return baseDataWriteError("小项", e.code, err) + } + if id, err = lookupCodeID(ctx, tx, `SELECT id FROM events WHERE code = ?`, e.code); err != nil { + return err + } + } + // 小项与它的组别都以本小项为载体推送(与组别整表替换同口径)。 + if err := s.bumpEvent(ctx, tx, id); err != nil { + return err + } + } + + for _, c := range plan.categories { + eventID, err := lookupCodeID(ctx, tx, `SELECT id FROM events WHERE code = ?`, c.eventCode) + if err != nil { + return err + } + if c.action == "added" { + if _, err := tx.ExecContext(ctx, + `INSERT INTO event_categories (event_id, name, multiplier, gender, age_min, age_max, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + eventID, c.name, c.multiplier, nullStr(c.gender), nullInt(c.ageMin), nullInt(c.ageMax), c.sortOrder); err != nil { + return baseDataWriteError("组别", c.eventCode+" / "+c.name, err) + } + continue + } + if _, err := tx.ExecContext(ctx, + `UPDATE event_categories SET multiplier = ?, gender = ?, age_min = ?, age_max = ?, sort_order = ? + WHERE event_id = ? AND name = ?`, + c.multiplier, nullStr(c.gender), nullInt(c.ageMin), nullInt(c.ageMax), + c.sortOrder, eventID, c.name); err != nil { + return baseDataWriteError("组别", c.eventCode+" / "+c.name, err) + } + } + return tx.Commit() +} + +// baseDataWriteError 把写库失败翻成现场能照着改的话:唯一索引撞了就说明编码已存在 +// (理论上计划阶段已经拦下,这里兜住并发导入这类竞态),其余仍走 5xx。 +func baseDataWriteError(sheet, key string, err error) error { + if isUniqueViolation(err) { + return conflict(sheet + " " + key + " 已存在,请先合并编码再重新导入") + } + if isForeignKeyViolation(err) { + return badRequest(sheet+" "+key+" 引用的上级编码不存在,请检查大项编码 / 小项编码", nil) + } + return internal(err) +} + +// lookupCodeID 按编码取主键;编码在计划阶段已校验存在,这里取不到说明有人并发改了库。 +func lookupCodeID(ctx context.Context, q queryer, query, code string) (int64, error) { + var id int64 + if err := q.QueryRowContext(ctx, query, code).Scan(&id); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return 0, conflict("编码 " + code + " 在写入过程中被改动,请重新预览后再导入") + } + return 0, internal(err) + } + return id, nil +} + +// ---------- 小工具 ---------- + +func baseDataStrPtr(v string) *string { + v = strings.TrimSpace(v) + if v == "" { + return nil + } + return &v +} + +func ptrText(v *string) string { + if v == nil { + return "" + } + return *v +} + +func intText(v *int) string { + if v == nil { + return "" + } + return strconv.Itoa(*v) +} + +func formatFloat(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} + +func addDiff(diffs *[]baseDataFieldDiff, header, before, after string) { + if before == after { + return + } + *diffs = append(*diffs, baseDataFieldDiff{Field: header, From: before, To: after}) +} + +func addBoolDiff(diffs *[]baseDataFieldDiff, header string, before, after bool) { + addDiff(diffs, header, boolText(before), boolText(after)) +} + +func boolText(v bool) string { + if v { + return "是" + } + return "否" +} + +// addedFields 用表里写的原文列出一行新增数据的字段变化(从无到有); +// 只列文件里真有、且填了值的列:空单元格对新增行没有信息量, +// 全列出来会让预览里每行挂一串「简称:(空) → (空)」。 +func addedFields(spec export.BaseDataSheetSpec, row export.BaseDataRow) []baseDataFieldDiff { + out := make([]baseDataFieldDiff, 0, len(spec.Headers)) + for _, h := range spec.Headers { + if !row.Has[h.Field] || row.Cells[h.Field] == "" { + continue + } + out = append(out, baseDataFieldDiff{Field: h.Header, From: "", To: row.Cells[h.Field]}) + } + return out +} + +// rowMessage 把 badRequest 系列的错误取成一句中文行级提示。 +// 枚举校验(normalize*)返回的 apiError 里带着给现场看的 message,直接用它; +// 这样导入的非法枚举值不会掉到 CHECK 约束上去报 500。 +func rowMessage(err error) string { + var api *apiError + if errors.As(err, &api) { + return api.message + } + return err.Error() +} + +// filterIDQuery 解析筛选型查询参数:留空表示不筛(返回 0);填了就必须是正整数。 +// 刻意不学 int64Query 的「解析失败当没填」——那样会把「筛某个单位」静默变成 +// 「导出全部」,现场拿到一份全量名单还以为筛过了。 +func filterIDQuery(r *http.Request, key, what string) (int64, error) { + raw := strings.TrimSpace(r.URL.Query().Get(key)) + if raw == "" { + return 0, nil + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n <= 0 { + return 0, badRequest(fmt.Sprintf("%s筛选参数 %s 需要是正整数(留空表示不筛选)", what, key), raw) + } + return n, nil +} + func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any, error) { dir := filepath.Join(s.dataDir, "exports") if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index f3e66ff..ed92442 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "unicode/utf8" "github.com/xuri/excelize/v2" @@ -59,7 +60,12 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) { Action: "auth.login", EntityType: "user", EntityID: u.ID, ActorID: u.ID, After: map[string]any{"username": u.Username}, }) - return userDTO(u), nil + out := userDTO(u) + // 刚建好的会话到期时间 = 此刻 + 当前有效期(与 createSession 同一请求内,误差在毫秒级); + // 下发它,界面登录后就能立刻显示「当前会话将于 … 过期」,不用等下一次 /auth/me。 + expiresAt := time.Now().UTC().Add(s.auth.SessionTTL()).Format(time.RFC3339) + out.SessionExpiresAt = &expiresAt + return out, nil } func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) { @@ -75,7 +81,31 @@ func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) { func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) { u := middleware.User(r.Context()) - return userDTO(u), nil + out := userDTO(u) + if u.Role == auth.RoleGuest { + return out, nil + } + + // 顺带把会话 Cookie 的期限同步到最新的到期时间: + // 滑动续期只改服务端 expires_at,Cookie 的 Max-Age 不跟着走的话, + // 浏览器会在「登录时刻 + 有效期」处丢掉 Cookie,人还是被登出。 + // /auth/me 是管理端启动与定时心跳都会走的接口,放在这里就够。 + c, err := r.Cookie(auth.CookieSession) + if err != nil || c.Value == "" { + return out, nil + } + _, sess, err := s.auth.Lookup(r.Context(), c.Value) + if err != nil || sess == nil { + return out, nil + } + expiresAt := sess.ExpiresAt.UTC().Format(time.RFC3339) + out.SessionExpiresAt = &expiresAt + csrf := "" + if c, err := r.Cookie(auth.CookieCSRF); err == nil { + csrf = c.Value + } + s.auth.RefreshSessionCookies(w, c.Value, csrf, sess.ExpiresAt) + return out, nil } type changePasswordInput struct { @@ -356,9 +386,19 @@ func (s *server) getEventHandler(w http.ResponseWriter, r *http.Request) (any, e func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any, error) { var in eventDTO - if err := decodeJSON(r, &in); err != nil { + keys, err := decodeJSONPresence(r, &in) + if err != nil { return nil, err } + // 与建单位 / 大项同口径:缺省 = 建表默认值(migrations/event/0004_sports_events.sql 里 + // scored 与 record_eligible 都 DEFAULT 1)。Go 的 bool 零值是 false,漏传会被静默建成 + // 「只排名不计分、不能破纪录」的小项 —— 这与导入侧按建表默认值建档的口径也不一致。 + if !keys.has("scored") { + in.Scored = true + } + if !keys.has("recordEligible") { + in.RecordEligible = true + } created, err := s.createEvent(r.Context(), in) if err != nil { return nil, err @@ -373,13 +413,22 @@ func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any return nil, err } var in eventDTO - if err := decodeJSON(r, &in); err != nil { + keys, err := decodeJSONPresence(r, &in) + if err != nil { return nil, err } before, err := s.getEvent(r.Context(), id) if err != nil { return nil, err } + // 更新仍是全量覆盖语义,但漏传这两个开关不等于「关掉它」:沿用库里的现值, + // 与 active 在单位 / 大项上的处理一致。 + if !keys.has("scored") { + in.Scored = before.Scored + } + if !keys.has("recordEligible") { + in.RecordEligible = before.RecordEligible + } updated, err := s.updateEvent(r.Context(), id, in) if err != nil { return nil, err @@ -611,9 +660,12 @@ type entryImportResult struct { Skipped int `json:"skipped"` Errors []string `json:"errors"` Conflicts []importConflict `json:"conflicts"` + // SameNameNewPersons 是「号码布不同 → 按同名的另一个人新建档案」的清单。 + // 只作报告:不弹窗、不阻塞导入,但必须让用户看得见。 + SameNameNewPersons []sameNameNewPerson `json:"sameNameNewPersons"` } -// importConflict 是「证件号或号码布已存在、但人员字段与库中不一致」的一行。 +// importConflict 是「命中了库里已有人员、但人员字段与库中不一致」的一行。 // 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。 type importConflict struct { Row int `json:"row"` @@ -886,9 +938,9 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor } // importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库; -// 非 nil 时按决策处理证件号/号码布命中的冲突,未出现在 decisions 里的冲突继续收集。 +// 非 nil 时按决策处理命中人员的冲突,未出现在 decisions 里的冲突继续收集。 func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) { - result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}} + result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{}} if len(rows) < 2 { return result, badRequest("报名表为空", nil) } @@ -904,8 +956,8 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio } } - // 同一文件里证件号、号码布都只能出现一次,否则无法判断是不是同一个人。 - seenIDCards := make(map[string]bool) + // 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错; + // 证件号只是可选档案信息,重复不再拦截(同一份表内、跨行、跨表都不因它拦截)。 seenBibs := make(map[string]bool) for n, row := range rows[1:] { line := n + 2 @@ -982,7 +1034,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio if athleteName != "" { idCard := cell("id_card") bib := cell("bib") - // 证件号与号码布二选一即可,都没有就无法区分同名的两个人。 + // 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。 if idCard == "" && bib == "" { result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib")) @@ -994,14 +1046,6 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数")) continue } - if idCard != "" { - if seenIDCards[idCard] { - result.Skipped++ - result.Errors = append(result.Errors, s.interceptRow(line, "证件号 %s 在报名表内重复出现", idCard)) - continue - } - seenIDCards[idCard] = true - } if bib != "" { if seenBibs[bib] { result.Skipped++ @@ -1023,13 +1067,16 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio }, decisions) if err != nil { result.Skipped++ - result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error())) + result.Errors = append(result.Errors, s.interceptRow(line, "%s", importRowMessage(err))) continue } if matched.Conflict != nil { result.Conflicts = append(result.Conflicts, *matched.Conflict) continue } + if matched.SameNameNewPerson != nil { + result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson) + } athleteID = &matched.AthleteID // keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。 teamID = matched.TeamID diff --git a/server/internal/httpapi/handlers_config_test.go b/server/internal/httpapi/handlers_config_test.go index 037c9f9..dce5711 100644 --- a/server/internal/httpapi/handlers_config_test.go +++ b/server/internal/httpapi/handlers_config_test.go @@ -231,12 +231,12 @@ func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) { } } -// 证件号命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。 +// 号码布命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。 func TestImportCollectsConflictWithoutWriting(t *testing.T) { env, ids, client := recorderWithSeed(t) - env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA) + env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA) - body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n" + body := importHeader + "\nM100,男子,B,张伟,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) @@ -285,8 +285,8 @@ func TestImportCollectsConflictWithoutWriting(t *testing.T) { } } -// 同一文件里证件号重复出现 → 该行报错并跳过。 -func TestImportRejectsDuplicateIDCardInFile(t *testing.T) { +// 同一文件里证件号重复出现不再报错:证件号是可选档案信息,不参与匹配也不拦截。 +func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) { env, _, client := recorderWithSeed(t) body := importHeader + "\n" + @@ -297,12 +297,14 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) { t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error) } result := decodeData[map[string]any](t, env2) - if result["created"] != float64(1) || result["skipped"] != float64(1) { - t.Fatalf("首行应新建、次行应跳过: %+v", result) + if result["skipped"] != float64(0) { + t.Fatalf("同表重复证件号不应再拦截: %+v", result) } - errs, _ := result["errors"].([]any) - if len(errs) != 1 || !strings.Contains(errs[0].(string), "重复") { - t.Fatalf("应报证件号重复,得到 %+v", errs) + if errs, _ := result["errors"].([]any); len(errs) != 0 { + t.Fatalf("不应有行级报错,得到 %+v", errs) + } + if result["created"] != float64(1) || result["updated"] != float64(1) { + t.Fatalf("首行新建、次行按姓名+单位命中同一人并更新报名: %+v", result) } var athletes int @@ -310,7 +312,118 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) { t.Fatalf("统计人员: %v", err) } if athletes != 1 { - t.Fatalf("重复证件号只应建 1 个人员,得到 %d", athletes) + t.Fatalf("同一个人只应建 1 个人员,得到 %d", athletes) + } +} + +// 新人带的证件号已被库里另一个人占用 → 明确的中文行级错误,不静默改库、不静默跳过。 +func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA) + + // 另一个人(李四 / 号码布 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) + } + result := decodeData[map[string]any](t, env2) + if result["created"] != float64(0) || result["skipped"] != float64(1) { + t.Fatalf("撞号的证件号应跳过该行: %+v", result) + } + errs, _ := result["errors"].([]any) + if len(errs) != 1 { + t.Fatalf("应报 1 条行级错误,得到 %+v", errs) + } + want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)" + if errs[0] != want { + t.Fatalf("错误文案不对:\n 得到 %q\n 期望 %q", errs[0], want) + } + + // 库里的张伟一行没动,也没有多出一个人。 + 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) + } +} + +// 号码布不同 → 按「同名的另一个人」新建档案,并在 sameNameNewPersons 里单独报告,不进冲突面板。 +func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) { + env, ids, client := recorderWithSeed(t) + env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA) + + body := importHeader + "\nM100,男子,A,张伟,,男,,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) + if item["name"] != "张伟" || item["teamCode"] != "A" || item["newBib"] != "2001" || item["existingBib"] != "1001" { + 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 { + t.Fatalf("统计人员: %v", err) + } + if athletes != 2 { + t.Fatalf("应新建第二个人,得到 %d 个", athletes) + } + // 原档案的号码布保持不动,新档案拿表里的号码布。 + var oldBib string + if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&oldBib); err != nil { + t.Fatalf("查询原人员: %v", err) + } + if oldBib != "1001" { + t.Fatalf("原档案号码布不应被改,得到 %s", oldBib) + } +} + +// 姓名 + 单位命中多条候选:不能替用户挑人,进冲突面板等决策。 +func TestImportAmbiguousNameTeamGoesToConflictPanel(t *testing.T) { + env, ids, client := recorderWithSeed(t) + // 同单位两个「张伟」,都没有号码布,表里来第三个候选信息 → 歧义。 + env.exec(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA) + + body := importHeader + "\nM100,男子,A,张伟,,男,,9001,,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(0) { + t.Fatalf("歧义行不落库也不跳过: %+v", result) + } + conflicts, _ := result["conflicts"].([]any) + if len(conflicts) != 1 { + t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts) + } + if conflicts[0].(map[string]any)["athleteId"] != float64(ids.athleteA) { + t.Fatalf("歧义冲突应以第一条候选为代表: %+v", conflicts[0]) } } @@ -360,9 +473,9 @@ func TestImportToleratesMissingOptionalColumns(t *testing.T) { // keep-existing:保留库中人员字段,报名跟随库里已有的单位。 func TestImportResolveKeepExisting(t *testing.T) { env, ids, client := recorderWithSeed(t) - env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA) + env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA) - body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n" + body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n" // 未给决策时,冲突原样返回。 status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]") @@ -415,9 +528,9 @@ func TestImportResolveKeepExisting(t *testing.T) { // keep-new:用表里的字段更新人员,再按表里的单位建报名。 func TestImportResolveKeepNew(t *testing.T) { env, ids, client := recorderWithSeed(t) - env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA) + env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA) - body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n" + body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n" decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]` status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions) if status != http.StatusOK || !env2.OK { @@ -618,44 +731,36 @@ func TestImportRejectsDuplicateBibInFile(t *testing.T) { } } -// 证件号命中但号码布与库中不同 → 冲突,快照与差异字段都带 bib,本轮不落库。 -func TestImportConflictIncludesBibDiff(t *testing.T) { +// 同名 + 号码布不同本应按「同名的另一个人」新建,但表里的证件号已被库里那个人占用时, +// 唯一索引的底线更高:明确报出占用者,既不静默改库也不静默跳过。 +func TestImportSameNameDifferentBibButTakenIDCard(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) - conflicts, _ := result["conflicts"].([]any) - if len(conflicts) != 1 { - t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts) + if result["skipped"] != float64(1) || result["created"] != float64(0) { + t.Fatalf("撞号应跳过该行: %+v", result) } - conflict := conflicts[0].(map[string]any) - if conflict["bib"] != "A200" { - t.Fatalf("冲突定位应带表里的号码布: %+v", conflict) + errs, _ := result["errors"].([]any) + want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)" + if len(errs) != 1 || errs[0] != want { + t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want) } - changed, _ := conflict["changedFields"].([]any) - if len(changed) != 1 || changed[0] != "bib" { - t.Fatalf("应只有 bib 变化,得到 %+v", changed) - } - existing := conflict["existing"].(map[string]any) - if existing["bib"] != "A100" { - t.Fatalf("existing 快照应带库中号码布: %+v", existing) - } - incoming := conflict["incoming"].(map[string]any) - if incoming["bib"] != "A200" { - t.Fatalf("incoming 快照应带表里号码布: %+v", incoming) + if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 { + 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) + var athletes int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil { + t.Fatalf("统计人员: %v", err) } - if bib != "A100" { - t.Fatalf("冲突不应改库中号码布,得到 %s", bib) + if athletes != 2 { + t.Fatalf("不应新建人员,得到 %d 个", athletes) } } @@ -1266,3 +1371,94 @@ func TestUploadedImagePathRoundTripsThroughSettings(t *testing.T) { t.Fatalf("配置里的图片路径应可匿名访问,得到 %d", resp.StatusCode) } } + +// 小项的 scored / recordEligible 漏传 = 建表默认值(migrations/event/0004 里都是 1), +// 显式 false 才是 false;更新时漏传沿用库里的现值,与单位 / 大项的 active 同口径。 +// 与基础数据包导入侧(export_handlers.go 里新建小项默认 true/true)也一致。 +func TestEventBoolDefaultsFollowSchemaDefaults(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + client := recorderClient(t, env) + + boolField := func(t *testing.T, body envelope, field string) bool { + t.Helper() + got, ok := decodeData[map[string]any](t, body)[field].(bool) + if !ok { + t.Fatalf("响应里 %s 不是布尔值: %+v", field, body.Data) + } + return got + } + + // 库里也真的按接口回报的值落着,不只是响应体好看。 + assertStoredFlags := func(t *testing.T, code string, want int) { + t.Helper() + var scored, recordEligible int + if err := env.ev.QueryRow(`SELECT scored, record_eligible FROM events WHERE code = ?`, code). + Scan(&scored, &recordEligible); err != nil { + t.Fatalf("查询小项 %s 的开关: %v", code, err) + } + if scored != want || recordEligible != want { + t.Fatalf("小项 %s 的开关应为 %d/%d,得到 %d/%d", code, want, want, scored, recordEligible) + } + } + + base := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省"} + + status, body := env.do(client, http.MethodPost, "/api/events", base, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("建小项应 200,得到 %d %+v", status, body.Error) + } + if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") { + t.Fatalf("漏传开关应取建表默认值 true: %+v", body.Data) + } + assertStoredFlags(t, "E-BOOL", 1) + eventID := int64(decodeData[map[string]any](t, body)["id"].(float64)) + + // 更新漏传沿用现值:库里是 true/true 时,只改名字不能把它们翻成 false。 + rename := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名一)"} + status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("改名应 200,得到 %d %+v", status, body.Error) + } + if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") { + t.Fatalf("更新漏传应沿用库里的 true: %+v", body.Data) + } + + // 显式 false 才关得掉:接口不能无视调用方的明确意图。 + turnOff := map[string]any{ + "sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名二)", + "scored": false, "recordEligible": false, + } + status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), turnOff, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("显式关开关应 200,得到 %d %+v", status, body.Error) + } + if boolField(t, body, "scored") || boolField(t, body, "recordEligible") { + t.Fatalf("显式 false 应落库为 false: %+v", body.Data) + } + + // 反过来:库里是 false/false 时,更新漏传也不能把它打开。 + rename["name"] = "开关缺省(改名三)" + status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("再改名应 200,得到 %d %+v", status, body.Error) + } + if boolField(t, body, "scored") || boolField(t, body, "recordEligible") { + t.Fatalf("更新漏传应沿用库里的 false: %+v", body.Data) + } + + // 新建时显式传 false 同样照办,缺省值不覆盖调用方的明确意图。 + explicitOff := map[string]any{ + "sportId": ids.sport, "code": "E-BOOL-OFF", "name": "开关显式关闭", + "scored": false, "recordEligible": false, + } + status, body = env.do(client, http.MethodPost, "/api/events", explicitOff, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("显式传 false 应 200,得到 %d %+v", status, body.Error) + } + if boolField(t, body, "scored") || boolField(t, body, "recordEligible") { + t.Fatalf("显式 false 应落库为 false: %+v", body.Data) + } + assertStoredFlags(t, "E-BOOL-OFF", 0) + assertStoredFlags(t, "E-BOOL", 0) +} diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go index eae0e86..0721fa8 100644 --- a/server/internal/httpapi/handlers_results.go +++ b/server/internal/httpapi/handlers_results.go @@ -63,6 +63,27 @@ func (s *server) unpublishHandler(w http.ResponseWriter, r *http.Request) (any, }) } +// settleResultsHandler 是「立即结算」:延迟结算下的人工兜底,按当前已发布成绩结算一次。 +func (s *server) settleResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in publishPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + return s.settleResults(r.Context(), in, middlewareUserID(r)) + }) +} + +// settlementStatusHandler 返回未结算的 (小项, 组别) 清单,供录入页提示与「立即结算」入口。 +func (s *server) settlementStatusHandler(w http.ResponseWriter, r *http.Request) (any, error) { + eventID, _ := int64Query(r, "eventId") + if eventID == 0 { + return nil, badRequest("eventId 必填", nil) + } + categoryID, _ := int64Query(r, "categoryId") + return s.settlementStatus(r.Context(), eventID, categoryID) +} + func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any, error) { id, err := pathID(r, "id") if err != nil { diff --git a/server/internal/httpapi/payload.go b/server/internal/httpapi/payload.go index 970d786..093abaf 100644 --- a/server/internal/httpapi/payload.go +++ b/server/internal/httpapi/payload.go @@ -45,6 +45,16 @@ type entryHeatPayload struct { RequestID string `json:"requestId"` } +// entryBatchPayload 是报名项批量操作的请求体: +// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名), +// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。 +type entryBatchPayload struct { + Action string `json:"action"` + IDs []int64 `json:"ids"` + Reason string `json:"reason"` + RequestID string `json:"requestId"` +} + type adjustmentInput struct { TeamID int64 `json:"teamId"` Delta float64 `json:"delta"` @@ -117,12 +127,29 @@ type settingsStandingsPatch struct { RankingMode *string `json:"rankingMode"` ShowIndividualBoard *bool `json:"showIndividualBoard"` ShowIndividualMedals *bool `json:"showIndividualMedals"` + // LiveOnPublish 是「分批发布时是否实时更新各单位总分与奖牌榜」;nil 表示不改动。 + LiveOnPublish *bool `json:"liveOnPublish"` +} + +// sessionSettingsPatch 是 PUT /api/system/session 的请求体: +// 只改「登录有效期(小时)」这一个系统级配置。 +type sessionSettingsPatch struct { + SessionHours *int `json:"sessionHours"` + RequestID string `json:"requestId"` } type categoriesPayload struct { Categories []categoryDTO `json:"categories"` } +// athletesBatchPayload 是运动员批量动作的请求体:action 决定停用还是彻底删除。 +type athletesBatchPayload struct { + Action string `json:"action"` + IDs []int64 `json:"ids"` + // DryRun 只统计不改库:前端用它先看清「会删掉多少、重算哪些小项」再确认。 + DryRun bool `json:"dryRun"` +} + type channelsPayload struct { Channels []channelDTO `json:"channels"` } diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go index a150e7d..b7e7bf9 100644 --- a/server/internal/httpapi/repo.go +++ b/server/internal/httpapi/repo.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "net/http" "slices" "strings" @@ -645,6 +646,323 @@ func (s *server) purgeBlocked(ctx context.Context, tx *sql.Tx, id int64, cause e return conflict("该运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,删除已回滚,人员和成绩都没有改动。请先删除相关报名与成绩后重试。") } +// ---------- 运动员批量动作 ---------- + +const ( + athleteBatchDisable = "disable" + athleteBatchPurge = "purge" +) + +// athleteBatchItem 是批量动作里逐条的结果。 +// +// 批量必然遇到「有人已经被别人删掉了」「有人 id 根本不存在」:整批失败会逼着操作员重试, +// 静默跳过又会让他以为整批都成了,所以一条一条回报。 +type athleteBatchItem struct { + ID int64 `json:"id"` + OK bool `json:"ok"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// athleteBatchResult 是批量动作的响应体:逐条成败 + 合计。 +// Purge 只在 action=purge 时出现,带合计删除规模与受影响小项,供前端提示「榜为什么变了」。 +type athleteBatchResult struct { + Action string `json:"action"` + DryRun bool `json:"dryRun,omitempty"` + Requested int `json:"requested"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Items []athleteBatchItem `json:"items"` + Purge *athletePurge `json:"purge,omitempty"` +} + +// purgeScope 是一条成绩所属的 (小项, 组别):删除后要按它重算积分与名次。 +type purgeScope struct{ eventID, categoryID int64 } + +func idArgs(ids []int64) []any { + args := make([]any, 0, len(ids)) + for _, id := range ids { + args = append(args, id) + } + return args +} + +// existingAthleteIDs 挑出这批 id 里真实存在的那些,保持传入顺序。 +func existingAthleteIDs(ctx context.Context, q queryer, ids []int64) ([]int64, error) { + rows, err := q.QueryContext(ctx, + `SELECT id FROM athletes WHERE id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + found := make(map[int64]bool, len(ids)) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, internal(err) + } + found[id] = true + } + if err := rows.Err(); err != nil { + return nil, internal(err) + } + out := make([]int64, 0, len(found)) + for _, id := range ids { + if found[id] { + out = append(out, id) + } + } + return out, nil +} + +// newBatchResult 按传入顺序拼逐条结果:不在 existing 里的 id 回报「运动员不存在」。 +func newBatchResult(action string, ids, existing []int64, dryRun bool, purge *athletePurge) athleteBatchResult { + ok := make(map[int64]bool, len(existing)) + for _, id := range existing { + ok[id] = true + } + items := make([]athleteBatchItem, 0, len(ids)) + result := athleteBatchResult{Action: action, DryRun: dryRun, Requested: len(ids), Purge: purge} + for _, id := range ids { + if ok[id] { + items = append(items, athleteBatchItem{ID: id, OK: true}) + result.Succeeded++ + continue + } + items = append(items, athleteBatchItem{ID: id, OK: false, Code: "NOT_FOUND", Message: "运动员不存在"}) + result.Failed++ + } + result.Items = items + return result +} + +// batchAthletes 是 POST /api/athletes/batch 的业务实现:一次请求完成整批停用 / 彻底删除。 +// +// 逐个调 DELETE /api/athletes/{id} 是「一次操作一条请求」,现场批量清理时会直接撞上写限流; +// 收成一次写既少一次配额,也让「部分成功」有个统一的地方逐条说清楚。 +// dryRun 只统计不改库,供前端在危险确认框里先列出会删掉什么、重算哪些小项。 +func (s *server) batchAthletes(ctx context.Context, action string, rawIDs []int64, dryRun bool) (athleteBatchResult, error) { + switch action { + case athleteBatchDisable, athleteBatchPurge: + default: + return athleteBatchResult{}, badRequest("action 只能是 disable / purge", action) + } + ids := dedupeIDs(rawIDs) + if len(ids) == 0 { + return athleteBatchResult{}, badRequest("ids 不能为空", nil) + } + if action == athleteBatchPurge { + return s.batchPurgeAthletes(ctx, ids, dryRun) + } + return s.batchDisableAthletes(ctx, ids, dryRun) +} + +// batchDisableAthletes 批量停用:与单条软删除同口径,只置 active = 0, +// 报名、成绩、流水一行不动,也不触发任何重算。 +func (s *server) batchDisableAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) { + existing, err := existingAthleteIDs(ctx, s.ev, ids) + if err != nil { + return athleteBatchResult{}, err + } + if !dryRun && len(existing) > 0 { + if _, err := s.ev.ExecContext(ctx, + `UPDATE athletes SET active = 0 WHERE id IN (`+placeholders(len(existing))+`)`, + idArgs(existing)...); err != nil { + return athleteBatchResult{}, internal(err) + } + } + return newBatchResult(athleteBatchDisable, ids, existing, dryRun, nil), nil +} + +// batchPurgeAthletes 批量物理删除:删人员及其报名 / 成绩 / 流水 / 纪录, +// 再按受影响的 (小项, 组别) 去重重算一次——重算是批量里最贵的一步,绝不该按人重复做。 +// +// 整批一个事务:任一 id 撞上外键就整体回滚并给 409,不会留下删了一半的库。 +// 「id 不存在 / 已被别人删掉」属于逐条结果,不影响其余人的删除。 +func (s *server) batchPurgeAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return athleteBatchResult{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + // 存在性在事务内一次查完:逐条 DELETE 再看 RowsAffected 会白跑半个事务, + // 而且「已被别人删掉」和「id 不存在」本来就该一起回报。 + existing, err := existingAthleteIDs(ctx, tx, ids) + if err != nil { + return athleteBatchResult{}, err + } + // 受影响范围必须在删之前收集:成绩没了就再也问不出它属于哪个组别。 + scopes, err := purgeScopesTx(ctx, tx, existing) + if err != nil { + return athleteBatchResult{}, err + } + counts, err := deleteAthletesTx(ctx, tx, existing) + if err != nil { + var api *apiError + if errors.As(err, &api) { + return athleteBatchResult{}, err + } + return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err) + } + + purge := &athletePurge{ + DeletedEntries: counts.DeletedEntries, + DeletedResults: counts.DeletedResults, + DeletedLedger: counts.DeletedLedger, + DeletedBreaks: counts.DeletedBreaks, + } + eventIDs := make([]int64, 0, len(scopes)) + for _, sc := range scopes { + if !slices.Contains(eventIDs, sc.eventID) { + eventIDs = append(eventIDs, sc.eventID) + } + } + purge.RecomputedEvent = eventIDs + if dryRun { + // 预览:删除都还在事务里没提交,defer 会回滚;统计与真删时同源,不会两处算法漂移。 + return newBatchResult(athleteBatchPurge, ids, existing, true, purge), nil + } + + actorID := middleware.User(ctx).ID + for _, sc := range scopes { + score, err := recomputeLedgerTx(ctx, tx, sc.eventID, sc.categoryID, actorID, "人员批量删除后重算") + if err != nil { + return athleteBatchResult{}, err + } + if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { + return athleteBatchResult{}, err + } + } + for _, eventID := range eventIDs { + if err := s.bumpEvent(ctx, tx, eventID); err != nil { + return athleteBatchResult{}, err + } + } + if len(eventIDs) > 0 { + if err := s.bumpStandings(ctx, tx, actorID); err != nil { + return athleteBatchResult{}, err + } + } + if err := tx.Commit(); err != nil { + return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err) + } + return newBatchResult(athleteBatchPurge, ids, existing, false, purge), nil +} + +// purgeScopesTx 收集这批人员名下有成绩的 (小项, 组别)。 +func purgeScopesTx(ctx context.Context, tx *sql.Tx, ids []int64) ([]purgeScope, error) { + if len(ids) == 0 { + return nil, nil + } + rows, err := tx.QueryContext(ctx, + `SELECT DISTINCT e.event_id, e.category_id + FROM results r JOIN entries e ON e.id = r.entry_id + WHERE e.athlete_id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...) + if err != nil { + return nil, internal(err) + } + defer func() { _ = rows.Close() }() + + out := make([]purgeScope, 0, 4) + for rows.Next() { + var sc purgeScope + if err := rows.Scan(&sc.eventID, &sc.categoryID); err != nil { + return nil, internal(err) + } + out = append(out, sc) + } + return out, rows.Err() +} + +// deleteAthletesTx 按外键方向清理这批人员的全部痕迹,返回删除规模。 +// 顺序与单条 purgeAthlete 一致:流水 → 纪录 → 成绩 → 报名 → 人员。 +func deleteAthletesTx(ctx context.Context, tx *sql.Tx, ids []int64) (athletePurge, error) { + var out athletePurge + if len(ids) == 0 { + return out, nil + } + ph := placeholders(len(ids)) + args := idArgs(ids) + // score_ledger 的三个引用各要一份占位符参数,拼三份副本(都是只读,不会互相污染)。 + ledgerArgs := append(append(append([]any{}, args...), args...), args...) + + for _, step := range []struct { + query string + args []any + count *int + }{ + {`DELETE FROM score_ledger + WHERE athlete_id IN (` + ph + `) + OR entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `)) + OR result_id IN (SELECT r.id FROM results r JOIN entries e ON e.id = r.entry_id + WHERE e.athlete_id IN (` + ph + `))`, + ledgerArgs, &out.DeletedLedger}, + {`DELETE FROM record_breaks + WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`, + args, &out.DeletedBreaks}, + {`DELETE FROM results + WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`, + args, &out.DeletedResults}, + {`DELETE FROM entries WHERE athlete_id IN (` + ph + `)`, args, &out.DeletedEntries}, + } { + res, err := tx.ExecContext(ctx, step.query, step.args...) + if err != nil { + return out, err + } + n, err := res.RowsAffected() + if err != nil { + return out, internal(err) + } + *step.count = int(n) + } + res, err := tx.ExecContext(ctx, `DELETE FROM athletes WHERE id IN (`+ph+`)`, args...) + if err != nil { + return out, err + } + n, err := res.RowsAffected() + if err != nil { + return out, internal(err) + } + // 存在性是同一事务里查过的,行数对不上只可能是并发抢先删了;报 500 会瞒掉真实状态, + // 用 409 明说并让整个事务回滚,别把成绩删了却报成功。 + if int(n) != len(ids) { + return out, conflict("有运动员在本次操作期间已被删除,批量删除已回滚,请刷新名单后重试") + } + return out, nil +} + +// batchPurgeBlocked 是批量删除的外键兜底:回滚并给出可读的 409,不让它冒成 500。 +func (s *server) batchPurgeBlocked(ctx context.Context, tx *sql.Tx, cause error) error { + if !isForeignKeyViolation(cause) { + return internal(cause) + } + s.logger.WarnContext(ctx, "批量彻底删除运动员撞上外键约束", "err", cause) + _ = tx.Rollback() + return conflict("有运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,批量删除已回滚,人员和成绩都没有改动。") +} + +// batchAthletesHandler 是 POST /api/athletes/batch:一次请求完成整批停用 / 彻底删除。 +func (s *server) batchAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in athletesBatchPayload + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + result, err := s.batchAthletes(r.Context(), in.Action, in.IDs, in.DryRun) + if err != nil { + return nil, err + } + // dryRun 只是预览,什么都没改,不该在审计里留下「做过一次批量删除」的假痕迹。 + if result.DryRun { + return result, nil + } + // 整批只登记一条审计:审计与放行日志都是一条一次请求,批量才真把写压力降下来。 + // 逐条明细与失败原因都进 after,事后能对着名单查。 + s.audit(r.Context(), "athlete.batch."+result.Action, "athlete", 0, nil, result) + return result, nil +} + // ---------- sports ---------- const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active` diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go index c60b738..f5d7b92 100644 --- a/server/internal/httpapi/repo_events.go +++ b/server/internal/httpapi/repo_events.go @@ -5,6 +5,8 @@ import ( "database/sql" "errors" "fmt" + "math" + "net/http" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" @@ -302,6 +304,9 @@ func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryD // replaceCategories 整体替换某小项的组别(devdocs/05 约定)。已有报名数据时拒绝替换, // 否则外键会把历史报名项一并带走。 +// +// 被拦下的只是「增删组别」:改名 / 改年龄 / 改性别 / 改权重走 updateCategory 就地更新, +// 所以这里的文案要点明范围,别让只改了个名字的人以为改名也不许。 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 @@ -310,7 +315,7 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate return nil, internal(err) } if has { - return nil, conflict("该项目已有报名数据,不能整体替换组别") + return nil, conflict("该项目已有报名数据,不能新增或删除组别;改名、改年龄、改性别、改权重可以直接保存") } tx, err := s.ev.BeginTx(ctx, nil) @@ -345,6 +350,168 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate return s.listCategories(ctx, eventID) } +// getCategory 读单个组别,并校验它属于该小项。不属于(或用别的小项 id 来探) +// 一律按「组别不存在」处理,不给跨小项改名的机会。 +func (s *server) getCategory(ctx context.Context, eventID, categoryID int64) (categoryDTO, error) { + var ( + c categoryDTO + gender sql.NullString + ageMin, ageMax sql.NullInt64 + ) + err := s.ev.QueryRowContext(ctx, + `SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order + FROM event_categories WHERE id = ? AND event_id = ?`, categoryID, eventID). + Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder) + if errors.Is(err, sql.ErrNoRows) { + return categoryDTO{}, notFound("组别不存在") + } + if err != nil { + return categoryDTO{}, internal(err) + } + c.Gender = strPtr(gender) + c.AgeMin = intPtr(ageMin) + c.AgeMax = intPtr(ageMax) + return c, nil +} + +// validateCategoryAges 年龄区间可以留空,填了就必须是 0–120 且下限不大于上限。 +// 整表替换的老路径不校验这两列,脏值进得去又改不动;就地更新这条路径补上。 +func validateCategoryAges(ageMin, ageMax *int) error { + for _, v := range []*int{ageMin, ageMax} { + if v != nil && (*v < 0 || *v > 120) { + return badRequest("组别年龄需要在 0-120 之间", v) + } + } + if ageMin != nil && ageMax != nil && *ageMin > *ageMax { + return badRequest("组别年龄下限不能大于上限", nil) + } + return nil +} + +// multiplierRecomputeEpsilon 是「组别权重是否真的变了」的判定容差。 +// +// 权重是算分公式的输入,改了就必须重算;但同一个数字经 JSON 往返一圈可能差 1e-16, +// 用 == 会把「原样提交」也当成改动、白重算一次全组积分。1e-9 远小于现场能填出的最小差 +// (权重最小刻度 0.1),又远大于浮点噪声。 +const multiplierRecomputeEpsilon = 1e-9 + +func multiplierChanged(before, after float64) bool { + return math.Abs(before-after) > multiplierRecomputeEpsilon +} + +// updateCategory 就地更新单个组别,可改 name / multiplier / gender / ageMin / ageMax / sortOrder。 +// +// 与 replaceCategories 的「DELETE 全表 + INSERT」不同,这里只发一条 UPDATE:组别 id 不变, +// entries.category_id 以及它名下的成绩、流水、名次、积分全部原地保留。 +// 有报名数据的小项正是靠它改名 / 改年龄 / 改性别 / 改权重 / 改排序;增删组别仍然只能走整表替换。 +// +// 只有 multiplier 真的变了才在同一个事务里重算该组别的流水与名次:权重进算分公式, +// 不重算就会让已发布成绩的积分与榜单和配置静默不一致。name / gender / ageMin / ageMax / +// sortOrder 都不参与算分,改了不重算(报名、成绩、流水一个字节都不动)。 +// 第二个返回值报告本次是否触发了重算,供审计留痕。 +func (s *server) updateCategory(ctx context.Context, eventID, categoryID int64, in categoryDTO) (categories []categoryDTO, recomputed bool, err error) { + name := strings.TrimSpace(in.Name) + 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 := validateCategoryAges(in.AgeMin, in.AgeMax); err != nil { + return nil, false, err + } + // 先读旧值:要不要重算全靠这次比对,拿不到旧值就无从判断。 + before, err := s.getCategory(ctx, eventID, categoryID) + if err != nil { + return nil, false, err + } + newMultiplier := multiplierOrOne(in.Multiplier) + recompute := multiplierChanged(before.Multiplier, newMultiplier) + + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return nil, false, internal(err) + } + defer func() { _ = tx.Rollback() }() + + // event_id 一起进 WHERE:id 存在但挂在别的小项上时影响 0 行,按「组别不存在」处理。 + res, err := tx.ExecContext(ctx, + `UPDATE event_categories SET name = ?, multiplier = ?, gender = ?, age_min = ?, age_max = ?, sort_order = ? + WHERE id = ? AND event_id = ?`, + name, newMultiplier, nullStr(in.Gender), nullInt(in.AgeMin), nullInt(in.AgeMax), + in.SortOrder, categoryID, eventID) + if isUniqueViolation(err) { + return nil, false, conflict("组别名称重复:" + name) + } + if err != nil { + return nil, false, internal(err) + } + if err := requireChanges(res, "组别不存在"); err != nil { + return nil, false, err + } + + actorID := middleware.User(ctx).ID + if recompute { + // 与新权重保持一致:作废旧流水、按新分数重建,并把名次写回 results(榜单看的就是它)。 + score, err := recomputeLedgerTx(ctx, tx, eventID, categoryID, actorID, "组别权重变更重算") + if err != nil { + return nil, false, err + } + if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { + return nil, false, err + } + } + if err := s.bumpEvent(ctx, tx, eventID); err != nil { + return nil, false, err + } + if recompute { + if err := s.bumpStandings(ctx, tx, actorID); err != nil { + return nil, false, err + } + } + if err := tx.Commit(); err != nil { + return nil, false, internal(err) + } + cats, err := s.listCategories(ctx, eventID) + if err != nil { + return nil, false, err + } + return cats, recompute, nil +} + +// updateCategoryHandler 是 PUT /api/events/{id}/categories/{categoryId}:组别的就地更新。 +func (s *server) updateCategoryHandler(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 categoryDTO + 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, recomputed, err := s.updateCategory(r.Context(), eventID, categoryID, in) + if err != nil { + return nil, err + } + after, err := s.getCategory(r.Context(), eventID, categoryID) + if err != nil { + return nil, err + } + // 审计要能看出这次是不是还动了权重:权重变更会连带重算已发布成绩的积分与榜单, + // 事后查「榜为什么变了」时,光看新旧权重容易漏掉这一层。 + s.audit(r.Context(), "event.category.update", "event_category", categoryID, before, + map[string]any{"category": after, "recomputedLedger": recomputed}) + return updated, nil +} + // ---------- score channels ---------- func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) { diff --git a/server/internal/httpapi/repo_import.go b/server/internal/httpapi/repo_import.go index e4f0dd4..8db3ca8 100644 --- a/server/internal/httpapi/repo_import.go +++ b/server/internal/httpapi/repo_import.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" ) func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64, error) { @@ -35,10 +36,21 @@ func (in athleteImportRow) decisionKey() string { } // athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。 +// SameNameNewPerson 非空表示本次按「同名的另一个人」新建了档案(只作报告,不拦截)。 type athleteImportMatch struct { - AthleteID int64 - TeamID int64 - Conflict *importConflict + AthleteID int64 + TeamID int64 + Conflict *importConflict + SameNameNewPerson *sameNameNewPerson +} + +// sameNameNewPerson 是「号码布不同 → 按同名的另一个人新建」的清单项: +// 导入响应里单独列出来给现场看,避免新建了档案却毫无提示。 +type sameNameNewPerson struct { + Name string `json:"name"` + TeamCode string `json:"teamCode"` + NewBib string `json:"newBib"` + ExistingBib string `json:"existingBib"` } // existingAthlete 是库中已有人员的可比字段。 @@ -53,35 +65,136 @@ type existingAthlete struct { Age *int } -// matchImportedAthlete 按契约顺序匹配人员:证件号 → 号码布 → 姓名+单位(库中证件号为空则回填)→ 新建。 +// matchImportedAthlete 按新口径匹配人员:号码布 → 姓名 + 单位 → 新建。 +// 号码布是单人唯一标识,证件号不参与匹配(只作为可选档案信息写入/更新)。 // 命中人员字段与表里不一致时记为冲突;decisions 里对同一身份有决策时按决策落库。 func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) { - existing, err := s.athleteByIdentity(ctx, in) - switch { - case err == nil: - return s.matchExistingAthlete(ctx, in, existing, decisions) - case errors.Is(err, sql.ErrNoRows): - return s.matchOrCreateAthleteByName(ctx, in, decisions) - default: - return athleteImportMatch{}, internal(err) - } -} - -// athleteByIdentity 先按证件号精确匹配,未命中再按号码布精确匹配。 -func (s *server) athleteByIdentity(ctx context.Context, in athleteImportRow) (existingAthlete, error) { - if in.IDCard != "" { - ex, err := s.athleteByIDCard(ctx, in.IDCard) - if !errors.Is(err, sql.ErrNoRows) { - return ex, err + if in.Bib != "" { + ex, err := s.athleteByBib(ctx, in.Bib) + switch { + case err == nil: + return s.matchExistingAthlete(ctx, in, ex, decisions) + case !errors.Is(err, sql.ErrNoRows): + return athleteImportMatch{}, internal(err) } } - if in.Bib != "" { - return s.athleteByBib(ctx, in.Bib) - } - return existingAthlete{}, sql.ErrNoRows + return s.matchByNameTeam(ctx, in, decisions) } -// matchExistingAthlete 处理证件号/号码布命中的人员:无差异时直接复用并补空字段, +// 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 { + return athleteImportMatch{}, internal(err) + } + switch len(candidates) { + case 0: + return s.createImportedAthlete(ctx, in) + case 1: + ex := candidates[0] + // 只有表里也给了号码布、且与库里不同时才算「同名的另一个人」: + // 表里没给号码布只是「未指定」,不能凭空多建一个没有号码布的同名人。 + if ex.Bib != nil && *ex.Bib != "" && in.Bib != "" && *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, + } + return created, nil + } + return s.matchExistingAthlete(ctx, in, ex, decisions) + default: + return s.matchAmbiguousCandidates(ctx, in, candidates, decisions) + } +} + +// matchAmbiguousCandidates 处理姓名 + 单位命中多条候选:不能替用户挑人,一律进冲突面板。 +// 决策键是整行身份(证件号优先、否则号码布),无法区分同一行的多个候选, +// 所以面板只以第一条候选为代表;有决策时按决策作用于该候选。 +func (s *server) matchAmbiguousCandidates(ctx context.Context, in athleteImportRow, candidates []existingAthlete, decisions map[string]string) (athleteImportMatch, error) { + ex := candidates[0] + if _, decided := decisions[in.decisionKey()]; decided { + return s.matchExistingAthlete(ctx, in, ex, decisions) + } + conflict, err := s.importConflictFor(ctx, in, ex, changedAthleteFields(ex, in)) + if err != nil { + return athleteImportMatch{}, err + } + return athleteImportMatch{Conflict: &conflict}, nil +} + +// createImportedAthlete 新建人员:证件号只作为可选档案信息写入, +// 但它与库里另一个人撞号时仍然明确报错(id_card 的部分唯一索引保留不动,防的是录入错误)。 +func (s *server) createImportedAthlete(ctx context.Context, in athleteImportRow) (athleteImportMatch, error) { + if err := s.idCardTakenError(ctx, in.IDCard, 0); err != nil { + return athleteImportMatch{}, err + } + res, err := s.ev.ExecContext(ctx, + `INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`, + in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age)) + switch { + case isIDCardConflict(err): + // 并发下唯一索引兜底:再查一次占用者,把「撞上谁」说清楚。 + if taken := s.idCardTakenError(ctx, in.IDCard, 0); taken != nil { + return athleteImportMatch{}, taken + } + return athleteImportMatch{}, internal(err) + case isBibConflict(err): + return athleteImportMatch{}, conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib)) + case err != nil: + return athleteImportMatch{}, internal(err) + } + id, err := lastInsertID(res) + if err != nil { + return athleteImportMatch{}, internal(err) + } + return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil +} + +// idCardTakenError 检查证件号是否已被另一个人占用,占用时返回可直接给现场看的中文错误。 +// 证件号不参与匹配,但库里一个证件号只能属于一个人(唯一索引保留):撞号时必须报清楚占用者是谁, +// 不允许静默改库或静默跳过。excludeID 是被更新的人(新建传 0),允许他自己继续持有该证件号。 +func (s *server) idCardTakenError(ctx context.Context, idCard string, excludeID int64) error { + if idCard == "" { + return nil + } + var ( + name string + bib sql.NullString + ) + err := s.ev.QueryRowContext(ctx, + `SELECT name, bib FROM athletes WHERE id_card = ? AND id <> ? ORDER BY id LIMIT 1`, idCard, excludeID). + Scan(&name, &bib) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil + case err != nil: + return internal(err) + } + occupied := "未填" + if bib.Valid && bib.String != "" { + occupied = bib.String + } + return conflict(fmt.Sprintf("证件号 %s 已被另一个人占用(姓名 %s,号码布 %s)", idCard, name, occupied)) +} + +// importRowMessage 把业务错误转成行级提示:去掉「CONFLICT:」这类 code 前缀,只留中文 message。 +func importRowMessage(err error) string { + var api *apiError + if errors.As(err, &api) && api.message != "" { + return api.message + } + return err.Error() +} + +// matchExistingAthlete 处理号码布或姓名 + 单位命中的人员:无差异时直接复用并补空字段, // 有差异时走冲突流程,decisions 里对该身份有决策才落库。 func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) { changed := changedAthleteFields(ex, in) @@ -110,30 +223,6 @@ func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, } } -// matchOrCreateAthleteByName 处理证件号/号码布都未命中的情况: -// 姓名+单位命中且库中证件号为空 → 视为同一人(差异按冲突处理);否则新建人员。 -func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) { - existing, err := s.athleteByNameTeam(ctx, in.Name, in.TeamID) - switch { - case err == nil: - return s.matchExistingAthlete(ctx, in, existing, decisions) - case !errors.Is(err, sql.ErrNoRows): - return athleteImportMatch{}, internal(err) - } - - res, err := s.ev.ExecContext(ctx, - `INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`, - in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age)) - if err != nil { - return athleteImportMatch{}, internal(err) - } - id, err := lastInsertID(res) - if err != nil { - return athleteImportMatch{}, internal(err) - } - return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil -} - // importAthleteColumns 是人员匹配要读的字段,证件号/号码布/年龄用于回填与冲突判断。 const importAthleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age` @@ -155,22 +244,30 @@ func scanExistingAthlete(row interface{ Scan(...any) error }) (existingAthlete, return a, nil } -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)) -} - func (s *server) athleteByBib(ctx context.Context, bib string) (existingAthlete, error) { return scanExistingAthlete(s.ev.QueryRowContext(ctx, `SELECT `+importAthleteColumns+` FROM athletes WHERE bib = ? LIMIT 1`, bib)) } -// athleteByNameTeam 按「姓名+单位」找库中还没填证件号的人,用于回填身份字段。 -func (s *server) athleteByNameTeam(ctx context.Context, name string, teamID int64) (existingAthlete, error) { - return scanExistingAthlete(s.ev.QueryRowContext(ctx, - `SELECT `+importAthleteColumns+` FROM athletes - WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '') - ORDER BY id LIMIT 1`, name, teamID)) +// 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) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + out := make([]existingAthlete, 0, 1) + for rows.Next() { + ex, err := scanExistingAthlete(rows) + if err != nil { + return nil, err + } + out = append(out, ex) + } + return out, rows.Err() } // changedAthleteFields 列出表里与库中不一致的字段名,顺序固定便于前端展示。 @@ -208,7 +305,15 @@ func identityDiffers(dbVal *string, inVal string) bool { // backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄,已有值不动(差异已由 changedAthleteFields 判过)。 func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthlete, in athleteImportRow) error { if in.IDCard != "" && (ex.IDCard == nil || *ex.IDCard == "") { + if err := s.idCardTakenError(ctx, in.IDCard, ex.ID); err != nil { + return err + } if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, ex.ID); err != nil { + if isIDCardConflict(err) { + if taken := s.idCardTakenError(ctx, in.IDCard, ex.ID); taken != nil { + return taken + } + } return internal(err) } } @@ -261,10 +366,23 @@ func (s *server) teamCodeByID(ctx context.Context, id int64) (string, error) { // overwriteAthleteFromImport 用表里的字段覆盖库中人员(keep-new 决策)。 // 表里没填的字段保留库里的值,避免决策后反而丢字段。 func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in athleteImportRow) error { + if in.IDCard != "" { + if err := s.idCardTakenError(ctx, in.IDCard, id); err != nil { + return err + } + } if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender), id_card = COALESCE(?, id_card), bib = COALESCE(?, bib), age = COALESCE(?, age) WHERE id = ?`, in.Name, in.TeamID, nullStr(&in.Gender), nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age), id); err != nil { + switch { + case isIDCardConflict(err): + if taken := s.idCardTakenError(ctx, in.IDCard, id); taken != nil { + return taken + } + case isBibConflict(err): + return conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib)) + } return internal(err) } return nil diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index 7975820..3199767 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -5,10 +5,12 @@ import ( "database/sql" "errors" "fmt" + "net/http" "strconv" "strings" "time" + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" "git.yeij.top/AskaEth/Scoreboard/server/internal/export" @@ -203,6 +205,11 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E return dto.EventSettings{}, err } } + if p.LiveOnPublish != nil { + if err := set(dto.SettingKeyLiveOnPublish, strconv.FormatBool(*p.LiveOnPublish)); err != nil { + return dto.EventSettings{}, err + } + } } if patch.DefaultProfileID != nil { code := "" @@ -263,6 +270,104 @@ func boolDefault(v string, def bool) bool { } } +// ---------- 系统级设置:登录有效期 ---------- +// +// 存在系统库 system.db 的 settings(键 security.session_hours),与赛事级的 +// /api/settings(event.db,随赛事包走)不共用:会话寿命是这台机器的属性, +// 切换到另一个赛事项目时不该跟着变。 + +func (s *server) getSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + return s.sessionSettings(r) +} + +func (s *server) updateSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) { + var in sessionSettingsPatch + if err := decodeJSON(r, &in); err != nil { + return nil, err + } + if in.SessionHours == nil { + return nil, badRequest("登录有效期 sessionHours 必填", nil) + } + hours := *in.SessionHours + if hours < auth.MinSessionHours || hours > auth.MaxSessionHours { + return nil, badRequest( + fmt.Sprintf("登录有效期(小时)需在 %d~%d 之间", auth.MinSessionHours, auth.MaxSessionHours), hours) + } + return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { + if err := s.writeSessionHours(r.Context(), hours); err != nil { + return nil, err + } + s.audit(r.Context(), "settings.session_hours", "system", 0, nil, + map[string]any{"sessionHours": hours}) + return s.sessionSettings(r) + }) +} + +// sessionSettings 组装面板要的登录有效期:生效值 + 取值范围 + 当前会话到期时间。 +func (s *server) sessionSettings(r *http.Request) (dto.SessionSettings, error) { + values, err := loadSettingsMap(r.Context(), s.sys) + if err != nil { + return dto.SessionSettings{}, err + } + return dto.SessionSettings{ + SessionHours: auth.EffectiveSessionHours(values), + DefaultHours: auth.DefaultSessionHours, + MinHours: auth.MinSessionHours, + MaxHours: auth.MaxSessionHours, + ExpiresAt: s.sessionExpiresAt(r), + }, nil +} + +// sessionExpiresAt 取当前请求所用会话的到期时间(RFC3339 UTC);没有有效会话时为 nil。 +// 前端要把它按本机时区显示成「当前会话将于 … 过期」,所以必须带时区,不能下发库里的裸时间串。 +func (s *server) sessionExpiresAt(r *http.Request) *string { + c, err := r.Cookie(auth.CookieSession) + if err != nil || c.Value == "" { + return nil + } + _, sess, err := s.auth.Lookup(r.Context(), c.Value) + if err != nil || sess == nil { + return nil + } + v := sess.ExpiresAt.UTC().Format(time.RFC3339) + return &v +} + +// writeSessionHours 落库登录有效期并立刻在进程内生效。 +// 只写配置、不动任何已存在会话的 expires_at:改设置不该把正在录入的人踢下线。 +func (s *server) writeSessionHours(ctx context.Context, hours int) error { + tx, err := s.sys.BeginTx(ctx, nil) + if err != nil { + return internal(err) + } + defer func() { _ = tx.Rollback() }() + + upsert := func(key, value string) error { + _, err := tx.ExecContext(ctx, + `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, + key, value) + if err != nil { + return internal(err) + } + return nil + } + if err := upsert(auth.SessionHoursKey, strconv.Itoa(hours)); err != nil { + return err + } + // 记下这个值是面板写的:读取时就不再把它当成迁移种下的历史占位值, + // 操作员特意选的 12 才不会在重启后被悄悄改成默认的 24。 + if err := upsert(auth.SessionHoursSourceKey, auth.SessionHoursSourcePanel); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return internal(err) + } + // 重启才生效会让现场以为「改了面板没反应」,所以同一请求里就换掉进程内的有效期。 + s.auth.SetSessionTTL(time.Duration(hours) * time.Hour) + return nil +} + // ---------- 大屏公告 ---------- // 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder, diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index 64b7fff..884cd13 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -7,6 +7,7 @@ import ( "errors" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" + "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" "git.yeij.top/AskaEth/Scoreboard/server/internal/export" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" "git.yeij.top/AskaEth/Scoreboard/server/internal/rules" @@ -340,6 +341,22 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64 // 重算再插一份导致该通道分数翻倍。聚合流水本身不归属组别,且重建通道会换掉通道 id // (source_id 随之失效),故按 event_id 全量作废;下方补算整项聚合流水时会重新插入全量。 func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) { + score, err := computeEventScore(ctx, tx, eventID, categoryID) + if err != nil { + return domain.EventScore{}, err + } + if err := writeLedgerTx(ctx, tx, score, eventID, categoryID, actorID, reason); err != nil { + return domain.EventScore{}, err + } + return score, nil +} + +// writeLedgerTx 把已算好的 score 落成流水:先作废范围内旧流水,再插入新流水。 +// +// 与 recomputeLedgerTx 拆开是为了「延迟结算」:那时名次与流水都要等整组录完才落, +// 但分数仍要能算出来(结算时才算,见 publishResults)。计算不读 score_ledger, +// 所以「先算后作废」与老的「先作废后算」结果完全一致。 +func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID, actorID int64, reason string) error { scope := `source_type IN ('result', 'record_bonus') AND event_id = ?` args := []any{eventID} if categoryID > 0 { @@ -351,25 +368,21 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act `UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ? WHERE voided = 0 AND `+scope, append([]any{nullID(actorID), reason}, args...)...); err != nil { - return domain.EventScore{}, internal(err) + return internal(err) } - score, err := computeEventScore(ctx, tx, eventID, categoryID) - if err != nil { - return domain.EventScore{}, err - } ledger := score.Ledger if categoryID > 0 { // 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废; // 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。 hasAggregate, err := hasAggregateChannel(ctx, tx, eventID) if err != nil { - return domain.EventScore{}, err + return err } if hasAggregate { all, err := computeEventScore(ctx, tx, eventID, 0) if err != nil { - return domain.EventScore{}, err + return err } ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger)) for _, l := range score.Ledger { @@ -390,10 +403,10 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID), l.SourceType, l.SourceID, l.Points, boolToInt(l.CountToTeam), nullID(actorID)); err != nil { - return domain.EventScore{}, internal(err) + return internal(err) } } - return score, nil + return nil } // isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员, @@ -445,6 +458,106 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR // 而且编辑页会把整组当成「已发布」锁死,补录无门。 const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')` +// publishableResult 是 publishableResultCond 的 Go 版判据,供分批结算的纯函数使用。 +// 两处判据必须同源:SQL 那份决定哪些行能被发布,这份决定「这一条算不算已经出成绩」。 +func publishableResult(r domain.Result) bool { + return r.ResultValue != nil || r.Rank > 0 || r.Status != domain.ResultStatusOK +} + +// groupStats 统计某组「该出成绩的报名项数」与「已经有可发布成绩的项数」。 +// +// 非 registered 的报名项(scratched / dq / dns)本来就不该等它出成绩: +// 计分把非 registered 视为 0 分且不占名次,等它只会让整组永远结算不了。 +func groupStats(entries []domain.Entry, results []domain.Result) (total, published int) { + byEntry := make(map[int64]domain.Result, len(results)) + for _, r := range results { + byEntry[r.EntryID] = r + } + for _, e := range entries { + if e.Status != domain.EntryStatusRegistered { + continue + } + total++ + if r, ok := byEntry[e.ID]; ok && r.State == domain.ResultStatePublished && publishableResult(r) { + published++ + } + } + return total, published +} + +// groupOf 从整项的行里挑出某个组别的报名与成绩。 +// 成绩表本身不带组别,只能靠报名项 id 反查,所以先收组内报名 id 再过滤成绩。 +func groupOf(entries []domain.Entry, results []domain.Result, categoryID int64) ([]domain.Entry, []domain.Result) { + inGroup := make(map[int64]bool) + groupEntries := make([]domain.Entry, 0, len(entries)) + for _, e := range entries { + if e.CategoryID != categoryID { + continue + } + inGroup[e.ID] = true + groupEntries = append(groupEntries, e) + } + groupResults := make([]domain.Result, 0, len(groupEntries)) + for _, r := range results { + if inGroup[r.EntryID] { + groupResults = append(groupResults, r) + } + } + return groupEntries, groupResults +} + +// scopeStats 按发布 / 结算的作用域累加 groupStats:categoryID > 0 只看该组别, +// categoryID = 0 看整项(逐组别累加,任何一组没录完都算没录完)。 +func scopeStats(ctx context.Context, q queryer, eventID, categoryID int64) (total, published int, err error) { + entries, err := loadEntriesQ(ctx, q, eventID, 0) + if err != nil { + return 0, 0, err + } + results, err := loadResultsQ(ctx, q, eventID, 0) + if err != nil { + return 0, 0, err + } + + categories := []int64{categoryID} + if categoryID == 0 { + cats, err := loadCategoriesQ(ctx, q, eventID) + if err != nil { + return 0, 0, err + } + categories = make([]int64, 0, len(cats)) + for _, cat := range cats { + categories = append(categories, cat.ID) + } + } + for _, id := range categories { + groupEntries, groupResults := groupOf(entries, results, id) + t, p := groupStats(groupEntries, groupResults) + total += t + published += p + } + return total, published, nil +} + +// scopeSettled 判断作用域内是否「所有批次都录完」,即可以结算。 +// 没有报名项(total = 0)视为录完:没有要等的东西,结算一次把范围清干净。 +func scopeSettled(ctx context.Context, q queryer, eventID, categoryID int64) (bool, error) { + total, published, err := scopeStats(ctx, q, eventID, categoryID) + if err != nil { + return false, err + } + return published == total, nil +} + +// liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。 +func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) { + var raw string + err := q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, dto.SettingKeyLiveOnPublish).Scan(&raw) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, internal(err) + } + return boolDefault(raw, true), nil +} + func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { if in.EventID == 0 { return nil, badRequest("eventId 必填", nil) @@ -470,7 +583,8 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i // 只发布「已有内容」的成绩行:现场分批比赛时(如 100 米预赛分 4 组、每组 8 条跑道), // 只跑完一组就要先把这一组上屏,没跑/没录的行必须留在 draft,不能被一起发布。 - // 判据见 publishableResultCond。名次与积分由下方重算按已发布部分计算,后续再发一批会自动纳入。 + // 判据见 publishableResultCond。名次与积分是否当场落库,取决于「发布后是否立即结算」 + // (见下方 liveOnPublishQ):实时模式按已发布部分重算,后续再发一批会自动纳入。 setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'), updated_at = datetime('now') WHERE event_id = ? AND ` + publishableResultCond @@ -483,12 +597,33 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i return nil, internal(err) } - score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "发布重算") + live, err := liveOnPublishQ(ctx, tx) if err != nil { return nil, err } - if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { - return nil, err + settle := live + if !live { + // 延迟结算:等该 (小项, 组别) 所有批次都录完才结算一次。 + // 结算时按整组重算,之前各批的成绩一起纳入,避免名次停留在分批算的口径。 + settle, err = scopeSettled(ctx, tx, in.EventID, catID) + if err != nil { + return nil, err + } + } + if settle { + // 名次也要等结算才写:奖牌榜统计读的就是 results.rank(见 buildRanked), + // 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动, + // 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。 + score, err := computeEventScore(ctx, tx, in.EventID, catID) + if err != nil { + return nil, err + } + if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { + return nil, err + } + if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil { + return nil, err + } } if catID == 0 { if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil { @@ -506,8 +641,11 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { return nil, err } - if err := s.bumpStandings(ctx, tx, userID); err != nil { - return nil, err + // 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。 + if settle { + if err := s.bumpStandings(ctx, tx, userID); err != nil { + return nil, err + } } if err := tx.Commit(); err != nil { return nil, internal(err) @@ -563,6 +701,9 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID if _, err := tx.ExecContext(ctx, query, args...); err != nil { return nil, internal(err) } + // 撤销发布是回滚动作,延迟结算也要立刻重算:这一组已经没有已发布成绩, + // 留着上一次结算写下的流水就是「榜上有分、成绩却撤了」。重算本身会把旧流水作废、 + // 按当前已发布内容重写,之后再次发布时判据回到「未结算」,行为自然衔接。 if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil { return nil, err } @@ -643,12 +784,28 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string, return resultDTO{}, internal(err) } // 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。 - score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason) - if err != nil { + // 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 + } } - if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { - return resultDTO{}, 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 + } } after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID)) @@ -658,8 +815,10 @@ 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 err := s.bumpStandings(ctx, tx, userID); err != nil { - return resultDTO{}, err + if settle { + if err := s.bumpStandings(ctx, tx, userID); err != nil { + return resultDTO{}, err + } } if err := tx.Commit(); err != nil { return resultDTO{}, internal(err) @@ -669,6 +828,119 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string, return s.getResult(ctx, resultID) } +// ---------- 分批结算 ---------- + +// settlementGroupDTO 是「未结算的 (小项, 组别)」:延迟结算下已发布成绩还没进榜单的那一组。 +// Required / Published 只数「该出成绩的报名项」,弃权 / 失格不计入,与结算判据同源。 +type settlementGroupDTO struct { + EventID int64 `json:"eventId"` + CategoryID int64 `json:"categoryId"` + CategoryName string `json:"categoryName"` + Required int `json:"required"` + Published int `json:"published"` +} + +// settlementStatusDTO 是 GET /api/results/settlement 的线上结构。 +type settlementStatusDTO struct { + LiveOnPublish bool `json:"liveOnPublish"` + Groups []settlementGroupDTO `json:"groups"` +} + +// settlementStatus 列出范围内尚未结算的组别,供录入页提示「榜单还没反映这批」。 +// 实时结算(默认)时清单恒为空:那种模式下每次发布都当场结算了。 +func (s *server) settlementStatus(ctx context.Context, eventID, catID int64) (settlementStatusDTO, error) { + out := settlementStatusDTO{Groups: []settlementGroupDTO{}} + live, err := liveOnPublishQ(ctx, s.ev) + if err != nil { + return out, err + } + out.LiveOnPublish = live + if live { + return out, nil + } + + categories, err := loadCategoriesQ(ctx, s.ev, eventID) + if err != nil { + return out, err + } + entries, err := loadEntriesQ(ctx, s.ev, eventID, 0) + if err != nil { + return out, err + } + results, err := loadResultsQ(ctx, s.ev, eventID, 0) + if err != nil { + return out, err + } + for _, cat := range categories { + if catID > 0 && cat.ID != catID { + continue + } + groupEntries, groupResults := groupOf(entries, results, cat.ID) + required, published := groupStats(groupEntries, groupResults) + // 一条都没发布 = 还没开始结算;已录完 = 下次发布就会结算(或被立即结算过)。 + if required == 0 || published == 0 || published == required { + continue + } + out.Groups = append(out.Groups, settlementGroupDTO{ + EventID: eventID, + CategoryID: cat.ID, + CategoryName: cat.Name, + Required: required, + Published: published, + }) + } + return out, nil +} + +// settleResults 立即结算:无视延迟结算设置,按当前已发布成绩重算积分流水并刷新榜单。 +// +// 这是现场安全绳:只要有一条成绩永远录不出来(退赛却没标 scratched), +// 延迟结算就永远不会自动触发,必须有一个显式入口把已发布部分结算掉。 +func (s *server) settleResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { + if in.EventID == 0 { + return nil, badRequest("eventId 必填", nil) + } + catID := int64(0) + if in.CategoryID != nil { + catID = *in.CategoryID + } + + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return nil, internal(err) + } + defer func() { _ = tx.Rollback() }() + + before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) + if err != nil { + return nil, err + } + score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "立即结算") + if err != nil { + return nil, err + } + if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { + return nil, err + } + + after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) + if err != nil { + return nil, err + } + if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { + return nil, err + } + if err := s.bumpStandings(ctx, tx, userID); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, internal(err) + } + + s.audit(ctx, "result.settle", "event", in.EventID, before, after) + return s.resultsByEvent(ctx, in.EventID) +} + // ---------- 变更日志与版本 ---------- func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action string, results []resultDTO, actorID int64, message string) error { diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 41b92d6..d189033 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -197,6 +197,11 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler)) mux.HandleFunc("PUT /api/settings", s.write(auth.RoleAdmin, s.updateSettingsHandler)) + // 登录有效期是机器级配置(系统库 settings),与上面的赛事级 /api/settings 不共用; + // 它决定所有人的会话寿命,所以读写都限 admin。 + mux.HandleFunc("GET /api/system/session", s.read(auth.RoleAdmin, s.getSessionSettingsHandler)) + mux.HandleFunc("PUT /api/system/session", s.write(auth.RoleAdmin, s.updateSessionSettingsHandler)) + // 基础配置 mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler)) mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler)) @@ -218,6 +223,8 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /api/events/{id}/entries", s.public(s.listEntriesHandler)) mux.HandleFunc("GET /api/events/{id}/categories", s.public(s.listCategoriesHandler)) mux.HandleFunc("PUT /api/events/{id}/categories", s.write(auth.RoleRecorder, s.replaceCategoriesHandler)) + // 单个组别的就地更新:有报名数据时改名 / 改年龄 / 改性别 / 改权重只走这条,不重建组别。 + 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)) @@ -231,17 +238,25 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /api/athletes/{id}", s.public(s.getAthleteHandler)) mux.HandleFunc("PUT /api/athletes/{id}", s.write(auth.RoleRecorder, s.updateAthleteHandler)) mux.HandleFunc("DELETE /api/athletes/{id}", s.write(auth.RoleRecorder, s.deleteAthleteHandler)) + // 批量动作端点:一次请求完成整批停用 / 彻底删除,逐条回报成败;逐个删会逐个消耗写配额。 + mux.HandleFunc("POST /api/athletes/batch", s.write(auth.RoleRecorder, s.batchAthletesHandler)) mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler)) mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler)) // 批次(heat)是录入 / 展示分组,不参与算分,所以没有独立的「发布」概念。 mux.HandleFunc("PUT /api/entries/heat", s.write(auth.RoleRecorder, s.setEntriesHeatHandler)) + // 报名项批量操作:退赛(scratched,可恢复)/ 恢复 / 移出报名(物理删除,不可恢复)。 + // 权限与发布同档(recorder),不新增角色;跨小项混选允许,服务端按 (小项, 组别) 分组重算。 + mux.HandleFunc("POST /api/entries/batch", s.write(auth.RoleRecorder, s.batchEntriesHandler)) // 成绩 mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler)) mux.HandleFunc("PUT /api/results/draft", s.write(auth.RoleRecorder, s.saveDraftHandler)) mux.HandleFunc("POST /api/results/publish", s.write(auth.RoleRecorder, s.publishHandler)) mux.HandleFunc("POST /api/results/unpublish", s.write(auth.RoleRecorder, s.unpublishHandler)) + // 分批延迟结算:settlement 给录入页查「哪些组还没结算」,settle 是人工兜底(写审计)。 + mux.HandleFunc("GET /api/results/settlement", s.read(auth.RoleRecorder, s.settlementStatusHandler)) + mux.HandleFunc("POST /api/results/settle", s.write(auth.RoleRecorder, s.settleResultsHandler)) mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler)) // 积分与排名 diff --git a/server/internal/httpapi/session_ttl_test.go b/server/internal/httpapi/session_ttl_test.go new file mode 100644 index 0000000..252d0d4 --- /dev/null +++ b/server/internal/httpapi/session_ttl_test.go @@ -0,0 +1,330 @@ +package httpapi_test + +import ( + "database/sql" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" +) + +// ---------- 登录有效期(系统级设置)的测试脚手架 ---------- + +type sessionSettingsDTO struct { + SessionHours int `json:"sessionHours"` + DefaultHours int `json:"defaultSessionHours"` + MinHours int `json:"minHours"` + MaxHours int `json:"maxHours"` + ExpiresAt *string `json:"expiresAt"` +} + +func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +// sysSetting 读系统库 settings 里的键。 +func sysSetting(t *testing.T, env *testEnv, key string) (string, bool) { + t.Helper() + var v string + err := env.sys.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v) + if errors.Is(err, sql.ErrNoRows) { + return "", false + } + if err != nil { + t.Fatalf("读系统设置 %s: %v", key, err) + } + return v, true +} + +// sessionExpiry 取某用户当前会话的到期时间(测试里每个用户只保留一个会话)。 +func sessionExpiry(t *testing.T, env *testEnv, username string) time.Time { + t.Helper() + var raw string + if err := env.sys.QueryRow( + `SELECT s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE u.username = ?`, + username).Scan(&raw); err != nil { + t.Fatalf("读 %s 的会话到期时间: %v", username, err) + } + at, err := time.Parse(time.DateTime, raw) + if err != nil { + t.Fatalf("解析到期时间 %q: %v", raw, err) + } + return at +} + +// sessionLastSeen 取某用户当前会话的活动时间。 +func sessionLastSeen(t *testing.T, env *testEnv, username string) time.Time { + t.Helper() + var raw string + if err := env.sys.QueryRow( + `SELECT COALESCE(s.last_seen_at, '') FROM sessions s JOIN users u ON u.id = s.user_id WHERE u.username = ?`, + username).Scan(&raw); err != nil { + t.Fatalf("读 %s 的会话活动时间: %v", username, err) + } + at, err := time.Parse(time.DateTime, raw) + if err != nil { + t.Fatalf("解析活动时间 %q: %v", raw, err) + } + return at +} + +func setSessionExpiry(t *testing.T, env *testEnv, username string, at time.Time) { + t.Helper() + if _, err := env.sys.Exec( + `UPDATE sessions SET expires_at = ? + WHERE user_id = (SELECT id FROM users WHERE username = ?)`, + at.UTC().Format(time.DateTime), username); err != nil { + t.Fatalf("改 %s 的会话到期时间: %v", username, err) + } +} + +func adminClient(t *testing.T, env *testEnv) *http.Client { + t.Helper() + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + return client +} + +// ---------- 默认值与落库 ---------- + +// 面板默认 24 小时:迁移种下的历史占位值 12 从没被任何代码读过,按「未配置」处理; +// 写完之后落到系统库,重启(新建 Service)也读得到。 +func TestSessionSettingsDefaultsAndWrite(t *testing.T) { + env := newTestEnv(t) + client := adminClient(t, env) + + status, body := env.do(client, http.MethodGet, "/api/system/session", nil, false) + if status != http.StatusOK || !body.OK { + t.Fatalf("读取登录有效期应 200,得到 %d %+v", status, body.Error) + } + got := decodeData[sessionSettingsDTO](t, body) + if got.SessionHours != 24 || got.DefaultHours != 24 || got.MinHours != 1 || got.MaxHours != 720 { + t.Fatalf("默认值/范围不符:%+v", got) + } + if got.ExpiresAt == nil || !strings.HasSuffix(*got.ExpiresAt, "Z") { + t.Fatalf("应回带当前会话的到期时间(RFC3339 UTC),得到 %v", got.ExpiresAt) + } + if v, ok := sysSetting(t, env, auth.SessionHoursKey); !ok || v != "12" { + t.Fatalf("初始库里应是迁移种下的 12,得到 %q(存在=%v)", v, ok) + } + + status, body = env.do(client, http.MethodPut, "/api/system/session", + map[string]any{"sessionHours": 72}, true) + if status != http.StatusOK || !body.OK { + t.Fatalf("写入应 200,得到 %d %+v", status, body.Error) + } + if got := decodeData[sessionSettingsDTO](t, body); got.SessionHours != 72 { + t.Fatalf("应回显 72,得到 %+v", got) + } + if v, _ := sysSetting(t, env, auth.SessionHoursKey); v != "72" { + t.Fatalf("落库应为 72,得到 %q", v) + } + // 来源标记:有这个键,读取时就不会把值当成迁移占位值。 + if v, _ := sysSetting(t, env, auth.SessionHoursSourceKey); v != auth.SessionHoursSourcePanel { + t.Fatalf("应登记来源 %q,得到 %q", auth.SessionHoursSourcePanel, v) + } + + // 重启等价物:新起一个 Service 读同一个库,仍是 72h。 + restarted := auth.New(env.sys, auth.Options{Logger: testLogger(), BcryptCost: bcrypt.MinCost}) + if restarted.SessionTTL() != 72*time.Hour { + t.Fatalf("重启后应仍读 72h,得到 %s", restarted.SessionTTL()) + } +} + +// 非法值一律 400 + 中文提示,且不落库;读写都是 admin-only。 +func TestSessionSettingsValidationAndPermission(t *testing.T) { + env := newTestEnv(t) + client := adminClient(t, env) + + for _, tc := range []struct { + name string + body map[string]any + }{ + {"缺字段", map[string]any{}}, + {"过小", map[string]any{"sessionHours": 0}}, + {"负数", map[string]any{"sessionHours": -3}}, + {"过大", map[string]any{"sessionHours": 721}}, + } { + status, body := env.do(client, http.MethodPut, "/api/system/session", tc.body, true) + if status != http.StatusBadRequest || body.Error == nil || body.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("%s应 400 VALIDATION_FAILED,得到 %d %+v", tc.name, status, body.Error) + } + if !strings.Contains(body.Error.Message, "登录有效期") { + t.Fatalf("%s 的中文提示应说明是登录有效期,得到 %q", tc.name, body.Error.Message) + } + } + if v, _ := sysSetting(t, env, auth.SessionHoursKey); v != "12" { + t.Fatalf("非法值不该落库,库里是 %q", v) + } + + env.createUser("recorder", "recorder-pass", "recorder") + recorder, _ := env.client() + env.login(recorder, "recorder", "recorder-pass") + if status, _ := env.do(recorder, http.MethodGet, "/api/system/session", nil, false); status != http.StatusForbidden { + t.Fatalf("recorder 读登录有效期应 403,得到 %d", status) + } + if status, _ := env.do(recorder, http.MethodPut, "/api/system/session", + map[string]any{"sessionHours": 1}, true); status != http.StatusForbidden { + t.Fatalf("recorder 写登录有效期应 403,得到 %d", status) + } +} + +// ---------- 滑动续期 ---------- + +// 会话只要还在被使用就顺延;节流生效时不会每次请求都写 expires_at。 +func TestSlidingRenewalThrottledOverHTTP(t *testing.T) { + env := newTestEnv(t) + client := adminClient(t, env) + + // 模拟「已经消耗掉大半寿命」:剩 23h58m(不足 24h-1m 的节流窗口)。 + setSessionExpiry(t, env, "admin", time.Now().UTC().Add(23*time.Hour+58*time.Minute)) + before := sessionExpiry(t, env, "admin") + + status, body := env.do(client, http.MethodGet, "/api/auth/me", nil, false) + if status != http.StatusOK || !body.OK { + t.Fatalf("请求应成功,得到 %d %+v", status, body.Error) + } + renewed := sessionExpiry(t, env, "admin") + if diff := renewed.Sub(before); diff < time.Minute || diff > 3*time.Minute { + t.Fatalf("应把到期时间顺延约 2 分钟(到 now+24h),实际 %s → %s(差 %s)", + before.Format(time.DateTime), renewed.Format(time.DateTime), diff) + } + + // 紧接着的多次请求都落在节流窗口内:expires_at 与 last_seen_at 都一动不动 + // (改动前是每个请求写一次 last_seen_at,现在最多每分钟一次)。 + seen := sessionLastSeen(t, env, "admin") + for i := 0; i < 5; i++ { + if status, _ := env.do(client, http.MethodGet, "/api/auth/me", nil, false); status != http.StatusOK { + t.Fatalf("第 %d 次请求应成功,得到 %d", i, status) + } + if got := sessionExpiry(t, env, "admin"); !got.Equal(renewed) { + t.Fatalf("节流窗口内不该再写 expires_at:%s → %s", + renewed.Format(time.DateTime), got.Format(time.DateTime)) + } + if got := sessionLastSeen(t, env, "admin"); !got.Equal(seen) { + t.Fatalf("节流窗口内不该再写 last_seen_at:%s → %s", + seen.Format(time.DateTime), got.Format(time.DateTime)) + } + } +} + +// 改设置不动在线的人:旧会话的 expires_at 不变、请求照常成功,下一次使用时按新有效期延长。 +func TestSessionHoursChangeKeepsOnlineSessions(t *testing.T) { + env := newTestEnv(t) + boss := adminClient(t, env) + if status, body := env.do(boss, http.MethodPut, "/api/system/session", + map[string]any{"sessionHours": 1}, true); status != http.StatusOK { + t.Fatalf("先设成 1 小时应 200,得到 %d %+v", status, body.Error) + } + + env.createUser("recorder", "recorder-pass", "recorder") + worker, _ := env.client() + env.login(worker, "recorder", "recorder-pass") + before := sessionExpiry(t, env, "recorder") + + // 管理员把有效期改成 72 小时。 + if status, body := env.do(boss, http.MethodPut, "/api/system/session", + map[string]any{"sessionHours": 72}, true); status != http.StatusOK { + t.Fatalf("改成 72 小时应 200,得到 %d %+v", status, body.Error) + } + if after := sessionExpiry(t, env, "recorder"); !after.Equal(before) { + t.Fatalf("改设置不该动已登录会话的 expires_at:%s → %s", + before.Format(time.DateTime), after.Format(time.DateTime)) + } + + // 旧会话仍被认,而且拿到的是新的(更长的)到期时间。 + status, body := env.do(worker, http.MethodGet, "/api/auth/me", nil, false) + if status != http.StatusOK || !body.OK { + t.Fatalf("旧会话不该被踢下线,得到 %d %+v", status, body.Error) + } + me := decodeData[map[string]any](t, body) + if me["role"] != "recorder" { + t.Fatalf("旧会话应仍是 recorder 身份,得到 %v", me["role"]) + } + if me["sessionExpiresAt"] == nil { + t.Fatal("me 应回带当前会话的到期时间") + } + after := sessionExpiry(t, env, "recorder") + if diff := after.Sub(before); diff < 70*time.Hour { + t.Fatalf("下一次使用应按新有效期顺延到 ~72h,实际 %s → %s", + before.Format(time.DateTime), after.Format(time.DateTime)) + } +} + +// 到期校验不能被改坏:手工把 expires_at 改到 1 分钟前,写请求必须 401 AUTH_REQUIRED。 +func TestExpiredSessionReturnsAuthRequired(t *testing.T) { + env := newTestEnv(t) + client := adminClient(t, env) + setSessionExpiry(t, env, "admin", time.Now().UTC().Add(-time.Minute)) + + status, body := env.do(client, http.MethodPut, "/api/settings", + map[string]any{"event": map[string]any{"name": "秋季运动会"}}, true) + if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" { + t.Fatalf("过期会话写请求应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error) + } +} + +// ---------- 安全口径没有被放宽 ---------- + +func TestSessionSettingsKeepsSecurityPosture(t *testing.T) { + env := newTestEnv(t) + env.createUser("admin", "admin-pass", "admin") + + // 登录下发的 Cookie 仍带 HttpOnly + SameSite=Lax;CSRF Cookie 仍可被前端读取。 + resp, err := http.Post(env.server.URL+"/api/auth/login", "application/json", + strings.NewReader(`{"username":"admin","password":"admin-pass"}`)) + if err != nil { + t.Fatalf("登录: %v", err) + } + defer func() { _ = resp.Body.Close() }() + var session, csrf *http.Cookie + for _, c := range resp.Cookies() { + switch c.Name { + case auth.CookieSession: + session = c + case auth.CookieCSRF: + csrf = c + } + } + if session == nil || !session.HttpOnly || session.SameSite != http.SameSiteLaxMode || session.Secure { + t.Fatalf("会话 Cookie 应 HttpOnly + SameSite=Lax:%+v", session) + } + if csrf == nil || csrf.HttpOnly { + t.Fatalf("CSRF Cookie 应可被前端读取(非 HttpOnly):%+v", csrf) + } + + client, _ := env.client() + env.login(client, "admin", "admin-pass") + + // CSRF 双提交仍然拦写:不带 X-CSRF-Token 的写请求一律 403。 + if status, body := env.do(client, http.MethodPut, "/api/system/session", + map[string]any{"sessionHours": 2}, false); status != http.StatusForbidden || + body.Error == nil || body.Error.Code != "FORBIDDEN" { + t.Fatalf("缺 CSRF 头的写请求应 403 FORBIDDEN,得到 %d %+v", status, body.Error) + } + + // must_change_password 仍然拦写操作。 + env.createUser("fresh", "fresh-pass", "admin") + if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE username = 'fresh'`); err != nil { + t.Fatalf("置 must_change_password: %v", err) + } + fresh, _ := env.client() + env.login(fresh, "fresh", "fresh-pass") + if status, body := env.do(fresh, http.MethodPut, "/api/system/session", + map[string]any{"sessionHours": 2}, true); status != http.StatusForbidden || + body.Error == nil || body.Error.Message != "请先修改初始密码" { + t.Fatalf("待改密账号的写请求应被拦下,得到 %d %+v", status, body.Error) + } + + // 未登录访客读不到登录有效期。 + guest, _ := env.client() + if status, _ := env.do(guest, http.MethodGet, "/api/system/session", nil, false); status != http.StatusForbidden { + t.Fatalf("访客读登录有效期应 403,得到 %d", status) + } +} diff --git a/server/internal/httpapi/settlement_internal_test.go b/server/internal/httpapi/settlement_internal_test.go new file mode 100644 index 0000000..9ed6b7f --- /dev/null +++ b/server/internal/httpapi/settlement_internal_test.go @@ -0,0 +1,149 @@ +package httpapi + +import ( + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// 分批结算的判据是纯函数,这里逐类钉住它的口径: +// 「所有批次都录完」= 该组所有 registered 报名项都有可发布的已发布成绩。 +func TestGroupStatsSettlementJudgement(t *testing.T) { + value := 11.58 + cases := []struct { + name string + entries []domain.Entry + results []domain.Result + wantTotal int + wantPublished int + }{ + { + name: "全部发完", + entries: []domain.Entry{ + {ID: 1, Status: domain.EntryStatusRegistered}, + {ID: 2, Status: domain.EntryStatusRegistered}, + }, + results: []domain.Result{ + {EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished}, + {EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished}, + }, + wantTotal: 2, + wantPublished: 2, + }, + { + name: "有人弃权(scratched 不该再等它)", + entries: []domain.Entry{ + {ID: 1, Status: domain.EntryStatusRegistered}, + {ID: 2, Status: domain.EntryStatusRegistered}, + {ID: 3, Status: domain.EntryStatusScratched}, + }, + results: []domain.Result{ + {EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished}, + {EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished}, + }, + wantTotal: 2, + wantPublished: 2, + }, + { + name: "有人失格(dq/dns 同样已有现场结论)", + entries: []domain.Entry{ + {ID: 1, Status: domain.EntryStatusRegistered}, + {ID: 2, Status: domain.EntryStatusRegistered}, + {ID: 3, Status: domain.EntryStatusDNS}, + }, + results: []domain.Result{ + {EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished}, + {EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished}, + }, + wantTotal: 2, + wantPublished: 2, + }, + { + name: "有草稿但不该发布(还没跑 / 还没录)", + entries: []domain.Entry{ + {ID: 1, Status: domain.EntryStatusRegistered}, + {ID: 2, Status: domain.EntryStatusRegistered}, + }, + results: []domain.Result{ + {EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished}, + {EntryID: 2, Status: domain.ResultStatusOK, State: domain.ResultStateDraft}, + }, + wantTotal: 2, + wantPublished: 1, + }, + { + name: "已发布但空且 ok 的行不算成绩", + entries: []domain.Entry{ + {ID: 1, Status: domain.EntryStatusRegistered}, + }, + results: []domain.Result{ + {EntryID: 1, Status: domain.ResultStatusOK, State: domain.ResultStatePublished}, + }, + wantTotal: 1, + wantPublished: 0, + }, + { + name: "DNS 这类没有成绩值的现场结论可以发布", + entries: []domain.Entry{ + {ID: 1, Status: domain.EntryStatusRegistered}, + }, + results: []domain.Result{ + {EntryID: 1, Status: domain.ResultStatusDNS, State: domain.ResultStatePublished}, + }, + wantTotal: 1, + wantPublished: 1, + }, + { + name: "整组弃权:没有要等的人", + entries: []domain.Entry{{ID: 1, Status: domain.EntryStatusScratched}}, + results: nil, + wantTotal: 0, + wantPublished: 0, + }, + { + name: "没有报名项", + entries: nil, + results: nil, + wantTotal: 0, + wantPublished: 0, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + total, published := groupStats(tc.entries, tc.results) + if total != tc.wantTotal || published != tc.wantPublished { + t.Fatalf("groupStats = (%d, %d),期望 (%d, %d)", total, published, tc.wantTotal, tc.wantPublished) + } + got := published == total + want := tc.wantPublished == tc.wantTotal + if got != want { + t.Fatalf("结算判据 = %v,期望 %v", got, want) + } + }) + } +} + +// groupOf 必须只留下该组别的行:成绩表不带组别,靠报名 id 反查时最容易把别组的成绩算进来。 +func TestGroupOfFiltersByCategory(t *testing.T) { + entries := []domain.Entry{ + {ID: 1, CategoryID: 7}, + {ID: 2, CategoryID: 7}, + {ID: 3, CategoryID: 8}, + } + results := []domain.Result{ + {ID: 11, EntryID: 1}, + {ID: 12, EntryID: 2}, + {ID: 13, EntryID: 3}, + } + + groupEntries, groupResults := groupOf(entries, results, 7) + if len(groupEntries) != 2 || len(groupResults) != 2 { + t.Fatalf("第 7 组应只剩 2 条报名 / 2 条成绩,得到 %d / %d", len(groupEntries), len(groupResults)) + } + for _, r := range groupResults { + if r.EntryID == 3 { + t.Fatalf("别组的成绩不该被算进来: %+v", r) + } + } +} diff --git a/server/internal/httpapi/system_test.go b/server/internal/httpapi/system_test.go index 5b17a52..3e4f4c7 100644 --- a/server/internal/httpapi/system_test.go +++ b/server/internal/httpapi/system_test.go @@ -254,7 +254,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) { assertKeys(t, "settings.display", asMap(t, settings["display"]), []string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource"}) assertKeys(t, "settings.standings", asMap(t, settings["standings"]), - []string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"}) + []string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals", "liveOnPublish"}) _, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false) var rows []map[string]any diff --git a/web/src/admin/components/baseData/AthletesSection.vue b/web/src/admin/components/baseData/AthletesSection.vue index decebb7..4a076a3 100644 --- a/web/src/admin/components/baseData/AthletesSection.vue +++ b/web/src/admin/components/baseData/AthletesSection.vue @@ -1,11 +1,12 @@ + + + + diff --git a/web/src/admin/components/system/sessionSettings.ts b/web/src/admin/components/system/sessionSettings.ts new file mode 100644 index 0000000..758c3df --- /dev/null +++ b/web/src/admin/components/system/sessionSettings.ts @@ -0,0 +1,69 @@ +import type { CurrentUser } from '@/shared' + +/** + * 登录有效期(系统级设置)的纯函数:范围校验与到期时间显示。 + * + * 抽成不依赖 Vue 的模块,是为了能在 node --test 下直接测到边界(0、721、小数、空串), + * 而不必挂载组件;面板与用户菜单共用同一份格式化逻辑,避免两处显示不一致。 + */ + +/** 服务端 /api/system/session 未就绪时的兜底范围,与 auth.MinSessionHours / MaxSessionHours 一致。 */ +export const SESSION_HOURS_FALLBACK = { minHours: 1, maxHours: 720, defaultHours: 24 } as const + +export interface SessionHoursBounds { + minHours: number + maxHours: number +} + +export type SessionHoursCheck = { ok: true; hours: number } | { ok: false; message: string } + +/** 校验面板输入的登录有效期(整数小时)。服务端仍会独立校验一遍,这里只是为了当场给出中文提示。 */ +export function validateSessionHours(raw: string | number, bounds: SessionHoursBounds): SessionHoursCheck { + const text = typeof raw === 'number' ? String(raw) : raw.trim() + if (text === '') return { ok: false, message: '请填写登录有效期(小时)' } + if (!/^\d+$/.test(text)) return { ok: false, message: '登录有效期只能是整数小时' } + const hours = Number(text) + if (hours < bounds.minHours || hours > bounds.maxHours) { + return { ok: false, message: `登录有效期需在 ${bounds.minHours}~${bounds.maxHours} 小时之间` } + } + return { ok: true, hours } +} + +/** 把小时数说成人话,现场一眼能看出「这是登录后能待多久」。 */ +export function describeSessionLifetime(hours: number): string { + if (!Number.isFinite(hours) || hours <= 0) return '登录有效期未设置' + const days = hours / 24 + const dayText = Number.isInteger(days) ? `${days} 天` : `${days.toFixed(1)} 天` + return `登录后最长可保持 ${hours} 小时(${dayText})不用重新登录;期间只要继续操作,到期时间会自动顺延。` +} + +/** + * 从 /auth/me 的响应里取当前会话的到期时间。 + * + * CurrentUser 类型定义在 shared/types(本轮不改共享包),字段是附加下发的, + * 所以这里按可选字段读,值不合法时一律当「没有」处理。 + */ +export function readSessionExpiresAt( + user: (CurrentUser & { sessionExpiresAt?: unknown }) | null | undefined, +): string | null { + const raw = user?.sessionExpiresAt + if (typeof raw !== 'string' || raw.trim() === '') return null + return Number.isNaN(new Date(raw).getTime()) ? null : raw +} + +function pad(value: number): string { + return String(value).padStart(2, '0') +} + +/** + * 把服务端下发的 RFC3339(UTC)到期时间渲染成本机时区的「当前会话将于 2026-09-12 08:31 过期」。 + * 服务端只给带时区的时间串,换算成本机时间由前端做——现场看的是自己电脑上的钟。 + */ +export function formatSessionExpiry(iso: string | null | undefined): string { + if (iso === null || iso === undefined || iso === '') return '当前会话的到期时间未知' + const at = new Date(iso) + if (Number.isNaN(at.getTime())) return '当前会话的到期时间未知' + const text = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ` + + `${pad(at.getHours())}:${pad(at.getMinutes())}` + return `当前会话将于 ${text} 过期` +} diff --git a/web/src/admin/components/system/systemApi.ts b/web/src/admin/components/system/systemApi.ts index 654bcb6..92de09c 100644 --- a/web/src/admin/components/system/systemApi.ts +++ b/web/src/admin/components/system/systemApi.ts @@ -879,6 +879,18 @@ export const rulesApi = { export type EventSettingsPatch = Partial +/** + * 分批结算开关的取值。 + * + * shared/types 的 EventSettings 还没收敛这个字段(改共享类型会影响大屏), + * 所以在管理端按可选字段读取:旧服务端 / 示例数据不带它时按「实时更新」处理, + * 与 Go 侧 ParseEventSettings 的默认值一致。 + */ +export function liveOnPublishOf(settings: EventSettings | null | undefined): boolean { + const value = (settings?.standings as { liveOnPublish?: unknown } | undefined)?.liveOnPublish + return typeof value === 'boolean' ? value : true +} + export const settingsApi = { async save(patch: EventSettingsPatch): Promise> { return withMockInfo( @@ -894,6 +906,104 @@ export const settingsApi = { }, } +// --------------------------------------------------------------------------- +// 分批结算(GET /api/results/settlement、POST /api/results/settle) +// --------------------------------------------------------------------------- + +/** 未结算的 (小项, 组别):延迟结算下已发布成绩还没进榜单的那一组。 */ +export interface SettlementGroup { + eventId: number + categoryId: number + categoryName: string + /** 该出成绩的报名项数(弃权 / 失格不计入)。 */ + required: number + /** 其中已经有可发布成绩的条数。 */ + published: number +} + +export interface SettlementStatus { + /** false = 延迟结算:发布只落成绩,等整组录完才更新榜单。 */ + liveOnPublish: boolean + groups: SettlementGroup[] +} + +function readSettlementStatus(raw: unknown): SettlementStatus { + if (!isRecord(raw)) throw new ApiError('PARSE_FAILED', '结算状态格式不符', 0, raw) + const groups = Array.isArray(raw.groups) ? raw.groups : [] + return { + liveOnPublish: raw.liveOnPublish !== false, + groups: groups.filter(isRecord).map((item) => ({ + eventId: readNumber(item, 'eventId') ?? 0, + categoryId: readNumber(item, 'categoryId') ?? 0, + categoryName: readString(item, 'categoryName') ?? '', + required: readNumber(item, 'required') ?? 0, + published: readNumber(item, 'published') ?? 0, + })), + } +} + +export const settlementApi = { + /** 查未结算清单;这是提示用的辅助信息,读失败由调用方决定是否忽略。 */ + async status(eventId: number, categoryId?: number | null): Promise { + const body = await requestJson('/results/settlement', { + query: { eventId, categoryId: categoryId ?? undefined }, + }) + return readSettlementStatus(body) + }, + + /** 立即结算:按当前已发布成绩重算积分流水并刷新榜单(写操作不降级)。 */ + async settle(eventId: number, categoryId?: number | null): Promise { + await requestJson('/results/settle', { + method: 'POST', + json: { eventId, categoryId: categoryId ?? null }, + }) + }, +} + +// --------------------------------------------------------------------------- +// 登录有效期(系统级设置:GET/PUT /api/system/session) +// --------------------------------------------------------------------------- + +export interface SessionSettings { + sessionHours: number + defaultSessionHours: number + minHours: number + maxHours: number + /** 当前会话的到期时间(RFC3339 UTC);未登录为 null。 */ + expiresAt: string | null +} + +/** + * 登录有效期存在系统库(system.db),不是赛事级 /api/settings: + * 换一个赛事项目,这台机器上所有人的会话寿命不该跟着变。 + * + * 读写都不降级示例数据:假的「已保存」会让管理员以为改完就生效了。 + */ +export const sessionSettingsApi = { + async load(): Promise { + return toSessionSettings(await requestJson('/system/session')) + }, + + async save(hours: number): Promise { + return toSessionSettings( + await requestJson('/system/session', { method: 'PUT', json: { sessionHours: hours } }), + ) + }, +} + +function toSessionSettings(value: unknown): SessionSettings { + if (!isRecord(value)) throw new ApiError('PARSE_FAILED', '登录有效期响应格式不符', 0, value) + const sessionHours = readNumber(value, 'sessionHours') + if (sessionHours === null) throw new ApiError('PARSE_FAILED', '登录有效期响应缺少 sessionHours', 0, value) + return { + sessionHours, + defaultSessionHours: readNumber(value, 'defaultSessionHours') ?? sessionHours, + minHours: readNumber(value, 'minHours') ?? 1, + maxHours: readNumber(value, 'maxHours') ?? 720, + expiresAt: readString(value, 'expiresAt'), + } +} + // --------------------------------------------------------------------------- // 字体(内置字体 + 上传字体) // --------------------------------------------------------------------------- diff --git a/web/src/admin/layouts/AdminLayout.vue b/web/src/admin/layouts/AdminLayout.vue index 3fbd996..d83febe 100644 --- a/web/src/admin/layouts/AdminLayout.vue +++ b/web/src/admin/layouts/AdminLayout.vue @@ -1,9 +1,10 @@ @@ -200,7 +224,7 @@ onMounted(async () => { @@ -879,6 +1241,33 @@ async function applyRules(): Promise { gap: 16px; align-items: start; } +/* 报名名单排在结果导出卡片下方,同样占满整行。 */ +.entries-card { + margin-top: 16px; +} +/* 基础数据包:导出与导入各一张卡,宽度与赛事包一致。 */ +.basedata-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); + gap: 16px; + align-items: start; +} +.error-list { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: var(--mx-r-xs); + background: rgba(248, 113, 113, 0.1); +} +.error-line { + font-size: 12px; + color: var(--mx-error); + line-height: 1.6; +} +.field-diff { + display: block; +} /* 项目列表与导出 / 导入卡片不同,是整行的总览表。 */ .project-grid > .wide { grid-column: 1 / -1; @@ -993,7 +1382,8 @@ async function applyRules(): Promise { } @media (max-width: 1279px) { .project-grid, - .rules-grid { + .rules-grid, + .basedata-grid { grid-template-columns: 1fr; } } diff --git a/web/src/admin/pages/ReviewPage.vue b/web/src/admin/pages/ReviewPage.vue index 44a3497..3b1b979 100644 --- a/web/src/admin/pages/ReviewPage.vue +++ b/web/src/admin/pages/ReviewPage.vue @@ -13,6 +13,7 @@ import PageHeader from '../components/PageHeader.vue' import ConfirmDialog from '../components/ConfirmDialog.vue' import { adminApi } from '../api/client' import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring' +import { liveOnPublishOf } from '../components/system/systemApi' import { judgeEntryRow, type EntryRowJudgement } from './entryGuards' import { useAuthStore } from '../stores/auth' import { useEventStore } from '../stores/event' @@ -72,6 +73,11 @@ const returnTarget = ref(null) const returnedKeys = ref([]) const canWrite = computed(() => !auth.readOnly && data.canWrite) +/** + * 延迟结算:发布只落成绩,榜单等该组全部录完才更新。 + * 这里用的是快照里的设置;读不到(非 admin 且快照未到)时按默认「实时结算」处理。 + */ +const deferredSettlement = computed(() => !liveOnPublishOf(data.settings)) /** 非 admin 会话的快照会把折算系数清零(dto.WithoutAdminFields),0 兜底成 1,否则个人项目折算分全变 0。 */ const weight = computed(() => { const w = data.settings?.standings.individualToTeamWeight ?? 1 @@ -352,6 +358,20 @@ function requestPublish(group: DraftGroup): void { publishTarget.value = group } +/** 发布确认框正文:延迟结算下不能说「发布即重算积分榜」。 */ +const publishMessage = computed(() => { + const group = publishTarget.value + if (group === null) return '' + const scope = + skippedCount(group) > 0 + ? `本组还有 ${skippedCount(group)} 条没有成绩,本次将发布已填的 ${publishableCount(group)} 条;其余保持未发布,补录后再发布即可。` + : `本次将发布该组全部 ${publishableCount(group)} 条。` + const tail = deferredSettlement.value + ? '发布后成绩立即上屏;延迟结算已开启,名次与榜单等该组全部录完后才更新。' + : '发布后立即上屏并重算积分榜。' + return `${scope}本次影响 ${group.affectedTeams} 个单位,最大分差 ${formatPoints(group.maxDelta)} 分。${tail}` +}) + async function confirmPublish(): Promise { const group = publishTarget.value if (group === null) return @@ -361,7 +381,11 @@ async function confirmPublish(): Promise { publishTarget.value = null await load() await Promise.all([data.refreshStandings(), data.refreshEvents()]) - toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`) + if (deferredSettlement.value) { + toast.success('已发布本批成绩', `延迟结算:${group.categoryName} 全组录完后才更新名次与榜单`) + } else { + toast.success('发布成功,积分榜已更新', `${group.eventName} · ${group.categoryName}`) + } } catch (caught) { toast.error('发布失败', caught instanceof Error ? caught.message : null) } finally { @@ -633,13 +657,7 @@ watch( :open="publishTarget !== null" tone="warning" title="确认发布该组成绩?" - :message=" - publishTarget === null - ? '' - : skippedCount(publishTarget) > 0 - ? `本组还有 ${skippedCount(publishTarget)} 条没有成绩,本次将发布已填的 ${publishableCount(publishTarget)} 条;其余保持未发布,补录后再发布即可。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。` - : `本次将发布该组全部 ${publishableCount(publishTarget)} 条。本次影响 ${publishTarget.affectedTeams} 个单位,最大分差 ${formatPoints(publishTarget.maxDelta)} 分。发布后立即上屏并重算积分榜。` - " + :message="publishMessage" :details="publishTarget === null ? [] : publishTarget.deltas.slice(0, 8).map((item) => `${item.name}:${item.delta > 0 ? '+' : ''}${formatPoints(item.delta)} 分`)" confirm-text="确认发布" :pending="publishing" diff --git a/web/src/admin/pages/SystemPage.vue b/web/src/admin/pages/SystemPage.vue index 400ded0..abdca38 100644 --- a/web/src/admin/pages/SystemPage.vue +++ b/web/src/admin/pages/SystemPage.vue @@ -4,20 +4,23 @@ import PageHeader from '../components/PageHeader.vue' import UsersPanel from '../components/system/UsersPanel.vue' import BackupPanel from '../components/system/BackupPanel.vue' import EventInfoPanel from '../components/system/EventInfoPanel.vue' +import SessionPanel from '../components/system/SessionPanel.vue' import { useAuthStore } from '../stores/auth' import { useEventStore } from '../stores/event' /** * 系统页(devdocs/07 第 1 节)。 * - * 三块内容互相独立,用页签切换而不是塞进一屏:用户管理需要横向空间, + * 四块内容互相独立,用页签切换而不是塞进一屏:用户管理需要横向空间, * 备份和赛事信息又很少同时改。每个面板各自负责加载态 / 空态 / 错误态。 */ -type TabKey = 'users' | 'backup' | 'event' +type TabKey = 'users' | 'session' | 'backup' | 'event' const TABS: ReadonlyArray<{ key: TabKey; label: string }> = [ { key: 'users', label: '用户管理' }, + // 登录有效期是机器级配置(system.db),与赛事信息那种随赛事包走的配置分开摆。 + { key: 'session', label: '登录有效期' }, { key: 'backup', label: '备份' }, { key: 'event', label: '赛事信息' }, ] @@ -42,7 +45,7 @@ watch(