Files
Scoreboard/server/internal/httpapi/repo_events.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

839 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"
"math"
"net/http"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- events ----------
const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
award_medals`
func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
var (
e eventDTO
scheduledAt, venue, note sql.NullString
awardMedals int
)
err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType,
&e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status,
&scheduledAt, &venue, &e.SortOrder, &note, &e.AvgMode, &awardMedals)
e.AwardMedals = awardMedals != 0
e.ScheduledAt = strPtr(scheduledAt)
e.Venue = strPtr(venue)
e.Remark = strPtr(note)
return e, err
}
func (s *server) listEvents(ctx context.Context, sportID int64) ([]eventDTO, error) {
query := `SELECT ` + eventColumns + ` FROM events`
args := []any{}
if sportID > 0 {
query += ` WHERE sport_id = ?`
args = append(args, sportID)
}
query += ` ORDER BY sort_order, code`
rows, err := s.ev.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]eventDTO, 0)
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, e)
}
return out, rows.Err()
}
func (s *server) getEvent(ctx context.Context, id int64) (eventDTO, error) {
e, err := eventByIDQ(ctx, s.ev, id)
if err != nil {
return eventDTO{}, err
}
cats, err := s.listCategories(ctx, id)
if err != nil {
return eventDTO{}, err
}
chs, err := s.listChannels(ctx, id)
if err != nil {
return eventDTO{}, err
}
e.Categories = cats
e.Channels = chs
return e, nil
}
// eventByIDQ 在给定连接/事务内读取小项(不含组别/通道),供写事务内构造 change_log 载荷复用。
func eventByIDQ(ctx context.Context, q queryer, id int64) (eventDTO, error) {
e, err := scanEvent(q.QueryRowContext(ctx, `SELECT `+eventColumns+` FROM events WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return eventDTO{}, notFound("小项不存在")
}
if err != nil {
return eventDTO{}, internal(err)
}
return e, nil
}
func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) {
if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
avgMode, err := normalizeAvgMode(in.AvgMode)
if err != nil {
return eventDTO{}, err
}
eventType, err := normalizeEventType(in.EventType)
if err != nil {
return eventDTO{}, err
}
resultMode, err := normalizeResultMode(in.ResultMode)
if err != nil {
return eventDTO{}, err
}
resultType, err := normalizeResultType(in.ResultType)
if err != nil {
return eventDTO{}, err
}
compare, err := normalizeCompare(in.Compare)
if err != nil {
return eventDTO{}, err
}
status, err := normalizeEventStatus(in.Status)
if err != nil {
return eventDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
award_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.SportID, in.Code, in.Name,
eventType, resultMode, resultType, compare,
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode,
boolToInt(in.AwardMedals))
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
if isForeignKeyViolation(err) {
return eventDTO{}, badRequest("sportId 不存在", nil)
}
if err != nil {
return eventDTO{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return eventDTO{}, internal(err)
}
// 项目增删改要推给大屏,否则底栏/分项页拿不到新项目。
if err := s.bumpEvent(ctx, tx, id); err != nil {
return eventDTO{}, err
}
if err := tx.Commit(); err != nil {
return eventDTO{}, internal(err)
}
return s.getEvent(ctx, id)
}
func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) {
avgMode, err := normalizeAvgMode(in.AvgMode)
if err != nil {
return eventDTO{}, err
}
eventType, err := normalizeEventType(in.EventType)
if err != nil {
return eventDTO{}, err
}
resultMode, err := normalizeResultMode(in.ResultMode)
if err != nil {
return eventDTO{}, err
}
resultType, err := normalizeResultType(in.ResultType)
if err != nil {
return eventDTO{}, err
}
compare, err := normalizeCompare(in.Compare)
if err != nil {
return eventDTO{}, err
}
status, err := normalizeEventStatus(in.Status)
if err != nil {
return eventDTO{}, err
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return eventDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
// 先读旧值:multiplier / avg_mode / scored / award_medals 这四个字段进算分与奖牌口径,
// 「是否真变」决定要不要重算,拿不到旧值就无从判断。
before, err := eventByIDQ(ctx, tx, id)
if err != nil {
return eventDTO{}, err
}
newMultiplier := multiplierOrOne(in.Multiplier)
res, err := tx.ExecContext(ctx,
`UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?,
result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?,
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?,
award_medals = ?
WHERE id = ?`,
in.SportID, in.Code, in.Name,
eventType, resultMode, resultType, compare,
in.RecordEligible, in.Scored, newMultiplier, status,
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode,
boolToInt(in.AwardMedals), id)
if isUniqueViolation(err) {
return eventDTO{}, conflict("小项编码已存在")
}
if isForeignKeyViolation(err) {
return eventDTO{}, badRequest("sportId 不存在", nil)
}
if err != nil {
return eventDTO{}, internal(err)
}
if err := requireChanges(res, "小项不存在"); err != nil {
return eventDTO{}, err
}
// 只有这四个进公式的字段真的变了才重算:改名 / 换场地这类改动重算全项既慢又会
// 无谓地作废、重写一遍流水。avgMode 之类的空值口径已在 normalize 阶段落成具体值,可直接比。
recompute := multiplierChanged(before.Multiplier, newMultiplier) ||
before.AvgMode != avgMode ||
before.Scored != in.Scored ||
before.AwardMedals != in.AwardMedals
actorID := middleware.User(ctx).ID
if recompute {
score, err := recomputeLedgerTx(ctx, tx, id, 0, actorID, "小项计分配置变更重算")
if err != nil {
return eventDTO{}, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return eventDTO{}, err
}
}
if err := s.bumpEvent(ctx, tx, id); err != nil {
return eventDTO{}, err
}
if recompute {
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return eventDTO{}, err
}
}
if err := tx.Commit(); err != nil {
return eventDTO{}, internal(err)
}
return s.getEvent(ctx, id)
}
// bumpEvent 在写事务内推送小项变更;组别/通道的改动同样以本小项为载体。
func (s *server) bumpEvent(ctx context.Context, tx *sql.Tx, eventID int64) error {
ev, err := eventByIDQ(ctx, tx, eventID)
if err != nil {
return err
}
return bumpVersion(ctx, tx, "events", eventID, "upsert", ev, middleware.User(ctx).ID)
}
func (s *server) deleteEvent(ctx context.Context, id int64) error {
has, err := exists(ctx, s.ev, `SELECT 1 FROM entries WHERE event_id = ? LIMIT 1`, id)
if err != nil {
return internal(err)
}
if has {
return conflict("小项已有报名数据,不能删除")
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
for _, stmt := range []string{
`DELETE FROM event_score_channels WHERE event_id = ?`,
`DELETE FROM event_categories WHERE event_id = ?`,
`DELETE FROM events WHERE id = ?`,
} {
if _, err := tx.ExecContext(ctx, stmt, id); err != nil {
return internal(err)
}
}
if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return internal(err)
}
return nil
}
func multiplierOrOne(v float64) float64 {
if v <= 0 {
return 1
}
return v
}
// normalizeAvgMode 校验团体成绩的平均值口径;空值按 valid(与库中默认值一致)。
func normalizeAvgMode(v string) (string, error) {
switch v {
case "":
return string(domain.AvgModeValid), nil
case string(domain.AvgModeValid), string(domain.AvgModeAll):
return v, nil
default:
return "", badRequest("avgMode 只能是 valid / all", v)
}
}
// ---------- categories ----------
func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]categoryDTO, 0)
for rows.Next() {
var (
c categoryDTO
gender sql.NullString
ageMin, ageMax sql.NullInt64
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder); err != nil {
return nil, internal(err)
}
c.Gender = strPtr(gender)
c.AgeMin = intPtr(ageMin)
c.AgeMax = intPtr(ageMax)
out = append(out, c)
}
return out, rows.Err()
}
// replaceCategories 整体替换某小项的组别(devdocs/05 约定)。已有报名数据时拒绝替换,
// 否则外键会把历史报名项一并带走。
//
// 被拦下的只是「增删组别」:改名 / 改年龄 / 改性别 / 改权重走 updateCategory 就地更新,
// 所以这里的文案要点明范围,别让只改了个名字的人以为改名也不许。
//
// 字段校验与 updateCategory 共用同一套(validateCategoryGender / validateCategoryAges):
// 新建或重排组别时这条路是唯一入口,漏校验就会让 ageMin=500、gender='M' 这样的值直接进库
// (gender 撞 CHECK 报 500,年龄则静默入库),而就地更新路径又拒绝原样保存。
func (s *server) replaceCategories(ctx context.Context, eventID int64, in []categoryDTO) ([]categoryDTO, error) {
has, err := exists(ctx, s.ev,
`SELECT 1 FROM entries e JOIN event_categories c ON c.id = e.category_id
WHERE c.event_id = ? LIMIT 1`, eventID)
if err != nil {
return nil, internal(err)
}
if has {
return nil, conflict("该项目已有报名数据,不能新增或删除组别;改名、改年龄、改性别、改权重可以直接保存")
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM event_categories WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
for i, c := range in {
if strings.TrimSpace(c.Name) == "" {
return nil, badRequest("组别 name 必填", i)
}
if err := validateCategoryGender(c.Gender); err != nil {
return nil, err
}
if err := validateCategoryAges(c.AgeMin, c.AgeMax); err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_categories (event_id, name, multiplier, gender, age_min, age_max, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
eventID, c.Name, multiplierOrOne(c.Multiplier), nullStr(c.Gender), nullInt(c.AgeMin), nullInt(c.AgeMax), c.SortOrder); err != nil {
if isUniqueViolation(err) {
return nil, conflict("组别名称重复:" + c.Name)
}
return nil, internal(err)
}
}
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.listCategories(ctx, eventID)
}
// getCategory 读单个组别,并校验它属于该小项。不属于(或用别的小项 id 来探)
// 一律按「组别不存在」处理,不给跨小项改名的机会。
func (s *server) getCategory(ctx context.Context, eventID, categoryID int64) (categoryDTO, error) {
var (
c categoryDTO
gender sql.NullString
ageMin, ageMax sql.NullInt64
)
err := s.ev.QueryRowContext(ctx,
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
FROM event_categories WHERE id = ? AND event_id = ?`, categoryID, eventID).
Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder)
if errors.Is(err, sql.ErrNoRows) {
return categoryDTO{}, notFound("组别不存在")
}
if err != nil {
return categoryDTO{}, internal(err)
}
c.Gender = strPtr(gender)
c.AgeMin = intPtr(ageMin)
c.AgeMax = intPtr(ageMax)
return c, nil
}
// validateCategoryAges 年龄区间可以留空,填了就必须是 0–120 且下限不大于上限。
// 两条写入路径(整表替换与就地更新)共用:以前只有就地更新校验,整表替换能建出
// ageMin=500 / ageMax=-3 的组别,脏值进得去、之后就地更新又拒绝原样保存。
func validateCategoryAges(ageMin, ageMax *int) error {
for _, v := range []*int{ageMin, ageMax} {
if v != nil && (*v < 0 || *v > 120) {
return badRequest("组别年龄需要在 0-120 之间", v)
}
}
if ageMin != nil && ageMax != nil && *ageMin > *ageMax {
return badRequest("组别年龄下限不能大于上限", nil)
}
return nil
}
// validateCategoryGender 组别性别可以留空(= 不限),填了就必须是「男」或「女」。
//
// event_categories.gender 有 CHECK (gender IN ('男','女')),不拦就落成 500 + 原始约束文本;
// 整表替换与就地更新共用这一条,两条路径的 400 语义才一致。
// 刻意不做 TrimSpace:带空格的值按非法拒绝,「校验放行、落库撞 CHECK」比直接报错更难查。
func validateCategoryGender(gender *string) error {
if gender == nil {
return nil
}
if v := *gender; v != "" && v != "男" && v != "女" {
return badRequest("组别 gender 只能是 男 / 女", gender)
}
return nil
}
// multiplierRecomputeEpsilon 是「组别权重是否真的变了」的判定容差。
//
// 权重是算分公式的输入,改了就必须重算;但同一个数字经 JSON 往返一圈可能差 1e-16,
// 用 == 会把「原样提交」也当成改动、白重算一次全组积分。1e-9 远小于现场能填出的最小差
// (权重最小刻度 0.1),又远大于浮点噪声。
const multiplierRecomputeEpsilon = 1e-9
func multiplierChanged(before, after float64) bool {
return math.Abs(before-after) > multiplierRecomputeEpsilon
}
// updateCategory 就地更新单个组别,可改 name / multiplier / gender / ageMin / ageMax / sortOrder。
//
// 与 replaceCategories 的「DELETE 全表 + INSERT」不同,这里只发一条 UPDATE:组别 id 不变,
// entries.category_id 以及它名下的成绩、流水、名次、积分全部原地保留。
// 有报名数据的小项正是靠它改名 / 改年龄 / 改性别 / 改权重 / 改排序;增删组别仍然只能走整表替换。
//
// 只有 multiplier 真的变了才在同一个事务里重算该组别的流水与名次:权重进算分公式,
// 不重算就会让已发布成绩的积分与榜单和配置静默不一致。name / gender / ageMin / ageMax /
// sortOrder 都不参与算分,改了不重算(报名、成绩、流水一个字节都不动)。
// 第二个返回值报告本次是否触发了重算,供审计留痕。
func (s *server) updateCategory(ctx context.Context, eventID, categoryID int64, in categoryDTO) (categories []categoryDTO, recomputed bool, err error) {
name := strings.TrimSpace(in.Name)
if name == "" {
return nil, false, badRequest("组别 name 必填", nil)
}
if err := validateCategoryGender(in.Gender); err != nil {
return nil, false, err
}
if err := validateCategoryAges(in.AgeMin, in.AgeMax); err != nil {
return nil, false, err
}
// 先读旧值:要不要重算全靠这次比对,拿不到旧值就无从判断。
before, err := s.getCategory(ctx, eventID, categoryID)
if err != nil {
return nil, false, err
}
newMultiplier := multiplierOrOne(in.Multiplier)
recompute := multiplierChanged(before.Multiplier, newMultiplier)
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, false, internal(err)
}
defer func() { _ = tx.Rollback() }()
// event_id 一起进 WHERE:id 存在但挂在别的小项上时影响 0 行,按「组别不存在」处理。
res, err := tx.ExecContext(ctx,
`UPDATE event_categories SET name = ?, multiplier = ?, gender = ?, age_min = ?, age_max = ?, sort_order = ?
WHERE id = ? AND event_id = ?`,
name, newMultiplier, nullStr(in.Gender), nullInt(in.AgeMin), nullInt(in.AgeMax),
in.SortOrder, categoryID, eventID)
if isUniqueViolation(err) {
return nil, false, conflict("组别名称重复:" + name)
}
if err != nil {
return nil, false, internal(err)
}
if err := requireChanges(res, "组别不存在"); err != nil {
return nil, false, err
}
actorID := middleware.User(ctx).ID
if recompute {
// 与新权重保持一致:作废旧流水、按新分数重建,并把名次写回 results(榜单看的就是它)。
score, err := recomputeLedgerTx(ctx, tx, eventID, categoryID, actorID, "组别权重变更重算")
if err != nil {
return nil, false, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, false, err
}
}
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return nil, false, err
}
if recompute {
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return nil, false, err
}
}
if err := tx.Commit(); err != nil {
return nil, false, internal(err)
}
cats, err := s.listCategories(ctx, eventID)
if err != nil {
return nil, false, err
}
return cats, recompute, nil
}
// updateCategoryHandler 是 PUT /api/events/{id}/categories/{categoryId}:组别的就地更新。
func (s *server) updateCategoryHandler(w http.ResponseWriter, r *http.Request) (any, error) {
eventID, err := pathID(r, "id")
if err != nil {
return nil, err
}
categoryID, err := pathID(r, "categoryId")
if err != nil {
return nil, err
}
var in categoryDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getCategory(r.Context(), eventID, categoryID)
if err != nil {
return nil, err
}
updated, recomputed, err := s.updateCategory(r.Context(), eventID, categoryID, in)
if err != nil {
return nil, err
}
after, err := s.getCategory(r.Context(), eventID, categoryID)
if err != nil {
return nil, err
}
// 审计要能看出这次是不是还动了权重:权重变更会连带重算已发布成绩的积分与榜单,
// 事后查「榜为什么变了」时,光看新旧权重容易漏掉这一层。
s.audit(r.Context(), "event.category.update", "event_category", categoryID, before,
map[string]any{"category": after, "recomputedLedger": recomputed})
return updated, nil
}
// ---------- score channels ----------
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
count_to_medals, count_to_team_medals
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]channelDTO, 0)
for rows.Next() {
var (
c channelDTO
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
c.CountToTeam = boolPtr(countToTeam != 0)
c.CountToMedals = boolPtr(countToMedals != 0)
c.CountToTeamMedals = boolPtr(countToTeamMedals != 0)
out = append(out, c)
}
return out, rows.Err()
}
// channelCountsToTeam 读请求体里的通道开关:缺省(null)按 true 处理,
// 保证不带该字段的旧客户端保存通道时不会把单位总分关掉。
func channelCountsToTeam(c channelDTO) bool {
return c.CountToTeam == nil || *c.CountToTeam
}
// channelFlag 是通道两个奖牌开关的缺省口径:缺省(null)按 true 处理,
// 与「升级前所有通道都发牌、个人奖牌计入单位榜」的既有行为一致。
func channelFlag(v *bool) bool {
return v == nil || *v
}
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
for i, c := range in {
if c.Scope != "athlete" && c.Scope != "team" {
return nil, badRequest("通道 scope 只能是 athlete/team", i)
}
switch c.Basis {
case "result", "manual", "aggregate":
default:
return nil, badRequest("通道 basis 非法", i)
}
if c.ProfileID == 0 {
return nil, badRequest("通道 profileId 必填", i)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team,
count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c)),
boolToInt(channelFlag(c.CountToMedals)), boolToInt(channelFlag(c.CountToTeamMedals))); err != nil {
if isUniqueViolation(err) {
return nil, conflict("同一 scope+basis 只能配置一个通道")
}
if isForeignKeyViolation(err) {
return nil, badRequest("profileId 不存在", i)
}
return nil, internal(err)
}
}
// 这是「DELETE 全表 + 重新 INSERT」:通道 id 每次都变,挂在旧 id 上的 channel_ranks
// 会被外键级联清空。不重算就会留下「通道换了、名次是空的」——大屏奖牌榜随之清零。
// 名次与流水一起按新通道重建,并推榜单。团体手工名次不挂通道外键,录入数据不受影响。
actorID := middleware.User(ctx).ID
score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "通道配置变更重算")
if err != nil {
return nil, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, err
}
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return nil, err
}
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.listChannels(ctx, eventID)
}
// ---------- entries ----------
const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name`
func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
var (
e entryDTO
athleteID sql.NullInt64
memberNames sql.NullString
bib, seed, rem sql.NullString
heat sql.NullInt64
athleteName sql.NullString
teamCode sql.NullString
teamName sql.NullString
shortName sql.NullString
categoryName sql.NullString
)
if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames,
&bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil {
return e, err
}
e.AthleteID = i64Ptr(athleteID)
e.MemberNames = parseNames(memberNames)
e.Bib = strPtr(bib)
e.Seed = strPtr(seed)
e.Heat = i64Ptr(heat)
e.Remark = strPtr(rem)
e.AthleteName = strPtr(athleteName)
e.TeamCode = teamCode.String
e.TeamName = teamName.String
e.ShortName = strPtr(shortName)
e.CategoryName = categoryName.String
return e, nil
}
const entrySelect = `SELECT ` + entryColumns + `
FROM entries e
LEFT JOIN athletes a ON a.id = e.athlete_id
JOIN teams t ON t.id = e.team_id
JOIN event_categories c ON c.id = e.category_id`
func (s *server) listEntries(ctx context.Context, eventID int64) ([]entryDTO, error) {
rows, err := s.ev.QueryContext(ctx, entrySelect+` WHERE e.event_id = ? ORDER BY c.sort_order, e.id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]entryDTO, 0)
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, e)
}
return out, rows.Err()
}
// listEntriesWithResults 返回报名项及其成绩(无成绩时 result=null)。
func (s *server) listEntriesWithResults(ctx context.Context, eventID int64) ([]entryWithResultDTO, error) {
entries, err := s.listEntries(ctx, eventID)
if err != nil {
return nil, err
}
results, err := s.resultsByEvent(ctx, eventID)
if err != nil {
return nil, err
}
byEntry := make(map[int64]resultDTO, len(results))
for _, r := range results {
byEntry[r.EntryID] = r
}
out := make([]entryWithResultDTO, 0, len(entries))
for _, e := range entries {
item := entryWithResultDTO{entryDTO: e}
if r, ok := byEntry[e.ID]; ok {
cp := r
item.Result = &cp
}
out = append(out, item)
}
return out, nil
}
// maxEntryHeat 是批次号上限:批次只是现场录入的编排分组,四位以上没有实际意义;
// 设上限同时挡住误填的大数(例如把成绩错填进批次号)。
const maxEntryHeat = 999
// setEntriesHeat 给一批报名项统一设置批次(heat 为 nil 表示清除批次 / 未分批)。
//
// 只改 entries.heat,不碰任何成绩与积分:批次不参与算分,排名与积分仍按「小项 + 组别」算。
// 幂等:重复提交同一个批次号结果一致。要求所有报名项属于同一个小项(跨小项批量设置是误操作)。
func (s *server) setEntriesHeat(ctx context.Context, entryIDs []int64, heat *int64) (entryHeatResultDTO, error) {
ids := dedupeIDs(entryIDs)
if len(ids) == 0 {
return entryHeatResultDTO{}, badRequest("entryIds 不能为空", nil)
}
if heat != nil && (*heat < 1 || *heat > maxEntryHeat) {
return entryHeatResultDTO{}, badRequest(fmt.Sprintf("heat 需要是 1-%d 的整数,或传 null 清除批次", maxEntryHeat), nil)
}
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...)
if err != nil {
return entryHeatResultDTO{}, internal(err)
}
defer func() { _ = rows.Close() }()
found := make(map[int64]bool, len(ids))
events := make(map[int64]bool)
for rows.Next() {
var (
entryID int64
eventID int64
)
if err := rows.Scan(&entryID, &eventID); err != nil {
return entryHeatResultDTO{}, internal(err)
}
found[entryID] = true
events[eventID] = true
}
if err := rows.Err(); err != nil {
return entryHeatResultDTO{}, internal(err)
}
missing := make([]int64, 0)
for _, id := range ids {
if !found[id] {
missing = append(missing, id)
}
}
if len(missing) > 0 {
return entryHeatResultDTO{}, badRequest("部分报名项不存在", missing)
}
// 批次挂在「小项 + 组别」之下,批量设置跨小项一定是选错了行。
if len(events) > 1 {
return entryHeatResultDTO{}, badRequest("不能跨小项批量设置批次,请在同一小项内操作", nil)
}
res, err := s.ev.ExecContext(ctx,
`UPDATE entries SET heat = ? WHERE id IN (`+placeholders(len(ids))+`)`,
append([]any{nullI64(heat)}, args...)...)
if err != nil {
return entryHeatResultDTO{}, internal(err)
}
updated, err := res.RowsAffected()
if err != nil {
return entryHeatResultDTO{}, internal(err)
}
return entryHeatResultDTO{Updated: int(updated), Heat: heat, EntryIDs: ids}, nil
}