## 运动员「彻底删除」= 真删除
`DELETE /api/athletes/{id}?purge=1`(不带参数仍是「停用」软删除)。在一个事务里:
删积分流水(含已作废历史流水)→ 删破纪录登记 → 删成绩 → 删报名 → 删人员 →
对受影响的 (小项, 组别) 调 recomputeLedgerTx + applyComputedRanks 重算流水与名次 →
bumpEvent/bumpStandings 推大屏 → 审计 athlete.purge(含删除规模)。
响应带 {deletedEntries, deletedResults, deletedLedger, deletedBreaks, recomputedEvents}。
必须先删 results 再删 entries(results.entry_id 无 ON DELETE);流水按 athlete_id / entry_id /
result_id 三种引用一把清,漏一种就留悬挂。
管理端「运动员」行加「彻底删除」按钮 + 危险确认框(如实写明报名与成绩一并删除、
相关小项排名与积分会重算),成功 toast 报出删除规模。
## 报名表 status 列容错
新增 entry_status.go 的 normalizeImportStatus:大小写不敏感(DNS/Registered 都认),
认「正常/退赛/弃赛/请假/取消资格/犯规/未出场/未到/缺席」等中文别名,留空即 registered,
认不出则给中文行级错误并跳过该行。此前原样入库,撞 entries.status 的 CHECK 约束后
抛原始 SQLite 错误,填表人看不懂。
## 修 isUniqueViolation 把 CHECK 失败误报成「已存在」
SQLite 的 CHECK / NOT NULL 失败也带 "constraint failed",旧匹配把「小项 status 取值非法」
报成 409「小项编码已存在」,现场会去找一个根本不存在的重名编码。收紧为只认
"unique constraint failed"。实测:重复编码仍 409;非法 status 不再误报。
## 安装包平铺《报名名单导入规范》
deploy/prepare-extra.mjs 改清单式,部署包根目录除 使用说明.txt 外新增
报名名单导入规范.txt。
测试:服务端 12 包全绿(新增 athlete_purge_test.go、entry_status_test.go);
前端 vue-tsc 通过、166 例全过;Console 语法检查 17 文件。
另在真二进制 + 真 SQLite 上跑通破坏性路径:删人后本人痕迹全为 0、他人不受影响、
榜单按新排序重算、foreign_key_check 无悬挂。docs/ 与 devdocs/ 同步更新。
868 lines
26 KiB
Go
868 lines
26 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"slices"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||
)
|
||
|
||
// 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移,
|
||
// 仓储查询按实体分文件放在 httpapi 内,避免改动 store 的公开签名。
|
||
|
||
func strPtr(ns sql.NullString) *string {
|
||
if !ns.Valid {
|
||
return nil
|
||
}
|
||
v := ns.String
|
||
return &v
|
||
}
|
||
|
||
func i64Ptr(ni sql.NullInt64) *int64 {
|
||
if !ni.Valid {
|
||
return nil
|
||
}
|
||
v := ni.Int64
|
||
return &v
|
||
}
|
||
|
||
func intPtr(ni sql.NullInt64) *int {
|
||
if !ni.Valid {
|
||
return nil
|
||
}
|
||
v := int(ni.Int64)
|
||
return &v
|
||
}
|
||
|
||
func f64Ptr(nf sql.NullFloat64) *float64 {
|
||
if !nf.Valid {
|
||
return nil
|
||
}
|
||
v := nf.Float64
|
||
return &v
|
||
}
|
||
|
||
func nullStr(p *string) any {
|
||
if p == nil || *p == "" {
|
||
return nil
|
||
}
|
||
return *p
|
||
}
|
||
|
||
func nullI64(p *int64) any {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
return *p
|
||
}
|
||
|
||
func boolPtr(v bool) *bool { return &v }
|
||
|
||
// boolToInt 把布尔写给 SQLite 的 0/1 列。
|
||
func boolToInt(v bool) int {
|
||
if v {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func nullInt(p *int) any {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
return *p
|
||
}
|
||
|
||
func nullF64(p *float64) any {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
return *p
|
||
}
|
||
|
||
func lastInsertID(res sql.Result) (int64, error) {
|
||
id, err := res.LastInsertId()
|
||
if err != nil {
|
||
return 0, fmt.Errorf("获取自增 id: %w", err)
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
// requireChanges 把「影响行数为 0」翻译成 404。
|
||
func requireChanges(res sql.Result, message string) error {
|
||
n, err := res.RowsAffected()
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
if n == 0 {
|
||
return notFound(message)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// isUniqueViolation 判断错误是否来自 UNIQUE 约束。
|
||
//
|
||
// 必须只认 "unique constraint failed":SQLite 把 CHECK / NOT NULL / FOREIGN KEY 失败
|
||
// 也报成 "... constraint failed",早先按 "constraint failed" 宽松匹配会把它们一律
|
||
// 当成「已存在」,于是「小项 status 取值非法」这种错被报成「小项编码已存在」,
|
||
// 现场会照着提示去找一个根本不存在的重名编码。CHECK / NOT NULL 属于另一类错误,
|
||
// 交给调用方按内部错误处理,别在这里吞掉。
|
||
func isUniqueViolation(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
|
||
}
|
||
|
||
func isForeignKeyViolation(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
return strings.Contains(strings.ToLower(err.Error()), "foreign key")
|
||
}
|
||
|
||
func exists(ctx context.Context, db *sql.DB, query string, args ...any) (bool, error) {
|
||
var one int
|
||
err := db.QueryRowContext(ctx, query, args...).Scan(&one)
|
||
switch {
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
return false, nil
|
||
case err != nil:
|
||
return false, err
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
// ---------- teams ----------
|
||
|
||
const teamColumns = `id, code, name, short_name, logo_path, color, sort_order, active`
|
||
|
||
func scanTeam(row interface{ Scan(...any) error }) (teamDTO, error) {
|
||
var (
|
||
t teamDTO
|
||
shortName, logoPath, color sql.NullString
|
||
)
|
||
err := row.Scan(&t.ID, &t.Code, &t.Name, &shortName, &logoPath, &color, &t.SortOrder, &t.Active)
|
||
t.ShortName = strPtr(shortName)
|
||
t.LogoPath = strPtr(logoPath)
|
||
t.Color = strPtr(color)
|
||
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`)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make([]teamDTO, 0)
|
||
for rows.Next() {
|
||
t, err := scanTeam(rows)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *server) teamMap(ctx context.Context) (map[int64]teamDTO, error) {
|
||
return teamMapQ(ctx, s.ev)
|
||
}
|
||
|
||
// teamMapQ 与 teamMap 同义,但可在事务内使用,保证榜单快照与写操作看到同一状态。
|
||
func teamMapQ(ctx context.Context, q queryer) (map[int64]teamDTO, error) {
|
||
rows, err := q.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make(map[int64]teamDTO)
|
||
for rows.Next() {
|
||
t, err := scanTeam(rows)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
out[t.ID] = t
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) {
|
||
return teamByIDQ(ctx, s.ev, id)
|
||
}
|
||
|
||
// teamByIDQ 在给定连接/事务内读取单位,供写事务内构造 change_log 载荷复用。
|
||
func teamByIDQ(ctx context.Context, q queryer, id int64) (teamDTO, error) {
|
||
t, err := scanTeam(q.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id))
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return teamDTO{}, notFound("单位不存在")
|
||
}
|
||
if err != nil {
|
||
return teamDTO{}, internal(err)
|
||
}
|
||
return t, nil
|
||
}
|
||
|
||
func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) {
|
||
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
|
||
return teamDTO{}, badRequest("code 与 name 必填", nil)
|
||
}
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return teamDTO{}, internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
res, err := tx.ExecContext(ctx,
|
||
`INSERT INTO teams (code, name, short_name, logo_path, color, sort_order, active)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active)
|
||
if isUniqueViolation(err) {
|
||
return teamDTO{}, conflict("单位编码已存在")
|
||
}
|
||
if err != nil {
|
||
return teamDTO{}, internal(err)
|
||
}
|
||
id, err := lastInsertID(res)
|
||
if err != nil {
|
||
return teamDTO{}, internal(err)
|
||
}
|
||
team, err := teamByIDQ(ctx, tx, id)
|
||
if err != nil {
|
||
return teamDTO{}, err
|
||
}
|
||
// 单位改名/新建要推给大屏,否则榜单里的单位名不会刷新。
|
||
if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil {
|
||
return teamDTO{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return teamDTO{}, internal(err)
|
||
}
|
||
return team, nil
|
||
}
|
||
|
||
func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) {
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return teamDTO{}, internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
res, err := tx.ExecContext(ctx,
|
||
`UPDATE teams SET code = ?, name = ?, short_name = ?, logo_path = ?, color = ?, sort_order = ?, active = ?
|
||
WHERE id = ?`,
|
||
in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active, id)
|
||
if isUniqueViolation(err) {
|
||
return teamDTO{}, conflict("单位编码已存在")
|
||
}
|
||
if err != nil {
|
||
return teamDTO{}, internal(err)
|
||
}
|
||
if err := requireChanges(res, "单位不存在"); err != nil {
|
||
return teamDTO{}, err
|
||
}
|
||
team, err := teamByIDQ(ctx, tx, id)
|
||
if err != nil {
|
||
return teamDTO{}, err
|
||
}
|
||
if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil {
|
||
return teamDTO{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return teamDTO{}, internal(err)
|
||
}
|
||
return team, nil
|
||
}
|
||
|
||
func (s *server) deleteTeam(ctx context.Context, id int64) error {
|
||
// 业务数据软删除:置 active=0,保留历史成绩与流水。
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
res, err := tx.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
if err := requireChanges(res, "单位不存在"); err != nil {
|
||
return err
|
||
}
|
||
if err := bumpVersion(ctx, tx, "teams", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
|
||
return err
|
||
}
|
||
return tx.Commit()
|
||
}
|
||
|
||
// ---------- athletes ----------
|
||
|
||
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age, active`
|
||
|
||
func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) {
|
||
var (
|
||
a athleteDTO
|
||
gender, empNo, idCard, bib sql.NullString
|
||
teamID, age sql.NullInt64
|
||
)
|
||
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &bib, &age, &a.Active)
|
||
a.Gender = strPtr(gender)
|
||
a.EmployeeNo = strPtr(empNo)
|
||
a.IDCard = strPtr(idCard)
|
||
a.Bib = strPtr(bib)
|
||
a.Age = intPtr(age)
|
||
if teamID.Valid {
|
||
a.TeamID = teamID.Int64
|
||
}
|
||
return a, err
|
||
}
|
||
|
||
// isIDCardConflict 判断错误是否来自 athletes.id_card 的唯一索引。
|
||
// 不能复用 isUniqueViolation:它把 CHECK 约束失败也算进去,会把性别非法误报成证件号重复。
|
||
func isIDCardConflict(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
return strings.Contains(strings.ToLower(err.Error()), "athletes.id_card")
|
||
}
|
||
|
||
// isBibConflict 判断错误是否来自 athletes.bib 的唯一索引,文案要与证件号冲突区分开。
|
||
func isBibConflict(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
return strings.Contains(strings.ToLower(err.Error()), "athletes.bib")
|
||
}
|
||
|
||
func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) {
|
||
query := `SELECT ` + athleteColumns + ` FROM athletes`
|
||
args := []any{}
|
||
if teamID > 0 {
|
||
query += ` WHERE team_id = ?`
|
||
args = append(args, teamID)
|
||
}
|
||
query += ` ORDER BY name`
|
||
|
||
rows, err := s.ev.QueryContext(ctx, query, args...)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make([]athleteDTO, 0)
|
||
for rows.Next() {
|
||
a, err := scanAthlete(rows)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
out = append(out, a)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *server) getAthlete(ctx context.Context, id int64) (athleteDTO, error) {
|
||
a, err := scanAthlete(s.ev.QueryRowContext(ctx, `SELECT `+athleteColumns+` FROM athletes WHERE id = ?`, id))
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return athleteDTO{}, notFound("运动员不存在")
|
||
}
|
||
if err != nil {
|
||
return athleteDTO{}, internal(err)
|
||
}
|
||
return a, nil
|
||
}
|
||
|
||
// validateAthleteAge 年龄可以留空,填了就必须落在 0–120(JSON 已保证是整数)。
|
||
func validateAthleteAge(age *int) error {
|
||
if age != nil && (*age < 0 || *age > 120) {
|
||
return badRequest("age 需要在 0-120 之间", nil)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) {
|
||
if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 {
|
||
return athleteDTO{}, badRequest("name 与 teamId 必填", nil)
|
||
}
|
||
if err := validateAthleteAge(in.Age); err != nil {
|
||
return athleteDTO{}, err
|
||
}
|
||
// 证件号与号码布都是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。
|
||
if err := s.checkAthleteIdentity(ctx, in, 0); err != nil {
|
||
return athleteDTO{}, err
|
||
}
|
||
res, err := s.ev.ExecContext(ctx,
|
||
`INSERT INTO athletes (name, gender, team_id, employee_no, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), nullStr(in.Bib), nullInt(in.Age), in.Active)
|
||
switch {
|
||
case isIDCardConflict(err):
|
||
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||
case isBibConflict(err):
|
||
return athleteDTO{}, conflict("号码布已被其它人员使用")
|
||
case isForeignKeyViolation(err):
|
||
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
||
case err != nil:
|
||
return athleteDTO{}, internal(err)
|
||
}
|
||
id, err := lastInsertID(res)
|
||
if err != nil {
|
||
return athleteDTO{}, internal(err)
|
||
}
|
||
return s.getAthlete(ctx, id)
|
||
}
|
||
|
||
// checkAthleteIdentity 校验证件号/号码布是否已被他人占用(id 为 0 表示新建)。
|
||
func (s *server) checkAthleteIdentity(ctx context.Context, in athleteDTO, id int64) error {
|
||
if in.IDCard != nil && *in.IDCard != "" {
|
||
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? AND id <> ? LIMIT 1`, *in.IDCard, id)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
if dup {
|
||
return conflict("证件号已被其它人员使用")
|
||
}
|
||
}
|
||
if in.Bib != nil && *in.Bib != "" {
|
||
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE bib = ? AND id <> ? LIMIT 1`, *in.Bib, id)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
if dup {
|
||
return conflict("号码布已被其它人员使用")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) {
|
||
if err := validateAthleteAge(in.Age); err != nil {
|
||
return athleteDTO{}, err
|
||
}
|
||
if err := s.checkAthleteIdentity(ctx, in, id); err != nil {
|
||
return athleteDTO{}, err
|
||
}
|
||
res, err := s.ev.ExecContext(ctx,
|
||
`UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, id_card = ?, bib = ?, age = ?, active = ? WHERE id = ?`,
|
||
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), nullStr(in.Bib), nullInt(in.Age), in.Active, id)
|
||
switch {
|
||
case isIDCardConflict(err):
|
||
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||
case isBibConflict(err):
|
||
return athleteDTO{}, conflict("号码布已被其它人员使用")
|
||
case isForeignKeyViolation(err):
|
||
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
||
case err != nil:
|
||
return athleteDTO{}, internal(err)
|
||
}
|
||
if err := requireChanges(res, "运动员不存在"); err != nil {
|
||
return athleteDTO{}, err
|
||
}
|
||
return s.getAthlete(ctx, id)
|
||
}
|
||
|
||
func (s *server) deleteAthlete(ctx context.Context, id int64) error {
|
||
res, err := s.ev.ExecContext(ctx, `UPDATE athletes SET active = 0 WHERE id = ?`, id)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
return requireChanges(res, "运动员不存在")
|
||
}
|
||
|
||
// athletePurge 是物理删除的规模统计:随响应体返回给前端做提示,同时进审计。
|
||
type athletePurge struct {
|
||
DeletedEntries int `json:"deletedEntries"`
|
||
DeletedResults int `json:"deletedResults"`
|
||
DeletedLedger int `json:"deletedLedger"`
|
||
DeletedBreaks int `json:"deletedBreaks"`
|
||
RecomputedEvent []int64 `json:"recomputedEvents"`
|
||
}
|
||
|
||
// purgeAthlete 物理删除人员及其全部痕迹,并在同一事务内重算受影响的积分。
|
||
//
|
||
// 删除顺序按外键方向来(PRAGMA foreign_key_list 实测):
|
||
// score_ledger 三种引用都先清(athlete_id / entry_id / result_id,后两者本来是 SET NULL,
|
||
// 但留着会变成指向不了任何东西的空壳流水,voided = 1 的历史流水同样要删);
|
||
// 再删 record_breaks(对 entries 是 CASCADE,显式删更清楚);
|
||
// 然后 results(对 entries 无 ON DELETE,必须先于 entries 删)、entries、athletes。
|
||
//
|
||
// 成绩被删会让名次与得分变化,所以删完必须按 (小项, 组别) 重算;
|
||
// 重算走 recomputeLedgerTx,它只作废引擎产出的流水,人工调整绝不动。
|
||
func (s *server) purgeAthlete(ctx context.Context, id int64) (athletePurge, error) {
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return athletePurge{}, internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
// 先收集受影响范围:这些成绩所属的 (小项, 组别) 在删除后要重算。
|
||
type scope struct{ eventID, categoryID int64 }
|
||
scopeRows, err := tx.QueryContext(ctx,
|
||
`SELECT DISTINCT e.event_id, e.category_id
|
||
FROM results r JOIN entries e ON e.id = r.entry_id
|
||
WHERE e.athlete_id = ?`, id)
|
||
if err != nil {
|
||
return athletePurge{}, internal(err)
|
||
}
|
||
scopes := make([]scope, 0, 4)
|
||
for scopeRows.Next() {
|
||
var sc scope
|
||
if err := scopeRows.Scan(&sc.eventID, &sc.categoryID); err != nil {
|
||
_ = scopeRows.Close()
|
||
return athletePurge{}, internal(err)
|
||
}
|
||
scopes = append(scopes, sc)
|
||
}
|
||
err = scopeRows.Err()
|
||
_ = scopeRows.Close()
|
||
if err != nil {
|
||
return athletePurge{}, internal(err)
|
||
}
|
||
|
||
var out athletePurge
|
||
for _, step := range []struct {
|
||
query string
|
||
args []any
|
||
count *int
|
||
}{
|
||
{`DELETE FROM score_ledger
|
||
WHERE athlete_id = ?
|
||
OR entry_id IN (SELECT id FROM entries WHERE athlete_id = ?)
|
||
OR result_id IN (SELECT r.id FROM results r JOIN entries e ON e.id = r.entry_id
|
||
WHERE e.athlete_id = ?)`, []any{id, id, id}, &out.DeletedLedger},
|
||
{`DELETE FROM record_breaks
|
||
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id = ?)`, []any{id}, &out.DeletedBreaks},
|
||
{`DELETE FROM results
|
||
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id = ?)`, []any{id}, &out.DeletedResults},
|
||
{`DELETE FROM entries WHERE athlete_id = ?`, []any{id}, &out.DeletedEntries},
|
||
} {
|
||
res, err := tx.ExecContext(ctx, step.query, step.args...)
|
||
if err != nil {
|
||
return athletePurge{}, s.purgeBlocked(ctx, tx, id, err)
|
||
}
|
||
n, err := res.RowsAffected()
|
||
if err != nil {
|
||
return athletePurge{}, internal(err)
|
||
}
|
||
*step.count = int(n)
|
||
}
|
||
res, err := tx.ExecContext(ctx, `DELETE FROM athletes WHERE id = ?`, id)
|
||
if err != nil {
|
||
return athletePurge{}, s.purgeBlocked(ctx, tx, id, err)
|
||
}
|
||
// 走到这里说明并发删过了,回滚别把成绩删了却报成功。
|
||
if err := requireChanges(res, "运动员不存在"); err != nil {
|
||
return athletePurge{}, err
|
||
}
|
||
|
||
// 重算:删完成绩后按组别重建流水,别人的名次与得分随新排序更新。
|
||
// 只重建流水还不够——成绩表与奖牌统计看的是 results.rank,必须像发布那样把名次写回去。
|
||
actorID := middleware.User(ctx).ID
|
||
eventIDs := make([]int64, 0, len(scopes))
|
||
for _, sc := range scopes {
|
||
score, err := recomputeLedgerTx(ctx, tx, sc.eventID, sc.categoryID, actorID, "人员删除后重算")
|
||
if err != nil {
|
||
return athletePurge{}, err
|
||
}
|
||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||
return athletePurge{}, err
|
||
}
|
||
if !slices.Contains(eventIDs, sc.eventID) {
|
||
eventIDs = append(eventIDs, sc.eventID)
|
||
}
|
||
}
|
||
// 推大屏:受影响小项与榜单,否则大屏还挂着旧分数。
|
||
for _, eventID := range eventIDs {
|
||
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
|
||
return athletePurge{}, err
|
||
}
|
||
}
|
||
if len(eventIDs) > 0 {
|
||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||
return athletePurge{}, err
|
||
}
|
||
}
|
||
out.RecomputedEvent = eventIDs
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
return athletePurge{}, s.purgeBlocked(ctx, tx, id, err)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// purgeBlocked 兜底:撞上外键约束时回滚并给出可读的 409,不让它冒成 500。
|
||
// 正常路径已经按外键方向把引用清干净了,走到这里说明还有没覆盖到的引用,必须留日志证据。
|
||
func (s *server) purgeBlocked(ctx context.Context, tx *sql.Tx, id int64, cause error) error {
|
||
if !isForeignKeyViolation(cause) {
|
||
return internal(cause)
|
||
}
|
||
s.logger.WarnContext(ctx, "彻底删除人员撞上外键约束", "athleteId", id, "err", cause)
|
||
// 连接池只有一条连接,先把事务交还再返回,避免后续查询自锁。
|
||
_ = tx.Rollback()
|
||
return conflict("该运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,删除已回滚,人员和成绩都没有改动。请先删除相关报名与成绩后重试。")
|
||
}
|
||
|
||
// ---------- sports ----------
|
||
|
||
const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active`
|
||
|
||
func scanSport(row interface{ Scan(...any) error }) (sportDTO, error) {
|
||
var (
|
||
sp sportDTO
|
||
shortName sql.NullString
|
||
defaultProfileID sql.NullInt64
|
||
)
|
||
err := row.Scan(&sp.ID, &sp.Code, &sp.Name, &shortName, &defaultProfileID, &sp.SortOrder, &sp.Active)
|
||
sp.ShortName = strPtr(shortName)
|
||
sp.DefaultProfileID = i64Ptr(defaultProfileID)
|
||
return sp, err
|
||
}
|
||
|
||
func (s *server) listSports(ctx context.Context) ([]sportDTO, error) {
|
||
rows, err := s.ev.QueryContext(ctx, `SELECT `+sportColumns+` FROM sports ORDER BY sort_order, code`)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make([]sportDTO, 0)
|
||
for rows.Next() {
|
||
sp, err := scanSport(rows)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
out = append(out, sp)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *server) getSport(ctx context.Context, id int64) (sportDTO, error) {
|
||
sp, err := scanSport(s.ev.QueryRowContext(ctx, `SELECT `+sportColumns+` FROM sports WHERE id = ?`, id))
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return sportDTO{}, notFound("大项不存在")
|
||
}
|
||
if err != nil {
|
||
return sportDTO{}, internal(err)
|
||
}
|
||
return sp, nil
|
||
}
|
||
|
||
func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) {
|
||
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
|
||
return sportDTO{}, badRequest("code 与 name 必填", nil)
|
||
}
|
||
res, err := s.ev.ExecContext(ctx,
|
||
`INSERT INTO sports (code, name, short_name, default_profile_id, sort_order, active)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active)
|
||
if isUniqueViolation(err) {
|
||
return sportDTO{}, conflict("大项编码已存在")
|
||
}
|
||
if err != nil {
|
||
return sportDTO{}, internal(err)
|
||
}
|
||
id, err := lastInsertID(res)
|
||
if err != nil {
|
||
return sportDTO{}, internal(err)
|
||
}
|
||
return s.getSport(ctx, id)
|
||
}
|
||
|
||
func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportDTO, error) {
|
||
res, err := s.ev.ExecContext(ctx,
|
||
`UPDATE sports SET code = ?, name = ?, short_name = ?, default_profile_id = ?, sort_order = ?, active = ?
|
||
WHERE id = ?`,
|
||
in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active, id)
|
||
if isUniqueViolation(err) {
|
||
return sportDTO{}, conflict("大项编码已存在")
|
||
}
|
||
if err != nil {
|
||
return sportDTO{}, internal(err)
|
||
}
|
||
if err := requireChanges(res, "大项不存在"); err != nil {
|
||
return sportDTO{}, err
|
||
}
|
||
return s.getSport(ctx, id)
|
||
}
|
||
|
||
func (s *server) deleteSport(ctx context.Context, id int64) error {
|
||
// 大项有子项目时不允许软删,避免项目挂在不可见的大项下。
|
||
has, err := exists(ctx, s.ev, `SELECT 1 FROM events WHERE sport_id = ? LIMIT 1`, id)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
if has {
|
||
return conflict("大项下仍有小项,不能删除")
|
||
}
|
||
res, err := s.ev.ExecContext(ctx, `UPDATE sports SET active = 0 WHERE id = ?`, id)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
return requireChanges(res, "大项不存在")
|
||
}
|
||
|
||
// ---------- scoring profiles ----------
|
||
|
||
func (s *server) listProfiles(ctx context.Context) ([]profileDTO, error) {
|
||
rows, err := s.ev.QueryContext(ctx,
|
||
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
|
||
FROM scoring_profiles ORDER BY code`)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make([]profileDTO, 0)
|
||
for rows.Next() {
|
||
var p profileDTO
|
||
if err := rows.Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
out = append(out, p)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
for i := range out {
|
||
rules, err := s.profileRules(ctx, out[i].ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out[i].Rules = rules
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *server) profileRules(ctx context.Context, profileID int64) ([]profileRuleDTO, error) {
|
||
rows, err := s.ev.QueryContext(ctx,
|
||
`SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, profileID)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make([]profileRuleDTO, 0)
|
||
for rows.Next() {
|
||
var r profileRuleDTO
|
||
if err := rows.Scan(&r.Rank, &r.Points); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *server) getProfile(ctx context.Context, id int64) (profileDTO, error) {
|
||
var p profileDTO
|
||
err := s.ev.QueryRowContext(ctx,
|
||
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
|
||
FROM scoring_profiles WHERE id = ?`, id).
|
||
Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return profileDTO{}, notFound("积分模板不存在")
|
||
}
|
||
if err != nil {
|
||
return profileDTO{}, internal(err)
|
||
}
|
||
rules, err := s.profileRules(ctx, p.ID)
|
||
if err != nil {
|
||
return profileDTO{}, err
|
||
}
|
||
p.Rules = rules
|
||
return p, nil
|
||
}
|
||
|
||
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
|
||
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
|
||
return profileDTO{}, badRequest("code 与 name 必填", nil)
|
||
}
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return profileDTO{}, internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
res, err := tx.ExecContext(ctx,
|
||
`INSERT INTO scoring_profiles (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero)
|
||
if isUniqueViolation(err) {
|
||
return profileDTO{}, conflict("积分模板编码已存在")
|
||
}
|
||
if err != nil {
|
||
return profileDTO{}, internal(err)
|
||
}
|
||
id, err := lastInsertID(res)
|
||
if err != nil {
|
||
return profileDTO{}, internal(err)
|
||
}
|
||
if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil {
|
||
return profileDTO{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return profileDTO{}, internal(err)
|
||
}
|
||
return s.getProfile(ctx, id)
|
||
}
|
||
|
||
func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) {
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return profileDTO{}, internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
res, err := tx.ExecContext(ctx,
|
||
`UPDATE scoring_profiles SET code = ?, name = ?, tie_mode = ?, record_bonus_mode = ?,
|
||
award_ranks = ?, last_place_zero = ?, updated_at = datetime('now')
|
||
WHERE id = ?`,
|
||
in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero, id)
|
||
if isUniqueViolation(err) {
|
||
return profileDTO{}, conflict("积分模板编码已存在")
|
||
}
|
||
if err != nil {
|
||
return profileDTO{}, internal(err)
|
||
}
|
||
if err := requireChanges(res, "积分模板不存在"); err != nil {
|
||
return profileDTO{}, err
|
||
}
|
||
if in.Rules != nil {
|
||
if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil {
|
||
return profileDTO{}, err
|
||
}
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return profileDTO{}, internal(err)
|
||
}
|
||
return s.getProfile(ctx, id)
|
||
}
|
||
|
||
func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules []profileRuleDTO) error {
|
||
if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil {
|
||
return internal(err)
|
||
}
|
||
for _, r := range rules {
|
||
if r.Rank < 1 {
|
||
return badRequest("积分名次必须 >= 1", r)
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`,
|
||
profileID, r.Rank, r.Points); err != nil {
|
||
return internal(err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func orDefault(v, def string) string {
|
||
if strings.TrimSpace(v) == "" {
|
||
return def
|
||
}
|
||
return v
|
||
}
|