From 9d4ee0946cf51ad456b2b12be9d946c11462732c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 10 Sep 2026 22:37:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=90=E5=8A=A8=E5=91=98=E5=BD=BB?= =?UTF-8?q?=E5=BA=95=E5=88=A0=E9=99=A4=EF=BC=88=E7=9C=9F=E7=89=A9=E7=90=86?= =?UTF-8?q?=E5=88=A0=E9=99=A4=20+=20=E9=87=8D=E7=AE=97=E7=A7=AF=E5=88=86?= =?UTF-8?q?=EF=BC=89+=20=E6=8A=A5=E5=90=8D=E8=A1=A8=20status=20=E5=AE=B9?= =?UTF-8?q?=E9=94=99=20+=20=E9=83=A8=E7=BD=B2=E5=8C=85=E5=B8=A6=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 运动员「彻底删除」= 真删除 `DELETE /api/athletes/{id}?purge=1`(不带参数仍是「停用」软删除)。在一个事务里: 删积分流水(含已作废历史流水)→ 删破纪录登记 → 删成绩 → 删报名 → 删人员 → 对受影响的 (小项, 组别) 调 recomputeLedgerTx + applyComputedRanks 重算流水与名次 → bumpEvent/bumpStandings 推大屏 → 审计 athlete.purge(含删除规模)。 响应带 {deletedEntries, deletedResults, deletedLedger, deletedBreaks, recomputedEvents}。 必须先删 results 再删 entries(results.entry_id 无 ON DELETE);流水按 athlete_id / entry_id / result_id 三种引用一把清,漏一种就留悬挂。 管理端「运动员」行加「彻底删除」按钮 + 危险确认框(如实写明报名与成绩一并删除、 相关小项排名与积分会重算),成功 toast 报出删除规模。 ## 报名表 status 列容错 新增 entry_status.go 的 normalizeImportStatus:大小写不敏感(DNS/Registered 都认), 认「正常/退赛/弃赛/请假/取消资格/犯规/未出场/未到/缺席」等中文别名,留空即 registered, 认不出则给中文行级错误并跳过该行。此前原样入库,撞 entries.status 的 CHECK 约束后 抛原始 SQLite 错误,填表人看不懂。 ## 修 isUniqueViolation 把 CHECK 失败误报成「已存在」 SQLite 的 CHECK / NOT NULL 失败也带 "constraint failed",旧匹配把「小项 status 取值非法」 报成 409「小项编码已存在」,现场会去找一个根本不存在的重名编码。收紧为只认 "unique constraint failed"。实测:重复编码仍 409;非法 status 不再误报。 ## 安装包平铺《报名名单导入规范》 deploy/prepare-extra.mjs 改清单式,部署包根目录除 使用说明.txt 外新增 报名名单导入规范.txt。 测试:服务端 12 包全绿(新增 athlete_purge_test.go、entry_status_test.go); 前端 vue-tsc 通过、166 例全过;Console 语法检查 17 文件。 另在真二进制 + 真 SQLite 上跑通破坏性路径:删人后本人痕迹全为 0、他人不受影响、 榜单按新排序重算、foreign_key_check 无悬挂。docs/ 与 devdocs/ 同步更新。 --- README.md | 1 + console/README.md | 4 +- deploy/prepare-extra.mjs | 26 +- docs/使用手册.md | 44 +- docs/常见问题.md | 42 ++ docs/报名名单导入规范.md | 28 +- docs/部署指南.md | 6 + server/internal/httpapi/athlete_purge_test.go | 376 ++++++++++++++++++ server/internal/httpapi/entry_status.go | 32 ++ server/internal/httpapi/entry_status_test.go | 54 +++ server/internal/httpapi/handlers_config.go | 43 +- .../internal/httpapi/handlers_config_test.go | 71 ++++ server/internal/httpapi/repo.go | 144 ++++++- server/internal/httpapi/server.go | 2 +- server/migrations/event/migration_test.go | 28 +- web/src/admin/api/client.ts | 8 + .../components/baseData/AthletesSection.vue | 89 ++++- .../admin/components/baseData/baseDataApi.ts | 39 +- .../admin/components/baseData/mockBaseData.ts | 21 + .../admin/components/baseData/useBaseData.ts | 11 + web/src/admin/tests/athlete-purge-api.test.ts | 185 +++++++++ web/src/shared/api.ts | 64 ++- 22 files changed, 1264 insertions(+), 54 deletions(-) create mode 100644 server/internal/httpapi/athlete_purge_test.go create mode 100644 server/internal/httpapi/entry_status.go create mode 100644 server/internal/httpapi/entry_status_test.go create mode 100644 web/src/admin/tests/athlete-purge-api.test.ts diff --git a/README.md b/README.md index 40fea1a..7f71b2e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - **小项类型齐全**:个人 / 团体 / 对抗比赛 / **个人+团体**(同一个项目同时按个人与团体通道记分) - **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗 - **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用 +- **运动员名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」把人员档案连同他名下的报名、成绩与积分流水一起删掉且不可恢复,并自动按新排序重算受影响小项的排名与积分(大屏同步更新) - **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事 - **结果导出**:Excel 总表、全部分项、分项成绩表、单位积分明细可分别导出 - **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo diff --git a/console/README.md b/console/README.md index 6ed2696..ff18900 100644 --- a/console/README.md +++ b/console/README.md @@ -32,7 +32,7 @@ console/ │ └── tray-icon.js # 托盘图标(base64 内嵌,无外部资源文件) ├── scripts/ │ └── check-syntax.js # pnpm check:跨平台递归 node --check -├── extra/ # 打包前由 deploy/build.bat 暂存 使用说明.txt(extraFiles 指向它) +├── extra/ # 打包前由 deploy/prepare-extra.mjs 暂存 使用说明.txt 与 报名名单导入规范.txt(extraFiles 指向它) └── renderer/ ├── index.html # 管理窗口(含自绘标题栏) ├── styles.css @@ -182,7 +182,7 @@ pnpm pack # 只出目录,不生成安装包,便于现场调试 - 产物:NSIS 安装包 + zip(`Scoreboard Console-0.1.0-x64.*`)。 - `extraFiles` 会把 `../dist/scoreboard.exe`、`../config.yaml`、`config.json`、`../rules` 拷到安装目录 —— 这四样里的后三样(`config.json`、`config.yaml`、`rules/`)在打包态只当**模板**:首次启动时复制到用户数据目录,之后 Console 与 `backup.bat` 都只用用户目录那份(见「配置与规则目录」一节)。`config.json` 缺失时 Console 会回落到内置默认值。 -- `使用说明.txt` 是可选的:`deploy/build.bat` 在打包前依次找 `docs/使用说明.txt`、`docs/使用手册.md`,命中就暂存到 `console/extra/使用说明.txt`(`extraFiles` 会把该目录内容平铺到部署包根目录),都没有就跳过并在构建日志里注明。 +- 部署包根目录的说明书是可选的:`deploy/prepare-extra.mjs` 在打包前按一张清单暂存到 `console/extra/`(`extraFiles` 会把该目录内容平铺到安装目录根):`docs/使用手册.md` → `使用说明.txt`,`docs/报名名单导入规范.md` → `报名名单导入规范.txt`(各自优先取 `docs/` 下的同名 `.txt`)。缺哪个就在构建日志里注明并跳过。 - 图标在 `resources/icon.ico`(`package.json` 的 `win.icon` 指向它)。 - 主程序文件名由 `win.executableName` 决定,现在是 `Scoreboard_Console.exe`;`productName` 仍是 `Scoreboard Console`(显示名、`userData` 目录不变)。 - `nsis.include` 指向 `resources/installer-custom.nsh`(构建时由 `deploy/prepare-extra.mjs` 生成,已 gitignore):许可页顶部文案 + 记住上次安装目录(重装时默认沿用,不退回系统默认路径)。 diff --git a/deploy/prepare-extra.mjs b/deploy/prepare-extra.mjs index 2382355..1739617 100644 --- a/deploy/prepare-extra.mjs +++ b/deploy/prepare-extra.mjs @@ -1,5 +1,5 @@ // 构建前准备两样东西: -// 1) 把 docs/ 下的使用说明暂存到 console/extra/,供 electron-builder 的 extraFiles 平铺到部署包根目录; +// 1) 把 docs/ 下的使用说明与报名名单导入规范暂存到 console/extra/,供 electron-builder 的 extraFiles 平铺到部署包根目录; // 2) 生成安装器自定义脚本(console/resources/installer-custom.nsh):许可页顶部文案(署名来自构建机本地文件) // + 记住上次安装目录(重装时沿用,而不是退回系统默认路径)。 // 用 Node 而不是批处理:中文文件名/中文内容在 .bat 里会被 cmd 解析错乱。 @@ -8,17 +8,23 @@ import { dirname, join } from 'node:path' const root = dirname(import.meta.dirname) -// ---- 1. usage doc ----------------------------------------------------------- +// ---- 1. usage docs ---------------------------------------------------------- +// 都暂存成 .txt:部署包根目录里给现场双击打开,.md 在记事本里全是符号。 +// 源文件优先取 docs/ 下同名 .txt(便于临时替换),否则用仓库里的 .md。 const extraDir = join(root, 'console', 'extra') mkdirSync(extraDir, { recursive: true }) -const usageTarget = join(extraDir, '使用说明.txt') -const usageSources = [join(root, 'docs', '使用说明.txt'), join(root, 'docs', '使用手册.md')] -const usageSource = usageSources.find((file) => existsSync(file)) -if (usageSource) { - copyFileSync(usageSource, usageTarget) - console.log(` usage doc staged from ${usageSource}`) -} else { - console.log(' [docs] no usage doc, skip') +const docsToStage = [ + { target: '使用说明.txt', sources: ['使用说明.txt', '使用手册.md'], label: 'usage doc' }, + { target: '报名名单导入规范.txt', sources: ['报名名单导入规范.md'], label: 'entry import spec' }, +] +for (const doc of docsToStage) { + const source = doc.sources.map((name) => join(root, 'docs', name)).find((file) => existsSync(file)) + if (!source) { + console.log(` [docs] no ${doc.label}, skip`) + continue + } + copyFileSync(source, join(extraDir, doc.target)) + console.log(` ${doc.label} staged from ${source}`) } // ---- 2. installer custom script --------------------------------------------- diff --git a/docs/使用手册.md b/docs/使用手册.md index 02410ca..75ee8bf 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -323,9 +323,35 @@ 1. 点「新增运动员」,填写姓名 *、性别、年龄、所属单位 *、工号 / 学号、证件号、号码布等(年龄可留空,填了必须是 0–120 的整数); 2. **证件号(如身份证号)与号码布二者取其一**即可唯一标识一个人,用于区分重名;两个人员不能共用同一个证件号,也不能共用同一个号码布,重复会被拒绝;没有身份证信息的人可以只填号码布。已有历史数据两项可以都为空; -3. 「编辑」修改,「停用」后不再出现在新报名选择中; +3. 每行有三个动作:**编辑**、**停用**(停用后按钮变成「启用」)、**彻底删除**; 4. 个人项目的报名项会关联到运动员,团体项目可以留空。 +**停用与彻底删除的区别** + +| 状态 | 人员列表里 | 还能被报名选中吗 | 他名下的报名 / 成绩 / 积分 | 能恢复吗 | +| --- | --- | --- | --- | --- | +| 启用(默认) | 在 | 能 | 保留 | —— | +| 停用 | 还在(标记「已停用」) | 不能 | **保留** | 点「启用」即可 | +| 彻底删除 | **不在了** | 不能 | **一并删除** | 恢复不了 | + +「停用」是软删除,只是不让他再报名;「彻底删除」是**真删除**。操作步骤: + +1. 点该行右侧红色的「彻底删除」; +2. 在确认框里核对提示(会写明报名与成绩一并删除、相关小项的排名与积分会重算),再点一次「彻底删除」; +3. 完成后页面给出删除规模,例如「已彻底删除「张伟」:报名 1 条、成绩 1 条、积分流水 2 条,已重算 1 个小项的积分与名次」。 + +**「彻底删除」会永久删掉什么** + +- 这个人的**人员档案**:从运动员名册里消失,报名选择器里也选不到,界面上再也恢复不了; +- 他名下的**报名**与**成绩**; +- 这些成绩产生的**积分流水**(含已作废的历史流水)与**破纪录记录**。 + +删掉成绩会改变同组其他人的名次与得分,所以系统在同一个动作里**按新排序重算受影响小项的排名与积分**,并把结果推到大屏。因此: + +- 已经有成绩的比赛,删人**会让名次和积分发生变化**(例如原来的第二名升到第一、得分跟着变),删完请核对一下榜单; +- **只是想暂时不让他报名**,请用「停用」——它保留全部历史数据,随时可以再启用; +- 该人的姓名也不会再出现在「分项成绩表」「个人积分榜」上:这些榜单是现算的,数据删了他就不在榜里。 + ### 10.7 报名导入 报名有两种方式,都在本页完成:**从已有名单勾选**适合零散补报,**表格导入**适合批量录入。 @@ -354,15 +380,21 @@ 1. 点「下载 CSV 模板」获取表头样例; 2. 表头共 11 列:`event_code`、`category`、`team_code`、`athlete_name`、`id_card`、`gender`、`age`、`bib`、`seed`、`status`、`member_names`(大小写不敏感)。其中 `id_card`(证件号)、`gender`(性别)、`age`(年龄)为可选列,缺失不影响导入,其余列必须包含; 3. **只要有姓名的行,`id_card` 与 `bib` 必须至少填一个**,否则该行报错并跳过;`gender` 只认「男」「女」,其他值忽略;`age` 可留空,填了必须是 0–120 的整数;同一份文件里同一个证件号或同一个号码布重复出现时,重复的行会报错; -4. 人员匹配顺序: +4. **`status` 列很宽容**:大小写不敏感(`DNS`、`Registered`、`registered` 都认),也认中文别名—— + - `正常` / `正常参赛` / `参赛` → 正常参赛; + - `退赛` / `弃赛` / `请假` → 退赛; + - `取消资格` / `犯规` → 取消资格; + - `未出场` / `未到` / `缺席` → 未出场。 + 留空即「正常参赛」。真正认不出的写法会给出中文行级错误、该行跳过,例如「第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空)」; +5. 人员匹配顺序: 1. 先按 `id_card` 匹配已有人员; 2. 没匹配到,再按 `bib`(号码布)匹配; 3. 还没匹配到,按「姓名 + 单位」匹配且该人员证件号为空,视为同一人,把表里给的证件号 / 号码布 / 年龄补进去(不算冲突);如果该人员已有号码布且与表里不同,则算冲突; - 4. 都没匹配到则新建人员(姓名、性别、年龄、单位、证件号、号码布); -5. `bib` 列会同时写入人员档案与报名记录;从已有名单勾选生成的报名也会带上人员的证件号(优先)或号码布; -6. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表); + 4. 都没匹配到则新建人员(姓名、性别、年龄、单位、证件号、号码布)——被「彻底删除」的人(见 10.6)档案已经不在库里,匹配不到他,再次导入会当作**新人员**重新建档,他以前的报名与成绩不会回来; +6. `bib` 列会同时写入人员档案与报名记录;从已有名单勾选生成的报名也会带上人员的证件号(优先)或号码布; +7. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表); - **CSV 编码不用管**:UTF-8、带 BOM 的 UTF-8(Excel 的「CSV UTF-8」)、GBK / GB18030(中文 Windows 上 Excel「另存为 CSV」的默认编码)、UTF-16 都能直接导入,中文不会乱码,服务端会自动识别; -7. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。 +8. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。 **人员信息冲突的处理** diff --git a/docs/常见问题.md b/docs/常见问题.md index d6883e9..5994e76 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -280,3 +280,45 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 同一层还有 `config.yaml`(服务端配置)、`console\config.json`(Console 配置)与 `rules\`(规则包目录),也都在安装目录之外,同样不会被覆盖安装或卸载删掉。 要换位置:改 `%LOCALAPPDATA%\ScoreboardConsole\console\config.json` 里的 `server.dataDir`(支持绝对路径,相对路径按安装目录解析),重启 Console 生效。Console 会把这个目录用 `-data` 传给服务端,**它优先于 `config.yaml` 里的 `dataDir`**,所以只改 `config.yaml` 是不生效的。详见部署指南第 3.4 节与第 3.5 节。 + +**Q56:运动员为什么删不掉?怎么彻底删掉一个人?** + +「基础数据 → 运动员」每行有三个动作:**编辑**、**停用**(已停用的人显示为「启用」)、**彻底删除**。以前只提供「停用」,所以看着像「删不掉」;现在点该行右侧红色的「彻底删除」,在二次确认框里再点一次「彻底删除」即可,删完页面会给出规模提示,例如「已彻底删除「张伟」:报名 1 条、成绩 1 条、积分流水 2 条,已重算 1 个小项的积分与名次」。它删掉什么、为什么会让榜单变化,见 Q57。 + +**Q57:停用和彻底删除有什么区别?** + +| 动作 | 人员列表里 | 还能被报名选中吗 | 他名下的报名 / 成绩 / 积分 | 能恢复吗 | +| --- | --- | --- | --- | --- | +| 停用 | 还在(标「已停用」) | 不能 | 保留 | 点「启用」即可 | +| 彻底删除 | **不在了** | 不能 | **一并删除** | 恢复不了 | + +- **停用**是软删除:人还留在名单里,只是不再出现在新报名的选择里;历史报名、成绩与积分全部保留;随时可以再启用。只是想暂时不让他报名,用它就够了。 +- **彻底删除是真删除**:人员档案,以及他名下的报名、成绩、由这些成绩产生的积分流水(含已作废的历史流水)与破纪录记录,都会从库里删掉,界面上无法恢复。删完他也就不再出现在人员列表、报名选择和各类榜单里。 +- **删人会改动榜单**:删掉成绩会改变同组其他人的名次与得分,所以系统会在删除的同时**按新排序重算受影响小项的排名与积分**并推送到大屏。已经有成绩的比赛,删人后原来的第二名可能升到第一、得分跟着变,删完请核对一下榜单。 +- 已经发布的比赛要改人,更稳妥的做法是撤销发布、改好成绩再重新发布(见 Q8);「彻底删除」是给「这个人根本不该出现在系统里」的情况用的。 + +**Q58:删掉的人怎么又出现在名单里了?** + +彻底删除之后,如果又在报名表里导入同一个人(同一个证件号或号码布),他会以**全新的档案**重新出现在运动员名单里——因为旧档案已经连同他的报名与成绩一起删掉了,系统按证件号 / 号码布找不到他,就当作新人员建档。所以名单里突然多出一个「以前删过的人」,通常就是某次导入带回来的。 + +- 新出现的这条是**全新的记录**:以前的报名、成绩与积分不会回来,需要重新录; +- 不想他再出现,就让各单位不要把这个人填进报名表;确实不该有的,再「彻底删除」一次即可。 + +**Q59:报名表的 status 能填中文吗?** + +能。`status` 列**大小写不敏感**(`DNS`、`Registered`、`registered` 都认),也认这些中文别名: + +- `正常` / `正常参赛` / `参赛` → 正常参赛; +- `退赛` / `弃赛` / `请假` → 退赛; +- `取消资格` / `犯规` → 取消资格; +- `未出场` / `未到` / `缺席` → 未出场。 + +留空就是「正常参赛」。这四个是**报名**状态,和「成绩录入」里每个成绩的状态(有效 / DNS / DQ / DNF / 退赛,见 Q11)是两回事。逐列说明见 [报名名单导入规范](./报名名单导入规范.md)。 + +**Q60:为什么导入报 status「xxx」无法识别?** + +说明那一行的 `status` 填了系统不认的词(例如「已报到」「未参赛」)。这一行会被跳过并给出中文提示,其余行照常导入: + +> 第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空) + +改法:换成四个标准词之一(`registered` / `scratched` / `dq` / `dns`,大小写都行)或 Q59 里的中文别名,也可以直接留空(= 正常参赛),改完重新上传即可。对照表见 [报名名单导入规范 · 常见报错怎么办](./报名名单导入规范.md#六常见报错怎么办)。 diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md index a0237fc..622598f 100644 --- a/docs/报名名单导入规范.md +++ b/docs/报名名单导入规范.md @@ -30,7 +30,7 @@ | `age` | 可选 | 年龄,0–120 的整数 | `26` | | `bib` | 可选 | 号码布 | `101` | | `seed` | 可选 | 道次 / 种子,随便写或留空 | `种子1` | -| `status` | 可选 | 报名状态,只能填 4 个英文小写词之一(见下) | `registered` | +| `status` | 可选 | 报名状态:4 个英文词(大小写都行)或等价的中文别名(见下) | `registered` | | `member_names` | 可选 | 团体项目的队员名单,多人用 `/` 隔开 | `张三/李四/王五` | > 「必须」指的是**这一列的表头要有**。至于每格里能不能留空,看下面第三节逐列的说明。 @@ -74,17 +74,21 @@ ### `seed` 道次 / 种子 自由填写,可以留空。系统只做记录,不参与算分、不影响名次。 -### `status` 报名状态 —— 只能写这 4 个英文词 -**必须小写,不能用中文,也不能大写**: +### `status` 报名状态 —— 英文词或中文别名都认 +**大小写不敏感**(`registered`、`Registered`、`DNS` 都认),也可以直接写中文: -| 写法 | 含义 | -|---|---| -| `registered` | 正常参赛 | -| `scratched` | 退赛 / 弃赛 | -| `dq` | 取消资格 | -| `dns` | 未出场 | +| 英文写法 | 等价的中文别名 | 含义 | +|---|---|---| +| `registered` | `正常` / `正常参赛` / `参赛` | 正常参赛 | +| `scratched` | `退赛` / `弃赛` / `请假` | 退赛 / 弃赛 | +| `dq` | `取消资格` / `犯规` | 取消资格 | +| `dns` | `未出场` / `未到` / `缺席` | 未出场 | -留空 = `registered`(正常参赛)。**填了别的词(包括 `DNS` 这种大写、或「退赛」中文)这一行会被跳过**,所以表里尽量留空,特殊情况再填。 +留空 = `registered`(正常参赛),拿不准就留空,特殊情况再填。 + +真正**认不出**的写法(例如「已报到」「未参赛」):这一行会被跳过,并给出中文行级提示,其余行照常导入。 + +> 第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空) ### `member_names` 队员名单 团体项目用这一列写队员,多人之间用 `/`、`,` 或 `,` 分开: @@ -141,7 +145,7 @@ - [ ] `category` 与系统里的组别名一字不差 - [ ] 个人行都有姓名,且都有证件号或号码布 - [ ] 一份表里没有重复的证件号、没有重复的号码布 -- [ ] `status` 只用了那 4 个英文小写词或留空 +- [ ] `status` 只用那 4 个英文词(大小写都行)或第三节列的中文别名,不写别的词;拿不准就留空 - [ ] 全表没有因为"怕麻烦"把某些列删掉——**表头 8 个必须列一个都不能少**(哪怕整列是空的) --- @@ -159,6 +163,7 @@ | 第 N 行:小项 XXX 没有组别 | 该小项一个组别都没配 | 先在「组别」里配好,再导入 | | 第 N 行:填写了 athlete_name 就必须填 id_card 或 bib | 个人行缺两项唯一标识 | 补证件号或号码布;确实都没有的,请先在「运动员」里登记 | | 第 N 行:证件号 / 号码布 … 在报名表内重复出现 | 同一份表里同一个人出现了两次 | 删掉重复行(同一个人报多个项目时,`event_code` 必须不同) | +| 第 N 行:status「xxx」无法识别 | `status` 填了系统不认的词(4 个标准词以外的英文,或第三节没列的中文) | 改成 `registered` / `scratched` / `dq` / `dns`(大小写都行)或第三节列的中文别名,也可以留空(= 正常参赛);改完重新上传 | | 提示「冲突」,人员没有写入 | 这个人在系统里已有记录,但姓名 / 性别 / 年龄 / 单位 / 号码布跟表里**对不上** | 在导入结果里逐条选「保留已有数据」或「使用新数据」,选完再提交 | | 文件传不上去 | 超过 16MB | 拆分上传,或改用 xlsx | | 只导入了第一张表 | xlsx 只读第一个工作表 | 把要导入的数据放在第一个工作表里 | @@ -174,6 +179,7 @@ 2. **重复上传同一份表是安全的**,不会把名单变成两份。改完再传一遍就行。 - ⚠️ 但要注意「更新」是**整条覆盖**:表里空着的格会把系统里原来的值清掉(队员名单、道次、号码布都会被清空),`status` 空着会被重置成「正常参赛」。如果之前手工调整过某些人的状态,**重传前请在表里把 `status` 写清楚**。 3. 名单导入后,到「成绩录入」里录成绩、发布,积分榜就会自动更新。 +4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个证件号或号码布,系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。 --- diff --git a/docs/部署指南.md b/docs/部署指南.md index 7cb4618..fe71d91 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -39,6 +39,8 @@ deploy\build.bat > 文件名中的 `0.1.0` 来自 `console\package.json` 的版本号,会随版本升级变化;以 `dist\console\` 下的实际文件名为准。 > > 目标机器**不需要** Go / Node / pnpm,也不联网;上述工具只用于构建机。 +> +> 安装包与免安装包解出来的根目录里,除程序与配置模板外还平铺着两份用户文档 `使用说明.txt`、`报名名单导入规范.txt`(由构建脚本从 `docs\` 暂存,见 3.3)。 --- @@ -78,9 +80,13 @@ deploy\build.bat ├── config.json # Console 配置模板(首次启动复制到用户配置目录) ├── config.yaml # 服务端配置模板(同上) ├── rules\ # 规则包模板(同上) +├── 使用说明.txt # 用户手册,记事本直接打开 +├── 报名名单导入规范.txt # 报名表填写规范,可直接转发给各单位填表人 └── logs\console.log # 首次运行生成:运行日志 ``` +> 根目录里那两份 `.txt` 与 `scoreboard.exe`、`config.yaml`、`config.json`、`scoreboard.png`、`rules\` 平铺在同一层,是构建时从 `docs\` 暂存进包的用户文档:`使用说明.txt` 即使用手册,`报名名单导入规范.txt` 可直接发给填表人(两者都按纯文本存放,用记事本打开即可,中文不会乱码)。改动了仓库里的 `docs\` 文档后需要**重新打包**才会更新到包内。 + 配置、规则与数据**都不在安装目录里**,各是独立的一处(详见 3.4、3.5): ``` diff --git a/server/internal/httpapi/athlete_purge_test.go b/server/internal/httpapi/athlete_purge_test.go new file mode 100644 index 0000000..cfda3bd --- /dev/null +++ b/server/internal/httpapi/athlete_purge_test.go @@ -0,0 +1,376 @@ +package httpapi_test + +import ( + "database/sql" + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" +) + +// waitAudit 取一条审计的 after_json。审计中间件在响应写完之后才落库, +// 所以这里给一小段重试窗口,避免用例跟中间件抢时间。 +func waitAudit(t *testing.T, env *testEnv, action string, entityID int64) 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 = ?`, + action, entityID).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 "" +} + +// countRows 统计满足条件的行数。 +func countRows(t *testing.T, env *testEnv, query string, args ...any) int { + t.Helper() + var n int + if err := env.ev.QueryRow(query, args...).Scan(&n); err != nil { + t.Fatalf("统计行数: %v\n%s", err, query) + } + return n +} + +// activeLedgerPoints 取某个报名名下的有效分值(按流水 id 排序)。 +func activeLedgerPoints(t *testing.T, env *testEnv, entryID int64) []float64 { + t.Helper() + rows, err := env.ev.Query(`SELECT points FROM score_ledger WHERE entry_id = ? AND voided = 0 ORDER BY id`, entryID) + if err != nil { + t.Fatalf("读流水: %v", err) + } + defer func() { _ = rows.Close() }() + var out []float64 + for rows.Next() { + var p float64 + if err := rows.Scan(&p); err != nil { + t.Fatalf("扫描流水: %v", err) + } + out = append(out, p) + } + return out +} + +// equalFloats 比较两组分值(都是整数分,直接按元素比)。 +func equalFloats(a, b []float64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// publishResults 走真实的草稿 + 发布接口把成绩发布出去,积分榜据此算分。 +func publishResults(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string, rows ...map[string]any) { + t.Helper() + if status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": eventID, "requestId": tag + "-draft", "results": rows, + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error) + } + if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", + map[string]any{"eventId": eventID, "requestId": tag + "-publish"}, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error) + } +} + +// teamPoints 取积分榜上某个单位的总分。 +func teamPoints(t *testing.T, env *testEnv, client *http.Client, teamID int64) float64 { + t.Helper() + status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("读积分榜应 200,得到 %d %+v", status, env2.Error) + } + for _, row := range decodeData[[]map[string]any](t, env2) { + if row["teamId"] == float64(teamID) { + points, _ := row["totalPoints"].(float64) + return points + } + } + t.Fatalf("积分榜上没有单位 %d", teamID) + return 0 +} + +// 连报名都没有的人:物理删除成功,事后再查就是 404。 +func TestPurgeAthleteWithoutAnyData(t *testing.T) { + env, _, client := recorderWithSeed(t) + id := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) + VALUES ('独行侠', '女', (SELECT id FROM teams WHERE code = 'A'), 1)`) + + status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", id), nil, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("无数据的人员应能删除,得到 %d %+v", status, env2.Error) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, id); n != 0 { + t.Fatalf("人员应被删掉,得到 %d 条", n) + } + if status, _ := env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", id), nil, false); status != http.StatusNotFound { + t.Fatalf("删除后再查应 404,得到 %d", status) + } +} + +// 只有报名、没有成绩的人:报名一并删掉。 +func TestPurgeAthleteWithEntriesOnly(t *testing.T) { + env, ids, client := recorderWithSeed(t) + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 { + t.Fatalf("前置条件:athleteA 应有 1 条报名,得到 %d", n) + } + + status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", ids.athleteA), nil, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("只有报名的人应能删除,得到 %d %+v", status, env2.Error) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 0 { + t.Fatalf("报名应一并删除,得到 %d 条", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, ids.athleteA); n != 0 { + t.Fatalf("人员应被删掉,得到 %d 条", n) + } +} + +// 有已发布成绩的人:人员、报名、成绩、流水(含已作废的历史流水)全删干净, +// 并把受影响的组别重算——同组别的 B 从第二升到第一,得分从 8 变 10,且没有重复计分。 +func TestPurgeAthleteRecomputesLedger(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "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"}, + ) + + // 前置: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 := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) { + t.Fatalf("前置条件:B 应是 8 分,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamA); got != 10 { + t.Fatalf("前置条件:A 单位总分应是 10,得到 %v", got) + } + + // 破纪录登记与该运动员名下的历史流水(voided = 1)也要跟着消失。 + env.exec(t, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, bonus_points, state) + VALUES (?, ?, 'meet', 11.58, 5, 'pending')`, ids.event, ids.entryA) + env.exec(t, `INSERT INTO score_ledger (team_id, event_id, entry_id, athlete_id, source_type, source_id, points, voided) + VALUES (?, ?, ?, ?, 'result', ?, 7, 1)`, + ids.teamA, ids.event, ids.entryA, ids.athleteA, ids.entryA) + + var logBefore int64 + if err := env.ev.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&logBefore); err != nil { + t.Fatalf("读变更日志: %v", err) + } + + status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", ids.athleteA), nil, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("删除应 200,得到 %d %+v", status, env2.Error) + } + purged := decodeData[map[string]any](t, env2) + if purged["deletedEntries"] != float64(1) || purged["deletedResults"] != float64(1) || + purged["deletedLedger"] != float64(2) || purged["deletedBreaks"] != float64(1) { + t.Fatalf("响应应带回删除规模,得到 %+v", purged) + } + + // A 的痕迹全没了——包含 voided = 1 的历史流水。 + for _, c := range []struct { + name string + query string + }{ + {"人员", `SELECT COUNT(*) FROM athletes WHERE id = ?`}, + {"报名", `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`}, + {"名下的流水", `SELECT COUNT(*) FROM score_ledger WHERE athlete_id = ?`}, + {"报名名下的流水", `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`}, + } { + arg := ids.athleteA + if strings.Contains(c.name, "报名名下") { + arg = ids.entryA + } + if n := countRows(t, env, c.query, arg); n != 0 { + t.Fatalf("%s 应删干净,得到 %d 条", c.name, n) + } + } + if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryA); n != 0 { + t.Fatalf("成绩应删干净,得到 %d 条", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM record_breaks WHERE entry_id = ?`, ids.entryA); n != 0 { + t.Fatalf("破纪录登记应删干净,得到 %d 条", n) + } + + // 重算发生了:B 升到第一得 10 分,旧流水被作废保留痕迹,没有重复计分。 + if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{10}) { + t.Fatalf("B 应随新名次重算成 10 分,得到 %v", got) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`, ids.entryB); n != 2 { + t.Fatalf("B 应留下「1 条作废 + 1 条有效」,得到 %d 条", n) + } + 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) + } + + // B 的成绩跟着新名次重排:名次列写回 1,分数与发布态不变。 + var ( + rank sql.NullInt64 + value sql.NullFloat64 + state string + status2 string + ) + if err := env.ev.QueryRow(`SELECT rank, result_value, state, status FROM results WHERE entry_id = ?`, ids.entryB). + Scan(&rank, &value, &state, &status2); err != nil { + t.Fatalf("读 B 的成绩: %v", err) + } + if !rank.Valid || rank.Int64 != 1 { + t.Fatalf("B 的名次应重算成 1,得到 %+v", rank) + } + if !value.Valid || value.Float64 != 11.92 || state != "published" || status2 != "ok" { + t.Fatalf("B 的成绩内容不该被改动: value=%+v state=%s status=%s", value, state, status2) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 1 { + t.Fatalf("B 的报名不该被删,得到 %d 条", n) + } + + // 榜单跟着变:A 单位归零,B 单位拿到 10 分。 + if got := teamPoints(t, env, client, ids.teamA); got != 0 { + t.Fatalf("删掉 A 后其单位总分应归零,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamB); got != 10 { + t.Fatalf("B 的单位总分应重算成 10,得到 %v", got) + } + + // 审计带上删除规模与重算的小项。 + after := waitAudit(t, env, "athlete.purge", ids.athleteA) + for _, want := range []string{`"deletedEntries":1`, `"deletedResults":1`, fmt.Sprintf(`"recomputedEvents":[%d]`, ids.event)} { + if !strings.Contains(after, want) { + t.Fatalf("审计应含 %s,得到 %s", want, after) + } + } + + // 删干净之后库里不该留下任何悬挂外键。 + assertNoForeignKeyViolation(t, env) + + // 大屏收到通知:受影响小项与榜单的变更记录都在。 + pushed := countRows(t, env, + `SELECT COUNT(*) FROM change_log WHERE id > ? AND entity_type IN ('events', 'standings')`, logBefore) + if pushed < 2 { + t.Fatalf("应把受影响小项与榜单推给大屏,得到 %d 条变更记录", pushed) + } +} + +// 重算只碰被删成绩所在的组别:另一个组别的流水连 id 都不该变。 +func TestPurgeAthleteLeavesOtherCategoryUntouched(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) + entryB2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, ids.event, cat2, ids.teamB, ids.athB) + publishResults(t, env, client, ids.event, "purge-cat", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + map[string]any{"entryId": entryB2, "resultValue": 12.30, "resultText": "12.30", "unit": "s", "status": "ok"}, + ) + + var beforeID, beforeResultID, beforeEntryID int64 + var beforePoints float64 + if err := env.ev.QueryRow(`SELECT id, result_id, entry_id, points FROM score_ledger + WHERE entry_id = ? AND voided = 0`, entryB2). + Scan(&beforeID, &beforeResultID, &beforeEntryID, &beforePoints); err != nil { + t.Fatalf("读另一个组别的流水: %v", err) + } + + status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/athletes/%d?purge=1", ids.athleteA), nil, true) + if status != http.StatusOK { + t.Fatalf("删除应 200,得到 %d %+v", status, env2.Error) + } + purged := decodeData[map[string]any](t, env2) + // 只重算了 A 成绩所在的组别。 + if events, _ := purged["recomputedEvents"].([]any); len(events) != 1 || events[0] != float64(ids.event) { + t.Fatalf("只应重算受影响小项,得到 %+v", purged["recomputedEvents"]) + } + + var afterID, afterResultID, afterEntryID int64 + var afterPoints float64 + if err := env.ev.QueryRow(`SELECT id, result_id, entry_id, points FROM score_ledger + WHERE entry_id = ? AND voided = 0`, entryB2). + Scan(&afterID, &afterResultID, &afterEntryID, &afterPoints); err != nil { + t.Fatalf("重算后读另一个组别的流水: %v", err) + } + if afterID != beforeID || afterResultID != beforeResultID || afterEntryID != beforeEntryID || afterPoints != beforePoints { + t.Fatalf("另一个组别的流水不该被重算: 之前 id=%d result=%d entry=%d points=%v,之后 id=%d result=%d entry=%d points=%v", + beforeID, beforeResultID, beforeEntryID, beforePoints, afterID, afterResultID, afterEntryID, afterPoints) + } + // 另一个组别的报名与成绩也在。 + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, entryB2); n != 1 { + t.Fatalf("另一个组别的报名不该被动,得到 %d 条", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, entryB2); n != 1 { + t.Fatalf("另一个组别的成绩不该被动,得到 %d 条", n) + } +} + +// assertNoForeignKeyViolation 断言库里没有悬挂外键——级联删除最容易留下的后患。 +func assertNoForeignKeyViolation(t *testing.T, env *testEnv) { + t.Helper() + rows, err := env.ev.Query(`PRAGMA foreign_key_check`) + if err != nil { + t.Fatalf("外键检查: %v", err) + } + defer func() { _ = rows.Close() }() + if rows.Next() { + var table string + if err := rows.Scan(&table, new(any), new(any), new(any)); err != nil { + t.Fatalf("读外键检查结果: %v", err) + } + t.Fatalf("删除后存在外键悬挂,表 %s", table) + } +} + +// 人员不存在时(含 purge=1)404 沿用仓库既有语义。 +func TestPurgeMissingAthleteReturns404(t *testing.T) { + env, _, client := recorderWithSeed(t) + + status, env2 := env.do(client, http.MethodDelete, "/api/athletes/999999?purge=1", nil, true) + if status != http.StatusNotFound || env2.Error == nil || env2.Error.Message != "运动员不存在" { + t.Fatalf("不存在的人员应 404 运动员不存在,得到 %d %+v", status, env2.Error) + } +} + +// 不带参数的 DELETE 仍是停用:只置 active = 0,报名、成绩、流水全留着。 +func TestSoftDeleteAthleteKeepsData(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "soft", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + ) + + // purge=0 这类明确否定的写法同样按停用处理。 + for _, path := range []string{ + fmt.Sprintf("/api/athletes/%d", ids.athleteA), + fmt.Sprintf("/api/athletes/%d?purge=0", ids.athleteA), + } { + if status, env2 := env.do(client, http.MethodDelete, path, nil, true); status != http.StatusOK || !env2.OK { + t.Fatalf("DELETE %s 应 200,得到 %d %+v", path, status, env2.Error) + } + } + + if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 1 { + t.Fatalf("停用应把 active 置 0,得到 %d 条", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 { + t.Fatalf("停用不该动报名,得到 %d 条", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryA); n != 1 { + t.Fatalf("停用不该动成绩,得到 %d 条", n) + } + if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) { + t.Fatalf("停用不该动流水,得到 %v", got) + } +} diff --git a/server/internal/httpapi/entry_status.go b/server/internal/httpapi/entry_status.go new file mode 100644 index 0000000..8a42797 --- /dev/null +++ b/server/internal/httpapi/entry_status.go @@ -0,0 +1,32 @@ +package httpapi + +import ( + "fmt" + "strings" +) + +// normalizeImportStatus 把报名表 status 列归一成 entries.status 允许的四个取值。 +// +// entries.status 上有 CHECK (status IN ('registered','scratched','dq','dns')), +// 而填表人习惯写大写(DNS、Registered)或中文(退赛、取消资格),原样入库会撞约束, +// 报出来的又是原始 SQLite 错误,现场看不出是 status 这一格填错了。 +func normalizeImportStatus(raw string) (string, error) { + // 中文输入法打出的全角空格 TrimSpace 不认,先统一成半角再裁首尾。 + v := strings.TrimSpace(strings.ReplaceAll(raw, "\u3000", " ")) + + // 大小写不敏感,所以先转小写再比对;中文别名不受影响。 + switch strings.ToLower(v) { + case "": + // 空值即建表默认值:不填就是不填,按正常参赛处理。 + return "registered", nil + case "registered", "正常", "正常参赛", "参赛": + return "registered", nil + case "scratched", "退赛", "弃赛", "请假": + return "scratched", nil + case "dq", "取消资格", "犯规": + return "dq", nil + case "dns", "未出场", "未到", "缺席": + return "dns", nil + } + return "", fmt.Errorf("status「%s」无法识别,只能填 registered / scratched / dq / dns(或留空)", v) +} diff --git a/server/internal/httpapi/entry_status_test.go b/server/internal/httpapi/entry_status_test.go new file mode 100644 index 0000000..675fcc4 --- /dev/null +++ b/server/internal/httpapi/entry_status_test.go @@ -0,0 +1,54 @@ +package httpapi + +import ( + "strings" + "testing" +) + +// status 归一:大小写不敏感、认中文别名、空值按 registered,认不出的必须报错而不是原样入库。 +func TestNormalizeImportStatus(t *testing.T) { + ok := map[string]string{ + "": "registered", + " ": "registered", + "\u3000": "registered", // 只有全角空格 + "registered": "registered", + "Registered": "registered", + "REGISTERED": "registered", + " \u3000registered\u3000 ": "registered", + "正常": "registered", + "正常参赛": "registered", + "参赛": "registered", + "scratched": "scratched", + "Scratched": "scratched", + "退赛": "scratched", + "弃赛": "scratched", + "请假": "scratched", + "dq": "dq", + "DQ": "dq", + "取消资格": "dq", + "犯规": "dq", + "dns": "dns", + "DNS": "dns", + "未出场": "dns", + "未到": "dns", + "缺席": "dns", + } + for raw, want := range ok { + got, err := normalizeImportStatus(raw) + if err != nil || got != want { + t.Errorf("normalizeImportStatus(%q) = (%q, %v),期望 (%q, nil)", raw, got, err, want) + } + } + + for _, raw := range []string{"已报到", "unknown", "男子", "DNF", "registered/scratched"} { + got, err := normalizeImportStatus(raw) + if err == nil { + t.Errorf("normalizeImportStatus(%q) 应报错,得到 %q", raw, got) + continue + } + // 错误消息要能直接拼在「第 N 行:」后面,且带上原始值。 + if !strings.Contains(err.Error(), raw) || !strings.Contains(err.Error(), "无法识别") { + t.Errorf("normalizeImportStatus(%q) 的错误应含原值与「无法识别」,得到 %q", raw, err) + } + } +} diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index 2de1752..8208582 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -518,20 +518,40 @@ func (s *server) updateAthleteHandler(w http.ResponseWriter, r *http.Request) (a return updated, nil } -func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) error { +// deleteAthleteHandler 默认软删除(停用);带 purge=1 时物理删除人员、报名、成绩并重算受影响的积分。 +func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) { id, err := pathID(r, "id") if err != nil { - return err + return nil, err } before, err := s.getAthlete(r.Context(), id) if err != nil { - return err + return nil, err } - if err := s.deleteAthlete(r.Context(), id); err != nil { - return err + if !isQueryTrue(r, "purge") { + if err := s.deleteAthlete(r.Context(), id); err != nil { + return nil, err + } + s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil) + return nil, nil } - s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil) - return nil + purged, err := s.purgeAthlete(r.Context(), id) + if err != nil { + return nil, err + } + // 审计带上删除规模与重算过的小项,事后能对上「榜为什么变了」。 + s.audit(r.Context(), "athlete.purge", "athlete", id, before, purged) + return purged, nil +} + +// isQueryTrue 解析开关型查询参数:只认 1 / true(大小写不敏感),其余值一律当 false。 +// 写错的值不会误开开关——purge=0、purge=false 都还是软删除。 +func isQueryTrue(r *http.Request, key string) bool { + switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key))) { + case "1", "true": + return true + } + return false } // ---------- 报名表导入 ---------- @@ -776,6 +796,13 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:event_code / team_code 必填", line)) continue } + // status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。 + status, err := normalizeImportStatus(cell("status")) + if err != nil { + result.Skipped++ + result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error())) + continue + } eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode) if err != nil { @@ -872,7 +899,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio MemberNames: splitNames(cell("member_names")), Bib: nilIfEmpty(cell("bib")), Seed: nilIfEmpty(cell("seed")), - Status: orDefault(cell("status"), "registered"), + Status: status, }) switch { case err != nil: diff --git a/server/internal/httpapi/handlers_config_test.go b/server/internal/httpapi/handlers_config_test.go index f4789f6..037c9f9 100644 --- a/server/internal/httpapi/handlers_config_test.go +++ b/server/internal/httpapi/handlers_config_test.go @@ -878,6 +878,77 @@ func TestImportResolveKeepNewAgeOnlyOverwritesWhenGiven(t *testing.T) { } } +// status 列归一:DNS / 退赛 / 空串 / Registered 都能正常入库,库里存的是四个合法值之一。 +func TestImportNormalizesStatusColumn(t *testing.T) { + env, _, client := recorderWithSeed(t) + + cases := []struct{ cell, want string }{ + {"DNS", "dns"}, + {"退赛", "scratched"}, + {"", "registered"}, + {"Registered", "registered"}, + } + body := importHeader + "\n" + for i, c := range cases { + body += fmt.Sprintf("M100,男子,A,选手%d,11010119900101%04d,男,,,,%s,\n", i+1, i+1, c.cell) + } + 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(len(cases)) || result["skipped"] != float64(0) { + t.Fatalf("四种写法都应导入成功: %+v", result) + } + + for i, c := range cases { + idCard := fmt.Sprintf("11010119900101%04d", i+1) + var got string + if err := env.ev.QueryRow(`SELECT e.status FROM entries e JOIN athletes a ON a.id = e.athlete_id + WHERE a.id_card = ?`, idCard).Scan(&got); err != nil { + t.Fatalf("查询 status(%s): %v", c.cell, err) + } + if got != c.want { + t.Errorf("status=%q 应存成 %q,得到 %q", c.cell, c.want, got) + } + } +} + +// status 填了认不出的值:该行跳过,错误消息要是中文可读的行级提示,不能是原始 SQLite 报错。 +func TestImportRejectsUnknownStatus(t *testing.T) { + env, _, client := recorderWithSeed(t) + + body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,已报到,\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("乱填 status 的行应跳过: %+v", result) + } + errs, _ := result["errors"].([]any) + if len(errs) != 1 { + t.Fatalf("应有 1 条行级错误,得到 %+v", errs) + } + msg, _ := errs[0].(string) + for _, want := range []string{"第 2 行", "已报到", "registered / scratched / dq / dns"} { + if !strings.Contains(msg, want) { + t.Fatalf("错误消息应含 %q,得到 %q", want, msg) + } + } + + // 该行一个字段都不该落库:既不建人员,也不建报名。 + var entries int + if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entries e JOIN athletes a ON a.id = e.athlete_id + WHERE a.id_card = ?`, "110101199001011234").Scan(&entries); err != nil { + t.Fatalf("统计报名: %v", err) + } + if entries != 0 { + t.Fatalf("认不出 status 的行不应落库,得到 %d 条报名", entries) + } +} + // ---------- 图片上传与静态访问 ---------- // newUploadEnv 构造带赛事项目目录的测试环境,返回 uploads 目录的真实路径。 diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go index 0a8599b..b30470a 100644 --- a/server/internal/httpapi/repo.go +++ b/server/internal/httpapi/repo.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "slices" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" @@ -103,12 +104,18 @@ func requireChanges(res sql.Result, message string) error { return nil } +// isUniqueViolation 判断错误是否来自 UNIQUE 约束。 +// +// 必须只认 "unique constraint failed":SQLite 把 CHECK / NOT NULL / FOREIGN KEY 失败 +// 也报成 "... constraint failed",早先按 "constraint failed" 宽松匹配会把它们一律 +// 当成「已存在」,于是「小项 status 取值非法」这种错被报成「小项编码已存在」, +// 现场会照着提示去找一个根本不存在的重名编码。CHECK / NOT NULL 属于另一类错误, +// 交给调用方按内部错误处理,别在这里吞掉。 func isUniqueViolation(err error) bool { if err == nil { return false } - msg := strings.ToLower(err.Error()) - return strings.Contains(msg, "unique") || strings.Contains(msg, "constraint failed") + return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed") } func isForeignKeyViolation(err error) bool { @@ -467,6 +474,139 @@ func (s *server) deleteAthlete(ctx context.Context, id int64) error { return requireChanges(res, "运动员不存在") } +// athletePurge 是物理删除的规模统计:随响应体返回给前端做提示,同时进审计。 +type athletePurge struct { + DeletedEntries int `json:"deletedEntries"` + DeletedResults int `json:"deletedResults"` + DeletedLedger int `json:"deletedLedger"` + DeletedBreaks int `json:"deletedBreaks"` + RecomputedEvent []int64 `json:"recomputedEvents"` +} + +// purgeAthlete 物理删除人员及其全部痕迹,并在同一事务内重算受影响的积分。 +// +// 删除顺序按外键方向来(PRAGMA foreign_key_list 实测): +// score_ledger 三种引用都先清(athlete_id / entry_id / result_id,后两者本来是 SET NULL, +// 但留着会变成指向不了任何东西的空壳流水,voided = 1 的历史流水同样要删); +// 再删 record_breaks(对 entries 是 CASCADE,显式删更清楚); +// 然后 results(对 entries 无 ON DELETE,必须先于 entries 删)、entries、athletes。 +// +// 成绩被删会让名次与得分变化,所以删完必须按 (小项, 组别) 重算; +// 重算走 recomputeLedgerTx,它只作废引擎产出的流水,人工调整绝不动。 +func (s *server) purgeAthlete(ctx context.Context, id int64) (athletePurge, error) { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return athletePurge{}, internal(err) + } + defer func() { _ = tx.Rollback() }() + + // 先收集受影响范围:这些成绩所属的 (小项, 组别) 在删除后要重算。 + type scope struct{ eventID, categoryID int64 } + scopeRows, 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 = ?`, id) + if err != nil { + return athletePurge{}, internal(err) + } + scopes := make([]scope, 0, 4) + for scopeRows.Next() { + var sc scope + if err := scopeRows.Scan(&sc.eventID, &sc.categoryID); err != nil { + _ = scopeRows.Close() + return athletePurge{}, internal(err) + } + scopes = append(scopes, sc) + } + err = scopeRows.Err() + _ = scopeRows.Close() + if err != nil { + return athletePurge{}, internal(err) + } + + var out athletePurge + for _, step := range []struct { + query string + args []any + count *int + }{ + {`DELETE FROM score_ledger + WHERE athlete_id = ? + OR entry_id IN (SELECT id FROM entries WHERE athlete_id = ?) + OR result_id IN (SELECT r.id FROM results r JOIN entries e ON e.id = r.entry_id + WHERE e.athlete_id = ?)`, []any{id, id, id}, &out.DeletedLedger}, + {`DELETE FROM record_breaks + WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id = ?)`, []any{id}, &out.DeletedBreaks}, + {`DELETE FROM results + WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id = ?)`, []any{id}, &out.DeletedResults}, + {`DELETE FROM entries WHERE athlete_id = ?`, []any{id}, &out.DeletedEntries}, + } { + res, err := tx.ExecContext(ctx, step.query, step.args...) + if err != nil { + return athletePurge{}, s.purgeBlocked(ctx, tx, id, err) + } + n, err := res.RowsAffected() + if err != nil { + return athletePurge{}, internal(err) + } + *step.count = int(n) + } + res, err := tx.ExecContext(ctx, `DELETE FROM athletes WHERE id = ?`, id) + if err != nil { + return athletePurge{}, s.purgeBlocked(ctx, tx, id, err) + } + // 走到这里说明并发删过了,回滚别把成绩删了却报成功。 + if err := requireChanges(res, "运动员不存在"); err != nil { + return athletePurge{}, err + } + + // 重算:删完成绩后按组别重建流水,别人的名次与得分随新排序更新。 + // 只重建流水还不够——成绩表与奖牌统计看的是 results.rank,必须像发布那样把名次写回去。 + actorID := middleware.User(ctx).ID + eventIDs := make([]int64, 0, len(scopes)) + for _, sc := range scopes { + score, err := recomputeLedgerTx(ctx, tx, sc.eventID, sc.categoryID, actorID, "人员删除后重算") + if err != nil { + return athletePurge{}, err + } + if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { + return athletePurge{}, err + } + if !slices.Contains(eventIDs, sc.eventID) { + eventIDs = append(eventIDs, sc.eventID) + } + } + // 推大屏:受影响小项与榜单,否则大屏还挂着旧分数。 + for _, eventID := range eventIDs { + if err := s.bumpEvent(ctx, tx, eventID); err != nil { + return athletePurge{}, err + } + } + if len(eventIDs) > 0 { + if err := s.bumpStandings(ctx, tx, actorID); err != nil { + return athletePurge{}, err + } + } + out.RecomputedEvent = eventIDs + + if err := tx.Commit(); err != nil { + return athletePurge{}, s.purgeBlocked(ctx, tx, id, err) + } + return out, nil +} + +// purgeBlocked 兜底:撞上外键约束时回滚并给出可读的 409,不让它冒成 500。 +// 正常路径已经按外键方向把引用清干净了,走到这里说明还有没覆盖到的引用,必须留日志证据。 +func (s *server) purgeBlocked(ctx context.Context, tx *sql.Tx, id int64, cause error) error { + if !isForeignKeyViolation(cause) { + return internal(cause) + } + s.logger.WarnContext(ctx, "彻底删除人员撞上外键约束", "athleteId", id, "err", cause) + // 连接池只有一条连接,先把事务交还再返回,避免后续查询自锁。 + _ = tx.Rollback() + return conflict("该运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,删除已回滚,人员和成绩都没有改动。请先删除相关报名与成绩后重试。") +} + // ---------- sports ---------- const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active` diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 4827356..302de6c 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -209,7 +209,7 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("POST /api/athletes", s.write(auth.RoleRecorder, s.createAthleteHandler)) 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.writeVoid(auth.RoleRecorder, s.deleteAthleteHandler)) + mux.HandleFunc("DELETE /api/athletes/{id}", s.write(auth.RoleRecorder, s.deleteAthleteHandler)) 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)) diff --git a/server/migrations/event/migration_test.go b/server/migrations/event/migration_test.go index f686ce2..9a4135b 100644 --- a/server/migrations/event/migration_test.go +++ b/server/migrations/event/migration_test.go @@ -67,13 +67,29 @@ func applyBefore(t *testing.T, db *sql.DB, name string) { if _, err := db.Exec(string(body)); err != nil { t.Fatalf("应用迁移 %s: %v", n, err) } - // 与 store.migrate 一样登记,升级时才能只补跑 0016。 + // 与 store.migrate 一样登记,升级时才能只补跑后面的迁移。 if _, err := db.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, n); err != nil { t.Fatalf("登记迁移 %s: %v", n, err) } } } +// migrationCount 是嵌入的赛事库迁移条数:升级后登记数必须与文件数一致, +// 新增迁移时这条断言不用改。 +func migrationCount(t *testing.T) int { + t.Helper() + + fsys, err := fs.Sub(migrations.EventFS, "event") + if err != nil { + t.Fatalf("定位迁移目录: %v", err) + } + names, err := fs.Glob(fsys, "*.sql") + if err != nil { + t.Fatalf("列出迁移: %v", err) + } + return len(names) +} + func countRows(t *testing.T, db *sql.DB, table string) int { t.Helper() @@ -146,8 +162,8 @@ func TestMigration0016PreservesExistingRows(t *testing.T) { if err := store.MigrateEvent(db); err != nil { t.Fatalf("升级迁移: %v", err) } - if got := countRows(t, db, "schema_migrations"); got != 17 { - t.Errorf("迁移登记数 = %d,期望 17", got) + if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want { + t.Errorf("迁移登记数 = %d,期望 %d", got, want) } for table, want := range map[string]int{ @@ -345,12 +361,12 @@ func TestMigration0017PreservesExistingRows(t *testing.T) { } } - // 走真实升级路径:只补跑未登记的 0016、0017。 + // 走真实升级路径:只补跑未登记的 0017 及其后的迁移。 if err := store.MigrateEvent(db); err != nil { t.Fatalf("升级迁移: %v", err) } - if got := countRows(t, db, "schema_migrations"); got != 17 { - t.Errorf("迁移登记数 = %d,期望 17", got) + if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want { + t.Errorf("迁移登记数 = %d,期望 %d", got, want) } for table, want := range map[string]int{ "events": 1, "event_categories": 1, "event_score_channels": 1, "entries": 1, diff --git a/web/src/admin/api/client.ts b/web/src/admin/api/client.ts index 775a416..c8c5828 100644 --- a/web/src/admin/api/client.ts +++ b/web/src/admin/api/client.ts @@ -1,5 +1,6 @@ import { ref } from 'vue' import { ApiError, createApiClient } from '@/shared/api' +import type { AthletePurgeResult } from '@/shared/api' import type { AthleteStanding, AuditLog, @@ -156,6 +157,13 @@ export const adminApi = { breakdown: (teamId: number): Promise => call(() => client.standings.breakdown(teamId), () => mockDb.breakdown(teamId)), + /** + * 彻底删除运动员:服务端物理删除人员、报名与成绩,并重算受影响小项的积分与名次, + * 返回删除规模统计供页面提示。失败原样抛出:人员不存在(404)或网络错误都要让操作员看到那句话。 + */ + purgeAthlete: (id: number): Promise => + callWrite(() => client.athletes.purge(id)), + records: (): Promise => call(() => client.records.list(), () => mockDb.records()), diff --git a/web/src/admin/components/baseData/AthletesSection.vue b/web/src/admin/components/baseData/AthletesSection.vue index 0736963..decebb7 100644 --- a/web/src/admin/components/baseData/AthletesSection.vue +++ b/web/src/admin/components/baseData/AthletesSection.vue @@ -1,6 +1,7 @@ @@ -340,4 +419,12 @@ async function toggleActive(athlete: Athlete): Promise { .danger-text { color: var(--mx-red); } +.purge-error { + display: flex; + align-items: flex-start; + gap: 6px; + margin-bottom: 14px; + padding: 10px 12px; + line-height: 1.5; +} diff --git a/web/src/admin/components/baseData/baseDataApi.ts b/web/src/admin/components/baseData/baseDataApi.ts index 7f7c523..b66a01d 100644 --- a/web/src/admin/components/baseData/baseDataApi.ts +++ b/web/src/admin/components/baseData/baseDataApi.ts @@ -1,5 +1,6 @@ import { ref } from 'vue' import { ApiError, adminApi, mockMode, mockReason } from '../../api/client' +import type { AthletePurgeResult } from '@/shared/api' import type { Athlete, AthleteInput, @@ -41,12 +42,29 @@ const FALLBACK_CODES: readonly string[] = ['NETWORK', 'TIMEOUT', 'PARSE_FAILED'] */ export const baseDataFallback = ref(false) -function isFallback(error: unknown): boolean { - // 404 表示接口在当前服务端尚未实现,与整个后端没起来一样按「未就绪」处理。 - return error instanceof ApiError && (FALLBACK_CODES.includes(error.code) || error.status === 404) +interface CallOptions { + /** + * 404 是否也算「接口未实现」而降级到示例数据;默认 true。 + * + * 按 ID 的删除必须传 false:那里的 404 是「这条记录已经不在了」这种真实回答, + * 降级成示例数据会把「根本没删掉」显示成「删除成功」。 + */ + fallbackOn404?: boolean } -async function call(real: () => Promise, mock: () => T | Promise): Promise { +function isFallback(error: unknown, fallbackOn404: boolean): boolean { + if (!(error instanceof ApiError)) return false + if (FALLBACK_CODES.includes(error.code)) return true + // 404 表示接口在当前服务端尚未实现,与整个后端没起来一样按「未就绪」处理; + // 但按 ID 删除时 404 也可能是「这条记录已经不在了」,那种调用方会传 fallbackOn404: false。 + return fallbackOn404 && error.status === 404 +} + +async function call( + real: () => Promise, + mock: () => T | Promise, + options: CallOptions = {}, +): Promise { try { const value = await real() baseDataFallback.value = false @@ -56,7 +74,7 @@ async function call(real: () => Promise, mock: () => T | Promise): Prom } return value } catch (error) { - if (!isFallback(error)) throw error + if (!isFallback(error, options.fallbackOn404 ?? true)) throw error baseDataFallback.value = true mockMode.value = true mockReason.value = error instanceof Error ? error.message : '无法连接服务端' @@ -121,6 +139,17 @@ export const baseDataApi = { call(() => adminApi.client.athletes.update(id, input), () => mockBaseData.updateAthlete(id, input)), remove: (id: number): Promise => call(() => adminApi.client.athletes.remove(id), () => mockBaseData.removeAthlete(id)), + /** + * 彻底删除:服务端物理删除该人员及其报名、成绩并重算积分,返回删除规模统计。 + * + * 与同组的 remove 不同,这里故意不让 404 降级:404 是「人员已不在」的真实回答, + * 落到示例数据上会让页面把「没删掉」提示成「已彻底删除」,现场就出事了。 + * 后端整体连不上(网络 / 超时)时仍照常降级,示例数据里可以照删。 + */ + purge: (id: number): Promise => + call(() => adminApi.purgeAthlete(id), () => mockBaseData.purgeAthlete(id), { + fallbackOn404: false, + }), }, entries: { diff --git a/web/src/admin/components/baseData/mockBaseData.ts b/web/src/admin/components/baseData/mockBaseData.ts index 00c1cf4..6407c40 100644 --- a/web/src/admin/components/baseData/mockBaseData.ts +++ b/web/src/admin/components/baseData/mockBaseData.ts @@ -1,4 +1,5 @@ import { normalizeAvgMode, normalizeCountToTeam } from '@/shared' +import type { AthletePurgeResult } from '@/shared/api' import type { Athlete, AthleteInput, @@ -465,6 +466,26 @@ export class MockBaseData { this.athletes.splice(index, 1) } + /** + * 彻底删除:与真实服务端一致,人员、报名、成绩一起消失(这里就是把人从内存名单里移掉)。 + * + * 示例数据的报名只是每个小项上的一个条数、成绩与积分流水更是没有建模, + * 没有归属到具体人员的东西可删,所以统计各项都是 0、也没有小项需要重算; + * 页面照样能拿到完整的统计结构,不会是 undefined。 + */ + purgeAthlete(id: number): AthletePurgeResult { + const index = this.athletes.findIndex((athlete) => athlete.id === id) + if (index < 0) throw new Error(`运动员 ${id} 不存在`) + this.athletes.splice(index, 1) + return { + deletedEntries: 0, + deletedResults: 0, + deletedLedger: 0, + deletedBreaks: 0, + recomputedEvents: [], + } + } + // ── 报名导入 ────────────────────────────────────────────────────────────── async importEntries(file: File): Promise { diff --git a/web/src/admin/components/baseData/useBaseData.ts b/web/src/admin/components/baseData/useBaseData.ts index a5f2bf9..fcd76e4 100644 --- a/web/src/admin/components/baseData/useBaseData.ts +++ b/web/src/admin/components/baseData/useBaseData.ts @@ -19,6 +19,7 @@ import type { Team, TeamInput, } from '@/shared' +import type { AthletePurgeResult } from '@/shared/api' import { adminApi, mockMode, mockReason } from '../../api/client' import { useAuthStore } from '../../stores/auth' import { useEventStore } from '../../stores/event' @@ -233,6 +234,13 @@ async function removeAthlete(id: number): Promise { await reloadAthletes() } +/** 彻底删除:重新拉取名单,删除规模统计回给页面做提示;失败(人员已不在、网络错误)原样抛出。 */ +async function purgeAthlete(id: number): Promise { + const result = await baseDataApi.athletes.purge(id) + await reloadAthletes() + return result +} + function importEntries(file: File): Promise { return baseDataApi.entries.importFile(file) } @@ -307,6 +315,8 @@ export interface BaseDataController { saveProfile: (input: ScoringProfileInput, id: number | null) => Promise saveAthlete: (input: AthleteInput, id: number | null) => Promise removeAthlete: (id: number) => Promise + /** 返回删除规模统计(报名 / 成绩 / 流水条数、重算过的小项),页面据此提示「榜为什么变了」。 */ + purgeAthlete: (id: number) => Promise importEntries: (file: File) => Promise resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise /** 只改榜单口径的全局系数,其余设置原样回传;保存后同步 event store,页面立即生效。 */ @@ -354,6 +364,7 @@ export function useBaseData(): BaseDataController { saveProfile, saveAthlete, removeAthlete, + purgeAthlete, importEntries, resolveImportConflicts, saveIndividualToTeamWeight, diff --git a/web/src/admin/tests/athlete-purge-api.test.ts b/web/src/admin/tests/athlete-purge-api.test.ts new file mode 100644 index 0000000..ceef8f4 --- /dev/null +++ b/web/src/admin/tests/athlete-purge-api.test.ts @@ -0,0 +1,185 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +/** + * 运动员「彻底删除」的前端契约:方法 / 路径 + `?purge=1`,以及失败时服务端那句话的传递。 + * + * 服务端的原文(例如 404「运动员不存在」)一旦被换成「操作失败」现场就只能靠猜; + * 而 404 又是「人员已不在」的真实回答,绝不能降级成示例数据假装删掉了。 + */ + +interface Captured { + url?: string + method?: string + headers?: Record +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +let reply: () => Response = () => jsonResponse({ ok: true, data: null }) +let lastRequest: Captured = {} + +// 必须在导入被测模块之前装好:ApiClient 在构造时就把当时的 fetch 绑进实例, +// 之后再替换 globalThis.fetch 对它无效。所以这里装一个固定函数,每个用例只换回复。 +globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + lastRequest = { + url: String(input), + method: init?.method ?? 'GET', + headers: (init?.headers ?? {}) as Record, + } + return reply() +}) as typeof fetch + +function respondWith(response: () => Response): void { + lastRequest = {} + reply = response +} + +const { ApiError, adminApi } = await import('../api/client') +const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi') + +test('彻底删除走 DELETE /api/athletes/{id}?purge=1', async () => { + respondWith(() => jsonResponse({ ok: true, data: null })) + + await adminApi.purgeAthlete(7) + + assert.equal(lastRequest.url, '/api/athletes/7?purge=1') + assert.equal(lastRequest.method, 'DELETE') +}) + +// 字段名与服务端 athletePurge 一致;页面提示语要靠它说清「删了什么、重算了哪些小项」。 +test('成功响应带统计时,客户端拿到删除规模与重算小项', async () => { + respondWith(() => + jsonResponse({ + ok: true, + data: { deletedEntries: 1, deletedResults: 1, deletedLedger: 2, deletedBreaks: 0, recomputedEvents: [1] }, + }), + ) + + const result = await adminApi.purgeAthlete(7) + + assert.deepEqual(result, { + deletedEntries: 1, + deletedResults: 1, + deletedLedger: 2, + deletedBreaks: 0, + recomputedEvents: [1], + }) +}) + +// 删除在服务端已经提交、不可回滚:统计缺失时也只能按 0 报,不能冒出 undefined 或报成失败。 +test('服务端没带统计时按 0 / 空数组兜底', async () => { + respondWith(() => jsonResponse({ ok: true, data: null })) + + const result = await adminApi.purgeAthlete(7) + + assert.deepEqual(result, { + deletedEntries: 0, + deletedResults: 0, + deletedLedger: 0, + deletedBreaks: 0, + recomputedEvents: [], + }) +}) + +// 服务端中间件要求已登录的写请求带 X-CSRF-Token,漏掉就是 403,删不掉还看不出原因。 +test('彻底删除带上 CSRF 双提交 Cookie 的请求头', async () => { + respondWith(() => jsonResponse({ ok: true, data: null })) + const globalWithDocument = globalThis as { document?: unknown } + globalWithDocument.document = { cookie: 'csrf_token=token-123' } + try { + await adminApi.purgeAthlete(1) + } finally { + delete globalWithDocument.document + } + + assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'token-123') +}) + +test('失败时不吞错误:服务端那句原文原样带出', async () => { + respondWith(() => + jsonResponse({ ok: false, error: { code: 'INTERNAL', message: '服务端异常' } }, 500), + ) + + await assert.rejects( + () => adminApi.purgeAthlete(7), + (error: unknown) => { + assert.ok(error instanceof ApiError) + assert.equal(error.status, 500) + assert.equal(error.code, 'INTERNAL') + assert.equal(error.message, '服务端异常') + return true + }, + ) +}) + +// 页面调用的是 baseDataApi 这一层:权限 / 服务端错误都是真实回答,不能降级成示例数据假装删掉了。 +test('基础数据层把服务端错误原样抛给页面,不降级成示例数据', async () => { + baseDataFallback.value = false + respondWith(() => + jsonResponse({ ok: false, error: { code: 'FORBIDDEN', message: '需要管理员权限' } }, 403), + ) + + await assert.rejects(() => baseDataApi.athletes.purge(7), (error: Error) => + error.message.includes('需要管理员权限'), + ) + assert.equal(baseDataFallback.value, false, '不应切到示例数据') +}) + +// 404 是「人员已经被别人删掉」这种真实回答,不是「接口未实现」: +// 一旦降级到示例数据,页面会把「根本没删掉」提示成「已彻底删除」。 +test('purge 收到 404 时不降级,错误原样抛给页面', async () => { + baseDataFallback.value = false + respondWith(() => + jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '运动员不存在' } }, 404), + ) + + await assert.rejects( + () => baseDataApi.athletes.purge(7), + (error: unknown) => { + assert.ok(error instanceof ApiError) + assert.equal(error.status, 404) + assert.equal(error.message, '运动员不存在') + return true + }, + ) + assert.equal(baseDataFallback.value, false, '不应切到示例数据') +}) + +// 对照组:同组的软删除 remove 行为不变,404 仍按「接口未实现」降级到示例数据。 +// 示例数据里 id=1001 存在,能删掉就说明确实走了 mock 分支,而不是把 404 抛了出来。 +test('remove 的 404 仍降级到示例数据(既有行为未受影响)', async () => { + baseDataFallback.value = false + respondWith(() => + jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '运动员不存在' } }, 404), + ) + + await baseDataApi.athletes.remove(1001) + + assert.equal(baseDataFallback.value, true) +}) + +// 后端整体连不上时仍降级到示例数据;示例数据也返回完整的统计结构,页面不会拿到 undefined。 +// (示例数据里没有归属到人员的报名与成绩,所以各项是 0,用 id=1002 避开上一条已删掉的 1001。) +test('后端不可用降级示例数据时,统计结构依然完整', async () => { + baseDataFallback.value = false + respondWith(() => { + throw new TypeError('fetch failed') + }) + + const result = await baseDataApi.athletes.purge(1002) + + assert.deepEqual(result, { + deletedEntries: 0, + deletedResults: 0, + deletedLedger: 0, + deletedBreaks: 0, + recomputedEvents: [], + }) + assert.equal(baseDataFallback.value, true) +}) diff --git a/web/src/shared/api.ts b/web/src/shared/api.ts index 3570716..ee740a1 100644 --- a/web/src/shared/api.ts +++ b/web/src/shared/api.ts @@ -139,6 +139,33 @@ export interface EventsApi extends CrudApi { saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise } +/** + * 彻底删除的规模统计(服务端 `athletePurge`),同时进审计日志。 + * + * 名次与积分会随成绩消失而变化,所以 `recomputedEvents` 是给操作员看的重点: + * 删掉一个人之后,哪些小项的榜单被重算了。 + */ +export interface AthletePurgeResult { + deletedEntries: number + deletedResults: number + deletedLedger: number + deletedBreaks: number + /** 因成绩删除而重算了积分与名次的小项 ID。 */ + recomputedEvents: number[] +} + +export interface AthletesApi extends CrudApi { + /** + * 彻底删除(`DELETE /api/athletes/{id}?purge=1`)。 + * + * 与 `remove` 的软删除(停用,`active = 0`,数据都在)不同,这是物理删除: + * 服务端在同一事务里删掉该人员的人员行、报名、成绩与相关积分流水, + * 并按新排序重算受影响小项的积分与名次(别人的名次会变),随后推送大屏刷新。 + * 人员已被别人删掉时回 404。 + */ + purge(id: number): Promise +} + export interface EntriesApi { /** 报名表按文件导入,后端解析 xlsx。 */ importFile(file: File): Promise @@ -252,6 +279,39 @@ function createCrudApi(http: Http, base: string): CrudApi { } } +/** + * 读删除统计。 + * + * 统计缺失时按 0 / 空数组处理:删除在服务端已经提交、不可回滚,这时提示里冒出 undefined + * 或直接报「删除失败」都是在骗操作员。老服务端不认 ?purge=1、data 为 null 时也会走到这里。 + */ +function readPurgeResult(value: unknown): AthletePurgeResult { + const source = isRecord(value) ? value : {} + const count = (key: string): number => { + const raw = source[key] + return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0 + } + const events = source['recomputedEvents'] + return { + deletedEntries: count('deletedEntries'), + deletedResults: count('deletedResults'), + deletedLedger: count('deletedLedger'), + deletedBreaks: count('deletedBreaks'), + recomputedEvents: Array.isArray(events) + ? events.filter((item): item is number => typeof item === 'number') + : [], + } +} + +function createAthletesApi(http: Http): AthletesApi { + return { + ...createCrudApi(http, '/athletes'), + // 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;响应体带删除统计。 + purge: async (id) => + readPurgeResult(await http.request(`/athletes/${id}`, { method: 'DELETE', query: { purge: 1 } })), + } +} + function createEventsApi(http: Http): EventsApi { return { ...createCrudApi(http, '/events'), @@ -387,7 +447,7 @@ export class ApiClient { readonly sports: CrudApi readonly events: EventsApi readonly scoringProfiles: CrudApi - readonly athletes: CrudApi + readonly athletes: AthletesApi readonly entries: EntriesApi readonly results: ResultsApi readonly standings: StandingsApi @@ -452,7 +512,7 @@ export class ApiClient { this.sports = createCrudApi(http, '/sports') this.events = createEventsApi(http) this.scoringProfiles = createCrudApi(http, '/scoring-profiles') - this.athletes = createCrudApi(http, '/athletes') + this.athletes = createAthletesApi(http) this.entries = { importFile: (file) => {