package httpapi import ( "context" "database/sql" "encoding/json" "errors" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" "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 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) 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 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 if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil { return nil, internal(err) } 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) { 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 domain.EventScore{}, internal(err) } score, err := computeEventScore(ctx, tx, eventID, categoryID) if err != nil { return domain.EventScore{}, err } ledger := score.Ledger if categoryID > 0 { // 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废; // 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。 hasAggregate, err := hasAggregateChannel(ctx, tx, eventID) if err != nil { return domain.EventScore{}, err } if hasAggregate { all, err := computeEventScore(ctx, tx, eventID, 0) if err != nil { return domain.EventScore{}, 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, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID), l.SourceType, l.SourceID, l.Points, nullID(actorID)); err != nil { return domain.EventScore{}, internal(err) } } return score, 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 } // ---------- 发布 / 撤销 / 作废 ---------- 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 } setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'), updated_at = datetime('now') WHERE event_id = ?` 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) } 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 } 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 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) } // 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。 score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason) if err != nil { return resultDTO{}, err } if err := applyComputedRanks(ctx, tx, score.Ranked); 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 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) } // ---------- 变更日志与版本 ---------- 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, }) }