package realtime import ( "context" "database/sql" "encoding/json" "errors" "fmt" "sort" "strconv" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" ) // latestResultsLimit 控制快照里「最新成绩」的行数上限,避免大屏首包过大。 // 行会按「小项 + 组别」聚合后再下发,因此组数只会比这个值更少。 const latestResultsLimit = 20 // Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。 // 字段名与协议第 3 节一致;settings/standings/latestResults 的形状必须与 REST 同构, // 否则大屏收到首包就会按错误的键名渲染。 type Snapshot struct { Version int64 `json:"version"` Settings dto.EventSettings `json:"settings"` Teams []domain.Team `json:"teams"` Events []domain.Event `json:"events"` Standings []dto.StandingRow `json:"standings"` Records []Record `json:"records"` LatestResults []dto.EventResultGroup `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"` } // 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{ Teams: []domain.Team{}, Events: []domain.Event{}, Standings: []dto.StandingRow{}, Records: []Record{}, LatestResults: []dto.EventResultGroup{}, } if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&snap.Version); err != nil { return nil, fmt.Errorf("读取快照版本号: %w", err) } values, err := loadSettings(ctx, tx) if err != nil { return nil, err } if err := fillSettings(ctx, tx, values, 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, values); 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 } // loadSettings 原样读出 settings 表的键值:榜单口径要用原始字符串, // 而线上结构需要聚合后的嵌套对象,两者由调用方分别消费。 func loadSettings(ctx context.Context, tx *sql.Tx) (map[string]string, error) { rows, err := tx.QueryContext(ctx, `SELECT key, value FROM settings`) if err != nil { return nil, fmt.Errorf("读取赛事配置: %w", err) } defer func() { _ = rows.Close() }() out := make(map[string]string) for rows.Next() { var key, value string if err := rows.Scan(&key, &value); err != nil { return nil, fmt.Errorf("解析赛事配置: %w", err) } out[key] = value } return out, rows.Err() } // fillSettings 聚合成与 REST /api/settings 相同的嵌套结构。 // defaultProfileId 在 settings 表里存的是模板 code,需要反查一次主键。 func fillSettings(ctx context.Context, tx *sql.Tx, values map[string]string, snap *Snapshot) error { snap.Settings = dto.ParseEventSettings(values) code := values["scoring.default_profile"] if code == "" { return nil } var id int64 err := tx.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id) switch { case errors.Is(err, sql.ErrNoRows): // 配置引用的模板已被删除:保持 null,与 REST 一致,由管理端提示重选。 case err != nil: return fmt.Errorf("解析默认积分模板: %w", err) default: snap.Settings.DefaultProfileID = &id } return nil } 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, values map[string]string) error { ledger, err := loadLedger(ctx, tx) if err != nil { return err } ranked, err := loadRanked(ctx, tx) if err != nil { return err } rows := domain.BuildTeamStandings(domain.StandingsInput{ Teams: snap.Teams, Ledger: ledger, Ranked: ranked, IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1), ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true), }) snap.Standings = dto.StandingRows(rows, snap.Teams) 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 与 REST 的 buildRanked 同源:必须 join event_score_channels, // 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌。 func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) { rows, err := tx.QueryContext(ctx, ` SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status, e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id FROM results r JOIN entries e ON e.id = r.entry_id JOIN event_score_channels c ON c.event_id = r.event_id LEFT JOIN athletes a ON a.id = e.athlete_id WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok' AND (c.scope = 'team' OR e.athlete_id IS NOT NULL) ORDER BY r.id, c.sort_order, c.id`) if err != nil { return nil, fmt.Errorf("读取奖牌名次: %w", err) } defer func() { _ = rows.Close() }() var out []domain.RankedResult for rows.Next() { var ( rr domain.RankedResult value sql.NullFloat64 athleteID sql.NullInt64 rank int ) if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status, &rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil { return nil, fmt.Errorf("解析奖牌名次: %w", err) } rr.Rank = rank if value.Valid { v := value.Float64 rr.ResultValue = &v } rr.AthleteID = athleteID.Int64 rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}} out = append(out, rr) } 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() } // loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。 // 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。 func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error { rows, err := tx.QueryContext(ctx, ` SELECT r.event_id, COALESCE(ev.code, ''), COALESCE(ev.name, ''), e.category_id, COALESCE(cat.name, ''), COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''), r.entry_id, r.rank, e.team_id, COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''), COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status FROM results r JOIN entries e ON e.id = r.entry_id JOIN events ev ON ev.id = r.event_id LEFT JOIN event_categories cat ON cat.id = e.category_id LEFT JOIN teams t ON t.id = e.team_id LEFT JOIN athletes a ON a.id = e.athlete_id WHERE r.state = 'published' ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC LIMIT ?`, latestResultsLimit) if err != nil { return fmt.Errorf("读取最新成绩: %w", err) } defer func() { _ = rows.Close() }() type groupKey struct { eventID int64 categoryID int64 } index := make(map[groupKey]int) for rows.Next() { var ( eventID, categoryID int64 eventCode, eventName string categoryName string publishedAt string row dto.EventResultRow rank sql.NullInt64 value sql.NullFloat64 athleteID sql.NullInt64 athleteName string shortName string memberNames string isTie int ) if err := rows.Scan(&eventID, &eventCode, &eventName, &categoryID, &categoryName, &publishedAt, &row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName, &athleteID, &athleteName, &memberNames, &row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil { return fmt.Errorf("解析最新成绩: %w", err) } if rank.Valid { v := int(rank.Int64) row.Rank = &v } if value.Valid { v := value.Float64 row.ResultValue = &v } if athleteID.Valid && athleteID.Int64 != 0 { v := athleteID.Int64 row.AthleteID = &v } row.AthleteName = nilIfEmpty(athleteName) row.ShortName = nilIfEmpty(shortName) row.MemberNames = parseMemberNames(memberNames) row.IsTie = isTie != 0 key := groupKey{eventID: eventID, categoryID: categoryID} i, ok := index[key] if !ok { i = len(snap.LatestResults) index[key] = i snap.LatestResults = append(snap.LatestResults, dto.EventResultGroup{ EventID: eventID, EventCode: eventCode, EventName: eventName, CategoryID: categoryID, CategoryName: categoryName, PublishedAt: nilIfEmpty(publishedAt), Rows: []dto.EventResultRow{}, }) } snap.LatestResults[i].Rows = append(snap.LatestResults[i].Rows, row) } if err := rows.Err(); err != nil { return err } // 行按发布时间倒序取回,组内需改回名次顺序;无名次的排最后。 for i := range snap.LatestResults { sort.SliceStable(snap.LatestResults[i].Rows, func(a, b int) bool { ra, rb := snap.LatestResults[i].Rows[a], snap.LatestResults[i].Rows[b] switch { case ra.Rank == nil && rb.Rank == nil: return ra.EntryID < rb.EntryID case ra.Rank == nil: return false case rb.Rank == nil: return true default: return *ra.Rank < *rb.Rank } }) } return nil } // parseMemberNames 解析 entries.member_names;空值或坏 JSON 一律当作「无成员名单」。 func parseMemberNames(raw string) []string { if raw == "" { return nil } var out []string if err := json.Unmarshal([]byte(raw), &out); err != nil { return nil } return out } func nilIfEmpty(s string) *string { if s == "" { return nil } return &s } 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 } }