feat: 运动员彻底删除(真物理删除 + 重算积分)+ 报名表 status 容错 + 部署包带导入规范
## 运动员「彻底删除」= 真删除
`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/ 同步更新。
This commit is contained in:
1 parent
22ab49e73a
commit
9d4ee0946c
22 files changed
+1264
-54
No files matched your search
@@ -18,6 +18,7 @@
|
||||
- **小项类型齐全**:个人 / 团体 / 对抗比赛 / **个人+团体**(同一个项目同时按个人与团体通道记分)
|
||||
- **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗
|
||||
- **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用
|
||||
- **运动员名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」把人员档案连同他名下的报名、成绩与积分流水一起删掉且不可恢复,并自动按新排序重算受影响小项的排名与积分(大屏同步更新)
|
||||
- **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事
|
||||
- **结果导出**:Excel 总表、全部分项、分项成绩表、单位积分明细可分别导出
|
||||
- **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo
|
||||
|
||||
+2
-2
@@ -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):许可页顶部文案 + 记住上次安装目录(重装时默认沿用,不退回系统默认路径)。
|
||||
|
||||
+16
-10
@@ -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 ---------------------------------------------
|
||||
|
||||
+38
-6
@@ -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. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。
|
||||
|
||||
**人员信息冲突的处理**
|
||||
|
||||
|
||||
@@ -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#六常见报错怎么办)。
|
||||
+17
-11
@@ -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. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个证件号或号码布,系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 目录的真实路径。
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TeamBreakdown> =>
|
||||
call(() => client.standings.breakdown(teamId), () => mockDb.breakdown(teamId)),
|
||||
|
||||
/**
|
||||
* 彻底删除运动员:服务端物理删除人员、报名与成绩,并重算受影响小项的积分与名次,
|
||||
* 返回删除规模统计供页面提示。失败原样抛出:人员不存在(404)或网络错误都要让操作员看到那句话。
|
||||
*/
|
||||
purgeAthlete: (id: number): Promise<AthletePurgeResult> =>
|
||||
callWrite(() => client.athletes.purge(id)),
|
||||
|
||||
records: (): Promise<RecordEntry[]> =>
|
||||
call(() => client.records.list(), () => mockDb.records()),
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Athlete, AthleteInput, Gender } from '@/shared'
|
||||
import type { AthletePurgeResult } from '@/shared/api'
|
||||
import MxIcon from '../MxIcon.vue'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
import FormDialog from './FormDialog.vue'
|
||||
@@ -40,6 +41,12 @@ const confirmOpen = ref(false)
|
||||
const confirmTarget = ref<Athlete | null>(null)
|
||||
const confirmPending = ref(false)
|
||||
|
||||
const purgeOpen = ref(false)
|
||||
const purgeTarget = ref<Athlete | null>(null)
|
||||
const purgePending = ref(false)
|
||||
/** 删除失败时的说明留在页面上:服务端那句原文(人员已不在等)比一行「操作失败」有用得多。 */
|
||||
const purgeError = ref<string | null>(null)
|
||||
|
||||
function emptyForm(): AthleteForm {
|
||||
return { name: '', gender: '', age: '', teamId: '', employeeNo: '', idCard: '', bib: '', active: true }
|
||||
}
|
||||
@@ -174,6 +181,49 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
confirmPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function askPurge(athlete: Athlete): void {
|
||||
purgeTarget.value = athlete
|
||||
purgeError.value = null
|
||||
purgeOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除结果的提示语。
|
||||
*
|
||||
* 名次与积分会随成绩一起变,所以别只说「已删除」:把删了多少条、重算了哪些小项说清楚,
|
||||
* 操作员才不会回头发现榜单变了却找不到原因。
|
||||
*/
|
||||
function purgeSummary(name: string, result: AthletePurgeResult): string {
|
||||
const parts = [`报名 ${result.deletedEntries} 条`, `成绩 ${result.deletedResults} 条`]
|
||||
// 流水与纪录不是每次都有,为 0 时报出来只会让人以为删漏了什么。
|
||||
if (result.deletedLedger > 0) parts.push(`积分流水 ${result.deletedLedger} 条`)
|
||||
if (result.deletedBreaks > 0) parts.push(`纪录 ${result.deletedBreaks} 条`)
|
||||
const recomputed = result.recomputedEvents.length
|
||||
const tail = recomputed > 0 ? `,已重算 ${recomputed} 个小项的积分与名次` : ''
|
||||
return `已彻底删除「${name}」:${parts.join('、')}${tail}`
|
||||
}
|
||||
|
||||
async function confirmPurge(): Promise<void> {
|
||||
const target = purgeTarget.value
|
||||
// 双保险:确认框在请求期间禁用按钮,这里再挡一次重复点击。
|
||||
if (target === null || purgePending.value) return
|
||||
purgePending.value = true
|
||||
purgeError.value = null
|
||||
try {
|
||||
const result = await data.purgeAthlete(target.id)
|
||||
toast.success(purgeSummary(target.name, result))
|
||||
purgeOpen.value = false
|
||||
} catch (caught) {
|
||||
// 服务端的 message(人员已不在、网络错误…)必须原文展示,不能压成「删除失败」。
|
||||
const message = caught instanceof Error ? caught.message : '彻底删除失败'
|
||||
purgeError.value = message
|
||||
toast.error('彻底删除失败', message)
|
||||
purgeOpen.value = false
|
||||
} finally {
|
||||
purgePending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -207,6 +257,10 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="purgeError !== null" class="mx-badge mx-badge-error purge-error">
|
||||
<MxIcon name="warning" :size="14" /> {{ purgeError }}
|
||||
</div>
|
||||
|
||||
<div class="mx-table-wrap">
|
||||
<table class="mx-table mx-table-compact">
|
||||
<thead>
|
||||
@@ -259,6 +313,14 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
>
|
||||
{{ athlete.active ? '停用' : '启用' }}
|
||||
</button>
|
||||
<button
|
||||
class="mx-btn mx-btn-danger mx-btn-sm"
|
||||
type="button"
|
||||
:disabled="!data.canWrite.value || purgePending"
|
||||
@click="askPurge(athlete)"
|
||||
>
|
||||
{{ purgePending && purgeTarget?.id === athlete.id ? '删除中…' : '彻底删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -274,7 +336,9 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="bd-note">停用为软删除:历史报名与成绩保留,新报名不再可选该运动员。</p>
|
||||
<p class="bd-note">
|
||||
启用:正常出现在名单与报名选择里;停用为软删除:历史报名与成绩保留,新报名不再可选该运动员,随时可以再启用;彻底删除:真删除,人员档案连同他名下的报名与成绩一起从库里删掉、不可恢复,相关小项的排名与积分会按新排序重算。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormDialog
|
||||
@@ -333,6 +397,21 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
@confirm="confirmTarget && toggleActive(confirmTarget)"
|
||||
@cancel="confirmOpen = false"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="purgeOpen"
|
||||
tone="danger"
|
||||
title="彻底删除该运动员?"
|
||||
:message="`将把「${purgeTarget?.name ?? ''}」从人员档案中永久删除,不可恢复。`"
|
||||
:details="[
|
||||
'他名下的报名与成绩会一并删除,相关小项的排名与积分会重新计算',
|
||||
'已经有成绩的比赛,删人会让名次和积分发生变化;只是想暂时不让他报名,请用「停用」',
|
||||
]"
|
||||
confirm-text="彻底删除"
|
||||
:pending="purgePending"
|
||||
@confirm="confirmPurge"
|
||||
@cancel="purgeOpen = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -340,4 +419,12 @@ async function toggleActive(athlete: Athlete): Promise<void> {
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -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<T>(real: () => Promise<T>, mock: () => T | Promise<T>): Promise<T> {
|
||||
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<T>(
|
||||
real: () => Promise<T>,
|
||||
mock: () => T | Promise<T>,
|
||||
options: CallOptions = {},
|
||||
): Promise<T> {
|
||||
try {
|
||||
const value = await real()
|
||||
baseDataFallback.value = false
|
||||
@@ -56,7 +74,7 @@ async function call<T>(real: () => Promise<T>, mock: () => T | Promise<T>): 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<void> =>
|
||||
call(() => adminApi.client.athletes.remove(id), () => mockBaseData.removeAthlete(id)),
|
||||
/**
|
||||
* 彻底删除:服务端物理删除该人员及其报名、成绩并重算积分,返回删除规模统计。
|
||||
*
|
||||
* 与同组的 remove 不同,这里故意不让 404 降级:404 是「人员已不在」的真实回答,
|
||||
* 落到示例数据上会让页面把「没删掉」提示成「已彻底删除」,现场就出事了。
|
||||
* 后端整体连不上(网络 / 超时)时仍照常降级,示例数据里可以照删。
|
||||
*/
|
||||
purge: (id: number): Promise<AthletePurgeResult> =>
|
||||
call(() => adminApi.purgeAthlete(id), () => mockBaseData.purgeAthlete(id), {
|
||||
fallbackOn404: false,
|
||||
}),
|
||||
},
|
||||
|
||||
entries: {
|
||||
|
||||
@@ -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<EntryImportResult> {
|
||||
|
||||
@@ -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<void> {
|
||||
await reloadAthletes()
|
||||
}
|
||||
|
||||
/** 彻底删除:重新拉取名单,删除规模统计回给页面做提示;失败(人员已不在、网络错误)原样抛出。 */
|
||||
async function purgeAthlete(id: number): Promise<AthletePurgeResult> {
|
||||
const result = await baseDataApi.athletes.purge(id)
|
||||
await reloadAthletes()
|
||||
return result
|
||||
}
|
||||
|
||||
function importEntries(file: File): Promise<EntryImportResult> {
|
||||
return baseDataApi.entries.importFile(file)
|
||||
}
|
||||
@@ -307,6 +315,8 @@ export interface BaseDataController {
|
||||
saveProfile: (input: ScoringProfileInput, id: number | null) => Promise<ScoringProfile>
|
||||
saveAthlete: (input: AthleteInput, id: number | null) => Promise<Athlete>
|
||||
removeAthlete: (id: number) => Promise<void>
|
||||
/** 返回删除规模统计(报名 / 成绩 / 流水条数、重算过的小项),页面据此提示「榜为什么变了」。 */
|
||||
purgeAthlete: (id: number) => Promise<AthletePurgeResult>
|
||||
importEntries: (file: File) => Promise<EntryImportResult>
|
||||
resolveImportConflicts: (file: File, decisions: ImportConflictDecision[]) => Promise<EntryImportResult>
|
||||
/** 只改榜单口径的全局系数,其余设置原样回传;保存后同步 event store,页面立即生效。 */
|
||||
@@ -354,6 +364,7 @@ export function useBaseData(): BaseDataController {
|
||||
saveProfile,
|
||||
saveAthlete,
|
||||
removeAthlete,
|
||||
purgeAthlete,
|
||||
importEntries,
|
||||
resolveImportConflicts,
|
||||
saveIndividualToTeamWeight,
|
||||
|
||||
@@ -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<string, string>
|
||||
}
|
||||
|
||||
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<string, string>,
|
||||
}
|
||||
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)
|
||||
})
|
||||
+62
-2
@@ -139,6 +139,33 @@ export interface EventsApi extends CrudApi<Event, EventInput> {
|
||||
saveChannels(eventId: number, channels: ScoreChannelInput[]): Promise<ScoreChannel[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 彻底删除的规模统计(服务端 `athletePurge`),同时进审计日志。
|
||||
*
|
||||
* 名次与积分会随成绩消失而变化,所以 `recomputedEvents` 是给操作员看的重点:
|
||||
* 删掉一个人之后,哪些小项的榜单被重算了。
|
||||
*/
|
||||
export interface AthletePurgeResult {
|
||||
deletedEntries: number
|
||||
deletedResults: number
|
||||
deletedLedger: number
|
||||
deletedBreaks: number
|
||||
/** 因成绩删除而重算了积分与名次的小项 ID。 */
|
||||
recomputedEvents: number[]
|
||||
}
|
||||
|
||||
export interface AthletesApi extends CrudApi<Athlete, AthleteInput> {
|
||||
/**
|
||||
* 彻底删除(`DELETE /api/athletes/{id}?purge=1`)。
|
||||
*
|
||||
* 与 `remove` 的软删除(停用,`active = 0`,数据都在)不同,这是物理删除:
|
||||
* 服务端在同一事务里删掉该人员的人员行、报名、成绩与相关积分流水,
|
||||
* 并按新排序重算受影响小项的积分与名次(别人的名次会变),随后推送大屏刷新。
|
||||
* 人员已被别人删掉时回 404。
|
||||
*/
|
||||
purge(id: number): Promise<AthletePurgeResult>
|
||||
}
|
||||
|
||||
export interface EntriesApi {
|
||||
/** 报名表按文件导入,后端解析 xlsx。 */
|
||||
importFile(file: File): Promise<EntryImportResult>
|
||||
@@ -252,6 +279,39 @@ function createCrudApi<T, Input>(http: Http, base: string): CrudApi<T, Input> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读删除统计。
|
||||
*
|
||||
* 统计缺失时按 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<Athlete, AthleteInput>(http, '/athletes'),
|
||||
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;响应体带删除统计。
|
||||
purge: async (id) =>
|
||||
readPurgeResult(await http.request<unknown>(`/athletes/${id}`, { method: 'DELETE', query: { purge: 1 } })),
|
||||
}
|
||||
}
|
||||
|
||||
function createEventsApi(http: Http): EventsApi {
|
||||
return {
|
||||
...createCrudApi<Event, EventInput>(http, '/events'),
|
||||
@@ -387,7 +447,7 @@ export class ApiClient {
|
||||
readonly sports: CrudApi<Sport, SportInput>
|
||||
readonly events: EventsApi
|
||||
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
|
||||
readonly athletes: CrudApi<Athlete, AthleteInput>
|
||||
readonly athletes: AthletesApi
|
||||
readonly entries: EntriesApi
|
||||
readonly results: ResultsApi
|
||||
readonly standings: StandingsApi
|
||||
@@ -452,7 +512,7 @@ export class ApiClient {
|
||||
this.sports = createCrudApi<Sport, SportInput>(http, '/sports')
|
||||
this.events = createEventsApi(http)
|
||||
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')
|
||||
this.athletes = createCrudApi<Athlete, AthleteInput>(http, '/athletes')
|
||||
this.athletes = createAthletesApi(http)
|
||||
|
||||
this.entries = {
|
||||
importFile: (file) => {
|
||||
|
||||
Reference in new issue
Block a user