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

+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)
}
}