package httpapi import ( "context" "database/sql" "errors" "net/http" "sort" "strings" "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" ) // 「实时赛况」大屏页的当前项目判定。 // // 现场节奏:一个项目跑完一批就发布一批,下一场开始前大屏要一直显示上一场。 // 判定顺序(display.live_source=auto 时): // 1. 人工指定了 display.live_events → 用人工指定的(最多 2 个); // 2. 否则取 scheduled_at 落在「现在 ± liveEventWindow」内的小项,按离现在多近排序,最多 2 个; // 3. 都没有 → 取最近一次发布过成绩的小项(用户要求的「下一场开始前持续显示上一场」)。 // // manual 时只认人工指定,不做任何回退。 const ( // liveEventWindow 是排期窗口的半径:项目开始时间前后各 30 分钟都算「正在进行」。 // 取 30 分钟是因为一个田径小项(含多轮次)通常不会超过一小时; // 想更精确可以后续做成设置项,这里先给一个现场够用的固定值。 liveEventWindow = 30 * time.Minute liveReasonManual = "manual" liveReasonScheduled = "scheduled" liveReasonRecent = "recent" ) // liveScheduledCandidate 是排期命中的候选小项。 type liveScheduledCandidate struct { ID int64 At time.Time SortOrder int } // liveSelection 是判定结果:id 列表与每个 id 的入选原因。 type liveSelection struct { Source string IDs []int64 Reasons map[int64]string } // pickLiveEvents 是纯函数形式的回退链,方便单测覆盖各种组合。 // scheduled 传入的候选必须已经按窗口筛过。 func pickLiveEvents(source string, manual []int64, scheduled []liveScheduledCandidate, recent int64, now time.Time) liveSelection { out := liveSelection{Source: source, IDs: make([]int64, 0, dto.MaxLiveEvents), Reasons: map[int64]string{}} take := func(ids []int64, reason string) { for _, id := range ids { if id <= 0 || len(out.IDs) >= dto.MaxLiveEvents { return } if _, ok := out.Reasons[id]; ok { continue } out.IDs = append(out.IDs, id) out.Reasons[id] = reason } } if source == dto.LiveSourceManual { take(manual, liveReasonManual) return out } if len(manual) > 0 { take(manual, liveReasonManual) return out } if len(scheduled) > 0 { sort.SliceStable(scheduled, func(i, j int) bool { di := absDuration(scheduled[i].At.Sub(now)) dj := absDuration(scheduled[j].At.Sub(now)) if di != dj { return di < dj } if scheduled[i].SortOrder != scheduled[j].SortOrder { return scheduled[i].SortOrder < scheduled[j].SortOrder } return scheduled[i].ID < scheduled[j].ID }) ids := make([]int64, 0, len(scheduled)) for _, c := range scheduled { ids = append(ids, c.ID) } take(ids, liveReasonScheduled) return out } if recent > 0 { take([]int64{recent}, liveReasonRecent) } return out } func absDuration(d time.Duration) time.Duration { if d < 0 { return -d } return d } // resolveLiveEvents 读取设置与赛事库,判定当前项目。 func (s *server) resolveLiveEvents(ctx context.Context, now time.Time) (liveSelection, error) { values, err := loadSettingsMap(ctx, s.ev) if err != nil { return liveSelection{}, err } source := dto.NormalizeLiveSource(values[dto.SettingKeyLiveSource]) manual, err := s.existingEventIDs(ctx, dto.ParseLiveEventIDs(values[dto.SettingKeyLiveEvents])) if err != nil { return liveSelection{}, err } // manual 模式不必查排期与最近发布,省掉两次查询。 if source == dto.LiveSourceManual { return pickLiveEvents(source, manual, nil, 0, now), nil } scheduled, err := s.scheduledLiveCandidates(ctx, now) if err != nil { return liveSelection{}, err } recent, err := s.mostRecentPublishedEvent(ctx) if err != nil { return liveSelection{}, err } return pickLiveEvents(source, manual, scheduled, recent, now), nil } // existingEventIDs 丢掉已不存在的小项 id:人工指定的小项被删除后, // 大屏不该因为一个残留 id 报错或整块空白。 func (s *server) existingEventIDs(ctx context.Context, ids []int64) ([]int64, error) { out := make([]int64, 0, len(ids)) for _, id := range ids { var one int err := s.ev.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, id).Scan(&one) switch { case errors.Is(err, sql.ErrNoRows): continue case err != nil: return nil, internal(err) default: out = append(out, id) } } return out, nil } // scheduledLiveCandidates 取 scheduled_at 落在当前窗口内的小项。 func (s *server) scheduledLiveCandidates(ctx context.Context, now time.Time) ([]liveScheduledCandidate, error) { rows, err := s.ev.QueryContext(ctx, `SELECT id, scheduled_at, sort_order FROM events WHERE scheduled_at IS NOT NULL AND TRIM(scheduled_at) <> ''`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]liveScheduledCandidate, 0) for rows.Next() { var ( c liveScheduledCandidate raw string sortOrd int64 ) if err := rows.Scan(&c.ID, &raw, &sortOrd); err != nil { return nil, internal(err) } at, ok := parseScheduledAt(raw) if !ok || absDuration(at.Sub(now)) > liveEventWindow { continue } c.At = at c.SortOrder = int(sortOrd) out = append(out, c) } return out, rows.Err() } // mostRecentPublishedEvent 取最近一次发布过成绩的小项,取不到时返回 0(全新赛事)。 func (s *server) mostRecentPublishedEvent(ctx context.Context) (int64, error) { var id int64 err := s.ev.QueryRowContext(ctx, ` SELECT r.event_id FROM results r JOIN events ev ON ev.id = r.event_id WHERE r.state = 'published' AND r.status <> 'withdrawn' ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC LIMIT 1`).Scan(&id) if errors.Is(err, sql.ErrNoRows) { return 0, nil } if err != nil { return 0, internal(err) } return id, nil } // parseScheduledAt 解析 events.scheduled_at。 // // 管理端存的是 datetime-local 转出的 RFC3339(UTC),但手工改库或早期数据可能是 // 其它几种常见写法;认不出来的一律当成「没有排期」,不让一个大屏因为格式怪就整块不显示。 func parseScheduledAt(raw string) (time.Time, bool) { v := strings.TrimSpace(raw) if v == "" { return time.Time{}, false } layouts := []string{ time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999999", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02", } for _, layout := range layouts { if at, err := time.Parse(layout, v); err == nil { return at, true } } return time.Time{}, false } // dedupeIDs 去重并丢掉非法 id(0 / 负数),保持原顺序。 func dedupeIDs(ids []int64) []int64 { out := make([]int64, 0, len(ids)) seen := make(map[int64]bool, len(ids)) for _, id := range ids { if id <= 0 || seen[id] { continue } seen[id] = true out = append(out, id) } return out } // ---------- 接口 ---------- // liveEventsHandler 是 GET /api/display/live:大屏「实时赛况」页拉当前项目与已发布成绩。 // 同样对 guest 开放——局域网大屏不带会话,判定结论本身不含任何管理配置。 func (s *server) liveEventsHandler(w http.ResponseWriter, r *http.Request) (any, error) { ctx := r.Context() sel, err := s.resolveLiveEvents(ctx, time.Now()) if err != nil { return nil, err } out := dto.LiveEvents{Source: sel.Source, EventIDs: sel.IDs, Events: make([]dto.LiveEvent, 0, len(sel.IDs))} for _, id := range sel.IDs { ev, err := loadEventQ(ctx, s.ev, id) if err != nil { return nil, err } groups, err := s.liveResultGroups(ctx, id) if err != nil { return nil, err } out.Events = append(out.Events, dto.LiveEvent{ Event: dto.EventFromDomain(ev), Reason: sel.Reasons[id], Groups: groups, }) } return out, nil } // mainChannelRankJoin 是大屏成绩行上名次列的「主通道 + 通道名次」两段关联, // 与 realtime 侧 snapshot.go 的同名常量逐字一致(那边是 WS 快照的最新成绩,这里是实时赛况), // 两处必须一起改,否则分项成绩页与实时赛况的名次会打架。 // // 名次取「主通道」口径(devdocs 17 §11.3):主通道 = scope=athlete 优先,其次通道 sort_order 小者, // 与 export/load.go 的分项成绩表同一条规则。优先级是 **results.rank 优先、channel_ranks 兜底** // (见下面查询里的 COALESCE(r.rank, cr.rank)),不能照抄导出侧的 cr 优先:results.rank 是这条报名项 // 自己的名次,而「只更新名次」的发布(rankOnly,scoring.go)只写 results.rank、不重建 channel_ranks // ——cr 一优先,个人项目在这种发布之后会显示上一次的旧名次。r 为空时才回落到 cr, // 正好补上团体主体名次:团体名次有意不写 results.rank(applyComputedRanks 只认个人主体), // 不回落到 channel_ranks 的话,团体项目发布之后大屏上这一行就是一个「-」。 // // 关联用 cr.entry_id = e.id,覆盖 basis=result 的名次(含纯团体项目,subject_key='e:<报名项id>')。 // 覆盖不到聚合 / 团体手工名次:那类 channel_ranks 行的 entry_id / result_id 都是 NULL, // 主体是 t:<单位id> / a:<运动员id>,而这类主体在分项成绩表里本来就没有对应的一行 // (导出侧同一段子查询、同一处口径,见 export/load.go 的 loadEventRowsWithMembers)。 // 取 MIN(cr2.id) = 报名项自己那一份名次:团体报名挂上队员后同一通道下还有每个队员的个人副本, // 直接按 entry_id JOIN 会把这一行印 N+1 遍(与导出侧同一处理)。 const mainChannelRankJoin = ` LEFT JOIN event_score_channels mc ON mc.id = ( SELECT c2.id FROM event_score_channels c2 WHERE c2.event_id = r.event_id ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id LIMIT 1) LEFT JOIN channel_ranks cr ON cr.id = ( SELECT MIN(cr2.id) FROM channel_ranks cr2 WHERE cr2.channel_id = mc.id AND cr2.entry_id = e.id AND cr2.category_id = e.category_id)` // liveResultGroups 取某个小项已发布的成绩,按「小项 + 组别」聚合成组。 // // 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序、名次同样是 // results.rank 优先 + channel_ranks 兜底),但范围固定在这一个小项、且不设条数上限—— // 当前项目要显示完整名单,不能被 20 条截断。 func (s *server) liveResultGroups(ctx context.Context, eventID int64) ([]dto.EventResultGroup, error) { rows, err := s.ev.QueryContext(ctx, ` SELECT 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, COALESCE(r.rank, cr.rank), e.team_id, COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path, 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`+mainChannelRankJoin+` WHERE r.event_id = ? AND r.state = 'published' AND r.status <> 'withdrawn' ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC`, eventID) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() index := make(map[int64]int) out := make([]dto.EventResultGroup, 0) for rows.Next() { var ( eventCode, eventName string categoryID int64 categoryName string publishedAt string row dto.EventResultRow rank sql.NullInt64 value sql.NullFloat64 athleteID sql.NullInt64 athleteName string shortName string logoPath sql.NullString memberNames sql.NullString isTie int ) if err := rows.Scan(&eventCode, &eventName, &categoryID, &categoryName, &publishedAt, &row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName, &logoPath, &athleteID, &athleteName, &memberNames, &row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil { return nil, internal(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.LogoPath = nilIfEmpty(logoPath.String) row.MemberNames = parseNames(memberNames) row.IsTie = isTie != 0 key := categoryID i, ok := index[key] if !ok { i = len(out) index[key] = i out = append(out, dto.EventResultGroup{ EventID: eventID, EventCode: eventCode, EventName: eventName, CategoryID: categoryID, CategoryName: categoryName, PublishedAt: nilIfEmpty(publishedAt), Rows: []dto.EventResultRow{}, }) } out[i].Rows = append(out[i].Rows, row) } if err := rows.Err(); err != nil { return nil, internal(err) } // 行是按发布时间倒序取回的,组内要改回名次顺序;无名次的排最后。 for i := range out { sort.SliceStable(out[i].Rows, func(a, b int) bool { ra, rb := out[i].Rows[a], out[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 out, nil }