package realtime import ( "context" "database/sql" "encoding/json" "fmt" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // latestResultsLimit 控制快照里「最新成绩」的条数,避免大屏首包过大。 const latestResultsLimit = 20 // Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。 // 字段名与协议第 3 节一致。 type Snapshot struct { Version int64 `json:"version"` Settings map[string]json.RawMessage `json:"settings"` Teams []domain.Team `json:"teams"` Events []domain.Event `json:"events"` Standings []domain.StandingsRow `json:"standings"` Records []Record `json:"records"` LatestResults []LatestResult `json:"latestResults"` } // Record 是纪录榜的一行。 type Record struct { ID int64 `json:"id"` EventID int64 `json:"eventId"` Level string `json:"level"` ResultValue *float64 `json:"resultValue,omitempty"` ResultText string `json:"resultText,omitempty"` HolderName string `json:"holderName,omitempty"` HolderTeamID int64 `json:"holderTeamId,omitempty"` AchievedAt string `json:"achievedAt,omitempty"` IsCurrent bool `json:"isCurrent"` Source string `json:"source"` } // LatestResult 是已发布成绩的展示行,供大屏滚动与详情页使用。 type LatestResult struct { ID int64 `json:"id"` EventID int64 `json:"eventId"` EntryID int64 `json:"entryId"` TeamID int64 `json:"teamId"` AthleteID int64 `json:"athleteId,omitempty"` Rank int `json:"rank,omitempty"` ResultText string `json:"resultText,omitempty"` ResultValue *float64 `json:"resultValue,omitempty"` Unit string `json:"unit,omitempty"` IsTie bool `json:"isTie,omitempty"` Status string `json:"status"` State string `json:"state"` UpdatedAt string `json:"updatedAt,omitempty"` } // Snapshot 读取一份一致的完整状态。 // // 全部查询放在同一个只读事务里,且版本号取自同一事务, // 否则快照里的数据与版本号会错位,重连补齐时就会漏变更。 func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) { if h.db == nil { return nil, fmt.Errorf("实时中心未绑定赛事库") } tx, err := h.db.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("开启快照事务: %w", err) } defer func() { _ = tx.Rollback() }() snap := &Snapshot{ Settings: map[string]json.RawMessage{}, Teams: []domain.Team{}, Events: []domain.Event{}, Standings: []domain.StandingsRow{}, Records: []Record{}, LatestResults: []LatestResult{}, } if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&snap.Version); err != nil { return nil, fmt.Errorf("读取快照版本号: %w", err) } if err := loadSettings(ctx, tx, snap); err != nil { return nil, err } if err := loadTeams(ctx, tx, snap); err != nil { return nil, err } if err := loadEvents(ctx, tx, snap); err != nil { return nil, err } if err := loadStandings(ctx, tx, snap); err != nil { return nil, err } if err := loadRecords(ctx, tx, snap); err != nil { return nil, err } if err := loadLatestResults(ctx, tx, snap); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("提交快照事务: %w", err) } return snap, nil } func loadSettings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { rows, err := tx.QueryContext(ctx, `SELECT key, value FROM settings ORDER BY key`) if err != nil { return fmt.Errorf("读取赛事配置: %w", err) } defer func() { _ = rows.Close() }() for rows.Next() { var key, value string if err := rows.Scan(&key, &value); err != nil { return fmt.Errorf("解析赛事配置: %w", err) } snap.Settings[key] = rawJSONOrString(value) } return rows.Err() } func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { rows, err := tx.QueryContext(ctx, ` SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''), COALESCE(color, ''), sort_order, active FROM teams WHERE active = 1 ORDER BY sort_order, code`) if err != nil { return fmt.Errorf("读取单位: %w", err) } defer func() { _ = rows.Close() }() for rows.Next() { var ( t domain.Team active int ) if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil { return fmt.Errorf("解析单位: %w", err) } t.Active = active != 0 snap.Teams = append(snap.Teams, t) } return rows.Err() } func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { rows, err := tx.QueryContext(ctx, ` SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare, record_eligible, scored, multiplier, status, COALESCE(scheduled_at, ''), COALESCE(venue, ''), sort_order, COALESCE(remark, '') FROM events ORDER BY sort_order, id`) if err != nil { return fmt.Errorf("读取小项: %w", err) } defer func() { _ = rows.Close() }() for rows.Next() { var ( e domain.Event eventType string resultMode string resultType string compare string status string recordEligible int scored int ) if err := rows.Scan( &e.ID, &e.SportID, &e.Code, &e.Name, &eventType, &resultMode, &resultType, &compare, &recordEligible, &scored, &e.Multiplier, &status, &e.ScheduledAt, &e.Venue, &e.SortOrder, &e.Remark, ); err != nil { return fmt.Errorf("解析小项: %w", err) } e.EventType = domain.EventType(eventType) e.ResultMode = domain.ResultMode(resultMode) e.ResultType = domain.ResultType(resultType) e.Compare = domain.CompareMode(compare) e.Status = domain.EventStatus(status) e.RecordEligible = recordEligible != 0 e.Scored = scored != 0 snap.Events = append(snap.Events, e) } return rows.Err() } // loadStandings 复用 domain 的排名口径:积分来自流水,奖牌来自已发布名次, // 保证大屏看到的榜单与 REST /api/standings 一致。 func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { ledger, err := loadLedger(ctx, tx) if err != nil { return err } ranked, err := loadRanked(ctx, tx) if err != nil { return err } snap.Standings = domain.BuildTeamStandings(domain.StandingsInput{ Teams: snap.Teams, Ledger: ledger, Ranked: ranked, }) return nil } func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) { rows, err := tx.QueryContext(ctx, ` SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0), COALESCE(entry_id, 0), COALESCE(result_id, 0), source_type, source_id, points FROM score_ledger WHERE voided = 0 ORDER BY id`) if err != nil { return nil, fmt.Errorf("读取积分流水: %w", err) } defer func() { _ = rows.Close() }() var out []domain.LedgerEntry for rows.Next() { var ( e domain.LedgerEntry sourceType string ) if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &sourceType, &e.SourceID, &e.Points); err != nil { return nil, fmt.Errorf("解析积分流水: %w", err) } e.SourceType = domain.SourceType(sourceType) out = append(out, e) } return out, rows.Err() } // loadRanked 只取已发布的前三名,用于统计奖牌;名次明细的完整计算仍由成绩模块负责。 func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) { rows, err := tx.QueryContext(ctx, ` SELECT e.team_id, COALESCE(e.athlete_id, 0), r.rank FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.state = 'published' AND r.status = 'ok' AND r.rank BETWEEN 1 AND 3 ORDER BY r.id`) if err != nil { return nil, fmt.Errorf("读取奖牌名次: %w", err) } defer func() { _ = rows.Close() }() var out []domain.RankedResult for rows.Next() { var ( teamID, athleteID int64 rank int ) if err := rows.Scan(&teamID, &athleteID, &rank); err != nil { return nil, fmt.Errorf("解析奖牌名次: %w", err) } out = append(out, domain.RankedResult{ TeamID: teamID, AthleteID: athleteID, Rank: rank, Scope: domain.ScopeTeam, Shares: []domain.TeamShare{{TeamID: teamID, Weight: 1}}, }) } return out, rows.Err() } func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { rows, err := tx.QueryContext(ctx, ` SELECT id, event_id, level, result_value, COALESCE(result_text, ''), COALESCE(holder_name, ''), COALESCE(holder_team_id, 0), COALESCE(achieved_at, ''), is_current, source FROM records WHERE is_current = 1 ORDER BY event_id, level, id`) if err != nil { return fmt.Errorf("读取纪录榜: %w", err) } defer func() { _ = rows.Close() }() for rows.Next() { var ( r Record value sql.NullFloat64 isCurrent int ) if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &value, &r.ResultText, &r.HolderName, &r.HolderTeamID, &r.AchievedAt, &isCurrent, &r.Source); err != nil { return fmt.Errorf("解析纪录: %w", err) } if value.Valid { v := value.Float64 r.ResultValue = &v } r.IsCurrent = isCurrent != 0 snap.Records = append(snap.Records, r) } return rows.Err() } func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { rows, err := tx.QueryContext(ctx, ` SELECT r.id, r.event_id, r.entry_id, e.team_id, COALESCE(e.athlete_id, 0), COALESCE(r.rank, 0), COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status, r.state, COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, '') FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.state = 'published' ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at) DESC, r.id DESC LIMIT ?`, latestResultsLimit) if err != nil { return fmt.Errorf("读取最新成绩: %w", err) } defer func() { _ = rows.Close() }() for rows.Next() { var ( lr LatestResult value sql.NullFloat64 isTie int ) if err := rows.Scan(&lr.ID, &lr.EventID, &lr.EntryID, &lr.TeamID, &lr.AthleteID, &lr.Rank, &lr.ResultText, &value, &lr.Unit, &isTie, &lr.Status, &lr.State, &lr.UpdatedAt); err != nil { return fmt.Errorf("解析最新成绩: %w", err) } if value.Valid { v := value.Float64 lr.ResultValue = &v } lr.IsTie = isTie != 0 snap.LatestResults = append(snap.LatestResults, lr) } return rows.Err() } // rawJSONOrString 让 settings 的值保持原样:本来就是 JSON 的直接透传, // 纯文本则编码成 JSON 字符串,客户端统一按 JSON 解析。 func rawJSONOrString(value string) json.RawMessage { if json.Valid([]byte(value)) { return json.RawMessage(value) } quoted, err := json.Marshal(value) if err != nil { return json.RawMessage(`null`) } return quoted }