From 5ae49b001c01e5e977ff6f11faaae31b9e7ab1c7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 10 Sep 2026 23:48:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=82=E8=B5=9B=E5=8D=95=E4=BD=8D?= =?UTF-8?q?=E3=80=8C=E5=BD=BB=E5=BA=95=E5=88=A0=E9=99=A4=E3=80=8D=3D=20?= =?UTF-8?q?=E5=81=87=E5=88=A0=E9=99=A4=EF=BC=88=E7=95=8C=E9=9D=A2=E9=9A=90?= =?UTF-8?q?=E8=97=8F=EF=BC=8C=E6=95=B0=E6=8D=AE=E4=B8=8E=E6=A6=9C=E5=8D=95?= =?UTF-8?q?=E5=85=A8=E4=BF=9D=E7=95=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与运动员的真物理删除是**两套相反口径**,有意如此(两个按钮同名,代码与 文档里都做了显著区分): - 运动员 `?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/ 同步。 --- README.md | 1 + docs/上线检查清单.md | 1 + docs/使用手册.md | 26 +- docs/常见问题.md | 39 +++ docs/报名名单导入规范.md | 2 + server/internal/httpapi/dto.go | 2 + server/internal/httpapi/handlers_config.go | 41 ++- server/internal/httpapi/repo.go | 46 ++- server/internal/httpapi/team_purge_test.go | 287 ++++++++++++++++++ .../event/0018_teams_deleted_at.sql | 3 + server/migrations/event/migration_test.go | 68 +++++ web/src/admin/api/client.ts | 6 + .../components/baseData/TeamsSection.vue | 74 ++++- .../admin/components/baseData/baseDataApi.ts | 8 + .../admin/components/baseData/mockBaseData.ts | 12 + .../admin/components/baseData/useBaseData.ts | 9 + web/src/admin/tests/team-purge-api.test.ts | 132 ++++++++ web/src/shared/api.ts | 23 +- 18 files changed, 767 insertions(+), 13 deletions(-) create mode 100644 server/internal/httpapi/team_purge_test.go create mode 100644 server/migrations/event/0018_teams_deleted_at.sql create mode 100644 web/src/admin/tests/team-purge-api.test.ts diff --git a/README.md b/README.md index 7f71b2e..2f186e1 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗 - **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用 - **运动员名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」把人员档案连同他名下的报名、成绩与积分流水一起删掉且不可恢复,并自动按新排序重算受影响小项的排名与积分(大屏同步更新) +- **参赛单位名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」是**假删除**,只把单位从列表里移除(界面上没有恢复入口),它的报名、成绩、积分流水与名下运动员全部保留,**积分榜不会变化**;报名表里再次用到该单位编码会让它自动恢复 - **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事 - **结果导出**:Excel 总表、全部分项、分项成绩表、单位积分明细可分别导出 - **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index 824522a..a7d80f8 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -41,6 +41,7 @@ - [ ] 检查计分模板:个人前六名 `10/8/6/3/2/1`、团体 `15/10/8/4/2/1`、最后一名不得分 - [ ] 「只排名不计分」的项目(飞行人员战斗体能)导入后应显示为不计分 - [ ] 「基础数据 → 参赛单位」里各单位的 logo 都已上传(会显示在总积分榜、团体奖牌榜、分项成绩表与个人积分榜上) +- [ ] 同页核对**单位名单齐全、没有误删**:被「彻底删除」的单位不会出现在这个列表里(它的报名、成绩与积分仍在库里,积分榜照常显示它);发现少了就用它的编码导入一次报名表,它会重新出现(详见使用手册 10.1、常见问题 Q61–Q64) ## 5. 大屏 diff --git a/docs/使用手册.md b/docs/使用手册.md index 75ee8bf..f5788fb 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -262,7 +262,31 @@ 1. 点「新增单位」; 2. 填写代码 *(唯一,字母 / 数字 / `_` / `-`,最长 16 位)、全称 *、简称、主题色、排序、logo(可点输入框右侧「上传图片」直接传,png / jpg / webp / gif,单张不超过 5 MiB;也能手填已有路径,留空则不显示); -3. 「编辑」修改;「停用」为软删除——历史成绩与积分流水保留,但新报名不再可选该单位,可随时「启用」。 +3. 每行有三个动作:**编辑**、**停用**(已停用的单位显示为「启用」)、**彻底删除**。 + +**停用与彻底删除的区别** + +| 状态 | 单位列表里 | 还能被报名选中吗 | 它的报名 / 成绩 / 积分 | 能恢复吗 | +| --- | --- | --- | --- | --- | +| 启用(默认) | 在 | 能 | 保留 | —— | +| 停用 | 还在(标「已停用」) | 仍可手动选(「全选」不会自动带上它) | **保留** | 点「启用」即可 | +| 彻底删除 | **不在了** | 不能 | **保留** | 界面上没有恢复入口;再次导入用到它的编码会自动回来 | + +「停用」还是软删除:单位留在列表里,历史成绩与积分流水保留,随时能再「启用」。「彻底删除」是**假删除**——只把这个单位从参赛单位列表里移除,它的报名、成绩、积分流水、人工调整、纪录与名下的运动员**一条都没删**,**积分榜也不会变化**,不会触发任何重算。操作步骤: + +1. 点该行右侧红色的「彻底删除」; +2. 在确认框里核对提示(会写明该单位从列表与报名选择里移除、界面上无法恢复、已有报名与积分不受影响、积分榜不变),再点一次「彻底删除」; +3. 完成后该单位从列表里消失,页面提示「已彻底删除「XX」」。 + +**「彻底删除」之后会发生什么** + +- 该单位**不再出现在参赛单位列表**里,报名选择(含团体报名、运动员的「所属单位」下拉)里也选不到,界面上没有恢复入口; +- 它的**报名、成绩、积分流水、人工调整、纪录与名下运动员全部保留**——消失的只是这个单位本身; +- **积分榜照旧显示它**:单位本来有分数的话,总积分榜、团体奖牌榜上仍有它的名字与分数。这是刻意设计(数据没删,分数就不该凭空消失),不是故障; +- **再次导入报名表时,如果用到这个单位的编码,它会自动复活**并重新出现在列表里——这里和人员不同:删掉的人再次导入是**以新档案建档**(旧数据回不来),删掉的单位则是**原来那条记录直接恢复**,报名、成绩与积分一直都在(见 10.7); +- 单位名下还有运动员时同样可以删,这些运动员不会被一起删掉,但他们编辑起来会有些不便(见常见问题 Q64)。 + +> ⚠️ **别和运动员的「彻底删除」混为一谈**:两个页面上的按钮同名,行为完全不同。运动员的「彻底删除」是**真删除**——人员档案连同他名下的报名、成绩与积分流水一起从库里删掉,并重算受影响小项的排名与积分(见 10.6);参赛单位的「彻底删除」是**假删除**——业务数据一条不删,只让单位从列表里消失,积分榜不变。 > 上传的 logo 会显示在**大屏**的总积分榜、团体奖牌榜、分项成绩与个人积分奖牌榜上(个人积分奖牌榜显示在运动员姓名前面);没传 logo 的单位不占位,队名不会因此错位。管理端页面不显示 logo。 diff --git a/docs/常见问题.md b/docs/常见问题.md index 5994e76..92919fd 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -322,3 +322,42 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 > 第 12 行:status「已报到」无法识别,只能填 registered / scratched / dq / dns(或留空) 改法:换成四个标准词之一(`registered` / `scratched` / `dq` / `dns`,大小写都行)或 Q59 里的中文别名,也可以直接留空(= 正常参赛),改完重新上传即可。对照表见 [报名名单导入规范 · 常见报错怎么办](./报名名单导入规范.md#六常见报错怎么办)。 + +**Q61:参赛单位怎么彻底删除?和删运动员一样吗?** + +不一样,两个按钮同名、行为完全不同,现场别按错。「基础数据 → 参赛单位」每行有三个动作:**编辑**、**停用**(已停用的单位显示为「启用」)、**彻底删除**。点该行右侧红色的「彻底删除」,在确认框里核对提示后再点一次「彻底删除」即可,完成后该单位从列表里消失,页面提示「已彻底删除「XX」」。 + +一句话概括:**删运动员是真删,删单位是假删**。 + +| | 运动员的「彻底删除」(见 Q57) | 参赛单位的「彻底删除」 | +| --- | --- | --- | +| 数据 | 人员档案连同报名、成绩、积分流水、破纪录记录**一并从库里删掉** | **一条都不删**,报名 / 成绩 / 积分 / 人工调整 / 纪录 / 名下运动员全部保留 | +| 榜单 | **重算**受影响小项的排名与积分,删完榜单会变 | **积分榜不变**,不触发任何重算 | +| 列表 | 从运动员名册里消失 | 从参赛单位列表里消失 | +| 恢复 | 恢复不了 | 界面上没有恢复入口;再次导入用到它的编码会自动回来(见 Q62) | + +想让某个单位暂时不参赛,用「停用」就够了:单位还在列表里,历史数据保留,随时能再启用。详细说明见[使用手册 · 10.1 参赛单位](./使用手册.md#101-参赛单位)。 + +**Q62:删掉的单位怎么又出现在列表里了?** + +多半是导入带回来的:报名表里再次用到这个单位的编码(`team_code`),系统会把它**原样复活**——清掉删除标记、恢复成启用,重新出现在参赛单位列表里。因为单位编码是唯一的,系统按编码找到的就是原来那一行,所以**不会新建第二个同名单位**,原有的报名、成绩与积分也都还在。 + +这和人员的规则**看着像、结果不同**:删掉的人再次导入是**以全新档案建档**,旧数据不会回来(见 Q58);删掉的单位再次导入是**原来那个单位直接复活**,旧数据一直都在。 + +- 不想让它回来,就别在报名表里填这个单位编码; +- 名单里「突然多出一个以前删过的单位」,通常就是这么来的。 + +**Q63:为什么积分榜里还能看到已删除的单位?** + +这是**刻意设计**,不是 bug。「彻底删除」只是把单位从参赛单位列表里移除的假删除——它的报名、成绩与积分流水**一条都没删**。既然分数还在,积分榜(总积分榜、团体奖牌榜、分项成绩表)就会照旧列出这个单位的名字与分数,否则总分和名次就对不上了。 + +所以「单位列表里没有它」不等于「它的历史成绩不存在」,两处不一致是正常的。如果确实希望它的分数不再影响榜单,那不是「彻底删除单位」能解决的事——它动的只有列表里的条目,不碰任何成绩数据。 + +**Q64:单位下面还有运动员,能删吗?** + +能。单位被彻底删除时,名下的运动员**不会**被一起删掉:他们仍在「运动员」名册里,报名、成绩与积分全部保留。但有两处会变得不好操作,删之前最好先处理: + +- 运动员列表的「所属单位」列会显示成**未知单位**(那个单位已经不在列表里,名字查不到了);按单位筛选时也筛不到这些运动员,只有在「全部单位」下才看得到; +- 编辑这些运动员时,「所属单位」下拉里没有这个单位,**必须改选另一个单位才能保存**。 + +所以更稳妥的做法是:先把名下运动员调到别的单位(或者干脆用「停用」代替「彻底删除」),再决定要不要彻底删除这个单位。 diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md index 622598f..cd84873 100644 --- a/docs/报名名单导入规范.md +++ b/docs/报名名单导入规范.md @@ -52,6 +52,7 @@ ### `team_code` 单位编码 —— 必须填 在系统「基础数据 → 参赛单位」里给每个大单位配的**编码**,比如 `F1`、`F2`。系统里没有的会提示「单位 XXX 不存在」。 +被「彻底删除」的单位,导入时若再次用到它的编码,它会**重新出现(复活)**,这一行照常导入——单位编码是唯一的,系统按编码复用原来那条记录,它以前的报名、成绩与积分都还在。 ### `athlete_name` 运动员姓名 - **个人项目**:填姓名,必填。 @@ -180,6 +181,7 @@ - ⚠️ 但要注意「更新」是**整条覆盖**:表里空着的格会把系统里原来的值清掉(队员名单、道次、号码布都会被清空),`status` 空着会被重置成「正常参赛」。如果之前手工调整过某些人的状态,**重传前请在表里把 `status` 写清楚**。 3. 名单导入后,到「成绩录入」里录成绩、发布,积分榜就会自动更新。 4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个证件号或号码布,系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。 +5. **被「彻底删除」的单位则相反,导入时会「复活」**:管理员彻底删除某个单位,只是把它从单位列表里移除,它的报名与成绩一条都没删;表里再出现这个 `team_code`,系统按编码复用原来那条单位记录,清掉删除标记并让它重新出现在列表里,**原有的报名、成绩与积分都还在**,也不会新建第二个同名单位。 --- diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index d8716bb..d99c58e 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -12,6 +12,8 @@ type teamDTO struct { Color *string `json:"color"` SortOrder int `json:"sortOrder"` Active bool `json:"active"` + // DeletedAt 非空表示「彻底删除」(对界面不可见),行仍在库里;停用是另一回事(active = false)。 + DeletedAt *string `json:"deletedAt"` } type athleteDTO struct { diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index 8208582..13f61ed 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -165,7 +165,13 @@ func (s *server) updateSettingsHandler(w http.ResponseWriter, r *http.Request) ( // ---------- teams ---------- 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) { @@ -173,7 +179,15 @@ func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, er if err != nil { 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) { @@ -210,6 +224,7 @@ func (s *server) updateTeamHandler(w http.ResponseWriter, r *http.Request) (any, return updated, nil } +// deleteTeamHandler 默认软删除(停用);带 purge=1 时彻底删除(对界面不可见,行仍在库里)。 func (s *server) deleteTeamHandler(w http.ResponseWriter, r *http.Request) error { id, err := pathID(r, "id") if err != nil { @@ -219,10 +234,21 @@ func (s *server) deleteTeamHandler(w http.ResponseWriter, r *http.Request) error if err != nil { 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 } - 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 } @@ -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)) 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 if categoryName != "" { categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName) diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go index b30470a..b1bbe30 100644 --- a/server/internal/httpapi/repo.go +++ b/server/internal/httpapi/repo.go @@ -139,22 +139,32 @@ func exists(ctx context.Context, db *sql.DB, query string, args ...any) (bool, e // ---------- 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) { var ( t teamDTO 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.LogoPath = strPtr(logoPath) t.Color = strPtr(color) + t.DeletedAt = strPtr(deletedAt) return t, err } -func (s *server) listTeams(ctx context.Context) ([]teamDTO, error) { - rows, err := s.ev.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`) +// listTeams 列出参赛单位。默认排除「彻底删除」(deleted_at 非空)的单位, +// 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 { return nil, internal(err) } @@ -302,6 +312,34 @@ func (s *server) deleteTeam(ctx context.Context, id int64) error { 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 ---------- const athleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age, active` diff --git a/server/internal/httpapi/team_purge_test.go b/server/internal/httpapi/team_purge_test.go new file mode 100644 index 0000000..09a1ebc --- /dev/null +++ b/server/internal/httpapi/team_purge_test.go @@ -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) + } +} diff --git a/server/migrations/event/0018_teams_deleted_at.sql b/server/migrations/event/0018_teams_deleted_at.sql new file mode 100644 index 0000000..e93eae4 --- /dev/null +++ b/server/migrations/event/0018_teams_deleted_at.sql @@ -0,0 +1,3 @@ +-- 「彻底删除」= 对界面不可见的标记,不是物理删除:报名、成绩、积分流水、人工调整与纪录 +-- 照旧引用这一行,榜单不会变、也不需要重算。已有的软删除(active = 0)仍表示「停用」。 +ALTER TABLE teams ADD COLUMN deleted_at TEXT; diff --git a/server/migrations/event/migration_test.go b/server/migrations/event/migration_test.go index 9a4135b..ade91b7 100644 --- a/server/migrations/event/migration_test.go +++ b/server/migrations/event/migration_test.go @@ -461,3 +461,71 @@ func TestMigration0017PreservesExistingRows(t *testing.T) { 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) + } +} diff --git a/web/src/admin/api/client.ts b/web/src/admin/api/client.ts index c8c5828..18bc856 100644 --- a/web/src/admin/api/client.ts +++ b/web/src/admin/api/client.ts @@ -164,6 +164,12 @@ export const adminApi = { purgeAthlete: (id: number): Promise => callWrite(() => client.athletes.purge(id)), + /** + * 彻底删除参赛单位:与运动员不同,这是「假删除」——单位从列表里消失,但它的报名、成绩与 + * 积分流水一行不动,也不会触发任何重算。空响应,没有统计。失败原样抛出(含 404)。 + */ + purgeTeam: (id: number): Promise => callWrite(() => client.teams.purge(id)), + records: (): Promise => call(() => client.records.list(), () => mockDb.records()), diff --git a/web/src/admin/components/baseData/TeamsSection.vue b/web/src/admin/components/baseData/TeamsSection.vue index f69a98f..4359e36 100644 --- a/web/src/admin/components/baseData/TeamsSection.vue +++ b/web/src/admin/components/baseData/TeamsSection.vue @@ -41,6 +41,12 @@ const confirmOpen = ref(false) const confirmTarget = ref(null) const confirmPending = ref(false) +const purgeOpen = ref(false) +const purgeTarget = ref(null) +const purgePending = ref(false) +/** 删除失败时的说明留在页面上:服务端那句原文(单位已不在等)比一行「操作失败」有用得多。 */ +const purgeError = ref(null) + function emptyForm(): TeamForm { return { code: '', name: '', shortName: '', logoPath: '', color: '#3482FF', sortOrder: '0', active: true } } @@ -159,6 +165,33 @@ async function toggleActive(team: Team): Promise { confirmPending.value = false } } + +function askPurge(team: Team): void { + purgeTarget.value = team + purgeError.value = null + purgeOpen.value = true +} + +async function confirmPurge(): Promise { + 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 + } +} @@ -319,4 +381,12 @@ async function toggleActive(team: Team): Promise { .danger-text { color: var(--mx-red); } +.purge-error { + display: flex; + align-items: flex-start; + gap: 6px; + margin-bottom: 14px; + padding: 10px 12px; + line-height: 1.5; +} diff --git a/web/src/admin/components/baseData/baseDataApi.ts b/web/src/admin/components/baseData/baseDataApi.ts index b66a01d..d6e1123 100644 --- a/web/src/admin/components/baseData/baseDataApi.ts +++ b/web/src/admin/components/baseData/baseDataApi.ts @@ -89,6 +89,14 @@ export const baseDataApi = { call(() => adminApi.client.teams.create(input), () => mockBaseData.createTeam(input)), update: (id: number, input: TeamInput): Promise => call(() => adminApi.client.teams.update(id, input), () => mockBaseData.updateTeam(id, input)), + /** + * 彻底删除(假删除):单位从列表里消失,数据全部保留,失败原样抛出即可。 + * + * 与 athletes.purge 同理,这里不让 404 降级:404 是「单位已不在」的真实回答, + * 落到示例数据上会让页面把「没删掉」提示成「已彻底删除」。网络 / 超时仍照常降级。 + */ + purge: (id: number): Promise => + call(() => adminApi.purgeTeam(id), () => mockBaseData.purgeTeam(id), { fallbackOn404: false }), }, sports: { diff --git a/web/src/admin/components/baseData/mockBaseData.ts b/web/src/admin/components/baseData/mockBaseData.ts index 6407c40..4129f10 100644 --- a/web/src/admin/components/baseData/mockBaseData.ts +++ b/web/src/admin/components/baseData/mockBaseData.ts @@ -277,6 +277,18 @@ export class MockBaseData { 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[] { diff --git a/web/src/admin/components/baseData/useBaseData.ts b/web/src/admin/components/baseData/useBaseData.ts index fcd76e4..11336bb 100644 --- a/web/src/admin/components/baseData/useBaseData.ts +++ b/web/src/admin/components/baseData/useBaseData.ts @@ -169,6 +169,12 @@ async function saveTeam(input: TeamInput, id: number | null): Promise { return saved } +/** 彻底删除(假删除):重新拉单位列表,被标成不可见的单位自然就不再出现;失败(单位已不在、网络错误)原样抛出。 */ +async function purgeTeam(id: number): Promise { + await baseDataApi.teams.purge(id) + await reloadTeams() +} + async function saveSport(input: SportInput, id: number | null): Promise { const saved = id === null ? await baseDataApi.sports.create(input) : await baseDataApi.sports.update(id, input) await reloadSports() @@ -306,6 +312,8 @@ export interface BaseDataController { teamById: (id: number | null) => Team | undefined sportById: (id: number | null) => Sport | undefined saveTeam: (input: TeamInput, id: number | null) => Promise + /** 假删除:单位从列表里消失,数据全部保留,积分榜不变。 */ + purgeTeam: (id: number) => Promise saveSport: (input: SportInput, id: number | null) => Promise removeSport: (id: number) => Promise saveEvent: (input: EventInput, id: number | null) => Promise @@ -355,6 +363,7 @@ export function useBaseData(): BaseDataController { teamById, sportById, saveTeam, + purgeTeam, saveSport, removeSport, saveEvent, diff --git a/web/src/admin/tests/team-purge-api.test.ts b/web/src/admin/tests/team-purge-api.test.ts new file mode 100644 index 0000000..e90d9b4 --- /dev/null +++ b/web/src/admin/tests/team-purge-api.test.ts @@ -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 +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +let reply: () => Response = () => jsonResponse({ ok: true, data: null }) +let lastRequest: Captured = {} + +// 必须在导入被测模块之前装好:ApiClient 在构造时就把当时的 fetch 绑进实例, +// 之后再替换 globalThis.fetch 对它无效。所以这里装一个固定函数,每个用例只换回复。 +globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + lastRequest = { + url: String(input), + method: init?.method ?? 'GET', + headers: (init?.headers ?? {}) as Record, + } + return reply() +}) as typeof fetch + +function respondWith(response: () => Response): void { + lastRequest = {} + reply = response +} + +const { ApiError, adminApi } = await import('../api/client') +const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi') + +test('彻底删除走 DELETE /api/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) +}) diff --git a/web/src/shared/api.ts b/web/src/shared/api.ts index ee740a1..0c37146 100644 --- a/web/src/shared/api.ts +++ b/web/src/shared/api.ts @@ -166,6 +166,17 @@ export interface AthletesApi extends CrudApi { purge(id: number): Promise } +export interface TeamsApi extends CrudApi { + /** + * 彻底删除(`DELETE /api/teams/{id}?purge=1`)。 + * + * 注意口径与 AthletesApi.purge 不同:这里是「假删除」——只让该单位从默认列表里消失, + * 报名、成绩、积分流水与人工调整一行未动,积分榜不变,也不会有任何重算。 + * 响应体是空响应(没有统计对象);单位已被别人删掉时回 404。 + */ + purge(id: number): Promise +} + export interface EntriesApi { /** 报名表按文件导入,后端解析 xlsx。 */ importFile(file: File): Promise @@ -303,6 +314,14 @@ function readPurgeResult(value: unknown): AthletePurgeResult { } } +function createTeamsApi(http: Http): TeamsApi { + return { + ...createCrudApi(http, '/teams'), + // 与 request 共用同一套 options,query 由 buildUrl 拼成 ?purge=1;和运动员不同,这里是空响应。 + purge: (id) => http.requestVoid(`/teams/${id}`, { method: 'DELETE', query: { purge: 1 } }), + } +} + function createAthletesApi(http: Http): AthletesApi { return { ...createCrudApi(http, '/athletes'), @@ -443,7 +462,7 @@ function defaultRequestId(): string { export class ApiClient { readonly auth: AuthApi readonly settings: SettingsApi - readonly teams: CrudApi + readonly teams: TeamsApi readonly sports: CrudApi readonly events: EventsApi readonly scoringProfiles: CrudApi @@ -508,7 +527,7 @@ export class ApiClient { update: (patch) => http.request('/settings', { method: 'PUT', body: patch }), } - this.teams = createCrudApi(http, '/teams') + this.teams = createTeamsApi(http) this.sports = createCrudApi(http, '/sports') this.events = createEventsApi(http) this.scoringProfiles = createCrudApi(http, '/scoring-profiles')