Files
Scoreboard/server/internal/httpapi/repo.go
T
AskaEth 2c983bcfdf fix: 枚举入参校验(500→400)、5xx 错误串不再外泄、健康检查探库、限流键清理
服务端一批「会误导现场」的缺陷,全部补了测试:

- 枚举型入参(events 的 event_type/result_mode/result_type/compare/status、
  scoring_profiles 的 tie_mode/record_bonus_mode、entries.status、results.status)
  过去完全没有校验,非法值一路落到 SQLite 的 CHECK 约束,被统一错误处理当成
  服务端异常报成 500;现在统一在 enums.go 拦成 400 VALIDATION_FAILED,
  message 带字段名与合法取值、detail 带原始输入值。空值仍走原来的默认值。
- 5xx 响应体不再回显内部错误原文:新增 apiError.logDetail,SQLite / 引擎的
  原始错误串只进 ERROR 日志的 detail 字段。此前正好是反的——前端拿得到原文,
  日志里只有「服务端异常」。同时校准 newAPIError / internalMsg 的 skip 层数,
  拦截点不再退化成 response.go 里的构造函数。
- /api/health 新增 db / dbError:用 SELECT 1 探库(Ping 探不到「文件在、库读不动」),
  超时 5s 与 busy_timeout 同量级。状态码仍恒 200 —— Console 拿状态码当存活信号,
  数据库故障时回 5xx 只会变成重启循环,两个事实分两个字段表达。
- 修改密码时原密码错误由 401 改为 400 AUTH_FAILED(新增 wrongCredential):
  401 的语义是「本请求没有有效会话」,而这里会话是好的。
- 限流 hits map 在键数超过阈值时清理窗口内已无记录的键(窗口内活跃的键不动,
  否则堆键数就能冲掉别人的计数)。
- createEntry / updateEntry 拒绝「A 小项的报名挂在 B 小项的组别上」。
  数据库层仍无 (event_id, category_id) 复合外键(重建 entries 会牵动 results /
  score_ledger 的级联,风险高不做),这一层先兜住。
- 补测试:枚举字段逐项正反例、CSRF 反向用例、写限流 429、健康探库(含库故障)、
  5xx 不外泄原文、限流键清理不误删。
2026-09-11 14:28:25 +08:00

922 lines
28 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
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, 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, &deletedAt)
t.ShortName = strPtr(shortName)
t.LogoPath = strPtr(logoPath)
t.Color = strPtr(color)
t.DeletedAt = strPtr(deletedAt)
return t, err
}
// 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)
}
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()
}
// 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`
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)
}
tieMode, err := normalizeTieMode(in.TieMode)
if err != nil {
return profileDTO{}, err
}
recordBonusMode, err := normalizeRecordBonusMode(in.RecordBonusMode)
if err != nil {
return profileDTO{}, err
}
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, tieMode, recordBonusMode, 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) {
tieMode, err := normalizeTieMode(in.TieMode)
if err != nil {
return profileDTO{}, err
}
recordBonusMode, err := normalizeRecordBonusMode(in.RecordBonusMode)
if err != nil {
return profileDTO{}, err
}
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, tieMode, recordBonusMode, 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
}