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