与运动员的真物理删除是**两套相反口径**,有意如此(两个按钮同名,代码与
文档里都做了显著区分):
- 运动员 `?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/ 同步。
288 lines
13 KiB
Go
288 lines
13 KiB
Go
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)
|
||
}
|
||
}
|