Files
Scoreboard/server/internal/httpapi/repo_events.go
T
AskaEth 99c91f99f7 feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」

**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。

## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。

## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
  `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。

## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
  1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
  2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
  3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
  反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。

## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
  改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。

## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。

## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
  ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。

## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。

## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 14:05:48 +08:00

981 lines
36 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) {
// 与单位 / 大项 / 模板同口径:必填校验前先裁掉首尾空白,落库的也是裁过的值。
// 编码是基础数据包按 event_code 匹配的键,带空格的编码与肉眼相同的正常编码并存时,
// 导入只会报「小项不存在」。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.SportID == 0 || in.Code == "" || in.Name == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
// 小项名会被拼进分工作表模板的工作表名(「小项名称@组别名称」),名字里再出现 @ 就拆不开。
if err := checkNameSeparator("小项名称", in.Name); err != nil {
return eventDTO{}, err
}
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) {
// 与新建同口径:更新路径此前完全不校验,一次 PUT 就能把小项编码清成空串
// (规则包/项目包导出与导入匹配都会因此出错),且错误要到很久以后才暴露。
in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name)
if in.SportID == 0 || in.Code == "" || in.Name == "" {
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
}
// 与新建同口径:@ 是分工作表模板里小项与组别的分隔符,改名时也不许出现。
if err := checkNameSeparator("小项名称", in.Name); err != nil {
return eventDTO{}, err
}
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)
}
// deleteEvent 删除小项:作废它名下的成绩流水,再删通道 / 组别 / 小项本身。
//
// 守卫只挡「已有报名数据」,所以「只有团体名次、没有报名项」的小项(球类那种形态,
// 名次直接按单位录进 channel_manual_ranks)走得通。这类小项删除时,它产生的流水必须
// 在同一个事务里显式作废:score_ledger.event_id 是 ON DELETE SET NULL,先删小项就只把
// 外键置空(voided 仍是 0),分数留在榜上、而此后任何按 event_id 的重算都打不到这些行,
// 变成「永久残留、只能改库」(审查 01 的 P0)。
//
// 作废而不是删除:保留流水行作为审计痕迹(谁在什么时候因为什么作废)。范围与
// writeLedgerTx 的作废范围同口径——只动引擎产出的成绩分与破纪录加分,人工调整
// (source_type = 'adjustment')不在此列,它由 point_adjustments 管,删小项只把它的
// event_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() }()
actorID := middleware.User(ctx).ID
// 必须排在下面那条 DELETE FROM events 之前:event_id 一被置空,这条件就再也匹配不上。
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
WHERE voided = 0 AND event_id = ? AND source_type IN ('result', 'record_bonus')`,
nullID(actorID), "小项删除,本小项积分流水一并作废", id); err != nil {
return internal(err)
}
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}, actorID); err != nil {
return err
}
// 流水作废会直接改榜单内容:不推 standings,大屏会一直停在旧分上(要等下一次发布才刷新)。
if err := s.bumpStandings(ctx, tx, actorID); 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(与库中默认值一致)。
//
// 它没跟其它枚举校验一起进 enums.go,也不在比对前 TrimSpace:`" valid"` 这类值会被当成
// 非法直接拒成 400,而不是裁掉空格收下(P2-C24b)。
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) {
// 只读接口也先确认小项存在:否则它给 200 空数组,而同一件事在整表替换上是
// 404 / 500 / 400 三种答案(见 replaceCategories 的注释)。
if _, err := eventByIDQ(ctx, s.ev, eventID); err != nil {
return nil, err
}
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() }()
// 先确认小项存在:这是同一个「小项不存在」的四分之三——空表走到末尾 bumpEvent 才是 404,
// 非空表会一路撞 event_categories.event_id 的外键,报成 500「服务端异常」。
// 在同一个事务里读,免得检查完、DELETE 前对方把小项删掉。
if _, err := eventByIDQ(ctx, tx, eventID); err != nil {
return nil, err
}
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 := checkNameSeparator("组别名称", c.Name); err != nil {
return nil, err
}
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 := checkNameSeparator("组别名称", name); err != nil {
return nil, false, err
}
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) {
// 与 listCategories 同口径:小项不存在就 404,不给「200 空数组」。
if _, err := eventByIDQ(ctx, s.ev, eventID); err != nil {
return nil, err
}
rows, err := s.ev.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, aggregate_mode, 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
aggregateMode string
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &aggregateMode, &countToTeam,
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
// 只下发非缺省口径(sum):avg 是缺省值,老数据的响应体因此一个字节都不变。
if aggregateMode == string(domain.AggregateSum) {
c.AggregateMode = aggregateMode
}
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
}
// channelAggregateMode 读请求体里的聚合口径:缺省(空串)落成 avg,
// 保证不带该字段的旧客户端保存通道时口径不变(与库里的默认值一致)。
func channelAggregateMode(c channelDTO) string {
if c.AggregateMode == string(domain.AggregateSum) {
return string(domain.AggregateSum)
}
return string(domain.AggregateAvg)
}
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() }()
// 先确认小项存在(与 replaceCategories 同一处理由):不做这一步,小项不存在时
// 非空表撞 event_id 外键、却被报成「profileId 不存在」,让人去查一个没问题的模板。
event, err := eventByIDQ(ctx, tx, eventID)
if err != nil {
return nil, err
}
// compare=none 的小项排不出名次(名次靠人工录入),把 basis=result / aggregate 的通道存进去,
// 要等到发布时才会被 domain.ScoreEvent 以 ErrCompareRequired 拒绝(409 STATE_INVALID)——
// 那时成绩已经录了一半,回退代价高。规则包导入侧早有这条守卫
// (rules/diff.go checkCompareCompatible),手工在管理端配的这条路一直没人拦,在这里补上:
// 保存前整包拒收,一个字节都不写,并把「哪条通道、为什么」说清楚。
if event.Compare == string(domain.CompareNone) {
for i, c := range in {
if c.Basis != string(domain.BasisResult) && c.Basis != string(domain.BasisAggregate) {
continue
}
return nil, badRequest(fmt.Sprintf(
"小项「%s」的比较方向是「不比较」(compare=none),排不出名次:第 %d 条通道的 basis=%s 要按成绩排名,"+
"服务端算不出分、发布会失败。请把它改成「手动录名次」(basis=manual),"+
"或先给小项设置比较方向(compare=asc/desc)",
event.Name, i+1, c.Basis), map[string]any{"index": i, "basis": c.Basis, "compare": event.Compare})
}
}
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)
}
switch c.AggregateMode {
case "", string(domain.AggregateAvg), string(domain.AggregateSum):
default:
return nil, badRequest("通道 aggregateMode 只能是 avg/sum", 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, aggregate_mode,
count_to_team, count_to_medals, count_to_team_medals)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, channelAggregateMode(c),
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 ----------
// squad 挂在最后:列的追加不该动到既有列的下标(scanEntry 按位置扫,加在中间会整列错位)。
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, e.squad`
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,
&e.Squad); 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)
}
if err := rows.Err(); err != nil {
return nil, err
}
// 份额单独查一次再挂上:一个报名可能有多条(联队),join 进上面那条 SQL 会把报名行乘开。
shares, err := s.loadEntrySharesDTOs(ctx, s.ev, eventID)
if err != nil {
return nil, err
}
// 队员同理:一条报名 0..N 个,单独查一次按 entry_id 挂上(空数组 = 只报单位)。
athletes, err := s.loadEntryAthletesDTOs(ctx, s.ev, eventID)
if err != nil {
return nil, err
}
for i := range out {
out[i].TeamShares = entrySharesOf(shares[out[i].ID], out[i].TeamID, out[i].TeamCode, out[i].TeamName)
out[i].Athletes = entryAthletesOf(athletes[out[i].ID])
}
return out, nil
}
// 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)
}
// 与 POST /api/entries/batch 同一把尺子、同一句文案(直接复用 maxEntryBatch):
// 两个接口处理的都是「一批报名项」,上限不该各说各话。
// 位置与 entries_batch.go:73 一致——拦在任何一次读库 / 写库之前,先于下面那条
// IN (…) 的巨型 SQL,几万条 id 的请求不会被拼进 SQL 再被拒。
if len(ids) > maxEntryBatch {
return entryHeatResultDTO{}, badRequest(fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", maxEntryBatch), 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
}