## 需求(主人原话)
> 记分通道团体名次的名次来源缺一个模式,添加一个「按队内最低分」。
> 有一个特殊项目是取团队的最后一人的成绩(最后一名冲线)来排名的。
## 口径(这里最容易写反,所以定死)
新增 `aggregate_mode = 'worst'`。**不叫 `min`/`max`** —— 它的方向是**派生**的,叫 `min` 会让
「越大越好」的那半项目被写错。**跟着项目的比较方向自动取**:
| 项目 `compare` | 含义 | 队内最差 = |
| --- | --- | --- |
| `asc`(越小越好,如时间) | 行军 / 越野 | **max** = 最后一名冲线的那个人 |
| `desc`(越大越好,如次数) | 引体向上 | **min** = 做得最少的那个 |
方向判据**各只有一处**:Go 的 `domain.worseValue`、前端预览的 `worseThan`,
两边都有 asc/desc 双向用例钉着。规则包里写 `min`/`max` 会被校验**拦下并报错**。
## 改动
- **迁移 `0027`**:SQLite 改不了已有的 `CHECK`,按官方做法**重建 `event_score_channels`**,
取值放开到 `avg/sum/worst`;⚠️ 重建时**先把子表 `channel_ranks` 寄存到临时表再原样灌回**
(父表被 DROP 会级联清空名次 —— 这一步漏了就会把已发布的名次全丢)。
- **引擎**:`scoreByAggregate` 支持第三种口径。
- **接口 / 规则包**:读、写、校验、快照、导出五处都认第三个取值。
- **界面**:「聚合口径」由**两个按钮变三个**(平均 / 总和 / **队内最低分**),
提示文案写明两种方向,不丢一个 `worst` 让人猜。
- **前端预览同步**(`web/src/admin/api/scoring.ts`)—— 前一版刚修完「预览与后端算分不一致」,
这次不允许再开一个新口子。
- 文档:`docs/使用手册.md`、`docs/升级说明.md`、`devdocs/18` 各补一处。
## 两条边界(都是刻意的)
- **弃权 / 失格不算参与,且它的成绩不代表队伍**:队里 5 / 6 / 弃赛(0) → 队内最差是 **5**,不是 0。
否则谁都可以挂一个弃赛的人在队里垫底。
- **一个有效成绩都没有的主体一律不参与排名**(`avg_mode` 在这一口径下不起作用)。
若按 0 参与:`asc` 里 0 是全场最快、`desc` 里 0 是全场最差 —— 两边都会让没完赛的队白拿名次。
## 验证
- **老口径逐字节不变**:新增 golden `TestAggregateModesLegacyUnchanged`(`avg` / `sum` 结果与改动前一致)。
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **953 例** 0 fail / Console 96 例 0 fail。
- 迁移用例覆盖:老行原样、子表名次一行不少、id 不漂、非法值仍拒、幂等、级联删除仍在。
## 版本
`dev_0.5.10` → **`dev_0.5.11`**(含数据迁移 `0027`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
979 lines
36 KiB
Go
979 lines
36 KiB
Go
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, ¬e, &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 / worst):avg 是缺省值,老数据的响应体因此一个字节都不变。
|
||
c.AggregateMode = string(domain.AggregateModeOf(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 mode := domain.AggregateModeOf(c.AggregateMode); mode != "" {
|
||
return string(mode)
|
||
}
|
||
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), string(domain.AggregateWorst):
|
||
default:
|
||
return nil, badRequest("通道 aggregateMode 只能是 avg/sum/worst", 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
|
||
}
|