Files
Scoreboard/server/internal/httpapi/repo.go
T
AskaEth 877ff15823 feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

1474 lines
52 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"
"net/http"
"slices"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
// 本文件是赛事库(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) {
// 编码与名字落库前先裁掉首尾空白:编码是导入按 team_code 匹配的键,
// 「 A 」与「A」肉眼一样却互不相等(UNIQUE 按字节比),现场只会看到「单位不存在」。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || 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) {
// 更新与新建同口径:必填校验 + 裁空白。漏了这一段,一次 PUT 就能把单位的 code/name
// 清成空串或纯空格(编码是导入的匹配键,清掉之后导入再也认不出这个单位)。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || 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,
`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
}
// teamRestore 是恢复接口的回报。
//
// Restored 单独报出来,是因为「本来就没被隐藏」与「这一次真的恢复了」在界面上是两句话,
// 审计也只该为后者留痕:重复点恢复不该在审计里堆一串什么都没发生的记录。
type teamRestore struct {
// Before 只给审计用(被隐藏时的样子),不进响应体——响应体回答的是「它现在长什么样」。
Before teamDTO `json:"-"`
Team teamDTO `json:"team"`
Restored bool `json:"restored"`
}
// restoreTeam 把「彻底删除」(隐藏)的单位放回界面,是操作员显式点的那个恢复入口。
//
// 与 reviveTeam(导入命中编码时的顺带复活)的区别只有两点,语义本身完全一致:
// - 先确认它真的被隐藏:没被隐藏就按幂等成功返回现状,绝不顺手把「停用」改成启用;
// - 编码已被另一个可见单位占用时明确报 409,不让库里出现两个同 code 的可见单位。
//
// 隐藏时只动了 deleted_at / active 两列,报名、成绩、流水、人工调整、纪录一行未动,
// 所以这里同样不重算、不推榜单:成功后榜单与 change_log 应逐字节不变(有测试钉着)。
func (s *server) restoreTeam(ctx context.Context, id int64) (teamRestore, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return teamRestore{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, err := teamByIDQ(ctx, tx, id)
if err != nil {
return teamRestore{}, err
}
if before.DeletedAt == nil {
return teamRestore{Before: before, Team: before, Restored: false}, nil
}
// 编码是唯一索引,正常不可能撞上;但有人绕过导入路径手工改库时「已隐藏的 A」与
// 「新建的 A」会同时存在,此时恢复会造出两个同 code 的可见单位,榜单与导出都会指错行。
var dup int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM teams WHERE code = ? AND id <> ? AND deleted_at IS NULL`,
before.Code, id).Scan(&dup); err != nil {
return teamRestore{}, internal(err)
}
if dup > 0 {
return teamRestore{}, conflict("单位编码 " + before.Code + " 已被另一个可见单位占用,请先处理那个单位再恢复")
}
if _, err := tx.ExecContext(ctx,
`UPDATE teams SET deleted_at = NULL, active = 1 WHERE id = ?`, id); err != nil {
return teamRestore{}, internal(err)
}
after, err := teamByIDQ(ctx, tx, id)
if err != nil {
return teamRestore{}, err
}
if err := tx.Commit(); err != nil {
return teamRestore{}, internal(err)
}
return teamRestore{Before: before, Team: after, Restored: true}, nil
}
// restoreTeamHandler 处理 POST /api/teams/{id}/restore:把「彻底删除」的单位放回界面。
//
// 权限与彻底删除同档(recorder)——能隐藏的人就该能恢复,不额外收紧。
func (s *server) restoreTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
result, err := s.restoreTeam(r.Context(), id)
if err != nil {
return nil, err
}
// 只在真的改了库时记审计:重复点恢复、或对没隐藏的单位调用,都只是幂等回放,不是事件。
if result.Restored {
s.audit(r.Context(), "team.restore", "team", id, result.Before, result.Team)
}
return result, 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 的区别不是「谁更严」,而是**按索引名认人**:这里只看错误串里有没有
// "athletes.id_card",好把证件号冲突的文案与号码布冲突(isBibConflict)及其它唯一冲突分开。
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
}
// validateAthleteGender 性别可以留空(历史档案里就有空的),填了就必须是「男」或「女」。
//
// athletes.gender 有 CHECK (gender IN ('男', '女')),不在写入侧拦下来,现场把性别填成
// M / male 就只能看到 500 + 英文约束原文 —— 同一类错在组别就地更新路径上是 400。
// 与组别那条(validateCategoryGender)同口径:不做 TrimSpace,带空格的值按非法拒绝,
// 免得「校验通过、落库撞 CHECK」这种更难看的结果。
func validateAthleteGender(gender *string) error {
if gender == nil {
return nil
}
if v := *gender; v != "" && v != "男" && v != "女" {
return badRequest("gender 只能是 男 / 女", gender)
}
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
}
if err := validateAthleteGender(in.Gender); 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 := validateAthleteGender(in.Gender); 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() }()
// 先收集受影响范围:这些成绩所属的 (小项, 组别) 在删除后要重算。
// 这个局部 scope 与下面的包级 purgeScope 完全同构(P2-C24c)——合并不改任何行为,
// 但属于代码改动,本轮只按注释记一笔,留给下一轮统一。
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(个人名次),必须像发布那样把名次写回去;
// 奖牌榜与运动员榜看的是 channel_ranks,由 recomputeLedgerTx 一并重建。
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("该运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,删除已回滚,人员和成绩都没有改动。请先删除相关报名与成绩后重试。")
}
// ---------- 运动员批量动作 ----------
const (
athleteBatchDisable = "disable"
athleteBatchPurge = "purge"
)
// maxAthleteBatch 是单次批量动作的人数上限,与 maxEntryBatch(报名项,entries_batch.go)同量级:
// 前端一次勾选几十人是常态,500 足够覆盖「一个单位全选」,再大说明是脚本误用或误粘贴。
//
// 不直接复用 maxEntryBatch:那个常量从名字到文案都是「报名项」,拿它去限「人」会把两类对象说混,
// 操作员照着提示去报名表里找问题只会更糊涂。数值刻意保持一致,同族的批量接口用同一把尺子,
// 前端不必为每个接口记不同的分批阈值(上限值本身的分批依据与 entries_batch.go 的那条注释同源)。
//
// 没有这层上限时,几万条 id 会先被拼成一条 IN (...) 的巨型 SQL:撞穿 SQLite 的变量上限,
// 报出来是 500,而且单连接库会被这条 SQL 长时间占住(审查 D03 实测 4 万条即触发)。
const maxAthleteBatch = 500
// athleteBatchItem 是批量动作里逐条的结果。
//
// 批量必然遇到「有人已经被别人删掉了」「有人 id 根本不存在」:整批失败会逼着操作员重试,
// 静默跳过又会让他以为整批都成了,所以一条一条回报。
type athleteBatchItem struct {
ID int64 `json:"id"`
OK bool `json:"ok"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
// athleteBatchResult 是批量动作的响应体:逐条成败 + 合计。
// Purge 只在 action=purge 时出现,带合计删除规模与受影响小项,供前端提示「榜为什么变了」。
type athleteBatchResult struct {
Action string `json:"action"`
DryRun bool `json:"dryRun,omitempty"`
Requested int `json:"requested"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Items []athleteBatchItem `json:"items"`
Purge *athletePurge `json:"purge,omitempty"`
}
// purgeScope 是一条成绩所属的 (小项, 组别):删除后要按它重算积分与名次。
type purgeScope struct{ eventID, categoryID int64 }
func idArgs(ids []int64) []any {
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
return args
}
// existingAthleteIDs 挑出这批 id 里真实存在的那些,保持传入顺序。
func existingAthleteIDs(ctx context.Context, q queryer, ids []int64) ([]int64, error) {
rows, err := q.QueryContext(ctx,
`SELECT id FROM athletes WHERE id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
found := make(map[int64]bool, len(ids))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, internal(err)
}
found[id] = true
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
out := make([]int64, 0, len(found))
for _, id := range ids {
if found[id] {
out = append(out, id)
}
}
return out, nil
}
// newBatchResult 按传入顺序拼逐条结果:不在 existing 里的 id 回报「运动员不存在」。
func newBatchResult(action string, ids, existing []int64, dryRun bool, purge *athletePurge) athleteBatchResult {
ok := make(map[int64]bool, len(existing))
for _, id := range existing {
ok[id] = true
}
items := make([]athleteBatchItem, 0, len(ids))
result := athleteBatchResult{Action: action, DryRun: dryRun, Requested: len(ids), Purge: purge}
for _, id := range ids {
if ok[id] {
items = append(items, athleteBatchItem{ID: id, OK: true})
result.Succeeded++
continue
}
items = append(items, athleteBatchItem{ID: id, OK: false, Code: "NOT_FOUND", Message: "运动员不存在"})
result.Failed++
}
result.Items = items
return result
}
// batchAthletes 是 POST /api/athletes/batch 的业务实现:一次请求完成整批停用 / 彻底删除。
//
// 逐个调 DELETE /api/athletes/{id} 是「一次操作一条请求」,现场批量清理时会直接撞上写限流;
// 收成一次写既少一次配额,也让「部分成功」有个统一的地方逐条说清楚。
// dryRun 只统计不改库,供前端在危险确认框里先列出会删掉什么、重算哪些小项。
func (s *server) batchAthletes(ctx context.Context, action string, rawIDs []int64, dryRun bool) (athleteBatchResult, error) {
switch action {
case athleteBatchDisable, athleteBatchPurge:
default:
return athleteBatchResult{}, badRequest("action 只能是 disable / purge", action)
}
ids := dedupeIDs(rawIDs)
if len(ids) == 0 {
return athleteBatchResult{}, badRequest("ids 不能为空", nil)
}
// 上限拦在「读库 / 开事务」之前,与 entries_batch.go:73 同一个位置口径:
// 超限的请求不该先查一遍 id 是否存在、再拼出一条巨 SQL 去占住单连接。
if len(ids) > maxAthleteBatch {
return athleteBatchResult{}, badRequest(fmt.Sprintf("一次最多处理 %d 名运动员,请分批提交", maxAthleteBatch), nil)
}
if action == athleteBatchPurge {
return s.batchPurgeAthletes(ctx, ids, dryRun)
}
return s.batchDisableAthletes(ctx, ids, dryRun)
}
// batchDisableAthletes 批量停用:与单条软删除同口径,只置 active = 0,
// 报名、成绩、流水一行不动,也不触发任何重算。
func (s *server) batchDisableAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) {
existing, err := existingAthleteIDs(ctx, s.ev, ids)
if err != nil {
return athleteBatchResult{}, err
}
if !dryRun && len(existing) > 0 {
if _, err := s.ev.ExecContext(ctx,
`UPDATE athletes SET active = 0 WHERE id IN (`+placeholders(len(existing))+`)`,
idArgs(existing)...); err != nil {
return athleteBatchResult{}, internal(err)
}
}
return newBatchResult(athleteBatchDisable, ids, existing, dryRun, nil), nil
}
// batchPurgeAthletes 批量物理删除:删人员及其报名 / 成绩 / 流水 / 纪录,
// 再按受影响的 (小项, 组别) 去重重算一次——重算是批量里最贵的一步,绝不该按人重复做。
//
// 整批一个事务:任一 id 撞上外键就整体回滚并给 409,不会留下删了一半的库。
// 「id 不存在 / 已被别人删掉」属于逐条结果,不影响其余人的删除。
func (s *server) batchPurgeAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return athleteBatchResult{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
// 存在性在事务内一次查完:逐条 DELETE 再看 RowsAffected 会白跑半个事务,
// 而且「已被别人删掉」和「id 不存在」本来就该一起回报。
existing, err := existingAthleteIDs(ctx, tx, ids)
if err != nil {
return athleteBatchResult{}, err
}
// 受影响范围必须在删之前收集:成绩没了就再也问不出它属于哪个组别。
scopes, err := purgeScopesTx(ctx, tx, existing)
if err != nil {
return athleteBatchResult{}, err
}
counts, err := deleteAthletesTx(ctx, tx, existing)
if err != nil {
var api *apiError
if errors.As(err, &api) {
return athleteBatchResult{}, err
}
return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err)
}
purge := &athletePurge{
DeletedEntries: counts.DeletedEntries,
DeletedResults: counts.DeletedResults,
DeletedLedger: counts.DeletedLedger,
DeletedBreaks: counts.DeletedBreaks,
}
eventIDs := make([]int64, 0, len(scopes))
for _, sc := range scopes {
if !slices.Contains(eventIDs, sc.eventID) {
eventIDs = append(eventIDs, sc.eventID)
}
}
purge.RecomputedEvent = eventIDs
if dryRun {
// 预览:删除都还在事务里没提交,defer 会回滚;统计与真删时同源,不会两处算法漂移。
return newBatchResult(athleteBatchPurge, ids, existing, true, purge), nil
}
actorID := middleware.User(ctx).ID
for _, sc := range scopes {
score, err := recomputeLedgerTx(ctx, tx, sc.eventID, sc.categoryID, actorID, "人员批量删除后重算")
if err != nil {
return athleteBatchResult{}, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return athleteBatchResult{}, err
}
}
for _, eventID := range eventIDs {
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return athleteBatchResult{}, err
}
}
if len(eventIDs) > 0 {
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return athleteBatchResult{}, err
}
}
if err := tx.Commit(); err != nil {
return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err)
}
return newBatchResult(athleteBatchPurge, ids, existing, false, purge), nil
}
// purgeScopesTx 收集这批人员名下有成绩的 (小项, 组别)。
func purgeScopesTx(ctx context.Context, tx *sql.Tx, ids []int64) ([]purgeScope, error) {
if len(ids) == 0 {
return nil, nil
}
rows, 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 IN (`+placeholders(len(ids))+`)`, idArgs(ids)...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]purgeScope, 0, 4)
for rows.Next() {
var sc purgeScope
if err := rows.Scan(&sc.eventID, &sc.categoryID); err != nil {
return nil, internal(err)
}
out = append(out, sc)
}
return out, rows.Err()
}
// deleteAthletesTx 按外键方向清理这批人员的全部痕迹,返回删除规模。
// 顺序与单条 purgeAthlete 一致:流水 → 纪录 → 成绩 → 报名 → 人员。
func deleteAthletesTx(ctx context.Context, tx *sql.Tx, ids []int64) (athletePurge, error) {
var out athletePurge
if len(ids) == 0 {
return out, nil
}
ph := placeholders(len(ids))
args := idArgs(ids)
// score_ledger 的三个引用各要一份占位符参数,拼三份副本(都是只读,不会互相污染)。
ledgerArgs := append(append(append([]any{}, args...), args...), args...)
for _, step := range []struct {
query string
args []any
count *int
}{
{`DELETE FROM score_ledger
WHERE athlete_id IN (` + ph + `)
OR entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))
OR result_id IN (SELECT r.id FROM results r JOIN entries e ON e.id = r.entry_id
WHERE e.athlete_id IN (` + ph + `))`,
ledgerArgs, &out.DeletedLedger},
{`DELETE FROM record_breaks
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`,
args, &out.DeletedBreaks},
{`DELETE FROM results
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`,
args, &out.DeletedResults},
{`DELETE FROM entries WHERE athlete_id IN (` + ph + `)`, args, &out.DeletedEntries},
} {
res, err := tx.ExecContext(ctx, step.query, step.args...)
if err != nil {
return out, err
}
n, err := res.RowsAffected()
if err != nil {
return out, internal(err)
}
*step.count = int(n)
}
res, err := tx.ExecContext(ctx, `DELETE FROM athletes WHERE id IN (`+ph+`)`, args...)
if err != nil {
return out, err
}
n, err := res.RowsAffected()
if err != nil {
return out, internal(err)
}
// 存在性是同一事务里查过的,行数对不上只可能是并发抢先删了;报 500 会瞒掉真实状态,
// 用 409 明说并让整个事务回滚,别把成绩删了却报成功。
if int(n) != len(ids) {
return out, conflict("有运动员在本次操作期间已被删除,批量删除已回滚,请刷新名单后重试")
}
return out, nil
}
// batchPurgeBlocked 是批量删除的外键兜底:回滚并给出可读的 409,不让它冒成 500。
func (s *server) batchPurgeBlocked(ctx context.Context, tx *sql.Tx, cause error) error {
if !isForeignKeyViolation(cause) {
return internal(cause)
}
s.logger.WarnContext(ctx, "批量彻底删除运动员撞上外键约束", "err", cause)
_ = tx.Rollback()
return conflict("有运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,批量删除已回滚,人员和成绩都没有改动。")
}
// batchAthletesHandler 是 POST /api/athletes/batch:一次请求完成整批停用 / 彻底删除。
func (s *server) batchAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in athletesBatchPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
result, err := s.batchAthletes(r.Context(), in.Action, in.IDs, in.DryRun)
if err != nil {
return nil, err
}
// dryRun 只是预览,什么都没改,不该在审计里留下「做过一次批量删除」的假痕迹。
if result.DryRun {
return result, nil
}
// 整批只登记一条审计:审计与放行日志都是一条一次请求,批量才真把写压力降下来。
// 逐条明细与失败原因都进 after,事后能对着名单查。
s.audit(r.Context(), "athlete.batch."+result.Action, "athlete", 0, nil, result)
return result, nil
}
// ---------- 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) {
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || 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("大项编码已存在")
}
// default_profile_id 指向 scoring_profiles(id)(迁移 0004):指到不存在的模板时,
// 不认这个外键错就报 500「服务端异常」,而同一类错在 events.sport_id、channels.profile_id
// 上都是 400。按 badRequest 报出来,现场才知道该去改哪个字段。
if isForeignKeyViolation(err) {
return sportDTO{}, badRequest("defaultProfileId 不存在", in.DefaultProfileID)
}
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) {
// 更新与新建同口径:必填校验 + 裁空白,否则一次 PUT 就能把大项编码清成空串。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || in.Name == "" {
return sportDTO{}, badRequest("code 与 name 必填", nil)
}
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("大项编码已存在")
}
// 同新建:改到一个不存在的模板 id 上是 400(字段名 + 原值),不是 500。
if isForeignKeyViolation(err) {
return sportDTO{}, badRequest("defaultProfileId 不存在", in.DefaultProfileID)
}
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
}
// validateAwardRanks 校验积分模板的「录取名次」(scoring_profiles.award_ranks)。
//
// 口径照建表约束来(migrations/event/0003:CHECK (award_ranks >= 0)),不自己发明上限:
// 0 表示不按名次封顶——算分引擎只在 award_ranks > 0 时才按名次截断(domain.basePoints),
// 规则包侧(rules/validate.go)也只拒负数。不在这里拦,-1 会一路撞 CHECK 报 500 +
// 原始约束文本,而同一张表的 tieMode / recordBonusMode 早就是 400。
func validateAwardRanks(awardRanks int) error {
if awardRanks < 0 {
return badRequest("awardRanks 需要是不小于 0 的整数(0 表示不按名次封顶)", awardRanks)
}
return nil
}
// validateProfileAwardCoverage 拦住「分值表档位超出录取名次」的静默截断。
//
// 与上一条不同,这里不是字段取值范围的问题,而是两个字段**合起来**不成立:
// award_ranks > 0 时算分引擎把超出名次的档位一律当 0 分(domain/scoring.go 的 basePoints),
// 保存侧却从不校验两边的档位数,于是「分值表配到第 7 档、录取名次还留着 6」能安静落库——
// 模板页上第 7 档明明写着 2 分,榜上却是 0 分,界面上看不出任何异常(审查 01 P1)。
// 与其让人配一份算不出分的表,不如在保存时拒绝,并把两种改法直接写进错误里。
//
// list 传「保存后实际生效的档位」:本次带了 rules 就是它,没带就是库里现有的(只改 awardRanks
// 一样能把已有档位截断)。文案与规则包导入侧共用 rules.AwardRanksOverflow,两条路给同一句话。
func validateProfileAwardCoverage(awardRanks int, list []profileRuleDTO) error {
maxRank := 0
for _, r := range list {
if r.Rank > maxRank {
maxRank = r.Rank
}
}
if msg := rules.AwardRanksOverflow(awardRanks, maxRank); msg != "" {
return badRequest(msg, nil)
}
return nil
}
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || 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
}
if err := validateAwardRanks(in.AwardRanks); err != nil {
return profileDTO{}, err
}
if err := validateProfileAwardCoverage(in.AwardRanks, in.Rules); 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)
}
// 新建的模板此刻还没有任何引用(sports.default_profile_id 与
// event_score_channels.profile_id 都是指向 scoring_profiles(id) 的外键,
// 模板必须先存在才挂得上),所以这一次重算实际上是空转;留在这里是为了与
// 「改模板」走同一条落地路径,免得以后给新建流程加上「顺手挂到某处」的逻辑时漏掉重算。
// recomputeAllEvents 自带事务与推榜,别再包一层。
if _, err := s.recomputeAllEvents(ctx, middleware.User(ctx).ID); err != nil {
s.logger.Error("积分模板新建后重算失败", "err", err)
return profileDTO{}, internalMsg("积分模板已保存,但积分重算失败,成绩与积分尚未同步;请重试保存", err)
}
return s.getProfile(ctx, id)
}
func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) {
// 与新建同口径。这里不是「顺手补个校验」:清空 code 的模板会让 admin 的
// GET /api/rules/export 与 project.zip 永久 500(rules/validate.go 仍要求 code 非空),
// 而 PUT 是 recorder 档——低级角色能把管理员的导出打成不可用,且没有任何提示。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.Code == "" || 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
}
if err := validateAwardRanks(in.AwardRanks); err != nil {
return profileDTO{}, err
}
// 没带 rules 时按库里现有的档位校验:只把 awardRanks 调小,一样会把已有档位截断。
// 这一步必须在开事务之前读(赛事库只开一条连接,事务里再查会自锁)。
effectiveRules := in.Rules
if effectiveRules == nil {
cur, err := s.profileRules(ctx, id)
if err != nil {
return profileDTO{}, err
}
effectiveRules = cur
}
if err := validateProfileAwardCoverage(in.AwardRanks, effectiveRules); 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)
}
// 模板分值是算分公式的输入:改了就必须全量重算已发布成绩的积分与名次,并把新榜单推给大屏。
// recomputeAllEvents 内部自建事务并写 standings 版本,这里不要再套事务(嵌套会死锁在单连接上)。
if _, err := s.recomputeAllEvents(ctx, middleware.User(ctx).ID); err != nil {
s.logger.Error("积分模板更新后重算失败", "err", err)
return profileDTO{}, internalMsg("积分模板已保存,但积分重算失败,成绩与积分尚未同步;请重试保存", err)
}
return s.getProfile(ctx, id)
}
func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, list []profileRuleDTO) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil {
return internal(err)
}
// 名次去重:主键是 (profile_id, rank),模板页把两行填成同一个数字就会撞 UNIQUE,
// 报出来是 500 + 英文约束原文。事务虽然会回滚,但错误无法自解释,现场只能去猜。
// 先自己查一遍,报清是哪一名重复(与上面 rank < 1 的 400 同一层)。
seen := make(map[int]bool, len(list))
for _, r := range list {
if r.Rank < 1 {
return badRequest("积分名次必须 >= 1", r)
}
if seen[r.Rank] {
return badRequest(fmt.Sprintf("积分名次 %d 重复", r.Rank), r)
}
seen[r.Rank] = true
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
}