Files
Scoreboard/server/internal/httpapi/team_purge_test.go
T
AskaEth 5ae49b001c 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/ 同步。
2026-09-10 23:48:28 +08:00

288 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}