package httpapi import ( "context" "database/sql" "encoding/json" "errors" "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/rules" ) // queryer 让计分相关的加载函数既能走连接池(只读)也能走事务(发布时读写一致)。 type queryer interface { QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } // ---------- 加载:DB 行 → domain 输入 ---------- func loadEventQ(ctx context.Context, q queryer, eventID int64) (domain.Event, error) { var ( e domain.Event scheduledAt sql.NullString venue, remark sql.NullString ) err := q.QueryRowContext(ctx, `SELECT 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 FROM events WHERE id = ?`, eventID). 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, &remark, &e.AvgMode) if errors.Is(err, sql.ErrNoRows) { return domain.Event{}, notFound("小项不存在") } if err != nil { return domain.Event{}, internal(err) } e.ScheduledAt = scheduledAt.String e.Venue = venue.String e.Remark = remark.String return e, nil } func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.EventCategory, error) { rows, err := q.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([]domain.EventCategory, 0) for rows.Next() { var ( c domain.EventCategory 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 = gender.String if ageMin.Valid { v := int(ageMin.Int64) c.AgeMin = &v } if ageMax.Valid { v := int(ageMax.Int64) c.AgeMax = &v } out = append(out, c) } return out, rows.Err() } func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) { rows, err := q.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([]domain.ScoreChannel, 0) for rows.Next() { var ( c domain.ScoreChannel 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) } // 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分)。 if countToTeam == 0 { c.CountToTeam = boolPtr(false) } out = append(out, c) } return out, rows.Err() } func loadProfilesQ(ctx context.Context, q queryer, ids map[int64]bool) (map[int64]domain.ScoringProfile, error) { out := make(map[int64]domain.ScoringProfile, len(ids)) for id := range ids { var p domain.ScoringProfile err := q.QueryRowContext(ctx, `SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero FROM scoring_profiles WHERE id = ?`, id). Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero) if errors.Is(err, sql.ErrNoRows) { return nil, badRequest("积分模板不存在", id) } if err != nil { return nil, internal(err) } rows, err := q.QueryContext(ctx, `SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, id) if err != nil { return nil, internal(err) } rules := make([]domain.ScoringRule, 0) for rows.Next() { var r domain.ScoringRule if err := rows.Scan(&r.Rank, &r.Points); err != nil { _ = rows.Close() return nil, internal(err) } rules = append(rules, r) } err = rows.Err() _ = rows.Close() if err != nil { return nil, internal(err) } p.Rules = rules out[id] = p } return out, nil } func loadEntriesQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Entry, error) { query := `SELECT e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names, e.status, COALESCE(a.name, '') FROM entries e LEFT JOIN athletes a ON a.id = e.athlete_id WHERE e.event_id = ?` args := []any{eventID} if categoryID > 0 { query += ` AND e.category_id = ?` args = append(args, categoryID) } query += ` ORDER BY e.id` rows, err := q.QueryContext(ctx, query, args...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]domain.Entry, 0) for rows.Next() { var ( e domain.Entry athleteID sql.NullInt64 memberNames sql.NullString ) if err := rows.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames, &e.Status, &e.AthleteName); err != nil { return nil, internal(err) } if athleteID.Valid { e.AthleteID = athleteID.Int64 } e.MemberNames = parseNames(memberNames) out = append(out, e) } return out, rows.Err() } func loadResultsQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Result, error) { query := `SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie, r.points_override, r.status, r.state, r.note FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.event_id = ?` args := []any{eventID} if categoryID > 0 { query += ` AND e.category_id = ?` args = append(args, categoryID) } query += ` ORDER BY r.id` rows, err := q.QueryContext(ctx, query, args...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]domain.Result, 0) for rows.Next() { var ( r domain.Result rank sql.NullInt64 text, unit, note sql.NullString value, override sql.NullFloat64 ) if err := rows.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit, &r.IsTie, &override, &r.Status, &r.State, ¬e); err != nil { return nil, internal(err) } if rank.Valid { r.Rank = int(rank.Int64) } r.ResultText = text.String if value.Valid { v := value.Float64 r.ResultValue = &v } r.Unit = unit.String if override.Valid { v := override.Float64 r.PointsOverride = &v } r.Note = note.String out = append(out, r) } return out, rows.Err() } func loadRecordBonusQ(ctx context.Context, q queryer) (domain.RecordBonusConfig, error) { values, err := loadSettingsMap(ctx, q) if err != nil { return domain.RecordBonusConfig{}, err } // 统一走 rules 的解析:enabled / mode / levels 三个键必须一起读, // 只读前两个会让 levelPoints 永远查不到档位,破纪录加分恒为 0。 return rules.RecordBonusFromSettings(values), nil } func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain.RecordBreak, error) { rows, err := q.QueryContext(ctx, `SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state FROM record_breaks WHERE event_id = ? AND state = 'approved'`, eventID) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]domain.RecordBreak, 0) for rows.Next() { var ( b domain.RecordBreak value sql.NullFloat64 text sql.NullString ) if err := rows.Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State); err != nil { return nil, internal(err) } b.ResultValue = value.Float64 b.ResultText = text.String out = append(out, b) } return out, rows.Err() } // ---------- 计分 ---------- // computeEventScore 计算某小项(或某组别)的流水与名次明细。 func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64) (domain.EventScore, error) { ev, err := loadEventQ(ctx, q, eventID) if err != nil { return domain.EventScore{}, err } cats, err := loadCategoriesQ(ctx, q, eventID) if err != nil { return domain.EventScore{}, err } channels, err := loadChannelsQ(ctx, q, eventID) if err != nil { return domain.EventScore{}, err } profileIDs := make(map[int64]bool) for _, ch := range channels { profileIDs[ch.ProfileID] = true } profiles, err := loadProfilesQ(ctx, q, profileIDs) if err != nil { return domain.EventScore{}, err } entries, err := loadEntriesQ(ctx, q, eventID, categoryID) if err != nil { return domain.EventScore{}, err } results, err := loadResultsQ(ctx, q, eventID, categoryID) if err != nil { return domain.EventScore{}, err } bonusCfg, err := loadRecordBonusQ(ctx, q) if err != nil { return domain.EventScore{}, err } breaks, err := loadRecordBreaksQ(ctx, q, eventID) if err != nil { return domain.EventScore{}, err } var total domain.EventScore for _, cat := range cats { if categoryID > 0 && cat.ID != categoryID { continue } score, err := domain.ScoreEvent(domain.EventScoreInput{ Event: ev, Category: cat, Channels: channels, Profiles: profiles, Entries: entries, Results: results, RecordBonus: &bonusCfg, RecordBreaks: breaks, }) if err != nil { return domain.EventScore{}, mapDomainError(err) } total.Ledger = append(total.Ledger, score.Ledger...) total.Ranked = append(total.Ranked, score.Ranked...) } return total, nil } // recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。 // 作废范围是引擎产出的成绩分与破纪录加分;人工调整由 point_adjustments 管理,绝不在此作废。 // 聚合通道的流水 entry_id/result_id 都为空(名次主体是单位/运动员,不对应单条报名项), // 所以按组别重算时不能只靠 entry_id IN (...) 定位,否则 NULL 永不命中、旧流水存活, // 重算再插一份导致该通道分数翻倍。聚合流水本身不归属组别,且重建通道会换掉通道 id // (source_id 随之失效),故按 event_id 全量作废;下方补算整项聚合流水时会重新插入全量。 func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) { score, err := computeEventScore(ctx, tx, eventID, categoryID) if err != nil { return domain.EventScore{}, err } if err := writeLedgerTx(ctx, tx, score, eventID, categoryID, actorID, reason); err != nil { return domain.EventScore{}, err } return score, nil } // writeLedgerTx 把已算好的 score 落成流水:先作废范围内旧流水,再插入新流水。 // // 与 recomputeLedgerTx 拆开是为了「延迟结算」:那时名次与流水都要等整组录完才落, // 但分数仍要能算出来(结算时才算,见 publishResults)。计算不读 score_ledger, // 所以「先算后作废」与老的「先作废后算」结果完全一致。 func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID, actorID int64, reason string) error { scope := `source_type IN ('result', 'record_bonus') AND event_id = ?` args := []any{eventID} if categoryID > 0 { scope += ` AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?) OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'))` args = append(args, categoryID) } if _, err := tx.ExecContext(ctx, `UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ? WHERE voided = 0 AND `+scope, append([]any{nullID(actorID), reason}, args...)...); err != nil { return internal(err) } ledger := score.Ledger if categoryID > 0 { // 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废; // 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。 hasAggregate, err := hasAggregateChannel(ctx, tx, eventID) if err != nil { return err } if hasAggregate { all, err := computeEventScore(ctx, tx, eventID, 0) if err != nil { return err } ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger)) for _, l := range score.Ledger { if !isAggregateLedger(l) { ledger = append(ledger, l) } } for _, l := range all.Ledger { if isAggregateLedger(l) { ledger = append(ledger, l) } } } } for _, l := range ledger { if _, err := tx.ExecContext(ctx, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, count_to_team, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID), l.SourceType, l.SourceID, l.Points, boolToInt(l.CountToTeam), nullID(actorID)); err != nil { return internal(err) } } return nil } // isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员, // 落库时 entry_id 与 result_id 都为空。 func isAggregateLedger(l domain.LedgerEntry) bool { return l.SourceType == domain.SourceResult && l.EntryID == 0 && l.ResultID == 0 } // hasAggregateChannel 判断小项是否配置了聚合通道;没有时按组别重算无需补算整项。 func hasAggregateChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, error) { var n int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM event_score_channels WHERE event_id = ? AND basis = 'aggregate'`, eventID).Scan(&n); err != nil { return false, internal(err) } return n > 0, nil } // applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。 // 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。 func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error { best := make(map[int64]domain.RankedResult) for _, rr := range ranked { if rr.ResultID == 0 || rr.Rank <= 0 { continue } cur, ok := best[rr.ResultID] if !ok || (cur.Scope == domain.ScopeTeam && rr.Scope == domain.ScopeAthlete) { best[rr.ResultID] = rr } } for id, rr := range best { if _, err := tx.ExecContext(ctx, `UPDATE results SET rank = ? WHERE id = ?`, rr.Rank, id); err != nil { return internal(err) } } return nil } // ---------- 发布 / 撤销 / 作废 ---------- // publishableResultCond 判断一条 results 行是否承载了「可以上屏的成绩」: // - 有解析出来的成绩值(计时 / 距离 / 计数 / 评分项目); // - 有手工名次(resultMode=manual 或 compare=none 的项目,名次本身就是成绩); // - 状态不是 ok(DNS / DQ / DNF / 退赛 也是现场明确给出的结果,要能上屏)。 // // 空且状态为 ok 的行只是「还没跑 / 还没录」,发布时必须留在 draft: // 分批比赛时若把没跑的人一起置为 published,大屏的最新成绩会出现空行, // 而且编辑页会把整组当成「已发布」锁死,补录无门。 const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')` // publishableResult 是 publishableResultCond 的 Go 版判据,供分批结算的纯函数使用。 // 两处判据必须同源:SQL 那份决定哪些行能被发布,这份决定「这一条算不算已经出成绩」。 func publishableResult(r domain.Result) bool { return r.ResultValue != nil || r.Rank > 0 || r.Status != domain.ResultStatusOK } // groupStats 统计某组「该出成绩的报名项数」与「已经有可发布成绩的项数」。 // // 非 registered 的报名项(scratched / dq / dns)本来就不该等它出成绩: // 计分把非 registered 视为 0 分且不占名次,等它只会让整组永远结算不了。 func groupStats(entries []domain.Entry, results []domain.Result) (total, published int) { byEntry := make(map[int64]domain.Result, len(results)) for _, r := range results { byEntry[r.EntryID] = r } for _, e := range entries { if e.Status != domain.EntryStatusRegistered { continue } total++ if r, ok := byEntry[e.ID]; ok && r.State == domain.ResultStatePublished && publishableResult(r) { published++ } } return total, published } // groupOf 从整项的行里挑出某个组别的报名与成绩。 // 成绩表本身不带组别,只能靠报名项 id 反查,所以先收组内报名 id 再过滤成绩。 func groupOf(entries []domain.Entry, results []domain.Result, categoryID int64) ([]domain.Entry, []domain.Result) { inGroup := make(map[int64]bool) groupEntries := make([]domain.Entry, 0, len(entries)) for _, e := range entries { if e.CategoryID != categoryID { continue } inGroup[e.ID] = true groupEntries = append(groupEntries, e) } groupResults := make([]domain.Result, 0, len(groupEntries)) for _, r := range results { if inGroup[r.EntryID] { groupResults = append(groupResults, r) } } return groupEntries, groupResults } // scopeStats 按发布 / 结算的作用域累加 groupStats:categoryID > 0 只看该组别, // categoryID = 0 看整项(逐组别累加,任何一组没录完都算没录完)。 func scopeStats(ctx context.Context, q queryer, eventID, categoryID int64) (total, published int, err error) { entries, err := loadEntriesQ(ctx, q, eventID, 0) if err != nil { return 0, 0, err } results, err := loadResultsQ(ctx, q, eventID, 0) if err != nil { return 0, 0, err } categories := []int64{categoryID} if categoryID == 0 { cats, err := loadCategoriesQ(ctx, q, eventID) if err != nil { return 0, 0, err } categories = make([]int64, 0, len(cats)) for _, cat := range cats { categories = append(categories, cat.ID) } } for _, id := range categories { groupEntries, groupResults := groupOf(entries, results, id) t, p := groupStats(groupEntries, groupResults) total += t published += p } return total, published, nil } // scopeSettled 判断作用域内是否「所有批次都录完」,即可以结算。 // 没有报名项(total = 0)视为录完:没有要等的东西,结算一次把范围清干净。 func scopeSettled(ctx context.Context, q queryer, eventID, categoryID int64) (bool, error) { total, published, err := scopeStats(ctx, q, eventID, categoryID) if err != nil { return false, err } return published == total, nil } // liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。 func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) { var raw string err := q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, dto.SettingKeyLiveOnPublish).Scan(&raw) if err != nil && !errors.Is(err, sql.ErrNoRows) { return false, internal(err) } return boolDefault(raw, true), nil } func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { if in.EventID == 0 { return nil, badRequest("eventId 必填", nil) } catID := int64(0) if in.CategoryID != nil { catID = *in.CategoryID } // 发布是不可逆的对外动作,先留一份快照;失败只记日志与审计,不阻塞发布(08 第 6 节)。 s.backupBeforePublish(ctx) tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return nil, internal(err) } defer func() { _ = tx.Rollback() }() before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) if err != nil { return nil, err } // 只发布「已有内容」的成绩行:现场分批比赛时(如 100 米预赛分 4 组、每组 8 条跑道), // 只跑完一组就要先把这一组上屏,没跑/没录的行必须留在 draft,不能被一起发布。 // 判据见 publishableResultCond。名次与积分是否当场落库,取决于「发布后是否立即结算」 // (见下方 liveOnPublishQ):实时模式按已发布部分重算,后续再发一批会自动纳入。 setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'), updated_at = datetime('now') WHERE event_id = ? AND ` + publishableResultCond args := []any{nullID(userID), in.EventID} if catID > 0 { setPublished += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)` args = append(args, catID) } if _, err := tx.ExecContext(ctx, setPublished, args...); err != nil { return nil, internal(err) } live, err := liveOnPublishQ(ctx, tx) if err != nil { return nil, err } settle := live if !live { // 延迟结算:等该 (小项, 组别) 所有批次都录完才结算一次。 // 结算时按整组重算,之前各批的成绩一起纳入,避免名次停留在分批算的口径。 settle, err = scopeSettled(ctx, tx, in.EventID, catID) if err != nil { return nil, err } } if settle { // 名次也要等结算才写:奖牌榜统计读的就是 results.rank(见 buildRanked), // 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动, // 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。 score, err := computeEventScore(ctx, tx, in.EventID, catID) if err != nil { return nil, err } if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { return nil, err } if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil { return nil, err } } if catID == 0 { if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil { return nil, internal(err) } } after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) if err != nil { return nil, err } if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, ""); err != nil { return nil, err } if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { return nil, err } // 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。 if settle { if err := s.bumpStandings(ctx, tx, userID); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, internal(err) } s.audit(ctx, "result.publish", "event", in.EventID, before, after) return s.resultsByEvent(ctx, in.EventID) } // backupBeforePublish 在发布前留一份快照;失败只记日志与审计,不阻塞发布。 func (s *server) backupBeforePublish(ctx context.Context) { infos, err := s.backups.RunEvent("publish") if err != nil { if errors.Is(err, export.ErrNoDir) { return // 未配置数据目录:静默跳过,不写失败审计 } s.logger.Error("发布前备份失败", "err", err) s.audit(ctx, "system.backup_failed", "backup", 0, nil, map[string]any{"reason": "publish", "error": err.Error()}) return } if len(infos) == 0 { return } s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"reason": "publish", "path": infos[0].Path}) } func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { if in.EventID == 0 { return nil, badRequest("eventId 必填", nil) } catID := int64(0) if in.CategoryID != nil { catID = *in.CategoryID } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return nil, internal(err) } defer func() { _ = tx.Rollback() }() before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) if err != nil { return nil, err } query := `UPDATE results SET state = 'draft', verified_by = NULL, verified_at = NULL, updated_at = datetime('now') WHERE event_id = ? AND state = 'published'` args := []any{in.EventID} if catID > 0 { query += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)` args = append(args, catID) } if _, err := tx.ExecContext(ctx, query, args...); err != nil { return nil, internal(err) } // 撤销发布是回滚动作,延迟结算也要立刻重算:这一组已经没有已发布成绩, // 留着上一次结算写下的流水就是「榜上有分、成绩却撤了」。重算本身会把旧流水作废、 // 按当前已发布内容重写,之后再次发布时判据回到「未结算」,行为自然衔接。 if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil { return nil, err } if catID == 0 { if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'finished' WHERE id = ?`, in.EventID); err != nil { return nil, internal(err) } } after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) if err != nil { return nil, err } if err := recordRevision(ctx, tx, in.EventID, "unpublish", after, userID, ""); err != nil { return nil, err } if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { return nil, err } if err := s.bumpStandings(ctx, tx, userID); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, internal(err) } s.audit(ctx, "result.unpublish", "event", in.EventID, before, after) return s.resultsByEvent(ctx, in.EventID) } func (s *server) voidResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) { if reason == "" { return resultDTO{}, badRequest("reason 必填", nil) } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return resultDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() var ( before resultDTO eventID int64 catID int64 ) row := tx.QueryRowContext(ctx, `SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie, r.points_override, r.status, r.state, r.recorded_by, r.recorded_at, r.verified_by, r.verified_at, r.note, e.category_id FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.id = ?`, resultID) var ( rank, recordedBy, verifiedBy sql.NullInt64 text, unit, recordedAt, verifiedAt, note sql.NullString value, override sql.NullFloat64 ) if err := row.Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie, &override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, ¬e, &catID); err != nil { if errors.Is(err, sql.ErrNoRows) { return resultDTO{}, notFound("成绩不存在") } return resultDTO{}, internal(err) } before.EventID = eventID before.Rank = intPtr(rank) before.ResultText = text.String before.ResultValue = f64Ptr(value) before.Unit = unit.String before.PointsOverride = f64Ptr(override) before.RecordedBy = i64Ptr(recordedBy) before.RecordedAt = strPtr(recordedAt) before.VerifiedBy = i64Ptr(verifiedBy) before.VerifiedAt = strPtr(verifiedAt) before.Note = strPtr(note) if _, err := tx.ExecContext(ctx, `UPDATE results SET status = 'withdrawn', note = ?, updated_at = datetime('now') WHERE id = ?`, reason, resultID); err != nil { return resultDTO{}, internal(err) } // 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。 // withdrawn 仍是「可发布成绩」,作废不会让该组变成没录完,所以录完过的组照样能结算; // 没录完的组按延迟结算处理:名次与流水都等结算,只把作废这件事落库。 settle := true if live, err := liveOnPublishQ(ctx, tx); err != nil { return resultDTO{}, err } else if !live { settle, err = scopeSettled(ctx, tx, eventID, catID) if err != nil { return resultDTO{}, err } } if settle { score, err := computeEventScore(ctx, tx, eventID, catID) if err != nil { return resultDTO{}, err } if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { return resultDTO{}, err } if err := writeLedgerTx(ctx, tx, score, eventID, catID, userID, "作废成绩:"+reason); err != nil { return resultDTO{}, err } } after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID)) if err != nil { return resultDTO{}, internal(err) } if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil { return resultDTO{}, err } if settle { if err := s.bumpStandings(ctx, tx, userID); err != nil { return resultDTO{}, err } } if err := tx.Commit(); err != nil { return resultDTO{}, internal(err) } s.audit(ctx, "result.void", "result", resultID, before, after) return s.getResult(ctx, resultID) } // ---------- 分批结算 ---------- // settlementGroupDTO 是「未结算的 (小项, 组别)」:延迟结算下已发布成绩还没进榜单的那一组。 // Required / Published 只数「该出成绩的报名项」,弃权 / 失格不计入,与结算判据同源。 type settlementGroupDTO struct { EventID int64 `json:"eventId"` CategoryID int64 `json:"categoryId"` CategoryName string `json:"categoryName"` Required int `json:"required"` Published int `json:"published"` } // settlementStatusDTO 是 GET /api/results/settlement 的线上结构。 type settlementStatusDTO struct { LiveOnPublish bool `json:"liveOnPublish"` Groups []settlementGroupDTO `json:"groups"` } // settlementStatus 列出范围内尚未结算的组别,供录入页提示「榜单还没反映这批」。 // 实时结算(默认)时清单恒为空:那种模式下每次发布都当场结算了。 func (s *server) settlementStatus(ctx context.Context, eventID, catID int64) (settlementStatusDTO, error) { out := settlementStatusDTO{Groups: []settlementGroupDTO{}} live, err := liveOnPublishQ(ctx, s.ev) if err != nil { return out, err } out.LiveOnPublish = live if live { return out, nil } categories, err := loadCategoriesQ(ctx, s.ev, eventID) if err != nil { return out, err } entries, err := loadEntriesQ(ctx, s.ev, eventID, 0) if err != nil { return out, err } results, err := loadResultsQ(ctx, s.ev, eventID, 0) if err != nil { return out, err } for _, cat := range categories { if catID > 0 && cat.ID != catID { continue } groupEntries, groupResults := groupOf(entries, results, cat.ID) required, published := groupStats(groupEntries, groupResults) // 一条都没发布 = 还没开始结算;已录完 = 下次发布就会结算(或被立即结算过)。 if required == 0 || published == 0 || published == required { continue } out.Groups = append(out.Groups, settlementGroupDTO{ EventID: eventID, CategoryID: cat.ID, CategoryName: cat.Name, Required: required, Published: published, }) } return out, nil } // settleResults 立即结算:无视延迟结算设置,按当前已发布成绩重算积分流水并刷新榜单。 // // 这是现场安全绳:只要有一条成绩永远录不出来(退赛却没标 scratched), // 延迟结算就永远不会自动触发,必须有一个显式入口把已发布部分结算掉。 func (s *server) settleResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) { if in.EventID == 0 { return nil, badRequest("eventId 必填", nil) } catID := int64(0) if in.CategoryID != nil { catID = *in.CategoryID } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return nil, internal(err) } defer func() { _ = tx.Rollback() }() before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) if err != nil { return nil, err } score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "立即结算") if err != nil { return nil, err } if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { return nil, err } after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID) if err != nil { return nil, err } if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil { return nil, err } if err := s.bumpStandings(ctx, tx, userID); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, internal(err) } s.audit(ctx, "result.settle", "event", in.EventID, before, after) return s.resultsByEvent(ctx, in.EventID) } // ---------- 变更日志与版本 ---------- func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action string, results []resultDTO, actorID int64, message string) error { snapshot, err := json.Marshal(results) if err != nil { return internal(err) } // 父节点 = 该小项最近一次版本,串成一条主线;撤销后再发布会自然从撤销点分出新分支。 var parentID sql.NullInt64 if err := tx.QueryRowContext(ctx, `SELECT MAX(id) FROM result_revisions WHERE event_id = ?`, eventID).Scan(&parentID); err != nil { return internal(err) } _, err = tx.ExecContext(ctx, `INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, ledger_ids, actor_id, message) VALUES (?, ?, ?, ?, '[]', ?, ?)`, parentID, eventID, action, string(snapshot), nullID(actorID), message) if err != nil { return internal(err) } return nil } func recordChanges(ctx context.Context, tx *sql.Tx, entity string, items []resultDTO, action string, actorID int64) error { for _, item := range items { payload, err := json.Marshal(item) if err != nil { return internal(err) } if err := bumpVersion(ctx, tx, entity, item.ID, action, json.RawMessage(payload), actorID); err != nil { return err } } return nil } // bumpVersion 写一条 change_log;id 即实时协议的 version。 func bumpVersion(ctx context.Context, tx *sql.Tx, entityType string, entityID int64, action string, payload any, actorID int64) error { raw := []byte("{}") if payload != nil { b, err := json.Marshal(payload) if err != nil { return internal(err) } raw = b } var eid any if entityID != 0 { eid = entityID } if _, err := tx.ExecContext(ctx, `INSERT INTO change_log (entity_type, entity_id, action, payload_json, actor_id) VALUES (?, ?, ?, ?, ?)`, entityType, eid, action, string(raw), nullID(actorID)); err != nil { return internal(err) } return nil } // audit 登记审计(由 middleware.Auditor 在请求成功后落库)。 func (s *server) audit(ctx context.Context, action, entityType string, entityID int64, before, after any) { middleware.Record(ctx, middleware.AuditEntry{ Action: action, EntityType: entityType, EntityID: entityID, Before: before, After: after, }) }