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:
AskaEth committed 2026-09-10 23:48:28 +08:00
1 parent 9d4ee0946c
commit 5ae49b001c
18 files changed
+767 -13

No files matched your search

+1
View File
@@ -19,6 +19,7 @@
- **多显示器**:支持多屏各锁一页(kiosk 全屏),也能在管理端为指定显示器单独开窗或收窗
- **规则可配置**:积分规则以规则包(YAML)描述,可导入导出、跨赛事复用
- **运动员名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」把人员档案连同他名下的报名、成绩与积分流水一起删掉且不可恢复,并自动按新排序重算受影响小项的排名与积分(大屏同步更新)
- **参赛单位名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」是**假删除**,只把单位从列表里移除(界面上没有恢复入口),它的报名、成绩、积分流水与名下运动员全部保留,**积分榜不会变化**;报名表里再次用到该单位编码会让它自动恢复
- **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事
- **结果导出**:Excel 总表、全部分项、分项成绩表、单位积分明细可分别导出
- **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo
+1
View File
@@ -41,6 +41,7 @@
- [ ] 检查计分模板:个人前六名 `10/8/6/3/2/1`、团体 `15/10/8/4/2/1`、最后一名不得分
- [ ] 「只排名不计分」的项目(飞行人员战斗体能)导入后应显示为不计分
- [ ] 「基础数据 → 参赛单位」里各单位的 logo 都已上传(会显示在总积分榜、团体奖牌榜、分项成绩表与个人积分榜上)
- [ ] 同页核对**单位名单齐全、没有误删**:被「彻底删除」的单位不会出现在这个列表里(它的报名、成绩与积分仍在库里,积分榜照常显示它);发现少了就用它的编码导入一次报名表,它会重新出现(详见使用手册 10.1、常见问题 Q61–Q64)
## 5. 大屏
+25 -1
View File
@@ -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。
+39
View File
@@ -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:单位下面还有运动员,能删吗?**
能。单位被彻底删除时,名下的运动员**不会**被一起删掉:他们仍在「运动员」名册里,报名、成绩与积分全部保留。但有两处会变得不好操作,删之前最好先处理:
- 运动员列表的「所属单位」列会显示成**未知单位**(那个单位已经不在列表里,名字查不到了);按单位筛选时也筛不到这些运动员,只有在「全部单位」下才看得到;
- 编辑这些运动员时,「所属单位」下拉里没有这个单位,**必须改选另一个单位才能保存**。
所以更稳妥的做法是:先把名下运动员调到别的单位(或者干脆用「停用」代替「彻底删除」),再决定要不要彻底删除这个单位。
+2
View File
@@ -52,6 +52,7 @@
### `team_code` 单位编码 —— 必须填
在系统「基础数据 → 参赛单位」里给每个大单位配的**编码**,比如 `F1`、`F2`。系统里没有的会提示「单位 XXX 不存在」。
被「彻底删除」的单位,导入时若再次用到它的编码,它会**重新出现(复活)**,这一行照常导入——单位编码是唯一的,系统按编码复用原来那条记录,它以前的报名、成绩与积分都还在。
### `athlete_name` 运动员姓名
- **个人项目**:填姓名,必填。
@@ -180,6 +181,7 @@
- ⚠️ 但要注意「更新」是**整条覆盖**:表里空着的格会把系统里原来的值清掉(队员名单、道次、号码布都会被清空),`status` 空着会被重置成「正常参赛」。如果之前手工调整过某些人的状态,**重传前请在表里把 `status` 写清楚**。
3. 名单导入后,到「成绩录入」里录成绩、发布,积分榜就会自动更新。
4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个证件号或号码布,系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。
5. **被「彻底删除」的单位则相反,导入时会「复活」**:管理员彻底删除某个单位,只是把它从单位列表里移除,它的报名与成绩一条都没删;表里再出现这个 `team_code`,系统按编码复用原来那条单位记录,清掉删除标记并让它重新出现在列表里,**原有的报名、成绩与积分都还在**,也不会新建第二个同名单位。
---
+2
View File
@@ -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 {
+37 -4
View File
@@ -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)
+42 -4
View File
@@ -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`
+287
View File
@@ -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;
+68
View File
@@ -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)
}
}
+6
View File
@@ -164,6 +164,12 @@ export const adminApi = {
purgeAthlete: (id: number): Promise<AthletePurgeResult> =>
callWrite(() => client.athletes.purge(id)),
/**
* 彻底删除参赛单位:与运动员不同,这是「假删除」——单位从列表里消失,但它的报名、成绩与
* 积分流水一行不动,也不会触发任何重算。空响应,没有统计。失败原样抛出(含 404)。
*/
purgeTeam: (id: number): Promise<void> => callWrite(() => client.teams.purge(id)),
records: (): Promise<RecordEntry[]> =>
call(() => client.records.list(), () => mockDb.records()),
@@ -41,6 +41,12 @@ const confirmOpen = ref(false)
const confirmTarget = ref<Team | null>(null)
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 {
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
}
}
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>
<template>
@@ -188,6 +221,10 @@ async function toggleActive(team: Team): 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>
@@ -239,6 +276,14 @@ async function toggleActive(team: Team): Promise<void> {
>
{{ team.active ? '停用' : '启用' }}
</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>
</td>
</tr>
@@ -254,7 +299,9 @@ async function toggleActive(team: Team): Promise<void> {
</tbody>
</table>
</div>
<p class="bd-note">停用为软删除:历史成绩与积分流水保留,新报名不再可选该单位。</p>
<p class="bd-note">
启用:正常出现在单位列表与报名选择里;停用为软删除:历史成绩与积分流水保留,单位仍在列表与报名下拉里(标注「已停用」),只是「全选」不会再自动带上它,随时可以再启用;彻底删除:把该单位从列表中移除,界面上不可恢复,但它的报名、成绩与积分数据全部保留,积分榜不会变化。
</p>
</div>
<FormDialog
@@ -301,12 +348,27 @@ async function toggleActive(team: Team): Promise<void> {
:open="confirmOpen"
tone="warning"
title="停用该参赛单位?"
:message="`停用「${confirmTarget?.name ?? ''}」后,新报名不再可选该单位;历史成绩与积分流水保留,可随时重新启用。`"
:message="`停用「${confirmTarget?.name ?? ''}」后,它仍会出现在单位列表与报名下拉里(标注「已停用」),只是「全选」不会再自动带上它;历史成绩与积分流水保留,可随时重新启用。`"
confirm-text="停用"
:pending="confirmPending"
@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>
@@ -319,4 +381,12 @@ async function toggleActive(team: Team): 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>
@@ -89,6 +89,14 @@ export const baseDataApi = {
call(() => adminApi.client.teams.create(input), () => mockBaseData.createTeam(input)),
update: (id: number, input: TeamInput): Promise<Team> =>
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: {
@@ -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[] {
@@ -169,6 +169,12 @@ async function saveTeam(input: TeamInput, id: number | null): Promise<Team> {
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> {
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<Team>
/** 假删除:单位从列表里消失,数据全部保留,积分榜不变。 */
purgeTeam: (id: number) => Promise<void>
saveSport: (input: SportInput, id: number | null) => Promise<Sport>
removeSport: (id: number) => Promise<void>
saveEvent: (input: EventInput, id: number | null) => Promise<Event>
@@ -355,6 +363,7 @@ export function useBaseData(): BaseDataController {
teamById,
sportById,
saveTeam,
purgeTeam,
saveSport,
removeSport,
saveEvent,
+132
View File
@@ -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
View File
@@ -166,6 +166,17 @@ export interface AthletesApi extends CrudApi<Athlete, AthleteInput> {
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 {
/** 报名表按文件导入,后端解析 xlsx。 */
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 {
return {
...createCrudApi<Athlete, AthleteInput>(http, '/athletes'),
@@ -443,7 +462,7 @@ function defaultRequestId(): string {
export class ApiClient {
readonly auth: AuthApi
readonly settings: SettingsApi
readonly teams: CrudApi<Team, TeamInput>
readonly teams: TeamsApi
readonly sports: CrudApi<Sport, SportInput>
readonly events: EventsApi
readonly scoringProfiles: CrudApi<ScoringProfile, ScoringProfileInput>
@@ -508,7 +527,7 @@ export class ApiClient {
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.events = createEventsApi(http)
this.scoringProfiles = createCrudApi<ScoringProfile, ScoringProfileInput>(http, '/scoring-profiles')