feat: 参赛单位「彻底删除」= 假删除(界面隐藏,数据与榜单全保留)
与运动员的真物理删除是**两套相反口径**,有意如此(两个按钮同名,代码与
文档里都做了显著区分):
- 运动员 `?purge=1`:真删人员+报名+成绩+流水并重算积分(上一版 `9d4ee09`)
- 参赛单位 `?purge=1`:只置 `teams.deleted_at` + `active = 0`,让单位从列表里
消失;报名、成绩、积分流水、人工调整、纪录、名下运动员全部原样,
**不触发重算、积分榜不变**(单位下挂着几十号人,真删代价太大)
实现:
- 迁移 0018 `ALTER TABLE teams ADD COLUMN deleted_at TEXT`
- `listTeams` 默认排除已隐藏单位;`?includeDeleted=1` 仅 admin;
`GET /api/teams/{id}` 对已隐藏单位:admin 200 / 非 admin 404
- 审计 `team.purge`(detail 标明是隐藏);成功走 `writeVoid`(无统计对象)
- 报名导入按 `team_code` 命中已隐藏单位时复活(清 deleted_at、置 active=1)
- 故意不过滤的地方都加了注释:榜单补单位名、写路径与审计、导入按 code 查、
赛事包导出、大屏快照、按 team_id JOIN 取历史单位名——过滤会让历史成绩的
单位名变空白或让包的引用悬挂
管理端「参赛单位」行加「彻底删除」按钮 + 危险确认框(如实写明"从列表移除、
数据不受影响、积分榜不会变化"),失败原文展示、404 不降级。
顺带修一处界面文案与行为不符:单位页原写「停用后新报名不再可选该单位」,
但报名选择器其实仍会列出已停用单位供手动勾选(只有「全选」跳过它们),
已按真实行为改写(两处)。
测试:服务端 12 包全绿(新增 team_purge_test.go 与 0018 迁移用例);
前端 172 例全过(新增 team-purge-api.test.ts 6 例)。docs/ 与 devdocs/ 同步。
This commit is contained in:
1 parent
9d4ee0946c
commit
5ae49b001c
18 files changed
+767
-13
No files matched your search
@@ -19,6 +19,7 @@
|
|||||||
- **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗
|
- **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗
|
||||||
- **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用
|
- **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用
|
||||||
- **运动员名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」把人员档案连同他名下的报名、成绩与积分流水一起删掉且不可恢复,并自动按新排序重算受影响小项的排名与积分(大屏同步更新)
|
- **运动员名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」把人员档案连同他名下的报名、成绩与积分流水一起删掉且不可恢复,并自动按新排序重算受影响小项的排名与积分(大屏同步更新)
|
||||||
|
- **参赛单位名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」是**假删除**,只把单位从列表里移除(界面上没有恢复入口),它的报名、成绩、积分流水与名下运动员全部保留,**积分榜不会变化**;报名表里再次用到该单位编码会让它自动恢复
|
||||||
- **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事
|
- **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事
|
||||||
- **结果导出**:Excel 总表、全部分项、分项成绩表、单位积分明细可分别导出
|
- **结果导出**:Excel 总表、全部分项、分项成绩表、单位积分明细可分别导出
|
||||||
- **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo
|
- **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
- [ ] 检查计分模板:个人前六名 `10/8/6/3/2/1`、团体 `15/10/8/4/2/1`、最后一名不得分
|
- [ ] 检查计分模板:个人前六名 `10/8/6/3/2/1`、团体 `15/10/8/4/2/1`、最后一名不得分
|
||||||
- [ ] 「只排名不计分」的项目(飞行人员战斗体能)导入后应显示为不计分
|
- [ ] 「只排名不计分」的项目(飞行人员战斗体能)导入后应显示为不计分
|
||||||
- [ ] 「基础数据 → 参赛单位」里各单位的 logo 都已上传(会显示在总积分榜、团体奖牌榜、分项成绩表与个人积分榜上)
|
- [ ] 「基础数据 → 参赛单位」里各单位的 logo 都已上传(会显示在总积分榜、团体奖牌榜、分项成绩表与个人积分榜上)
|
||||||
|
- [ ] 同页核对**单位名单齐全、没有误删**:被「彻底删除」的单位不会出现在这个列表里(它的报名、成绩与积分仍在库里,积分榜照常显示它);发现少了就用它的编码导入一次报名表,它会重新出现(详见使用手册 10.1、常见问题 Q61–Q64)
|
||||||
|
|
||||||
## 5. 大屏
|
## 5. 大屏
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -262,7 +262,31 @@
|
|||||||
|
|
||||||
1. 点「新增单位」;
|
1. 点「新增单位」;
|
||||||
2. 填写代码 *(唯一,字母 / 数字 / `_` / `-`,最长 16 位)、全称 *、简称、主题色、排序、logo(可点输入框右侧「上传图片」直接传,png / jpg / webp / gif,单张不超过 5 MiB;也能手填已有路径,留空则不显示);
|
2. 填写代码 *(唯一,字母 / 数字 / `_` / `-`,最长 16 位)、全称 *、简称、主题色、排序、logo(可点输入框右侧「上传图片」直接传,png / jpg / webp / gif,单张不超过 5 MiB;也能手填已有路径,留空则不显示);
|
||||||
3. 「编辑」修改;「停用」为软删除——历史成绩与积分流水保留,但新报名不再可选该单位,可随时「启用」。
|
3. 每行有三个动作:**编辑**、**停用**(已停用的单位显示为「启用」)、**彻底删除**。
|
||||||
|
|
||||||
|
**停用与彻底删除的区别**
|
||||||
|
|
||||||
|
| 状态 | 单位列表里 | 还能被报名选中吗 | 它的报名 / 成绩 / 积分 | 能恢复吗 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 启用(默认) | 在 | 能 | 保留 | —— |
|
||||||
|
| 停用 | 还在(标「已停用」) | 仍可手动选(「全选」不会自动带上它) | **保留** | 点「启用」即可 |
|
||||||
|
| 彻底删除 | **不在了** | 不能 | **保留** | 界面上没有恢复入口;再次导入用到它的编码会自动回来 |
|
||||||
|
|
||||||
|
「停用」还是软删除:单位留在列表里,历史成绩与积分流水保留,随时能再「启用」。「彻底删除」是**假删除**——只把这个单位从参赛单位列表里移除,它的报名、成绩、积分流水、人工调整、纪录与名下的运动员**一条都没删**,**积分榜也不会变化**,不会触发任何重算。操作步骤:
|
||||||
|
|
||||||
|
1. 点该行右侧红色的「彻底删除」;
|
||||||
|
2. 在确认框里核对提示(会写明该单位从列表与报名选择里移除、界面上无法恢复、已有报名与积分不受影响、积分榜不变),再点一次「彻底删除」;
|
||||||
|
3. 完成后该单位从列表里消失,页面提示「已彻底删除「XX」」。
|
||||||
|
|
||||||
|
**「彻底删除」之后会发生什么**
|
||||||
|
|
||||||
|
- 该单位**不再出现在参赛单位列表**里,报名选择(含团体报名、运动员的「所属单位」下拉)里也选不到,界面上没有恢复入口;
|
||||||
|
- 它的**报名、成绩、积分流水、人工调整、纪录与名下运动员全部保留**——消失的只是这个单位本身;
|
||||||
|
- **积分榜照旧显示它**:单位本来有分数的话,总积分榜、团体奖牌榜上仍有它的名字与分数。这是刻意设计(数据没删,分数就不该凭空消失),不是故障;
|
||||||
|
- **再次导入报名表时,如果用到这个单位的编码,它会自动复活**并重新出现在列表里——这里和人员不同:删掉的人再次导入是**以新档案建档**(旧数据回不来),删掉的单位则是**原来那条记录直接恢复**,报名、成绩与积分一直都在(见 10.7);
|
||||||
|
- 单位名下还有运动员时同样可以删,这些运动员不会被一起删掉,但他们编辑起来会有些不便(见常见问题 Q64)。
|
||||||
|
|
||||||
|
> ⚠️ **别和运动员的「彻底删除」混为一谈**:两个页面上的按钮同名,行为完全不同。运动员的「彻底删除」是**真删除**——人员档案连同他名下的报名、成绩与积分流水一起从库里删掉,并重算受影响小项的排名与积分(见 10.6);参赛单位的「彻底删除」是**假删除**——业务数据一条不删,只让单位从列表里消失,积分榜不变。
|
||||||
|
|
||||||
> 上传的 logo 会显示在**大屏**的总积分榜、团体奖牌榜、分项成绩与个人积分奖牌榜上(个人积分奖牌榜显示在运动员姓名前面);没传 logo 的单位不占位,队名不会因此错位。管理端页面不显示 logo。
|
> 上传的 logo 会显示在**大屏**的总积分榜、团体奖牌榜、分项成绩与个人积分奖牌榜上(个人积分奖牌榜显示在运动员姓名前面);没传 logo 的单位不占位,队名不会因此错位。管理端页面不显示 logo。
|
||||||
|
|
||||||
|
|||||||
@@ -322,3 +322,42 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
|||||||
> 第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空)
|
> 第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空)
|
||||||
|
|
||||||
改法:换成四个标准词之一(`registered` / `scratched` / `dq` / `dns`,大小写都行)或 Q59 里的中文别名,也可以直接留空(= 正常参赛),改完重新上传即可。对照表见 [报名名单导入规范 · 常见报错怎么办](./报名名单导入规范.md#六常见报错怎么办)。
|
改法:换成四个标准词之一(`registered` / `scratched` / `dq` / `dns`,大小写都行)或 Q59 里的中文别名,也可以直接留空(= 正常参赛),改完重新上传即可。对照表见 [报名名单导入规范 · 常见报错怎么办](./报名名单导入规范.md#六常见报错怎么办)。
|
||||||
|
|
||||||
|
**Q61:参赛单位怎么彻底删除?和删运动员一样吗?**
|
||||||
|
|
||||||
|
不一样,两个按钮同名、行为完全不同,现场别按错。「基础数据 → 参赛单位」每行有三个动作:**编辑**、**停用**(已停用的单位显示为「启用」)、**彻底删除**。点该行右侧红色的「彻底删除」,在确认框里核对提示后再点一次「彻底删除」即可,完成后该单位从列表里消失,页面提示「已彻底删除「XX」」。
|
||||||
|
|
||||||
|
一句话概括:**删运动员是真删,删单位是假删**。
|
||||||
|
|
||||||
|
| | 运动员的「彻底删除」(见 Q57) | 参赛单位的「彻底删除」 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 数据 | 人员档案连同报名、成绩、积分流水、破纪录记录**一并从库里删掉** | **一条都不删**,报名 / 成绩 / 积分 / 人工调整 / 纪录 / 名下运动员全部保留 |
|
||||||
|
| 榜单 | **重算**受影响小项的排名与积分,删完榜单会变 | **积分榜不变**,不触发任何重算 |
|
||||||
|
| 列表 | 从运动员名册里消失 | 从参赛单位列表里消失 |
|
||||||
|
| 恢复 | 恢复不了 | 界面上没有恢复入口;再次导入用到它的编码会自动回来(见 Q62) |
|
||||||
|
|
||||||
|
想让某个单位暂时不参赛,用「停用」就够了:单位还在列表里,历史数据保留,随时能再启用。详细说明见[使用手册 · 10.1 参赛单位](./使用手册.md#101-参赛单位)。
|
||||||
|
|
||||||
|
**Q62:删掉的单位怎么又出现在列表里了?**
|
||||||
|
|
||||||
|
多半是导入带回来的:报名表里再次用到这个单位的编码(`team_code`),系统会把它**原样复活**——清掉删除标记、恢复成启用,重新出现在参赛单位列表里。因为单位编码是唯一的,系统按编码找到的就是原来那一行,所以**不会新建第二个同名单位**,原有的报名、成绩与积分也都还在。
|
||||||
|
|
||||||
|
这和人员的规则**看着像、结果不同**:删掉的人再次导入是**以全新档案建档**,旧数据不会回来(见 Q58);删掉的单位再次导入是**原来那个单位直接复活**,旧数据一直都在。
|
||||||
|
|
||||||
|
- 不想让它回来,就别在报名表里填这个单位编码;
|
||||||
|
- 名单里「突然多出一个以前删过的单位」,通常就是这么来的。
|
||||||
|
|
||||||
|
**Q63:为什么积分榜里还能看到已删除的单位?**
|
||||||
|
|
||||||
|
这是**刻意设计**,不是 bug。「彻底删除」只是把单位从参赛单位列表里移除的假删除——它的报名、成绩与积分流水**一条都没删**。既然分数还在,积分榜(总积分榜、团体奖牌榜、分项成绩表)就会照旧列出这个单位的名字与分数,否则总分和名次就对不上了。
|
||||||
|
|
||||||
|
所以「单位列表里没有它」不等于「它的历史成绩不存在」,两处不一致是正常的。如果确实希望它的分数不再影响榜单,那不是「彻底删除单位」能解决的事——它动的只有列表里的条目,不碰任何成绩数据。
|
||||||
|
|
||||||
|
**Q64:单位下面还有运动员,能删吗?**
|
||||||
|
|
||||||
|
能。单位被彻底删除时,名下的运动员**不会**被一起删掉:他们仍在「运动员」名册里,报名、成绩与积分全部保留。但有两处会变得不好操作,删之前最好先处理:
|
||||||
|
|
||||||
|
- 运动员列表的「所属单位」列会显示成**未知单位**(那个单位已经不在列表里,名字查不到了);按单位筛选时也筛不到这些运动员,只有在「全部单位」下才看得到;
|
||||||
|
- 编辑这些运动员时,「所属单位」下拉里没有这个单位,**必须改选另一个单位才能保存**。
|
||||||
|
|
||||||
|
所以更稳妥的做法是:先把名下运动员调到别的单位(或者干脆用「停用」代替「彻底删除」),再决定要不要彻底删除这个单位。
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
|
|
||||||
### `team_code` 单位编码 —— 必须填
|
### `team_code` 单位编码 —— 必须填
|
||||||
在系统「基础数据 → 参赛单位」里给每个大单位配的**编码**,比如 `F1`、`F2`。系统里没有的会提示「单位 XXX 不存在」。
|
在系统「基础数据 → 参赛单位」里给每个大单位配的**编码**,比如 `F1`、`F2`。系统里没有的会提示「单位 XXX 不存在」。
|
||||||
|
被「彻底删除」的单位,导入时若再次用到它的编码,它会**重新出现(复活)**,这一行照常导入——单位编码是唯一的,系统按编码复用原来那条记录,它以前的报名、成绩与积分都还在。
|
||||||
|
|
||||||
### `athlete_name` 运动员姓名
|
### `athlete_name` 运动员姓名
|
||||||
- **个人项目**:填姓名,必填。
|
- **个人项目**:填姓名,必填。
|
||||||
@@ -180,6 +181,7 @@
|
|||||||
- ⚠️ 但要注意「更新」是**整条覆盖**:表里空着的格会把系统里原来的值清掉(队员名单、道次、号码布都会被清空),`status` 空着会被重置成「正常参赛」。如果之前手工调整过某些人的状态,**重传前请在表里把 `status` 写清楚**。
|
- ⚠️ 但要注意「更新」是**整条覆盖**:表里空着的格会把系统里原来的值清掉(队员名单、道次、号码布都会被清空),`status` 空着会被重置成「正常参赛」。如果之前手工调整过某些人的状态,**重传前请在表里把 `status` 写清楚**。
|
||||||
3. 名单导入后,到「成绩录入」里录成绩、发布,积分榜就会自动更新。
|
3. 名单导入后,到「成绩录入」里录成绩、发布,积分榜就会自动更新。
|
||||||
4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个证件号或号码布,系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。
|
4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个证件号或号码布,系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。
|
||||||
|
5. **被「彻底删除」的单位则相反,导入时会「复活」**:管理员彻底删除某个单位,只是把它从单位列表里移除,它的报名与成绩一条都没删;表里再出现这个 `team_code`,系统按编码复用原来那条单位记录,清掉删除标记并让它重新出现在列表里,**原有的报名、成绩与积分都还在**,也不会新建第二个同名单位。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ type teamDTO struct {
|
|||||||
Color *string `json:"color"`
|
Color *string `json:"color"`
|
||||||
SortOrder int `json:"sortOrder"`
|
SortOrder int `json:"sortOrder"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
|
// DeletedAt 非空表示「彻底删除」(对界面不可见),行仍在库里;停用是另一回事(active = false)。
|
||||||
|
DeletedAt *string `json:"deletedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type athleteDTO struct {
|
type athleteDTO struct {
|
||||||
|
|||||||
@@ -165,7 +165,13 @@ func (s *server) updateSettingsHandler(w http.ResponseWriter, r *http.Request) (
|
|||||||
// ---------- teams ----------
|
// ---------- teams ----------
|
||||||
|
|
||||||
func (s *server) listTeamsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
func (s *server) listTeamsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
return s.listTeams(r.Context())
|
includeDeleted := isQueryTrue(r, "includeDeleted")
|
||||||
|
// 已彻底删除的单位不进任何列表,只有 admin 能用显式开关把它们翻出来排查。
|
||||||
|
if includeDeleted && !middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
|
||||||
|
return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN",
|
||||||
|
message: "只有管理员可以查看已彻底删除的单位"}
|
||||||
|
}
|
||||||
|
return s.listTeams(r.Context(), includeDeleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
@@ -173,7 +179,15 @@ func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.getTeam(r.Context(), id)
|
t, err := s.getTeam(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 彻底删除的单位对非 admin 按不存在处理;admin 仍查得到,便于排查。
|
||||||
|
if t.DeletedAt != nil && !middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
|
||||||
|
return nil, notFound("单位不存在")
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) createTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
func (s *server) createTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||||
@@ -210,6 +224,7 @@ func (s *server) updateTeamHandler(w http.ResponseWriter, r *http.Request) (any,
|
|||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deleteTeamHandler 默认软删除(停用);带 purge=1 时彻底删除(对界面不可见,行仍在库里)。
|
||||||
func (s *server) deleteTeamHandler(w http.ResponseWriter, r *http.Request) error {
|
func (s *server) deleteTeamHandler(w http.ResponseWriter, r *http.Request) error {
|
||||||
id, err := pathID(r, "id")
|
id, err := pathID(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -219,10 +234,21 @@ func (s *server) deleteTeamHandler(w http.ResponseWriter, r *http.Request) error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.deleteTeam(r.Context(), id); err != nil {
|
if !isQueryTrue(r, "purge") {
|
||||||
|
if err := s.deleteTeam(r.Context(), id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.audit(r.Context(), "team.delete", "team", id, before, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
after, err := s.hideTeam(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.audit(r.Context(), "team.delete", "team", id, before, nil)
|
// 记清楚这是「隐藏」而不是物理删除:报名、成绩、流水一行没动,审计要能自证。
|
||||||
|
s.audit(r.Context(), "team.purge", "team", id, before, map[string]any{
|
||||||
|
"hidden": true, "deletedAt": after.DeletedAt,
|
||||||
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,6 +842,13 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
|||||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:单位 %s 不存在", line, teamCode))
|
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:单位 %s 不存在", line, teamCode))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
|
||||||
|
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
|
||||||
|
if err := s.reviveTeam(ctx, teamID); err != nil {
|
||||||
|
result.Skipped++
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
|
||||||
|
continue
|
||||||
|
}
|
||||||
var categoryID int64
|
var categoryID int64
|
||||||
if categoryName != "" {
|
if categoryName != "" {
|
||||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
|
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
|
||||||
|
|||||||
@@ -139,22 +139,32 @@ func exists(ctx context.Context, db *sql.DB, query string, args ...any) (bool, e
|
|||||||
|
|
||||||
// ---------- teams ----------
|
// ---------- teams ----------
|
||||||
|
|
||||||
const teamColumns = `id, code, name, short_name, logo_path, color, sort_order, active`
|
const teamColumns = `id, code, name, short_name, logo_path, color, sort_order, active, deleted_at`
|
||||||
|
|
||||||
func scanTeam(row interface{ Scan(...any) error }) (teamDTO, error) {
|
func scanTeam(row interface{ Scan(...any) error }) (teamDTO, error) {
|
||||||
var (
|
var (
|
||||||
t teamDTO
|
t teamDTO
|
||||||
shortName, logoPath, color sql.NullString
|
shortName, logoPath, color sql.NullString
|
||||||
|
deletedAt sql.NullString
|
||||||
)
|
)
|
||||||
err := row.Scan(&t.ID, &t.Code, &t.Name, &shortName, &logoPath, &color, &t.SortOrder, &t.Active)
|
err := row.Scan(&t.ID, &t.Code, &t.Name, &shortName, &logoPath, &color, &t.SortOrder, &t.Active, &deletedAt)
|
||||||
t.ShortName = strPtr(shortName)
|
t.ShortName = strPtr(shortName)
|
||||||
t.LogoPath = strPtr(logoPath)
|
t.LogoPath = strPtr(logoPath)
|
||||||
t.Color = strPtr(color)
|
t.Color = strPtr(color)
|
||||||
|
t.DeletedAt = strPtr(deletedAt)
|
||||||
return t, err
|
return t, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) listTeams(ctx context.Context) ([]teamDTO, error) {
|
// listTeams 列出参赛单位。默认排除「彻底删除」(deleted_at 非空)的单位,
|
||||||
rows, err := s.ev.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`)
|
// includeDeleted 只给运维排查用,由 handler 限 admin;停用(active = 0)仍在列表里。
|
||||||
|
func (s *server) listTeams(ctx context.Context, includeDeleted bool) ([]teamDTO, error) {
|
||||||
|
query := `SELECT ` + teamColumns + ` FROM teams`
|
||||||
|
if !includeDeleted {
|
||||||
|
query += ` WHERE deleted_at IS NULL`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY sort_order, code`
|
||||||
|
|
||||||
|
rows, err := s.ev.QueryContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internal(err)
|
return nil, internal(err)
|
||||||
}
|
}
|
||||||
@@ -302,6 +312,34 @@ func (s *server) deleteTeam(ctx context.Context, id int64) error {
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hideTeam 彻底删除参赛单位:只打「对界面不可见」的标记,行本身留在库里,
|
||||||
|
// 返回更新后的单位(含 deletedAt)供审计记录。
|
||||||
|
//
|
||||||
|
// 报名、成绩、积分流水、人工调整、纪录、名下运动员全部原样,所以榜单不会变、
|
||||||
|
// 也不需要任何重算;active 一并置 0,任何漏了 deleted_at 判断的老查询也不会再选中它。
|
||||||
|
func (s *server) hideTeam(ctx context.Context, id int64) (teamDTO, error) {
|
||||||
|
res, err := s.ev.ExecContext(ctx,
|
||||||
|
`UPDATE teams SET deleted_at = datetime('now'), active = 0 WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return teamDTO{}, internal(err)
|
||||||
|
}
|
||||||
|
if err := requireChanges(res, "单位不存在"); err != nil {
|
||||||
|
return teamDTO{}, err
|
||||||
|
}
|
||||||
|
return s.getTeam(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reviveTeam 把被彻底删除的单位放回界面:导入再次命中它的编码时视为同一个单位复用。
|
||||||
|
// 单位编码有唯一索引,漏掉已隐藏的行只会撞索引或被当成新单位重复建档。
|
||||||
|
// 停用(active = 0)是另一种状态,这里一并恢复,否则复活后仍显示成「已停用」。
|
||||||
|
func (s *server) reviveTeam(ctx context.Context, id int64) error {
|
||||||
|
if _, err := s.ev.ExecContext(ctx,
|
||||||
|
`UPDATE teams SET deleted_at = NULL, active = 1 WHERE id = ? AND deleted_at IS NOT NULL`, id); err != nil {
|
||||||
|
return internal(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- athletes ----------
|
// ---------- athletes ----------
|
||||||
|
|
||||||
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age, active`
|
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age, active`
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package httpapi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getTeamList 取单位列表并断言 200。
|
||||||
|
func getTeamList(t *testing.T, env *testEnv, client *http.Client, path string) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
status, env2 := env.do(client, http.MethodGet, path, nil, false)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("GET %s 应 200,得到 %d %+v", path, status, env2.Error)
|
||||||
|
}
|
||||||
|
return decodeData[[]map[string]any](t, env2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findTeam 在列表里找某个单位,找不到返回 nil。
|
||||||
|
func findTeam(rows []map[string]any, id int64) map[string]any {
|
||||||
|
for _, row := range rows {
|
||||||
|
if row["id"] == float64(id) {
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findStandingTeam 在积分榜里找某个单位的行(榜单行的主键字段是 teamId)。
|
||||||
|
func findStandingTeam(rows []map[string]any, id int64) map[string]any {
|
||||||
|
for _, row := range rows {
|
||||||
|
if row["teamId"] == float64(id) {
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 彻底删除 = 对界面不可见:默认列表里没有它,但库里那行还在,业务数据一行没动。
|
||||||
|
func TestPurgeTeamHidesFromList(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamB), nil, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE id = ?`, ids.teamB); n != 1 {
|
||||||
|
t.Fatalf("彻底删除不是物理删除,单位行应还在库里,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE id = ? AND deleted_at IS NOT NULL AND active = 0`,
|
||||||
|
ids.teamB); n != 1 {
|
||||||
|
t.Fatalf("彻底删除应置 deleted_at 并把 active 归 0,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := getTeamList(t, env, client, "/api/teams")
|
||||||
|
if row := findTeam(rows, ids.teamB); row != nil {
|
||||||
|
t.Fatalf("默认列表不该有已彻底删除的单位,得到 %+v", row)
|
||||||
|
}
|
||||||
|
if findTeam(rows, ids.teamA) == nil {
|
||||||
|
t.Fatalf("默认列表应保留其它单位,得到 %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不带参数的 DELETE 仍是停用:active = 0,但仍在默认列表里——停用与彻底删除必须能区分开。
|
||||||
|
func TestSoftDeleteTeamStaysInList(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
publishResults(t, env, client, ids.event, "team-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/teams/%d", ids.teamA),
|
||||||
|
fmt.Sprintf("/api/teams/%d?purge=0", ids.teamA),
|
||||||
|
} {
|
||||||
|
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 teams WHERE id = ? AND active = 0 AND deleted_at IS NULL`,
|
||||||
|
ids.teamA); n != 1 {
|
||||||
|
t.Fatalf("停用只该置 active = 0,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
rows := getTeamList(t, env, client, "/api/teams")
|
||||||
|
row := findTeam(rows, ids.teamA)
|
||||||
|
if row == nil {
|
||||||
|
t.Fatalf("停用的单位仍应在默认列表里,得到 %+v", rows)
|
||||||
|
}
|
||||||
|
if row["active"] != false || row["deletedAt"] != nil {
|
||||||
|
t.Fatalf("停用的单位应是 active=false、deletedAt=null,得到 %+v", row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 假删除的核心:报名、成绩、积分流水、人工调整、纪录、名下运动员全部原样,榜单一分不变。
|
||||||
|
func TestPurgeTeamKeepsAllDataAndStandings(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
publishResults(t, env, client, ids.event, "team-hide",
|
||||||
|
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"},
|
||||||
|
)
|
||||||
|
// 人工调整与引用该单位的纪录。
|
||||||
|
adj := env.insert(t, `INSERT INTO point_adjustments (team_id, delta, category, reason)
|
||||||
|
VALUES (?, 5, 'bonus', 'A 的加分')`, ids.teamA)
|
||||||
|
env.exec(t, `INSERT INTO score_ledger (team_id, event_id, source_type, source_id, points)
|
||||||
|
VALUES (?, ?, 'adjustment', ?, 5)`, ids.teamA, ids.event, adj)
|
||||||
|
recordID := env.insert(t, `INSERT INTO records (event_id, level, holder_name, holder_team_id, is_current, source)
|
||||||
|
VALUES (?, 'meet', '张伟', ?, 1, 'history')`, ids.event, ids.teamA)
|
||||||
|
|
||||||
|
// 前置:A 单位 10 + 调整 5 = 15,B 单位 8。
|
||||||
|
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
|
||||||
|
t.Fatalf("前置条件:A 单位总分应是 15,得到 %v", got)
|
||||||
|
}
|
||||||
|
entriesBefore := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE team_id = ?`, ids.teamA)
|
||||||
|
resultsBefore := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryA)
|
||||||
|
ledgerBefore := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE team_id = ?`, ids.teamA)
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 界面不可见。
|
||||||
|
if findTeam(getTeamList(t, env, client, "/api/teams"), ids.teamA) != nil {
|
||||||
|
t.Fatalf("已彻底删除的单位不该出现在默认列表里")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务数据一行不少。
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE team_id = ?`, ids.teamA); n != entriesBefore {
|
||||||
|
t.Fatalf("报名不该被动:之前 %d 条,之后 %d 条", entriesBefore, n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryA); n != resultsBefore {
|
||||||
|
t.Fatalf("成绩不该被动:之前 %d 条,之后 %d 条", resultsBefore, n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE team_id = ?`, ids.teamA); n != ledgerBefore {
|
||||||
|
t.Fatalf("积分流水不该被动:之前 %d 条,之后 %d 条", ledgerBefore, n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM point_adjustments WHERE team_id = ?`, ids.teamA); n != 1 {
|
||||||
|
t.Fatalf("人工调整不该被动,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM records WHERE id = ? AND holder_team_id = ?`, recordID, ids.teamA); n != 1 {
|
||||||
|
t.Fatalf("纪录不该被动,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE team_id = ?`, ids.teamA); n != 1 {
|
||||||
|
t.Fatalf("名下运动员不该被动,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 0`, ids.entryA); n != 1 {
|
||||||
|
t.Fatalf("A 的有效流水应还是 1 条,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 榜单不变:A 仍是 15 分、B 仍是 8 分,名次与分项字段都没动,
|
||||||
|
// 且 A 的单位名照旧能补出来(大屏不会出现空白行)。
|
||||||
|
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
|
||||||
|
t.Fatalf("假删除不该改榜单:A 单位总分应还是 15,得到 %v", got)
|
||||||
|
}
|
||||||
|
if got := teamPoints(t, env, client, ids.teamB); got != 8 {
|
||||||
|
t.Fatalf("假删除不该改榜单:B 单位总分应还是 8,得到 %v", got)
|
||||||
|
}
|
||||||
|
status, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("读积分榜应 200,得到 %d", status)
|
||||||
|
}
|
||||||
|
row := findStandingTeam(decodeData[[]map[string]any](t, env2), ids.teamA)
|
||||||
|
if row == nil {
|
||||||
|
t.Fatalf("隐藏的单位若有分数,榜单上仍要有它(否则总分对不上)")
|
||||||
|
}
|
||||||
|
if row["teamCode"] != "A" || row["teamName"] != "第一分公司" {
|
||||||
|
t.Fatalf("隐藏单位的展示字段不该变空,得到 %+v", row)
|
||||||
|
}
|
||||||
|
var rank int
|
||||||
|
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryA).Scan(&rank); err != nil {
|
||||||
|
t.Fatalf("读成绩名次: %v", err)
|
||||||
|
}
|
||||||
|
if rank != 1 {
|
||||||
|
t.Fatalf("名次不该被重算,A 应还是 1,得到 %d", rank)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoForeignKeyViolation(t, env)
|
||||||
|
|
||||||
|
// 审计记的是「隐藏」,能查到。
|
||||||
|
after := waitAudit(t, env, "team.purge", ids.teamA)
|
||||||
|
if !strings.Contains(after, `"hidden":true`) || !strings.Contains(after, `"deletedAt":"`) {
|
||||||
|
t.Fatalf("审计应写明这是隐藏并记下时间,得到 %s", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单查已彻底删除的单位:admin 查得到(便于排查),非 admin 按不存在处理。
|
||||||
|
func TestPurgedTeamVisibleOnlyToAdmin(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK {
|
||||||
|
t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := fmt.Sprintf("/api/teams/%d", ids.teamA)
|
||||||
|
if status, _ := env.do(client, http.MethodGet, path, nil, false); status != http.StatusNotFound {
|
||||||
|
t.Fatalf("recorder 单查已删除的单位应 404,得到 %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
adminClient, _ := env.client()
|
||||||
|
env.login(adminClient, "admin", "admin-pass")
|
||||||
|
|
||||||
|
status, env2 := env.do(adminClient, http.MethodGet, path, nil, false)
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("admin 单查已删除的单位应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
if got := decodeData[map[string]any](t, env2); got["deletedAt"] == nil {
|
||||||
|
t.Fatalf("admin 查到的 DTO 应带 deletedAt,得到 %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?includeDeleted=1 只给 admin:admin 能拿到被隐藏的单位,recorder 直接 403。
|
||||||
|
func TestListTeamsIncludeDeletedRequiresAdmin(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK {
|
||||||
|
t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodGet, "/api/teams?includeDeleted=1", nil, false)
|
||||||
|
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
|
||||||
|
t.Fatalf("recorder 用 includeDeleted 应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
env.createUser("admin", "admin-pass", "admin")
|
||||||
|
adminClient, _ := env.client()
|
||||||
|
env.login(adminClient, "admin", "admin-pass")
|
||||||
|
|
||||||
|
rows := getTeamList(t, env, adminClient, "/api/teams?includeDeleted=1")
|
||||||
|
row := findTeam(rows, ids.teamA)
|
||||||
|
if row == nil {
|
||||||
|
t.Fatalf("admin 带 includeDeleted 应能看到被删的单位,得到 %+v", rows)
|
||||||
|
}
|
||||||
|
if row["deletedAt"] == nil {
|
||||||
|
t.Fatalf("被删单位的 DTO 应带 deletedAt,得到 %+v", row)
|
||||||
|
}
|
||||||
|
// 不带开关时 admin 看到的默认列表同样排除它。
|
||||||
|
if findTeam(getTeamList(t, env, adminClient, "/api/teams"), ids.teamA) != nil {
|
||||||
|
t.Fatalf("默认列表对 admin 也应排除被删的单位")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入命中已隐藏的单位:用编码复用同一个单位并复活,不新建第二个,也不报「单位不存在」。
|
||||||
|
func TestImportRevivesPurgedTeam(t *testing.T) {
|
||||||
|
env, ids, client := recorderWithSeed(t)
|
||||||
|
if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK {
|
||||||
|
t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 团体行(athlete_name 为空)只挂单位,正好走 team_code 这条路。
|
||||||
|
body := importHeader + "\nM100,男子,A,,,,,,,registered,王五/赵六\n"
|
||||||
|
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||||
|
if status != http.StatusOK || !env2.OK {
|
||||||
|
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
result := decodeData[map[string]any](t, env2)
|
||||||
|
if result["skipped"] != float64(0) || result["created"] != float64(1) {
|
||||||
|
t.Fatalf("已隐藏单位的编码应能直接导入,得到 %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE code = 'A'`); n != 1 {
|
||||||
|
t.Fatalf("不该重复建档,得到 %d 个编码为 A 的单位", n)
|
||||||
|
}
|
||||||
|
if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE id = ? AND deleted_at IS NULL AND active = 1`,
|
||||||
|
ids.teamA); n != 1 {
|
||||||
|
t.Fatalf("复活应清空 deleted_at 并恢复 active,得到 %d 条", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新出现在界面上:默认列表里有它,单查也不再是 404。
|
||||||
|
if findTeam(getTeamList(t, env, client, "/api/teams"), ids.teamA) == nil {
|
||||||
|
t.Fatalf("复活后应回到默认列表")
|
||||||
|
}
|
||||||
|
if status, _ := env.do(client, http.MethodGet, fmt.Sprintf("/api/teams/%d", ids.teamA), nil, false); status != http.StatusOK {
|
||||||
|
t.Fatalf("复活后单查应 200,得到 %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单位不存在时(含 purge=1)404 沿用仓库既有语义。
|
||||||
|
func TestPurgeMissingTeamReturns404(t *testing.T) {
|
||||||
|
env, _, client := recorderWithSeed(t)
|
||||||
|
|
||||||
|
status, env2 := env.do(client, http.MethodDelete, "/api/teams/999999?purge=1", nil, true)
|
||||||
|
if status != http.StatusNotFound || env2.Error == nil || env2.Error.Message != "单位不存在" {
|
||||||
|
t.Fatalf("不存在的单位应 404 单位不存在,得到 %d %+v", status, env2.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- 「彻底删除」= 对界面不可见的标记,不是物理删除:报名、成绩、积分流水、人工调整与纪录
|
||||||
|
-- 照旧引用这一行,榜单不会变、也不需要重算。已有的软删除(active = 0)仍表示「停用」。
|
||||||
|
ALTER TABLE teams ADD COLUMN deleted_at TEXT;
|
||||||
@@ -461,3 +461,71 @@ func TestMigration0017PreservesExistingRows(t *testing.T) {
|
|||||||
t.Error("迁移后存在外键悬挂")
|
t.Error("迁移后存在外键悬挂")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 老库升级:0018 只给 teams 补一列 deleted_at,已有单位与引用它的数据一行不少、默认「未删除」。
|
||||||
|
func TestMigration0018AddsDeletedAtForExistingTeams(t *testing.T) {
|
||||||
|
db := openEventDB(t)
|
||||||
|
applyBefore(t, db, "0018")
|
||||||
|
|
||||||
|
stmts := []string{
|
||||||
|
`INSERT INTO teams(code, name, active) VALUES ('T1', '第一分公司', 0)`,
|
||||||
|
// 单位被停用(active = 0)是老库里的状态,升级后不能跟「彻底删除」混为一谈。
|
||||||
|
`INSERT INTO athletes(name, team_id) VALUES ('张三', 1)`,
|
||||||
|
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||||||
|
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||||||
|
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
|
||||||
|
`INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`,
|
||||||
|
`INSERT INTO entries(event_id, category_id, team_id, athlete_id) VALUES (1, 1, 1, 1)`,
|
||||||
|
}
|
||||||
|
for _, s := range stmts {
|
||||||
|
if _, err := db.Exec(s); err != nil {
|
||||||
|
t.Fatalf("造数据 %q: %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var probe sql.NullString
|
||||||
|
if err := db.QueryRow(`SELECT deleted_at FROM teams`).Scan(&probe); err == nil {
|
||||||
|
t.Fatal("升级前不该有 deleted_at 列")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 走真实升级路径;迁移只登记一次,ALTER TABLE ADD COLUMN 不能重放(重放会报 duplicate column)。
|
||||||
|
for i := 1; i <= 2; i++ {
|
||||||
|
if err := store.MigrateEvent(db); err != nil {
|
||||||
|
t.Fatalf("第 %d 次迁移: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := countRows(t, db, "teams"); got != 1 {
|
||||||
|
t.Fatalf("升级后单位行数 = %d,期望 1", got)
|
||||||
|
}
|
||||||
|
if got := countRows(t, db, "athletes"); got != 1 {
|
||||||
|
t.Fatalf("升级后人员行数 = %d,期望 1", got)
|
||||||
|
}
|
||||||
|
if got := countRows(t, db, "entries"); got != 1 {
|
||||||
|
t.Fatalf("升级后报名行数 = %d,期望 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
name string
|
||||||
|
active int
|
||||||
|
deletedAt sql.NullString
|
||||||
|
)
|
||||||
|
if err := db.QueryRow(`SELECT name, active, deleted_at FROM teams WHERE id = 1`).
|
||||||
|
Scan(&name, &active, &deletedAt); err != nil {
|
||||||
|
t.Fatalf("读单位: %v", err)
|
||||||
|
}
|
||||||
|
if name != "第一分公司" || active != 0 {
|
||||||
|
t.Errorf("老单位字段被改动: name=%q active=%d", name, active)
|
||||||
|
}
|
||||||
|
if deletedAt.Valid {
|
||||||
|
t.Errorf("老单位的 deleted_at 应为空(未删除),得到 %q", deletedAt.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新列可写可清,列表过滤与导入复活都靠它。
|
||||||
|
if _, err := db.Exec(`UPDATE teams SET deleted_at = datetime('now') WHERE id = 1`); err != nil {
|
||||||
|
t.Fatalf("写 deleted_at: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`UPDATE teams SET deleted_at = NULL WHERE id = 1`); err != nil {
|
||||||
|
t.Fatalf("清 deleted_at: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,12 @@ export const adminApi = {
|
|||||||
purgeAthlete: (id: number): Promise<AthletePurgeResult> =>
|
purgeAthlete: (id: number): Promise<AthletePurgeResult> =>
|
||||||
callWrite(() => client.athletes.purge(id)),
|
callWrite(() => client.athletes.purge(id)),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 彻底删除参赛单位:与运动员不同,这是「假删除」——单位从列表里消失,但它的报名、成绩与
|
||||||
|
* 积分流水一行不动,也不会触发任何重算。空响应,没有统计。失败原样抛出(含 404)。
|
||||||
|
*/
|
||||||
|
purgeTeam: (id: number): Promise<void> => callWrite(() => client.teams.purge(id)),
|
||||||
|
|
||||||
records: (): Promise<RecordEntry[]> =>
|
records: (): Promise<RecordEntry[]> =>
|
||||||
call(() => client.records.list(), () => mockDb.records()),
|
call(() => client.records.list(), () => mockDb.records()),
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ const confirmOpen = ref(false)
|
|||||||
const confirmTarget = ref<Team | null>(null)
|
const confirmTarget = ref<Team | null>(null)
|
||||||
const confirmPending = ref(false)
|
const confirmPending = ref(false)
|
||||||
|
|
||||||
|
const purgeOpen = ref(false)
|
||||||
|
const purgeTarget = ref<Team | null>(null)
|
||||||
|
const purgePending = ref(false)
|
||||||
|
/** 删除失败时的说明留在页面上:服务端那句原文(单位已不在等)比一行「操作失败」有用得多。 */
|
||||||
|
const purgeError = ref<string | null>(null)
|
||||||
|
|
||||||
function emptyForm(): TeamForm {
|
function emptyForm(): TeamForm {
|
||||||
return { code: '', name: '', shortName: '', logoPath: '', color: '#3482FF', sortOrder: '0', active: true }
|
return { code: '', name: '', shortName: '', logoPath: '', color: '#3482FF', sortOrder: '0', active: true }
|
||||||
}
|
}
|
||||||
@@ -159,6 +165,33 @@ async function toggleActive(team: Team): Promise<void> {
|
|||||||
confirmPending.value = false
|
confirmPending.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function askPurge(team: Team): void {
|
||||||
|
purgeTarget.value = team
|
||||||
|
purgeError.value = null
|
||||||
|
purgeOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmPurge(): Promise<void> {
|
||||||
|
const target = purgeTarget.value
|
||||||
|
// 双保险:确认框在请求期间禁用按钮,这里再挡一次重复点击。
|
||||||
|
if (target === null || purgePending.value) return
|
||||||
|
purgePending.value = true
|
||||||
|
purgeError.value = null
|
||||||
|
try {
|
||||||
|
await data.purgeTeam(target.id)
|
||||||
|
toast.success(`已彻底删除「${target.name}」`)
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -188,6 +221,10 @@ async function toggleActive(team: Team): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="mx-table-wrap">
|
||||||
<table class="mx-table mx-table-compact">
|
<table class="mx-table mx-table-compact">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -239,6 +276,14 @@ async function toggleActive(team: Team): Promise<void> {
|
|||||||
>
|
>
|
||||||
{{ team.active ? '停用' : '启用' }}
|
{{ team.active ? '停用' : '启用' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="mx-btn mx-btn-danger mx-btn-sm"
|
||||||
|
type="button"
|
||||||
|
:disabled="!data.canWrite.value || purgePending"
|
||||||
|
@click="askPurge(team)"
|
||||||
|
>
|
||||||
|
{{ purgePending && purgeTarget?.id === team.id ? '删除中…' : '彻底删除' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -254,7 +299,9 @@ async function toggleActive(team: Team): Promise<void> {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p class="bd-note">停用为软删除:历史成绩与积分流水保留,新报名不再可选该单位。</p>
|
<p class="bd-note">
|
||||||
|
启用:正常出现在单位列表与报名选择里;停用为软删除:历史成绩与积分流水保留,单位仍在列表与报名下拉里(标注「已停用」),只是「全选」不会再自动带上它,随时可以再启用;彻底删除:把该单位从列表中移除,界面上不可恢复,但它的报名、成绩与积分数据全部保留,积分榜不会变化。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormDialog
|
<FormDialog
|
||||||
@@ -301,12 +348,27 @@ async function toggleActive(team: Team): Promise<void> {
|
|||||||
:open="confirmOpen"
|
:open="confirmOpen"
|
||||||
tone="warning"
|
tone="warning"
|
||||||
title="停用该参赛单位?"
|
title="停用该参赛单位?"
|
||||||
:message="`停用「${confirmTarget?.name ?? ''}」后,新报名不再可选该单位;历史成绩与积分流水保留,可随时重新启用。`"
|
:message="`停用「${confirmTarget?.name ?? ''}」后,它仍会出现在单位列表与报名下拉里(标注「已停用」),只是「全选」不会再自动带上它;历史成绩与积分流水保留,可随时重新启用。`"
|
||||||
confirm-text="停用"
|
confirm-text="停用"
|
||||||
:pending="confirmPending"
|
:pending="confirmPending"
|
||||||
@confirm="confirmTarget && toggleActive(confirmTarget)"
|
@confirm="confirmTarget && toggleActive(confirmTarget)"
|
||||||
@cancel="confirmOpen = false"
|
@cancel="confirmOpen = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:open="purgeOpen"
|
||||||
|
tone="danger"
|
||||||
|
title="彻底删除该参赛单位?"
|
||||||
|
:message="`将把「${purgeTarget?.name ?? ''}」从参赛单位列表中移除,之后不再出现在单位列表与报名选择里,界面上无法恢复。`"
|
||||||
|
:details="[
|
||||||
|
'已有的报名、成绩与积分记录不受影响,积分榜不会变化',
|
||||||
|
'只是想暂时不让它参加,请用「停用」',
|
||||||
|
]"
|
||||||
|
confirm-text="彻底删除"
|
||||||
|
:pending="purgePending"
|
||||||
|
@confirm="confirmPurge"
|
||||||
|
@cancel="purgeOpen = false"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -319,4 +381,12 @@ async function toggleActive(team: Team): Promise<void> {
|
|||||||
.danger-text {
|
.danger-text {
|
||||||
color: var(--mx-red);
|
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>
|
</style>
|
||||||
@@ -89,6 +89,14 @@ export const baseDataApi = {
|
|||||||
call(() => adminApi.client.teams.create(input), () => mockBaseData.createTeam(input)),
|
call(() => adminApi.client.teams.create(input), () => mockBaseData.createTeam(input)),
|
||||||
update: (id: number, input: TeamInput): Promise<Team> =>
|
update: (id: number, input: TeamInput): Promise<Team> =>
|
||||||
call(() => adminApi.client.teams.update(id, input), () => mockBaseData.updateTeam(id, input)),
|
call(() => adminApi.client.teams.update(id, input), () => mockBaseData.updateTeam(id, input)),
|
||||||
|
/**
|
||||||
|
* 彻底删除(假删除):单位从列表里消失,数据全部保留,失败原样抛出即可。
|
||||||
|
*
|
||||||
|
* 与 athletes.purge 同理,这里不让 404 降级:404 是「单位已不在」的真实回答,
|
||||||
|
* 落到示例数据上会让页面把「没删掉」提示成「已彻底删除」。网络 / 超时仍照常降级。
|
||||||
|
*/
|
||||||
|
purge: (id: number): Promise<void> =>
|
||||||
|
call(() => adminApi.purgeTeam(id), () => mockBaseData.purgeTeam(id), { fallbackOn404: false }),
|
||||||
},
|
},
|
||||||
|
|
||||||
sports: {
|
sports: {
|
||||||
|
|||||||
@@ -277,6 +277,18 @@ export class MockBaseData {
|
|||||||
return { ...team }
|
return { ...team }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 彻底删除(假删除):与真实服务端一致,只让这个单位从名单里消失。
|
||||||
|
*
|
||||||
|
* 服务端是打 `deleted_at` 标记(报名、成绩、积分流水一行未动),示例数据没有也不需要有这一列——
|
||||||
|
* 从内存名单里移掉,界面看到的结果就一样了。没有统计对象,和 athletes 的 purge 不同。
|
||||||
|
*/
|
||||||
|
purgeTeam(id: number): void {
|
||||||
|
const index = this.teams.findIndex((team) => team.id === id)
|
||||||
|
if (index < 0) throw new Error(`单位 ${id} 不存在`)
|
||||||
|
this.teams.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
// ── 大项 ──────────────────────────────────────────────────────────────────
|
// ── 大项 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
listSports(): Sport[] {
|
listSports(): Sport[] {
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ async function saveTeam(input: TeamInput, id: number | null): Promise<Team> {
|
|||||||
return saved
|
return saved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 彻底删除(假删除):重新拉单位列表,被标成不可见的单位自然就不再出现;失败(单位已不在、网络错误)原样抛出。 */
|
||||||
|
async function purgeTeam(id: number): Promise<void> {
|
||||||
|
await baseDataApi.teams.purge(id)
|
||||||
|
await reloadTeams()
|
||||||
|
}
|
||||||
|
|
||||||
async function saveSport(input: SportInput, id: number | null): Promise<Sport> {
|
async function saveSport(input: SportInput, id: number | null): Promise<Sport> {
|
||||||
const saved = id === null ? await baseDataApi.sports.create(input) : await baseDataApi.sports.update(id, input)
|
const saved = id === null ? await baseDataApi.sports.create(input) : await baseDataApi.sports.update(id, input)
|
||||||
await reloadSports()
|
await reloadSports()
|
||||||
@@ -306,6 +312,8 @@ export interface BaseDataController {
|
|||||||
teamById: (id: number | null) => Team | undefined
|
teamById: (id: number | null) => Team | undefined
|
||||||
sportById: (id: number | null) => Sport | undefined
|
sportById: (id: number | null) => Sport | undefined
|
||||||
saveTeam: (input: TeamInput, id: number | null) => Promise<Team>
|
saveTeam: (input: TeamInput, id: number | null) => Promise<Team>
|
||||||
|
/** 假删除:单位从列表里消失,数据全部保留,积分榜不变。 */
|
||||||
|
purgeTeam: (id: number) => Promise<void>
|
||||||
saveSport: (input: SportInput, id: number | null) => Promise<Sport>
|
saveSport: (input: SportInput, id: number | null) => Promise<Sport>
|
||||||
removeSport: (id: number) => Promise<void>
|
removeSport: (id: number) => Promise<void>
|
||||||
saveEvent: (input: EventInput, id: number | null) => Promise<Event>
|
saveEvent: (input: EventInput, id: number | null) => Promise<Event>
|
||||||
@@ -355,6 +363,7 @@ export function useBaseData(): BaseDataController {
|
|||||||
teamById,
|
teamById,
|
||||||
sportById,
|
sportById,
|
||||||
saveTeam,
|
saveTeam,
|
||||||
|
purgeTeam,
|
||||||
saveSport,
|
saveSport,
|
||||||
removeSport,
|
removeSport,
|
||||||
saveEvent,
|
saveEvent,
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 参赛单位「彻底删除」的前端契约:方法 / 路径 + `?purge=1`,以及失败时服务端那句话的传递。
|
||||||
|
*
|
||||||
|
* 口径与运动员不同:这是假删除(单位从列表里消失,报名、成绩、积分流水一行未动、积分榜不变),
|
||||||
|
* 所以响应是空响应、没有统计对象——这里一并钉住,免得有人照运动员那套抄。
|
||||||
|
*/
|
||||||
|
|
||||||
|
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/teams/{id}?purge=1', async () => {
|
||||||
|
respondWith(() => jsonResponse({ ok: true, data: null }))
|
||||||
|
|
||||||
|
await adminApi.purgeTeam(3)
|
||||||
|
|
||||||
|
assert.equal(lastRequest.url, '/api/teams/3?purge=1')
|
||||||
|
assert.equal(lastRequest.method, 'DELETE')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 服务端中间件要求已登录的写请求带 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.purgeTeam(3)
|
||||||
|
} 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.purgeTeam(3),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof ApiError)
|
||||||
|
assert.equal(error.status, 500)
|
||||||
|
assert.equal(error.code, 'INTERNAL')
|
||||||
|
assert.equal(error.message, '服务端异常')
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 404 是「单位已经被别人删掉」这种真实回答,不是「接口未实现」:
|
||||||
|
// 一旦降级到示例数据,页面会把「根本没删掉」提示成「已彻底删除」。
|
||||||
|
test('purge 收到 404 时不降级,错误原样抛给页面', async () => {
|
||||||
|
baseDataFallback.value = false
|
||||||
|
respondWith(() =>
|
||||||
|
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '参赛单位不存在' } }, 404),
|
||||||
|
)
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => baseDataApi.teams.purge(3),
|
||||||
|
(error: unknown) => {
|
||||||
|
assert.ok(error instanceof ApiError)
|
||||||
|
assert.equal(error.status, 404)
|
||||||
|
assert.equal(error.message, '参赛单位不存在')
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert.equal(baseDataFallback.value, false, '不应切到示例数据')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 对照组:同组其它接口的 404 仍按「接口未实现」降级到示例数据,只有 purge 不降级。
|
||||||
|
// (baseDataApi.teams 没暴露软删除 remove——页面上的「停用」走 update,所以拿 list 作对照。)
|
||||||
|
test('teams 其它接口的 404 仍降级到示例数据(既有行为未受影响)', async () => {
|
||||||
|
baseDataFallback.value = false
|
||||||
|
respondWith(() =>
|
||||||
|
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '接口不存在' } }, 404),
|
||||||
|
)
|
||||||
|
|
||||||
|
const teams = await baseDataApi.teams.list()
|
||||||
|
|
||||||
|
assert.ok(teams.length > 0, '应拿到示例数据的单位')
|
||||||
|
assert.equal(baseDataFallback.value, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 后端整体连不上时仍降级到示例数据;假删除没有统计对象,示例数据也不能凭空造一个出来。
|
||||||
|
// 用 id=2 避开上一条已经删掉的 1(示例数据是模块级单例)。
|
||||||
|
test('后端不可用降级示例数据时,假删除照常生效且不返回统计', async () => {
|
||||||
|
baseDataFallback.value = false
|
||||||
|
respondWith(() => {
|
||||||
|
throw new TypeError('fetch failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await baseDataApi.teams.purge(2)
|
||||||
|
|
||||||
|
assert.equal(result, undefined)
|
||||||
|
assert.equal(baseDataFallback.value, true)
|
||||||
|
})
|
||||||
+21
-2
@@ -166,6 +166,17 @@ export interface AthletesApi extends CrudApi<Athlete, AthleteInput> {
|
|||||||
purge(id: number): Promise<AthletePurgeResult>
|
purge(id: number): Promise<AthletePurgeResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TeamsApi extends CrudApi<Team, TeamInput> {
|
||||||
|
/**
|
||||||
|
* 彻底删除(`DELETE /api/teams/{id}?purge=1`)。
|
||||||
|
*
|
||||||
|
* 注意口径与 AthletesApi.purge 不同:这里是「假删除」——只让该单位从默认列表里消失,
|
||||||
|
* 报名、成绩、积分流水与人工调整一行未动,积分榜不变,也不会有任何重算。
|
||||||
|
* 响应体是空响应(没有统计对象);单位已被别人删掉时回 404。
|
||||||
|
*/
|
||||||
|
purge(id: number): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntriesApi {
|
export interface EntriesApi {
|
||||||
/** 报名表按文件导入,后端解析 xlsx。 */
|
/** 报名表按文件导入,后端解析 xlsx。 */
|
||||||
importFile(file: File): Promise<EntryImportResult>
|
importFile(file: File): Promise<EntryImportResult>
|
||||||
@@ -303,6 +314,14 @@ function readPurgeResult(value: unknown): AthletePurgeResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTeamsApi(http: Http): TeamsApi {
|
||||||
|
return {
|
||||||
|
...createCrudApi<Team, TeamInput>(http, '/teams'),
|
||||||
|
// 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;和运动员不同,这里是空响应。
|
||||||
|
purge: (id) => http.requestVoid(`/teams/${id}`, { method: 'DELETE', query: { purge: 1 } }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createAthletesApi(http: Http): AthletesApi {
|
function createAthletesApi(http: Http): AthletesApi {
|
||||||
return {
|
return {
|
||||||
...createCrudApi<Athlete, AthleteInput>(http, '/athletes'),
|
...createCrudApi<Athlete, AthleteInput>(http, '/athletes'),
|
||||||
@@ -443,7 +462,7 @@ function defaultRequestId(): string {
|
|||||||
export class ApiClient {
|
export class ApiClient {
|
||||||
readonly auth: AuthApi
|
readonly auth: AuthApi
|
||||||
readonly settings: SettingsApi
|
readonly settings: SettingsApi
|
||||||
readonly teams: CrudApi<Team, TeamInput>
|
readonly teams: TeamsApi
|
||||||
readonly sports: CrudApi<Sport, SportInput>
|
readonly sports: CrudApi<Sport, SportInput>
|
||||||
readonly events: EventsApi
|
readonly events: EventsApi
|
||||||
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
|
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
|
||||||
@@ -508,7 +527,7 @@ export class ApiClient {
|
|||||||
update: (patch) => http.request<EventSettings>('/settings', { method: 'PUT', body: patch }),
|
update: (patch) => http.request<EventSettings>('/settings', { method: 'PUT', body: patch }),
|
||||||
}
|
}
|
||||||
|
|
||||||
this.teams = createCrudApi<Team, TeamInput>(http, '/teams')
|
this.teams = createTeamsApi(http)
|
||||||
this.sports = createCrudApi<Sport, SportInput>(http, '/sports')
|
this.sports = createCrudApi<Sport, SportInput>(http, '/sports')
|
||||||
this.events = createEventsApi(http)
|
this.events = createEventsApi(http)
|
||||||
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')
|
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')
|
||||||
|
|||||||
Reference in new issue
Block a user