package httpapi import ( "context" "database/sql" "errors" "fmt" "net/http" "strconv" "strings" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/auth" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" "git.yeij.top/AskaEth/Scoreboard/server/internal/export" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" "git.yeij.top/AskaEth/Scoreboard/server/internal/revisions" ) // ---------- settings ---------- func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error) { rows, err := q.QueryContext(ctx, `SELECT key, value FROM settings`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make(map[string]string) for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { return nil, internal(err) } out[k] = v } return out, rows.Err() } func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) { return s.settingsSnapshotQ(ctx, s.ev) } // settingsSnapshotQ 在给定连接/事务内构造与 GET /api/settings 同构的配置, // 供写事务内的 change_log 载荷复用,保证大屏收到的 settings 与 REST 口径一致。 func (s *server) settingsSnapshotQ(ctx context.Context, q queryer) (dto.EventSettings, error) { values, err := loadSettingsMap(ctx, q) if err != nil { return dto.EventSettings{}, err } out := dto.ParseEventSettings(values) if code := values["scoring.default_profile"]; code != "" { var id int64 err := q.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id) switch { case errors.Is(err, sql.ErrNoRows): // 配置引用的模板已被删除:返回 null,由管理端提示重新选择。 case err != nil: return dto.EventSettings{}, internal(err) default: out.DefaultProfileID = &id } } return out, nil } func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.EventSettings, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return dto.EventSettings{}, internal(err) } defer func() { _ = tx.Rollback() }() set := func(key, value string) error { _, err := tx.ExecContext(ctx, `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, key, value) if err != nil { return internal(err) } return nil } setPtr := func(key string, v *string) error { if v == nil { return nil } return set(key, *v) } if p := patch.Event; p != nil { if err := setPtr("event.name", p.Name); err != nil { return dto.EventSettings{}, err } if err := setPtr("event.subtitle", p.Subtitle); err != nil { return dto.EventSettings{}, err } if err := setPtr("event.organizer", p.Host); err != nil { return dto.EventSettings{}, err } if err := setPtr("event.logo_path", p.LogoPath); err != nil { return dto.EventSettings{}, err } if err := setPtr("display.theme_color", p.PrimaryColor); err != nil { return dto.EventSettings{}, err } } if p := patch.Display; p != nil { if err := setPtr("display.background", p.Background); err != nil { return dto.EventSettings{}, err } if err := setPtr("display.logo", p.Logo); err != nil { return dto.EventSettings{}, err } if err := setPtr("display.title", p.Title); err != nil { return dto.EventSettings{}, err } if err := setPtr("display.subtitle", p.Subtitle); err != nil { return dto.EventSettings{}, err } if err := setPtr("display.theme_color", p.PrimaryColor); err != nil { return dto.EventSettings{}, err } if p.RotateSeconds != nil { // 前端 type=number 的 min/max 只是浏览器提示,服务端必须兜住,否则 1 或 9999 会落库。 if *p.RotateSeconds < minRotateSeconds || *p.RotateSeconds > maxRotateSeconds { return dto.EventSettings{}, badRequest("display.rotateSeconds 需在 5~120 之间", *p.RotateSeconds) } if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil { return dto.EventSettings{}, err } } // 字体 id 必须在写入时就校验:引用一个不存在的字体,大屏只会静默回落成黑体, // 现场看到的「配置没生效」比直接报错难排查得多。空串表示用默认字体。 if p.FontTitle != nil { if err := s.checkDisplayFont("display.fontTitle", *p.FontTitle); err != nil { return dto.EventSettings{}, err } if err := set("display.font_title", strings.TrimSpace(*p.FontTitle)); err != nil { return dto.EventSettings{}, err } } if p.FontBody != nil { if err := s.checkDisplayFont("display.fontBody", *p.FontBody); err != nil { return dto.EventSettings{}, err } if err := set("display.font_body", strings.TrimSpace(*p.FontBody)); err != nil { return dto.EventSettings{}, err } } // 实时赛况的当前项目:最多 2 个,且必须是真实存在的小项。 // 存不存在的 id 只会让大屏整块空白,现场还得回管理端排查,不如写入时就拒绝。 if p.LiveEvents != nil { if len(*p.LiveEvents) > dto.MaxLiveEvents { return dto.EventSettings{}, badRequest(fmt.Sprintf("display.liveEvents 最多 %d 个", dto.MaxLiveEvents), len(*p.LiveEvents)) } ids := dedupeIDs(*p.LiveEvents) for _, id := range ids { var exists int err := tx.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, id).Scan(&exists) if errors.Is(err, sql.ErrNoRows) { return dto.EventSettings{}, badRequest("display.liveEvents 引用了不存在的小项", id) } if err != nil { return dto.EventSettings{}, internal(err) } } if err := set(dto.SettingKeyLiveEvents, dto.FormatLiveEventIDs(ids)); err != nil { return dto.EventSettings{}, err } } if p.LiveSource != nil { source := dto.NormalizeLiveSource(*p.LiveSource) if source == "" { return dto.EventSettings{}, badRequest("display.liveSource 只能是 manual / auto", *p.LiveSource) } if err := set(dto.SettingKeyLiveSource, source); err != nil { return dto.EventSettings{}, err } } } if p := patch.Standings; p != nil { if p.IndividualToTeamWeight != nil { // 0 与负数会被 domain 按 1 处理,等于静默忽略用户配置,直接拒绝更明确。 if *p.IndividualToTeamWeight <= 0 { return dto.EventSettings{}, badRequest("standings.individualToTeamWeight 必须大于 0", *p.IndividualToTeamWeight) } if err := set("standings.individual_to_team_weight", strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil { return dto.EventSettings{}, err } } if err := setPtr("standings.mode", p.RankingMode); err != nil { return dto.EventSettings{}, err } if p.ShowIndividualBoard != nil { if err := set("standings.show_individual_board", strconv.FormatBool(*p.ShowIndividualBoard)); err != nil { return dto.EventSettings{}, err } } if p.ShowIndividualMedals != nil { if err := set("standings.show_individual_medals", strconv.FormatBool(*p.ShowIndividualMedals)); err != nil { return dto.EventSettings{}, err } } if p.LiveOnPublish != nil { if err := set(dto.SettingKeyLiveOnPublish, strconv.FormatBool(*p.LiveOnPublish)); err != nil { return dto.EventSettings{}, err } } } if patch.DefaultProfileID != nil { code := "" if *patch.DefaultProfileID != 0 { if err := tx.QueryRowContext(ctx, `SELECT code FROM scoring_profiles WHERE id = ?`, *patch.DefaultProfileID).Scan(&code); err != nil { if errors.Is(err, sql.ErrNoRows) { return dto.EventSettings{}, badRequest("defaultProfileId 不存在", nil) } return dto.EventSettings{}, internal(err) } } if err := set("scoring.default_profile", code); err != nil { return dto.EventSettings{}, err } } if patch.RequireApproval != nil { if err := set("results.review_required", strconv.FormatBool(*patch.RequireApproval)); err != nil { return dto.EventSettings{}, err } } // 配置写操作必须留一条 change_log,否则 broadcastChanges 推空、大屏的标题/主题/榜单口径不刷新。 snapshot, err := s.settingsSnapshotQ(ctx, tx) if err != nil { return dto.EventSettings{}, err } if err := bumpVersion(ctx, tx, "settings", 0, "upsert", snapshot, middleware.User(ctx).ID); err != nil { return dto.EventSettings{}, err } if err := tx.Commit(); err != nil { return dto.EventSettings{}, internal(err) } return snapshot, nil } // 大屏轮播间隔的取值范围,与 EventInfoPanel 的输入约束一致。 const ( minRotateSeconds = 5 maxRotateSeconds = 120 ) func atofDefault(v string, def float64) float64 { n, err := strconv.ParseFloat(strings.TrimSpace(v), 64) if err != nil { return def } return n } func boolDefault(v string, def bool) bool { switch strings.ToLower(strings.TrimSpace(v)) { case "true", "1", "yes": return true case "false", "0", "no": return false default: return def } } // ---------- 系统级设置:登录有效期 ---------- // // 存在系统库 system.db 的 settings(键 security.session_hours),与赛事级的 // /api/settings(event.db,随赛事包走)不共用:会话寿命是这台机器的属性, // 切换到另一个赛事项目时不该跟着变。 func (s *server) getSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) { return s.sessionSettings(r) } func (s *server) updateSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) { var in sessionSettingsPatch if err := decodeJSON(r, &in); err != nil { return nil, err } if in.SessionHours == nil { return nil, badRequest("登录有效期 sessionHours 必填", nil) } hours := *in.SessionHours if hours < auth.MinSessionHours || hours > auth.MaxSessionHours { return nil, badRequest( fmt.Sprintf("登录有效期(小时)需在 %d~%d 之间", auth.MinSessionHours, auth.MaxSessionHours), hours) } return nil, s.idempotent(w, r, in.RequestID, func() (any, error) { if err := s.writeSessionHours(r.Context(), hours); err != nil { return nil, err } s.audit(r.Context(), "settings.session_hours", "system", 0, nil, map[string]any{"sessionHours": hours}) return s.sessionSettings(r) }) } // sessionSettings 组装面板要的登录有效期:生效值 + 取值范围 + 当前会话到期时间。 func (s *server) sessionSettings(r *http.Request) (dto.SessionSettings, error) { values, err := loadSettingsMap(r.Context(), s.sys) if err != nil { return dto.SessionSettings{}, err } return dto.SessionSettings{ SessionHours: auth.EffectiveSessionHours(values), DefaultHours: auth.DefaultSessionHours, MinHours: auth.MinSessionHours, MaxHours: auth.MaxSessionHours, ExpiresAt: s.sessionExpiresAt(r), }, nil } // sessionExpiresAt 取当前请求所用会话的到期时间(RFC3339 UTC);没有有效会话时为 nil。 // 前端要把它按本机时区显示成「当前会话将于 … 过期」,所以必须带时区,不能下发库里的裸时间串。 func (s *server) sessionExpiresAt(r *http.Request) *string { c, err := r.Cookie(auth.CookieSession) if err != nil || c.Value == "" { return nil } _, sess, err := s.auth.Lookup(r.Context(), c.Value) if err != nil || sess == nil { return nil } v := sess.ExpiresAt.UTC().Format(time.RFC3339) return &v } // writeSessionHours 落库登录有效期并立刻在进程内生效。 // 只写配置、不动任何已存在会话的 expires_at:改设置不该把正在录入的人踢下线。 func (s *server) writeSessionHours(ctx context.Context, hours int) error { tx, err := s.sys.BeginTx(ctx, nil) if err != nil { return internal(err) } defer func() { _ = tx.Rollback() }() upsert := func(key, value string) error { _, err := tx.ExecContext(ctx, `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, key, value) if err != nil { return internal(err) } return nil } if err := upsert(auth.SessionHoursKey, strconv.Itoa(hours)); err != nil { return err } // 记下这个值是面板写的:读取时就不再把它当成迁移种下的历史占位值, // 操作员特意选的 12 才不会在重启后被悄悄改成默认的 24。 if err := upsert(auth.SessionHoursSourceKey, auth.SessionHoursSourcePanel); err != nil { return err } if err := tx.Commit(); err != nil { return internal(err) } // 重启才生效会让现场以为「改了面板没反应」,所以同一请求里就换掉进程内的有效期。 s.auth.SetSessionTTL(time.Duration(hours) * time.Hour) return nil } // ---------- 大屏公告 ---------- // 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder, // 而 PUT /api/settings 是 admin-only,公告的持久化只能走指令这一侧。 const ( announceTextKey = "display.announce_text" announceSecondsKey = "display.announce_seconds" defaultAnnounceSeconds = 20 ) // saveAnnouncement 落库当前公告正文与全屏推送时长;text 为空串即「无公告」。 func (s *server) saveAnnouncement(ctx context.Context, text string, seconds int, actorID int64) error { if seconds <= 0 { seconds = defaultAnnounceSeconds } return s.writeAnnouncement(ctx, actorID, announceTextKey, text, announceSecondsKey, strconv.Itoa(seconds)) } // clearAnnouncement 清空当前公告;全屏时长保留,供下次推送沿用。 func (s *server) clearAnnouncement(ctx context.Context, actorID int64) error { return s.writeAnnouncement(ctx, actorID, announceTextKey, "") } // writeAnnouncement 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。 // 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到公告变化。 func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...string) error { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return internal(err) } defer func() { _ = tx.Rollback() }() for i := 0; i+1 < len(kv); i += 2 { if _, err := tx.ExecContext(ctx, `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, kv[i], kv[i+1]); err != nil { return internal(err) } } snapshot, err := s.settingsSnapshotQ(ctx, tx) if err != nil { return err } if err := bumpVersion(ctx, tx, "settings", 0, "upsert", snapshot, actorID); err != nil { return err } if err := tx.Commit(); err != nil { return internal(err) } return nil } // ---------- standings ---------- func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) { teams, err := s.domainTeams(ctx, q) if err != nil { return domain.StandingsInput{}, err } athletes, err := s.domainAthletes(ctx, q) if err != nil { return domain.StandingsInput{}, err } ledger, err := s.activeLedger(ctx, q) if err != nil { return domain.StandingsInput{}, err } ranked, err := s.buildRanked(ctx, q) if err != nil { return domain.StandingsInput{}, err } values, err := loadSettingsMap(ctx, q) if err != nil { return domain.StandingsInput{}, err } weight := atofDefault(values["standings.individual_to_team_weight"], 1) exclude := !boolDefault(values["standings.medal_counts_team_events"], true) return domain.StandingsInput{ Teams: teams, Athletes: athletes, Ledger: ledger, Ranked: ranked, TieBreak: splitSettingList(values["standings.tie_break"]), IndividualToTeamWeight: weight, ExcludeTeamEventMedals: exclude, }, nil } // splitSettingList 拆分 settings 里的逗号分隔列表(如 standings.tie_break), // 口径与 rules 包对同一键的解析一致:去空白、丢空项,空值返回 nil 由 domain 落回默认序。 func splitSettingList(raw string) []string { if strings.TrimSpace(raw) == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } // standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单, // 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。 // 总积分榜与单位奖牌榜都要算:前者的顺序是数组顺序,后者的名次由 medalRank 带走。 func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) { in, err := s.loadStandingsInput(ctx, q) if err != nil { return nil, err } teams, err := teamMapQ(ctx, q) if err != nil { return nil, err } return dto.StandingRows( domain.BuildTeamStandings(in), domain.BuildMedalStandings(in), domainTeamsFromMap(teams), ), nil } // domainTeamsFromMap 把 teams 表的 DTO 还原成 domain.Team,供 dto.StandingRows 补齐展示字段。 // 这里用全量单位表(含停用单位):榜单兜底会带上流水里的停用单位,否则编码/名称会为空。 func domainTeamsFromMap(teams map[int64]teamDTO) []domain.Team { out := make([]domain.Team, 0, len(teams)) for _, t := range teams { out = append(out, domain.Team{ ID: t.ID, Code: t.Code, Name: t.Name, ShortName: strValue(t.ShortName), LogoPath: strValue(t.LogoPath), Color: strValue(t.Color), }) } return out } func strValue(s *string) string { if s == nil { return "" } return *s } // bumpStandings 在写事务内把整张榜单写入 change_log;榜单变化后必须调用,否则大屏不会刷新。 func (s *server) bumpStandings(ctx context.Context, tx *sql.Tx, actorID int64) error { snapshot, err := s.standingsSnapshot(ctx, tx) if err != nil { return err } return bumpVersion(ctx, tx, "standings", 0, "replace", snapshot, actorID) } func (s *server) domainTeams(ctx context.Context, q queryer) ([]domain.Team, error) { rows, err := q.QueryContext(ctx, `SELECT id, code, name, short_name, color, sort_order, active FROM teams WHERE active = 1`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]domain.Team, 0) for rows.Next() { var ( t domain.Team shortName sql.NullString color sql.NullString ) if err := rows.Scan(&t.ID, &t.Code, &t.Name, &shortName, &color, &t.SortOrder, &t.Active); err != nil { return nil, internal(err) } t.ShortName = shortName.String t.Color = color.String out = append(out, t) } return out, rows.Err() } func (s *server) domainAthletes(ctx context.Context, q queryer) ([]domain.Athlete, error) { rows, err := q.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]domain.Athlete, 0) for rows.Next() { var ( a domain.Athlete gender sql.NullString teamID sql.NullInt64 ) if err := rows.Scan(&a.ID, &a.Name, &gender, &teamID); err != nil { return nil, internal(err) } a.Gender = gender.String a.TeamID = teamID.Int64 out = append(out, a) } return out, rows.Err() } // buildRanked 从 channel_ranks 读名次明细(用于奖牌统计与个人榜)。 // // 数据源是「发布/重算时按通道全量重建」的 channel_ranks,而不是 results.rank: // 后者只是个人名次(见 applyComputedRanks),团体名次与聚合名次根本没有对应的成绩行。 // 三个奖牌开关随行携带(events.award_medals + 通道两个开关),domain 的榜单函数不必回查库。 // 只排名不计分(scored=0)的项目不产出奖牌,必须与导出侧同口径(export/load.loadRanked)。 func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedResult, error) { rows, err := q.QueryContext(ctx, `SELECT COALESCE(cr.result_id, 0), cr.event_id, cr.category_id, cr.rank, cr.result_value, cr.status, COALESCE(cr.entry_id, 0), COALESCE(cr.team_id, 0), COALESCE(cr.athlete_id, 0), COALESCE(a.name, ''), cr.scope, cr.channel_id, cr.basis, cr.subject_key, ev.award_medals, c.count_to_medals, c.count_to_team_medals FROM channel_ranks cr JOIN events ev ON ev.id = cr.event_id AND ev.scored = 1 JOIN event_score_channels c ON c.id = cr.channel_id LEFT JOIN athletes a ON a.id = cr.athlete_id WHERE cr.rank > 0 ORDER BY cr.event_id, cr.category_id, c.sort_order, c.id, cr.rank, cr.id`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]domain.RankedResult, 0) for rows.Next() { var ( rr domain.RankedResult value sql.NullFloat64 status sql.NullString awardMedals int countToMedals int countToTeamMedals int ) if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rr.Rank, &value, &status, &rr.EntryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID, &rr.Basis, &rr.SubjectKey, &awardMedals, &countToMedals, &countToTeamMedals); err != nil { return nil, internal(err) } rr.Status = domain.ResultStatus(status.String) if value.Valid { v := value.Float64 rr.ResultValue = &v } rr.AwardMedals = awardMedals != 0 rr.CountToMedals = countToMedals != 0 rr.CountToTeamMedals = countToTeamMedals != 0 out = append(out, rr) } if err := rows.Err(); err != nil { return nil, err } // 归属份额按报名项的配置补:channel_ranks 只存名次与主体(entry_id 有外键、可回查), // 份额是报名项上的配置,引擎与榜单两边都必须从同一处读—— // 没有报名项的名次(聚合 / 团体手工)与没配份额的老数据都退回「主单位独占 100%」, // 与 domain.ResolveShares 同一口径(老数据因此一分一牌都不变)。 shares, err := loadEntryTeamSharesQ(ctx, q, 0) if err != nil { return nil, err } for i := range out { out[i].Shares = domain.ResolveShares(shares[out[i].EntryID], out[i].TeamID) } return out, nil } // breakdownRankExpr 取一条单位流水的名次(devdocs 17 §11.3):优先按流水对应的通道与主体 // 关联 channel_ranks,关联不上时回落到 results.rank。 // // score_ledger 上没有通道列(LedgerEntry.ChannelID 没有落库),只能按流水自身能确定的主体找: // - 有 entry_id 的成绩流水:按报名项关联,多通道命中时取「主通道」(scope=athlete 优先,其次 sort_order); // - entry_id / result_id 都为空的聚合 / 团体手工名次流水:写入侧把 source_id 退化为通道 id,就用它 + 主体关联。 // // 组别用该行能唯一确定的那一个:报名项的组别(cat)优先;聚合流水没有报名项, // 靠 ca 回推(该单位在本小项只参加了一个组别时那个组别才是答案,见下方子查询)。 // 回推不出来也不按「碰巧撞上的另一组」报数:放宽到不限组别取最优名次,宁可留空也不张冠李戴。 const breakdownRankExpr = `COALESCE(( SELECT cr.rank FROM channel_ranks cr JOIN event_score_channels ch ON ch.id = cr.channel_id WHERE cr.event_id = l.event_id AND ((cat.id IS NOT NULL AND cr.category_id = cat.id) OR (cat.id IS NULL AND (ca.id IS NULL OR cr.category_id = ca.id))) AND ((l.entry_id IS NOT NULL AND cr.entry_id = l.entry_id) OR (l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result' AND cr.channel_id = l.source_id AND ((cr.scope = 'team' AND cr.team_id = l.team_id) OR (cr.scope = 'athlete' AND cr.athlete_id = l.athlete_id)))) ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id LIMIT 1), r.rank)` func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdownDTO, error) { team, err := s.getTeam(ctx, teamID) if err != nil { return teamBreakdownDTO{}, err } // 合计必须与总积分榜同口径:只算计入单位总分的流水, // 其中仅记单位的直加,记到个人的乘折算系数。 values, err := loadSettingsMap(ctx, s.ev) if err != nil { return teamBreakdownDTO{}, err } weight := atofDefault(values["standings.individual_to_team_weight"], 1) if weight <= 0 { weight = 1 } // 名次不再只读 results.rank:那是个人名次(见 applyComputedRanks), // 团体名次与聚合名次只落在 channel_ranks,读旧列会让下钻的名次列整列变空。 // 组别与导出口径一致(export.loadTeamDetailRows 的 COALESCE(c.name, ca.name, '')): // 「按队内平均」产出的聚合流水 entry_id 为空,取不到报名项的组别,得用 ca 回推的那个。 rows, err := s.ev.QueryContext(ctx, `SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, COALESCE(cat.name, ca.name), l.athlete_id, a.name, `+breakdownRankExpr+`, r.result_text, l.points, COALESCE(l.created_at, ''), l.entry_id FROM score_ledger l LEFT JOIN events ev ON ev.id = l.event_id LEFT JOIN entries e ON e.id = l.entry_id LEFT JOIN event_categories cat ON cat.id = e.category_id LEFT JOIN ( SELECT en2.event_id, MIN(en2.category_id) AS category_id FROM entries en2 WHERE en2.team_id = ? GROUP BY en2.event_id HAVING COUNT(DISTINCT en2.category_id) = 1 ) ac ON ac.event_id = l.event_id AND l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result' LEFT JOIN event_categories ca ON ca.id = ac.category_id LEFT JOIN athletes a ON a.id = l.athlete_id LEFT JOIN results r ON r.id = l.result_id WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1 ORDER BY l.id DESC`, teamID, teamID) if err != nil { return teamBreakdownDTO{}, internal(err) } defer func() { _ = rows.Close() }() out := teamBreakdownDTO{TeamID: team.ID, TeamCode: team.Code, TeamName: team.Name, Items: []breakdownItemDTO{}} // 每条流水背后那条成绩的版本身份;聚合流水 / 人工调整没有报名项,留零值(追不了就不追)。 keys := make([]revisions.Key, 0, 16) for rows.Next() { var ( item breakdownItemDTO eventID, athleteID sql.NullInt64 entryID sql.NullInt64 eventCode, eventName sql.NullString categoryName, athleteName sql.NullString rank sql.NullInt64 resultText sql.NullString ) if err := rows.Scan(&item.LedgerID, &item.SourceType, &eventID, &eventCode, &eventName, &categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt, &entryID); err != nil { return teamBreakdownDTO{}, internal(err) } item.EventID = i64Ptr(eventID) item.EventCode = strPtr(eventCode) item.EventName = strPtr(eventName) item.CategoryName = strPtr(categoryName) item.AthleteID = i64Ptr(athleteID) item.AthleteName = strPtr(athleteName) item.Rank = intPtr(rank) item.ResultText = strPtr(resultText) if item.AthleteID != nil && *item.AthleteID != 0 { item.Points *= weight } // 只有成绩流水本身带得上修订痕迹(见 revisions.KeyFor):破纪录加分 / 人工调整 / 聚合流水留零值。 key := revisions.Key{} if item.EventID != nil { key = revisions.KeyFor(item.SourceType, *item.EventID, entryID.Int64) } keys = append(keys, key) out.Items = append(out.Items, item) out.TotalPoints += item.Points } if err := rows.Err(); err != nil { return teamBreakdownDTO{}, internal(err) } s.attachBreakdownRevisions(ctx, out.Items, keys) return out, nil } // attachBreakdownRevisions 给明细行补上修订痕迹(谁在什么时候因为什么改过这条成绩)。 // // 查不出来只记日志、不报错:修订痕迹是「解释这一分怎么来的」的补充信息,追溯失败不该让 // 整张明细打不开 —— 现场要看的是分,不是角标。此时字段保持 nil,前端显示为空。 // // 操作人姓名只发给 recorder 及以上:这个接口是公开的(大屏也读它),而「谁点错的」 // 属于裁判自己要看的账,不该跟着 JSON 一起下发给未登录的访客 —— 与 /api/revisions // 只对 recorder 开放是同一个口径。时间与原因照发:那是给观众的解释,不是人名的账。 func (s *server) attachBreakdownRevisions(ctx context.Context, items []breakdownItemDTO, keys []revisions.Key) { if len(keys) == 0 { return } trails, err := revisions.Load(ctx, s.ev, keys) if err != nil { s.logger.Warn("读取成绩修订痕迹失败,明细按「未修订」展示", "err", err) return } showActor := middleware.User(ctx).Role.Allows(auth.RoleRecorder) actorIDs := make([]int64, 0, 4) byActor := make(map[int]int64) for i, key := range keys { trail, ok := trails[key] if !ok { continue } count := trail.Count items[i].RevisionCount = &count if count == 0 { continue } at := trail.At items[i].LastRevisionAt = &at if trail.Reason != "" { reason := trail.Reason items[i].LastRevisionReason = &reason } if trail.ActorID != nil && showActor { actorIDs = append(actorIDs, *trail.ActorID) byActor[i] = *trail.ActorID } } if len(actorIDs) == 0 || s.sys == nil { return } names, err := s.userNames(ctx, actorIDs) if err != nil { s.logger.Warn("读取修订操作人姓名失败,只显示时间与原因", "err", err) return } for i, id := range byActor { if name := names[id]; name != "" { items[i].LastRevisedBy = &name } } } // ---------- records ---------- // recordSourceHistory 是手工登记的历史纪录的来源标记,与 records.source 的 CHECK 一致。 // 只认它:source='current' 只能由「确认破纪录」写出来(见 upsertCurrentRecord)。 const recordSourceHistory = "history" func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) { query := `SELECT ` + recordEntryColumns + ` FROM records WHERE is_current = 1` args := []any{} if eventID > 0 { query += ` AND event_id = ?` args = append(args, eventID) } query += ` ORDER BY event_id, level` rows, err := s.ev.QueryContext(ctx, query, args...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]recordEntryDTO, 0) for rows.Next() { r, err := scanRecordEntry(rows) if err != nil { return nil, internal(err) } out = append(out, r) } return out, rows.Err() } func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, userID int64) (recordBreakDTO, error) { if in.EventID == 0 || in.EntryID == 0 || in.Level == "" { return recordBreakDTO{}, badRequest("eventId、entryId、level 必填", nil) } switch in.Level { case "meet", "city", "province", "national": default: return recordBreakDTO{}, badRequest("level 非法", in.Level) } if err := s.validateRecordBreakEntry(ctx, in); err != nil { return recordBreakDTO{}, err } res, err := s.ev.ExecContext(ctx, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, result_text, bonus_points, state) VALUES (?, ?, ?, ?, ?, 0, 'pending')`, in.EventID, in.EntryID, in.Level, in.ResultValue, in.ResultText) if isForeignKeyViolation(err) { return recordBreakDTO{}, badRequest("eventId/entryId 不存在", nil) } if err != nil { return recordBreakDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return recordBreakDTO{}, internal(err) } return s.getRecordBreak(ctx, id) } // validateRecordBreakEntry 拦住「凭空加分」:报名项必须属于该小项、小项可破纪录、 // 且已有已发布且状态正常的成绩,否则确认后会直接写加分流水与纪录库。 func (s *server) validateRecordBreakEntry(ctx context.Context, in recordBreakInput) error { var ( entryEvent int64 recordEligible bool ) err := s.ev.QueryRowContext(ctx, `SELECT e.event_id, ev.record_eligible FROM entries e JOIN events ev ON ev.id = e.event_id WHERE e.id = ?`, in.EntryID).Scan(&entryEvent, &recordEligible) if errors.Is(err, sql.ErrNoRows) { return badRequest("entryId 不存在", in.EntryID) } if err != nil { return internal(err) } if entryEvent != in.EventID { return badRequest("entryId 不属于该小项", in.EntryID) } if !recordEligible { return badRequest("该小项未开启破纪录登记", in.EventID) } var ( state string status string ) err = s.ev.QueryRowContext(ctx, `SELECT state, status FROM results WHERE entry_id = ?`, in.EntryID).Scan(&state, &status) if errors.Is(err, sql.ErrNoRows) { return badRequest("该报名项尚无成绩,不能登记破纪录", in.EntryID) } if err != nil { return internal(err) } if state != string(domain.ResultStatePublished) || status != string(domain.ResultStatusOK) { return badRequest("该成绩尚未发布或状态异常,不能登记破纪录", in.EntryID) } return nil } func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) { b, err := scanRecordBreak(s.ev.QueryRowContext(ctx, `SELECT `+recordBreakColumns+` FROM record_breaks WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return recordBreakDTO{}, notFound("破纪录登记不存在") } if err != nil { return recordBreakDTO{}, internal(err) } return b, nil } // recordBreakColumns 是 recordBreakDTO 的列清单;note 来自迁移 0021(可空)。 const recordBreakColumns = `id, event_id, entry_id, level, result_value, result_text, bonus_points, state, approved_by, approved_at, note` func scanRecordBreak(row interface{ Scan(...any) error }) (recordBreakDTO, error) { var ( b recordBreakDTO value sql.NullFloat64 text sql.NullString approvedBy sql.NullInt64 approvedAt sql.NullString note sql.NullString ) if err := row.Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State, &approvedBy, &approvedAt, ¬e); err != nil { return b, err } b.ResultValue = value.Float64 b.ResultText = text.String b.ApprovedBy = i64Ptr(approvedBy) b.ApprovedAt = strPtr(approvedAt) b.Note = strPtr(note) return b, nil } // listRecordBreaks 列出破纪录登记(最新在前)。state 为空表示不过滤; // 传了就按它过滤,非法状态由 handler 拦在更外层(避免一个拼错的状态静默回空列表)。 func (s *server) listRecordBreaks(ctx context.Context, state string) ([]recordBreakDTO, error) { query := `SELECT ` + recordBreakColumns + ` FROM record_breaks` args := []any{} if state != "" { query += ` WHERE state = ?` args = append(args, state) } query += ` ORDER BY id DESC` rows, err := s.ev.QueryContext(ctx, query, args...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]recordBreakDTO, 0) for rows.Next() { b, err := scanRecordBreak(rows) if err != nil { return nil, internal(err) } out = append(out, b) } return out, rows.Err() } func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (recordBreakDTO, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return recordBreakDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() var ( state string eventID int64 entryID int64 level string resultVal sql.NullFloat64 resultText sql.NullString ) if err := tx.QueryRowContext(ctx, `SELECT state, event_id, entry_id, level, result_value, result_text FROM record_breaks WHERE id = ?`, id). Scan(&state, &eventID, &entryID, &level, &resultVal, &resultText); err != nil { if errors.Is(err, sql.ErrNoRows) { return recordBreakDTO{}, notFound("破纪录登记不存在") } return recordBreakDTO{}, internal(err) } if state != "pending" && state != "rejected" { return recordBreakDTO{}, stateInvalid("该破纪录登记已处理") } // 重新批准(pending → approved 或 rejected → approved)都要清掉上一次的驳回理由: // note 的含义是「这条登记现在为什么被驳回」,批准后它已经不成立,留着只会误导。 if _, err := tx.ExecContext(ctx, `UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now'), note = NULL WHERE id = ?`, nullID(userID), id); err != nil { return recordBreakDTO{}, internal(err) } if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "确认破纪录加分"); err != nil { return recordBreakDTO{}, err } // 确认即更新纪录库,并把引擎算出的加分回填到登记上(devdocs/02 第 3 节)。 if err := upsertCurrentRecord(ctx, tx, eventID, entryID, level, resultVal, resultText); err != nil { return recordBreakDTO{}, err } if _, err := tx.ExecContext(ctx, `UPDATE record_breaks SET bonus_points = COALESCE(( SELECT SUM(points) FROM score_ledger WHERE source_type = 'record_bonus' AND event_id = ? AND entry_id = ? AND voided = 0), 0) WHERE id = ?`, eventID, entryID, id); err != nil { return recordBreakDTO{}, internal(err) } if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil { return recordBreakDTO{}, err } if err := s.bumpStandings(ctx, tx, userID); err != nil { return recordBreakDTO{}, err } if err := tx.Commit(); err != nil { return recordBreakDTO{}, internal(err) } s.audit(ctx, "record_break.approve", "record_break", id, map[string]any{"state": state}, map[string]any{"state": "approved"}) return s.getRecordBreak(ctx, id) } // rejectRecordBreak 驳回 / 作废一条破纪录登记(A41)。 // // 原本是 approved 的登记意味着它已经给该小项加过分,所以驳回不只是改状态: // // ① 状态改 rejected 并把理由写进 note; // ② 重算该小项(引擎只认 approved 的登记),加分与随之变动的名次流水一起撤掉,再推榜; // ③ 把「确认破纪录」写进纪录库的那条 current 纪录撤下来,并恢复该级别上一条纪录。 // // pending 直接驳回没有加过分,②③ 都不用做——这也是本文件里唯一区分「从哪个状态驳回」的地方。 func (s *server) rejectRecordBreak(ctx context.Context, id, userID int64, reason string) (recordBreakDTO, error) { if strings.TrimSpace(reason) == "" { return recordBreakDTO{}, badRequest("reason 必填", nil) } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return recordBreakDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() var ( state string eventID int64 level string ) if err := tx.QueryRowContext(ctx, `SELECT state, event_id, level FROM record_breaks WHERE id = ?`, id). Scan(&state, &eventID, &level); err != nil { if errors.Is(err, sql.ErrNoRows) { return recordBreakDTO{}, notFound("破纪录登记不存在") } return recordBreakDTO{}, internal(err) } if state == "rejected" { return recordBreakDTO{}, badRequest("该破纪录登记已被驳回/作废", nil) } if _, err := tx.ExecContext(ctx, `UPDATE record_breaks SET state = 'rejected', note = ? WHERE id = ?`, reason, id); err != nil { return recordBreakDTO{}, internal(err) } if state == "approved" { if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "驳回破纪录:"+reason); err != nil { return recordBreakDTO{}, err } if err := revertCurrentRecord(ctx, tx, eventID, level); err != nil { return recordBreakDTO{}, err } } // 登记表不进实时快照,但「破纪录登记」这一块要跟着刷新,所以照 approve 的口径写一条变更。 if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil { return recordBreakDTO{}, err } // 只有真的重算过才推榜:pending 驳回没动过任何分数,凭空推一次榜单只会让大屏白刷。 if state == "approved" { if err := s.bumpStandings(ctx, tx, userID); err != nil { return recordBreakDTO{}, err } } if err := tx.Commit(); err != nil { return recordBreakDTO{}, internal(err) } s.audit(ctx, "record_break.reject", "record_break", id, map[string]any{"state": state}, map[string]any{"state": "rejected", "reason": reason}) return s.getRecordBreak(ctx, id) } // revertCurrentRecord 撤下「确认破纪录」写进纪录库的那条当前纪录,并恢复该档位上一条纪录。 // // 只能按 (小项, 档位) 定位:records 上没有指回 record_breaks 的列,0021 也只加了一列理由, // 没有为此重建表。同一 (小项, 档位) 的当前纪录本来就只有一条(upsertCurrentRecord 每次都先 // 把旧的置 0 再插新的),所以「当前那条」就是这次批准写下的那条。 // 恢复对象取剩下 id 最大的一条;一条都没有就把该档位留空——「无纪录」是事实, // 留一条刚被撤掉的纪录反而会让纪录库自相矛盾。 func revertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID int64, level string) error { var currentID sql.NullInt64 err := tx.QueryRowContext(ctx, `SELECT id FROM records WHERE event_id = ? AND level = ? AND is_current = 1 ORDER BY id DESC LIMIT 1`, eventID, level).Scan(¤tID) if err != nil && !errors.Is(err, sql.ErrNoRows) { return internal(err) } if !currentID.Valid { return nil } if _, err := tx.ExecContext(ctx, `UPDATE records SET is_current = 0 WHERE id = ?`, currentID.Int64); err != nil { return internal(err) } if _, err := tx.ExecContext(ctx, `UPDATE records SET is_current = 1 WHERE id = (SELECT id FROM records WHERE event_id = ? AND level = ? AND id <> ? ORDER BY id DESC LIMIT 1)`, eventID, level, currentID.Int64); err != nil { return internal(err) } return nil } // upsertCurrentRecord 把确认后的破纪录写入 records:同小项同档位的旧纪录改为非当前, // 新纪录标为 current(devdocs/04 records 表;source=current 表示本届产生)。 func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64, level string, value sql.NullFloat64, text sql.NullString) error { var ( teamID int64 athleteID sql.NullInt64 athleteName sql.NullString teamName sql.NullString ) if err := tx.QueryRowContext(ctx, `SELECT e.team_id, e.athlete_id, a.name, t.name FROM entries e LEFT JOIN athletes a ON a.id = e.athlete_id LEFT JOIN teams t ON t.id = e.team_id WHERE e.id = ?`, entryID).Scan(&teamID, &athleteID, &athleteName, &teamName); err != nil { if errors.Is(err, sql.ErrNoRows) { return notFound("报名项不存在") } return internal(err) } holder := teamName.String if athleteID.Valid && athleteName.String != "" { holder = athleteName.String } if _, err := tx.ExecContext(ctx, `UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`, eventID, level); err != nil { return internal(err) } if _, err := tx.ExecContext(ctx, `INSERT INTO records (event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), 1, 'current')`, eventID, level, value, text, nullStr(&holder), nullID(teamID)); err != nil { return internal(err) } return nil } // recordEntryColumns 是 recordEntryDTO 的列清单。 const recordEntryColumns = `id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''), holder_name, holder_team_id, achieved_at, is_current, source` func scanRecordEntry(row interface{ Scan(...any) error }) (recordEntryDTO, error) { var ( r recordEntryDTO holderName sql.NullString holderTeamID sql.NullInt64 achievedAt sql.NullString ) if err := row.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText, &holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil { return r, err } r.HolderName = strPtr(holderName) r.HolderTeamID = i64Ptr(holderTeamID) r.AchievedAt = strPtr(achievedAt) return r, nil } func (s *server) getRecordEntry(ctx context.Context, id int64) (recordEntryDTO, error) { r, err := scanRecordEntry(s.ev.QueryRowContext(ctx, `SELECT `+recordEntryColumns+` FROM records WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return recordEntryDTO{}, notFound("纪录不存在") } if err != nil { return recordEntryDTO{}, internal(err) } return r, nil } // createRecordEntry 手工登记一条历史纪录(C11):同 (小项, 档位) 的旧 current 让位, // 新行成为该档位的当前纪录(与 upsertCurrentRecord 同口径,只是 source 记 history)。 // // holder 名称 / 单位都可以空:很多历史纪录只留下了一个成绩数字,人是谁已经查不到了; // 逼着录入员编一个名字,只会往库里塞假数据。 func (s *server) createRecordEntry(ctx context.Context, in recordEntryInput, userID int64) (recordEntryDTO, error) { if in.EventID == 0 { return recordEntryDTO{}, badRequest("eventId 必填", nil) } switch in.Level { case "meet", "city", "province", "national": default: return recordEntryDTO{}, badRequest("level 非法", in.Level) } if in.ResultValue == nil && in.ResultText == nil { return recordEntryDTO{}, badRequest("resultValue 与 resultText 至少填一个", nil) } // 只收 history:source='current' 是「确认破纪录」的产物,客户端造出来的 current // 会把一条没人批准过的成绩伪装成本届刚破的纪录,还会被后续批准当成上一条纪录恢复。 if in.Source != "" && in.Source != recordSourceHistory { return recordEntryDTO{}, badRequest("source 只能是 history", in.Source) } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return recordEntryDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() var exists int err = tx.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, in.EventID).Scan(&exists) if errors.Is(err, sql.ErrNoRows) { return recordEntryDTO{}, badRequest("eventId 不存在", in.EventID) } if err != nil { return recordEntryDTO{}, internal(err) } if _, err := tx.ExecContext(ctx, `UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`, in.EventID, in.Level); err != nil { return recordEntryDTO{}, internal(err) } res, err := tx.ExecContext(ctx, `INSERT INTO records (event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source) VALUES (?, ?, ?, ?, ?, ?, ?, 1, 'history')`, in.EventID, in.Level, nullF64(in.ResultValue), nullStr(in.ResultText), nullStr(in.HolderName), nullI64(in.HolderTeamID), nullStr(in.AchievedAt)) if isForeignKeyViolation(err) { return recordEntryDTO{}, badRequest("holderTeamId 不存在", in.HolderTeamID) } if err != nil { return recordEntryDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return recordEntryDTO{}, internal(err) } // 纪录库进了实时快照(realtime.snapshot 读 records),不写变更大屏上的纪录页不会刷新。 if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil { return recordEntryDTO{}, err } if err := tx.Commit(); err != nil { return recordEntryDTO{}, internal(err) } created, err := s.getRecordEntry(ctx, id) if err != nil { return recordEntryDTO{}, err } s.audit(ctx, "record.create", "record", id, nil, created) return created, nil } // deleteRecordEntry 删除一条纪录;删掉的若是当前纪录,恢复同 (小项, 档位) 上一条为当前, // 一条都没有就把该档位留空(与 revertCurrentRecord 同一口径)。 func (s *server) deleteRecordEntry(ctx context.Context, id, userID int64) error { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return internal(err) } defer func() { _ = tx.Rollback() }() var ( eventID int64 level string isCurrent bool ) if err := tx.QueryRowContext(ctx, `SELECT event_id, level, is_current FROM records WHERE id = ?`, id). Scan(&eventID, &level, &isCurrent); err != nil { if errors.Is(err, sql.ErrNoRows) { return notFound("纪录不存在") } return internal(err) } before, err := scanRecordEntry(tx.QueryRowContext(ctx, `SELECT `+recordEntryColumns+` FROM records WHERE id = ?`, id)) if err != nil { return internal(err) } if _, err := tx.ExecContext(ctx, `DELETE FROM records WHERE id = ?`, id); err != nil { return internal(err) } if isCurrent { if _, err := tx.ExecContext(ctx, `UPDATE records SET is_current = 1 WHERE id = (SELECT id FROM records WHERE event_id = ? AND level = ? ORDER BY id DESC LIMIT 1)`, eventID, level); err != nil { return internal(err) } } if err := bumpVersion(ctx, tx, "record", id, "void", map[string]any{"id": id}, userID); err != nil { return err } if err := tx.Commit(); err != nil { return internal(err) } s.audit(ctx, "record.delete", "record", id, before, nil) return nil } // ---------- adjustments ---------- func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) { where := []string{"1 = 1"} args := []any{} if teamID > 0 { where = append(where, "team_id = ?") args = append(args, teamID) } clause := " WHERE " + joinAnd(where) var total int if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM point_adjustments`+clause, args...).Scan(&total); err != nil { return pageResult[adjustmentDTO]{}, internal(err) } query := `SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at FROM point_adjustments` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?` args = append(args, size, (page-1)*size) rows, err := s.ev.QueryContext(ctx, query, args...) if err != nil { return pageResult[adjustmentDTO]{}, internal(err) } defer func() { _ = rows.Close() }() items := make([]adjustmentDTO, 0) for rows.Next() { var ( a adjustmentDTO eventID, createdBy sql.NullInt64 ) if err := rows.Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt); err != nil { return pageResult[adjustmentDTO]{}, internal(err) } a.EventID = i64Ptr(eventID) a.CreatedBy = i64Ptr(createdBy) items = append(items, a) } if err := rows.Err(); err != nil { return pageResult[adjustmentDTO]{}, internal(err) } return pageResult[adjustmentDTO]{Items: items, Total: total, Page: page, Size: size}, nil } func (s *server) createAdjustment(ctx context.Context, in adjustmentInput, userID int64) (adjustmentDTO, error) { values, err := loadSettingsMap(ctx, s.ev) if err != nil { return adjustmentDTO{}, err } if !boolDefault(values["manual_adjustment.enabled"], false) { return adjustmentDTO{}, stateInvalid("人工调整当前未启用") } if boolDefault(values["manual_adjustment.reason_required"], true) && strings.TrimSpace(in.Reason) == "" { return adjustmentDTO{}, badRequest("reason 必填", nil) } switch in.Category { case "penalty", "bonus", "correction": default: return adjustmentDTO{}, badRequest("category 非法", in.Category) } if in.TeamID == 0 { return adjustmentDTO{}, badRequest("teamId 必填", nil) } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return adjustmentDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `INSERT INTO point_adjustments (team_id, delta, category, reason, event_id, created_by) VALUES (?, ?, ?, ?, ?, ?)`, in.TeamID, in.Delta, in.Category, in.Reason, nullI64(in.EventID), nullID(userID)) if isForeignKeyViolation(err) { return adjustmentDTO{}, badRequest("teamId/eventId 不存在", nil) } if err != nil { return adjustmentDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return adjustmentDTO{}, internal(err) } if _, err := tx.ExecContext(ctx, `INSERT INTO score_ledger (team_id, event_id, source_type, source_id, points, created_by) VALUES (?, ?, 'adjustment', ?, ?, ?)`, in.TeamID, nullI64(in.EventID), id, in.Delta, nullID(userID)); err != nil { return adjustmentDTO{}, internal(err) } // 调整分进积分榜,必须写版本号,否则大屏不会刷新。 if err := s.bumpStandings(ctx, tx, userID); err != nil { return adjustmentDTO{}, err } if err := tx.Commit(); err != nil { return adjustmentDTO{}, internal(err) } s.audit(ctx, "adjustment.create", "adjustment", id, nil, in) return s.getAdjustment(ctx, id) } func (s *server) getAdjustment(ctx context.Context, id int64) (adjustmentDTO, error) { var ( a adjustmentDTO eventID, createdBy sql.NullInt64 ) err := s.ev.QueryRowContext(ctx, `SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at FROM point_adjustments WHERE id = ?`, id). Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return adjustmentDTO{}, notFound("人工调整不存在") } if err != nil { return adjustmentDTO{}, internal(err) } a.EventID = i64Ptr(eventID) a.CreatedBy = i64Ptr(createdBy) return a, nil } func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason string) (adjustmentDTO, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return adjustmentDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() var voided bool if err := tx.QueryRowContext(ctx, `SELECT voided FROM point_adjustments WHERE id = ?`, id).Scan(&voided); err != nil { if errors.Is(err, sql.ErrNoRows) { return adjustmentDTO{}, notFound("人工调整不存在") } return adjustmentDTO{}, internal(err) } if voided { return adjustmentDTO{}, stateInvalid("该人工调整已作废") } if _, err := tx.ExecContext(ctx, `UPDATE point_adjustments SET voided = 1, voided_by = ?, voided_at = datetime('now') WHERE id = ?`, nullID(userID), id); err != nil { return adjustmentDTO{}, internal(err) } if _, err := tx.ExecContext(ctx, `UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ? WHERE source_type = 'adjustment' AND source_id = ? AND voided = 0`, nullID(userID), reason, id); err != nil { return adjustmentDTO{}, internal(err) } if err := s.bumpStandings(ctx, tx, userID); err != nil { return adjustmentDTO{}, err } if err := tx.Commit(); err != nil { return adjustmentDTO{}, internal(err) } s.audit(ctx, "adjustment.void", "adjustment", id, map[string]any{"voided": false}, map[string]any{"voided": true, "reason": reason}) return s.getAdjustment(ctx, id) } // ---------- audit logs ---------- // listAuditLogs 支持 entityType / userId / action 三个过滤条件,任意组合。 // action 按子串模糊匹配:现场找的是「某类动作」(如 team.、result.publish), // 精确等值会逼操作员背全名。三个条件都进同一条 WHERE,组合时是 AND。 func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64, action string) (pageResult[auditLogDTO], error) { where := []string{"1 = 1"} args := []any{} if entityType != "" { where = append(where, "entity_type = ?") args = append(args, entityType) } if userID > 0 { where = append(where, "user_id = ?") args = append(args, userID) } if action != "" { // 转义 % / _ / \ 让它们按字面量匹配,避免用户输入被当成通配符。 where = append(where, `action LIKE ? ESCAPE '\'`) args = append(args, "%"+escapeLike(action)+"%") } clause := " WHERE " + joinAnd(where) var total int if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`+clause, args...).Scan(&total); err != nil { return pageResult[auditLogDTO]{}, internal(err) } query := `SELECT id, user_id, action, COALESCE(entity_type, ''), entity_id, before_json, after_json, ip, request_id, created_at FROM audit_logs` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?` args = append(args, size, (page-1)*size) rows, err := s.ev.QueryContext(ctx, query, args...) if err != nil { return pageResult[auditLogDTO]{}, internal(err) } defer func() { _ = rows.Close() }() items := make([]auditLogDTO, 0) for rows.Next() { var ( a auditLogDTO uid, eid sql.NullInt64 before sql.NullString after sql.NullString ip, reqID sql.NullString ) if err := rows.Scan(&a.ID, &uid, &a.Action, &a.EntityType, &eid, &before, &after, &ip, &reqID, &a.CreatedAt); err != nil { return pageResult[auditLogDTO]{}, internal(err) } a.UserID = i64Ptr(uid) a.EntityID = i64Ptr(eid) a.BeforeJSON = strPtr(before) a.AfterJSON = strPtr(after) a.IP = strPtr(ip) a.RequestID = strPtr(reqID) items = append(items, a) } if err := rows.Err(); err != nil { return pageResult[auditLogDTO]{}, internal(err) } return pageResult[auditLogDTO]{Items: items, Total: total, Page: page, Size: size}, nil } // escapeLike 把 LIKE 的通配符转义为字面量,配合 ESCAPE '\' 使用。 func escapeLike(s string) string { return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) } // ---------- backup ---------- // backup 生成系统库与赛事库的一致性快照(见 devdocs/08 第 6 节)。 // 手动备份写审计;定时备份不写(见 export.Service.StartAuto 的说明)。 func (s *server) backup(ctx context.Context) (backupResultDTO, error) { infos, err := s.backups.Run("manual") if err != nil { if errors.Is(err, export.ErrNoDir) { return backupResultDTO{}, internal(errors.New("未配置数据目录")) } return backupResultDTO{}, internal(err) } if len(infos) == 0 { return backupResultDTO{}, internal(errors.New("未配置数据目录")) } s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": infos[0].Path}) return backupResultDTO{Path: infos[0].Path, CreatedAt: infos[0].CreatedAt.UTC().Format(time.RFC3339)}, nil }