用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
868 lines
29 KiB
Go
868 lines
29 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`
|
|
|
|
func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
|
|
var (
|
|
e eventDTO
|
|
scheduledAt, venue, note sql.NullString
|
|
)
|
|
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)
|
|
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)
|
|
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)
|
|
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() }()
|
|
|
|
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 = ?
|
|
WHERE id = ?`,
|
|
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, 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
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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 就地更新,
|
|
// 所以这里的文案要点明范围,别让只改了个名字的人以为改名也不许。
|
|
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 := 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 且下限不大于上限。
|
|
// 整表替换的老路径不校验这两列,脏值进得去又改不动;就地更新这条路径补上。
|
|
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
|
|
}
|
|
|
|
// 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 in.Gender != nil && *in.Gender != "" && *in.Gender != "男" && *in.Gender != "女" {
|
|
return nil, false, badRequest("组别 gender 只能是 男 / 女", in.Gender)
|
|
}
|
|
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
|
|
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
|
|
)
|
|
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
|
|
return nil, internal(err)
|
|
}
|
|
c.CountToTeam = boolPtr(countToTeam != 0)
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// channelCountsToTeam 读请求体里的通道开关:缺省(null)按 true 处理,
|
|
// 保证不带该字段的旧客户端保存通道时不会把单位总分关掉。
|
|
func channelCountsToTeam(c channelDTO) bool {
|
|
return c.CountToTeam == nil || *c.CountToTeam
|
|
}
|
|
|
|
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)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c))); err != nil {
|
|
if isUniqueViolation(err) {
|
|
return nil, conflict("同一 scope+basis 只能配置一个通道")
|
|
}
|
|
if isForeignKeyViolation(err) {
|
|
return nil, badRequest("profileId 不存在", i)
|
|
}
|
|
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.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
|
|
}
|
|
|
|
func (s *server) getEntry(ctx context.Context, id int64) (entryDTO, error) {
|
|
e, err := scanEntry(s.ev.QueryRowContext(ctx, entrySelect+` WHERE e.id = ?`, id))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return entryDTO{}, notFound("报名项不存在")
|
|
}
|
|
if err != nil {
|
|
return entryDTO{}, internal(err)
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error) {
|
|
if in.EventID == 0 || in.CategoryID == 0 || in.TeamID == 0 {
|
|
return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil)
|
|
}
|
|
status, err := normalizeEntryStatus(in.Status)
|
|
if err != nil {
|
|
return entryDTO{}, err
|
|
}
|
|
if err := s.checkCategoryInEvent(ctx, in.EventID, in.CategoryID); err != nil {
|
|
return entryDTO{}, err
|
|
}
|
|
res, err := s.ev.ExecContext(ctx,
|
|
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, remark)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
|
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark))
|
|
if isUniqueViolation(err) {
|
|
return entryDTO{}, conflict("该报名项已存在")
|
|
}
|
|
if isForeignKeyViolation(err) {
|
|
return entryDTO{}, badRequest("eventId/categoryId/teamId/athleteId 不存在", nil)
|
|
}
|
|
if err != nil {
|
|
return entryDTO{}, internal(err)
|
|
}
|
|
id, err := lastInsertID(res)
|
|
if err != nil {
|
|
return entryDTO{}, internal(err)
|
|
}
|
|
return s.getEntry(ctx, id)
|
|
}
|
|
|
|
func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) {
|
|
// 先确认报名项存在:小项不可改,categoryId 要拿它的 event_id 来校验。
|
|
// 这条查询同时守住了 404 语义,别让后面的校验把它降级成 400。
|
|
eventID, err := s.entryEventID(ctx, id)
|
|
if err != nil {
|
|
return entryDTO{}, err
|
|
}
|
|
status, err := normalizeEntryStatus(in.Status)
|
|
if err != nil {
|
|
return entryDTO{}, err
|
|
}
|
|
if err := s.checkCategoryInEvent(ctx, eventID, in.CategoryID); err != nil {
|
|
return entryDTO{}, err
|
|
}
|
|
res, err := s.ev.ExecContext(ctx,
|
|
`UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?,
|
|
bib = ?, seed = ?, heat = ?, status = ?, remark = ? WHERE id = ?`,
|
|
in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
|
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, nullStr(in.Remark), id)
|
|
if isUniqueViolation(err) {
|
|
return entryDTO{}, conflict("该报名项已存在")
|
|
}
|
|
if isForeignKeyViolation(err) {
|
|
return entryDTO{}, badRequest("categoryId/teamId/athleteId 不存在", nil)
|
|
}
|
|
if err != nil {
|
|
return entryDTO{}, internal(err)
|
|
}
|
|
if err := requireChanges(res, "报名项不存在"); err != nil {
|
|
return entryDTO{}, err
|
|
}
|
|
return s.getEntry(ctx, id)
|
|
}
|
|
|
|
// entryEventID 读报名项所属小项:小项不可改,更新组别时要以它为准做归属校验。
|
|
// 找不到时给 404,调用方(更新)原本也靠 requireChanges 给出同样的语义。
|
|
func (s *server) entryEventID(ctx context.Context, id int64) (int64, error) {
|
|
var eventID int64
|
|
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, id).Scan(&eventID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, notFound("报名项不存在")
|
|
}
|
|
if err != nil {
|
|
return 0, internal(err)
|
|
}
|
|
return eventID, nil
|
|
}
|
|
|
|
// checkCategoryInEvent 拦住「A 小项的报名挂在 B 小项的组别上」。
|
|
//
|
|
// event_categories 只有 (event_id, name) 唯一约束,entries 也没有 (event_id, category_id)
|
|
// 复合外键,所以外键只能保证组别存在、保证不了同属一个小项。组别不存在时放行,
|
|
// 交给 INSERT/UPDATE 的外键报错,保持原有「...不存在」的 400 文案不变。
|
|
func (s *server) checkCategoryInEvent(ctx context.Context, eventID, categoryID int64) error {
|
|
var catEvent int64
|
|
err := s.ev.QueryRowContext(ctx, `SELECT event_id FROM event_categories WHERE id = ?`, categoryID).Scan(&catEvent)
|
|
switch {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
return nil
|
|
case err != nil:
|
|
return internal(err)
|
|
case catEvent != eventID:
|
|
return badRequest("categoryId 不属于该小项", categoryID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *server) deleteEntry(ctx context.Context, id int64) error {
|
|
// 报名项被成绩引用时不允许删除;业务数据一律软删(status=scratched)。
|
|
res, err := s.ev.ExecContext(ctx, `UPDATE entries SET status = 'scratched' WHERE id = ?`, id)
|
|
if err != nil {
|
|
return internal(err)
|
|
}
|
|
return requireChanges(res, "报名项不存在")
|
|
}
|
|
|
|
// 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
|
|
}
|