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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user