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
@@ -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("迁移后存在外键悬挂")
|
||||
}
|
||||
}
|
||||
|
||||
// 老库升级: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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user