Files
Scoreboard/server/internal/realtime/snapshot.go
T
AskaEth 56ed90ff8b fix(display,entry): 修大屏团体名次显示「-」与分项成绩第 3 栏缺第 1 名;发布改弹窗选口径;会话设备名与重命名;录分搜索框;版本 dev_0.5.15
本批七项(三处是现场报的 bug,四处是新功能):

① 大屏上「纯团体项目」(如团体 4x100 接力)的名次全是「-」
   根因不在算分:纯团体名次只落在 channel_ranks,而 results.rank 被有意收窄成
   「个人名次」、对团体主体一直是 NULL;大屏的两个读者只读 results.rank、没有兜底
   (导出件与积分明细下钻早就有兜底,所以当时是「导出有、屏幕没有」)。
   改成读侧回落:results.rank 优先、为空时用通道名次。算分与写回一行未动。
   ⚠️ 优先级的理由:只更新名次的发布只更新 results.rank、不重建通道名次,
   若通道名次优先,个人项目会在那种发布之后显示旧名次。
   ⚠️ 已发布的数据不需要重发或重算,升级后大屏下一次刷新即显示。
   ⚠️ 仍未覆盖:只在「团体名次」卡片里按单位录名次、成绩行上没填的那类(无成绩行可挂)。

② 分项成绩页第 3 栏看不到第 1 名
   根因:快照 latestResults 的窗口原来按「行」截断(LIMIT 20),而屏上是一组一栏,
   切口只能落在最旧那一栏(通常是第 3 栏)内部,把该组建号最早的行挤掉
   (手录项目从第 1 名往下录 → 第 1 名建号最早)。
   改成「最近 8 组 × 每组 10 行」(先按每组最新发布时间取 8 组,再每组按名次取前 10 行);
   组的 publishedAt 改为组内最新一条。底栏「最新完成项目」一起吃这个修复。
   ⚠️ 取舍:单组超过 10 行的部分不再下发(前端每栏只画 6 行、实时赛况上限 10 行);
   将来把每栏行数上限调到 10 以上时,必须同步调这里的 R。

③ 录分列表「运动员 / 队」列可限宽 + 逐行展开收起
   多人团体名单以前把整列撑宽。收起限宽 14em(省略号)、超过 24 个显示宽度
   (中文算 2)出现「展开 / 收起」;个人项目不出现按钮;展开状态只在本机内存。
   取舍:展开那一行会把列略微撑宽(成绩列右移约 80px),收起即恢复。

④ 录入页「以决赛成绩发布」从常驻勾选框改成发布弹窗里二选一
   默认「只更新名次(不计分、不发奖牌)」;每次打开都复位、不吃上一次;
   Alt+Enter 走同一个入口;取消 = 什么都不发生。
   审核页保持原样的常驻勾选框(两个页面口径一样、形态不同)。

⑤ 大屏控制页「在线大屏」列表:可读设备名 + 手动重命名 + 修重复条目
   - 自动设备标签(系统 · 浏览器 · 分辨率 · 手机/平板/桌面),由大屏端在握手时上报,
     服务端截断到 80/300 字符并清掉控制字符;老客户端不报时列表与升级前逐字一致。
     ⚠️ 浏览器没有读主机名/设备名的 API,自动标签只是辅助区分。
   - 手动重命名(录入员及以上,与切页同档):存在系统库 settings 的
     display.instance_aliases(机器级、重启与切赛事都不丢),清除即回到自动标签;
     显示优先级 alias → device → id(id 降级成小字)。上限 200 条,满了拒绝新增并提示,
     不静默顶掉别的设备。设备离线过久条目被清理后别名仍留着。
   - 修:以前给局域网设备切页后列表底部会多出一条该设备的离线条目
     (翻页会补发握手,而浏览器没有稳定 id、服务端每次新开一条实例)。
     现在浏览器本机记住实例 id(localStorage),服务端也复用同一条连接已有的 id。

⑥ 局域网地址条目都能出二维码
   以前只有第一条常驻出码;现在其余每条多一个「二维码」按钮:点一下临时显示,
   再点一次 / Esc / 点空白处收起,同一时刻只留一个,且只为当前打开的那条懒生成 SVG
   (不在渲染时为每条地址都算一遍)。第一条的常驻码与「复制链接」未变。

⑦ 录分列表加「搜姓名 / 号码布」搜索框(人多项目找人不费劲)
   复用既有的「姓名 / 工号 / 号码布」匹配口径(未新增第三份),支持模糊、大小写不敏感、
   全角空格、号码布数字口径(007 搜 7、7 搜 007);命中高亮;提示「找到 N 条 / 共 M 条」;
   × / Esc / 清除三种清空方式;搜不到给明确空状态。
   ⚠️ 搜索只影响表格显示:发布范围、进度条(仍是该批次总人数)、勾选、清空范围、
      预览算分一律不受影响,界面上也写明了这一点。
   ⚠️ 搜索状态下粘贴跟随「屏幕上看到的顺序」(隐藏的行不会被写到)——避免第二条成绩
      静默写到看不见的人身上。

测试:四套自检 ×2 全绿(finalcheck_0515*.log:前端 1096 例 / Console 96 例 / 服务端 14 条 ok,
EXIT=0)。①②都做了红/绿对照(改回旧实现,新用例当场变红;②的失败信息即现场形状
「第 3 栏应从第 1 名开始,实际名次 [5 6 7 8]」)。⑥⑦用真浏览器(playwright + chromium)
实机跑过(24 项 / 29 项全过,含「进度条数字不变」「搜索框里按 Alt+Enter 什么也不发生」)。

无数据库迁移;已有成绩、名次、积分与榜单不变。
文档:《升级说明》新增 dev_0.5.15;《使用手册》发布/录入/大屏控制三块与批量订正过期说法;
《常见问题》新增 Q105–Q107;《部署指南》《上线检查清单》同步;
devdocs/05(hello 新字段 + 别名接口)、13(第五十五轮)、16/06/07/18 与两处前端注释的旧窗口说法一并订正。
2026-09-17 18:19:38 +08:00

666 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
// 快照「最新成绩」的窗口口径:先取最近发布的 K 组,再每组保底取前 R 行。
//
// 不能按「总行数」截断:行是按发布时间倒序取的,而组是按「首次出现」建号的,
// 于是组数组的顺序 = 发布时间倒序——**屏上最旧的那一栏站在窗口尾部**,行数截断的切口
// 只会落进那一组里,把「建号最早的行」切掉。手录名次的项目通常从第 1 名往下录,
// 第 1 名的 results.id 最小 → 现场表现就是「最旧那一栏看不到第 1 名、从第 2 名开始」,
// 且症状跟着**栏位**走(同一批项目挪到前面几栏就正常)。
//
// 最坏情况下快照下发 8 × 10 = 80 行(旧口径是全局 20 行),首包因此变大;
// 换来的是每一组都从第 1 名开始,不再有「最旧一栏少一行」。
const (
// latestResultsGroupLimit 保留的**组数**上限。一组 = 一个小项 + 一个组别,即大屏的一栏。
// 前端单屏最多 4 栏(web/src/display/useScoreboard.ts 把 resultsCount 夹在 1..4),
// 底栏只需最新一组;留一倍余量给「刚发布、还没轮到显示」的组,取 8。
latestResultsGroupLimit = 8
// latestResultsRowsPerGroup 每组保留的**行数**上限,按名次取前 R 行。
// 前端每栏只画 6 行(web/src/display/results.ts 的 RESULT_ROWS_PER_COLUMN),
// 并列名次会让一栏超过 6 行;实时赛况页的同一批数据上限是 10 行
// (web/src/display/live.ts 的 MAX_LIVE_ROWS),兜底取 10。
latestResultsRowsPerGroup = 10
)
// Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。
// 字段名与协议第 3 节一致;settings/standings/latestResults 的形状必须与 REST 同构,
// 否则大屏收到首包就会按错误的键名渲染。
type Snapshot struct {
Version int64 `json:"version"`
Settings dto.EventSettings `json:"settings"`
Teams []dto.Team `json:"teams"`
Events []dto.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: []dto.Team{},
Events: []dto.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
}
teams, err := loadTeams(ctx, tx)
if err != nil {
return nil, err
}
snap.Teams = dto.TeamsFromDomain(teams)
if err := loadEvents(ctx, tx, snap); err != nil {
return nil, err
}
if err := loadStandings(ctx, tx, snap, teams, 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
}
// loadTeams 读取全量单位表(含停用单位):榜单展示字段要与 REST 一致,
// 停用但仍有流水的单位也必须能补出编码/名称/颜色,否则大屏会出现空白行。
func loadTeams(ctx context.Context, tx *sql.Tx) ([]domain.Team, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
FROM teams
ORDER BY sort_order, code`)
if err != nil {
return nil, fmt.Errorf("读取单位: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Team, 0)
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 nil, fmt.Errorf("解析单位: %w", err)
}
t.Active = active != 0
out = append(out, t)
}
return out, 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, ''), avg_mode
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, &e.AvgMode,
); 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, dto.EventFromDomain(e))
}
return rows.Err()
}
// loadStandings 复用 domain 的排名口径,且输入与 REST /api/standings 完全一致:
// 排名输入只含启用单位,展示字段用全量单位表补齐;个人流水按系数折算、奖牌开关生效、
// 只有配了计分通道的项目才产出名次,排序键取自规则包落库的 standings.tie_break。
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, teams []domain.Team, 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
}
activeTeams := make([]domain.Team, 0, len(teams))
for _, t := range teams {
if t.Active {
activeTeams = append(activeTeams, t)
}
}
in := domain.StandingsInput{
Teams: activeTeams,
Ledger: ledger,
Ranked: ranked,
TieBreak: splitTieBreak(values["standings.tie_break"]),
IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true),
}
// 单位奖牌榜的名次独立于总积分榜(金→银→铜→编码),随 medalRank 下发;
// 与 REST、xlsx 导出走的是同一个 domain.BuildMedalStandings。
snap.Standings = dto.StandingRows(
domain.BuildTeamStandings(in),
domain.BuildMedalStandings(in),
teams,
)
return nil
}
// splitTieBreak 解析 standings.tie_break(逗号分隔的排序键);空串返回 nil,由 domain 落回默认口径。
func splitTieBreak(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
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, count_to_team
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
countToTeam int
)
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &sourceType, &e.SourceID, &e.Points, &countToTeam); err != nil {
return nil, fmt.Errorf("解析积分流水: %w", err)
}
e.SourceType = domain.SourceType(sourceType)
e.CountToTeam = countToTeam != 0
out = append(out, e)
}
return out, rows.Err()
}
// loadRanked 与 REST 的 buildRanked、导出的 loadRanked 同源:数据源是发布/重算时
// 按通道全量重建的 channel_ranks,一条名次一行(多通道项目因此不会把一枚奖牌算成多枚,
// 没配通道的项目也不会有名次行)。三个奖牌开关随行携带,domain 这边不必回查库;
// 同时排除只排名不计分(scored=0)的项目,与导出侧口径一致。
func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) {
rows, err := tx.QueryContext(ctx, `
SELECT COALESCE(cr.result_id, 0), cr.event_id, cr.category_id, cr.rank, cr.result_value, cr.status,
COALESCE(cr.entry_id, 0), COALESCE(cr.team_id, 0), COALESCE(cr.athlete_id, 0),
COALESCE(a.name, ''), cr.scope, cr.channel_id, cr.basis, cr.subject_key,
ev.award_medals, c.count_to_medals, c.count_to_team_medals
FROM channel_ranks cr
JOIN events ev ON ev.id = cr.event_id AND ev.scored = 1
JOIN event_score_channels c ON c.id = cr.channel_id
LEFT JOIN athletes a ON a.id = cr.athlete_id
WHERE cr.rank > 0
ORDER BY cr.event_id, cr.category_id, c.sort_order, c.id, cr.rank, cr.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
status sql.NullString
awardMedals int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rr.Rank, &value, &status,
&rr.EntryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID,
&rr.Basis, &rr.SubjectKey, &awardMedals, &countToMedals, &countToTeamMedals); err != nil {
return nil, fmt.Errorf("解析奖牌名次: %w", err)
}
rr.Status = domain.ResultStatus(status.String)
if value.Valid {
v := value.Float64
rr.ResultValue = &v
}
rr.AwardMedals = awardMedals != 0
rr.CountToMedals = countToMedals != 0
rr.CountToTeamMedals = countToTeamMedals != 0
out = append(out, rr)
}
if err := rows.Err(); err != nil {
return nil, err
}
// 与 REST 的 buildRanked、导出的 loadRanked 同口径:联队份额按报名项配置补,
// 没有报名项的名次与没配份额的老数据退回「主单位独占 100%」。
// 大屏的奖牌榜读的就是这里——少补这一趟,联队的名次在大屏上只记给主单位。
shares, err := loadEntryTeamShares(ctx, tx)
if err != nil {
return nil, err
}
for i := range out {
out[i].Shares = domain.ResolveShares(shares[out[i].EntryID], out[i].TeamID)
}
return out, nil
}
// loadEntryTeamShares 读全部报名的份额(联队配置),按 entry_id 归组。
//
// 表不在(库还没跑到 0024)时返回空:没有份额表就没有联队配置,主单位独占 100% 正是老库
// 本来就该得到的结果,不是容错降级(见 httpapi.entryTeamSharesReady)。
func loadEntryTeamShares(ctx context.Context, tx *sql.Tx) (map[int64][]domain.TeamShare, error) {
var n int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'entry_team_shares'`).Scan(&n); err != nil {
return nil, fmt.Errorf("查询报名份额表: %w", err)
}
if n == 0 {
return nil, nil
}
rows, err := tx.QueryContext(ctx, `SELECT entry_id, team_id, points_share, medal_share
FROM entry_team_shares
ORDER BY entry_id, sort_order, team_id`)
if err != nil {
return nil, fmt.Errorf("读取报名份额: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]domain.TeamShare)
for rows.Next() {
var (
entryID int64
s domain.TeamShare
medal int
)
if err := rows.Scan(&entryID, &s.TeamID, &s.Weight, &medal); err != nil {
return nil, fmt.Errorf("扫描报名份额: %w", err)
}
if medal == 0 {
excluded := false
s.Medal = &excluded
}
out[entryID] = append(out[entryID], s)
}
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()
}
// mainChannelRankJoin 是大屏成绩行上名次列的「主通道 + 通道名次」两段关联,供 loadLatestResults
// 与 httpapi 的 liveResultGroups 使用(两边必须逐字相同,否则分项成绩页与实时赛况的名次会打架)。
//
// 名次取「主通道」口径(devdocs 17 §11.3):一行成绩只能出一个名次,主通道 = scope=athlete 优先,
// 其次通道 sort_order 小者,与 export/load.go 的分项成绩表同一条规则。优先级是
// **results.rank 优先、channel_ranks 兜底**(见下面各查询里的 COALESCE(r.rank, cr.rank)),
// 不能照抄导出侧的 cr 优先:results.rank 是这条报名项自己的名次,而「只更新名次」的发布
// (rankOnly,httpapi/scoring.go)只写 results.rank、不重建 channel_ranks——cr 一优先,
// 个人项目在这种发布之后会显示上一次的旧名次。反过来 r 为空时才回落到 cr,
// 正好补上团体主体名次:团体名次有意不写 results.rank(applyComputedRanks 只认个人主体)。
//
// 关联用 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 遍(与导出侧同一处理)。
//
// ⚠️ 与 export/load.go(loadEventRowsWithMembers 的 cr 子查询)、httpapi/live_events.go(liveResultGroups)
// 同源,改一处要改三处。
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)`
// latestResultsScopedSelect 是窗口函数的输入:已发布、未作废的成绩行,每行只带分组键、
// 发布时间与名次。时间表达式与主查询逐字相同——这里少一个兜底列,组的发布时间就会漂到别的列上。
// 名次口径也必须与主查询一致(results.rank 优先、channel_ranks 兜底),
// 否则「每组取前 R 行」和「组内展示顺序」会各按一套名次排。
const latestResultsScopedSelect = `
SELECT r.id AS rid, r.event_id AS event_id, e.category_id AS category_id,
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, '') AS ts,
COALESCE(r.rank, cr.rank) AS rank_value
FROM results r
JOIN entries e ON e.id = r.entry_id` + mainChannelRankJoin + `
WHERE r.state = 'published' AND r.status <> 'withdrawn'`
// loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。
// 已作废(status=withdrawn)的成绩必须排除,否则大屏会继续展示被作废的行;
// 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。
//
// 窗口是「K 组 × 每组 R 行」而不是「总 N 行」,理由见上面那组常量的注释。三件事都在 SQL 里定死:
//
// 1. recent_groups:先按**组的发布时间**(= 组内最新一条的时间)倒序取前 K 组。并列时间按
// 「该组最新那一行的 r.id」倒序决出——与旧实现「行按 ts DESC, r.id DESC 取回、组按首次出现建号」
// 的顺序**逐位相同**,组数组的顺序不变(`Snapshot.LatestResults` 是 WS 契约)。
// 2. bounded:每组按名次升序编号(无名次排最后,与下面 Go 侧那次 sort 同口径),只留前 R 行。
// ⚠️ 必须是「先定组、再每组截断」;退化成「取 N 行再分组」就还是同一个 bug,只是阈值变大。
// 3. 组的时间取 anchors 定的 group_ts(组内最新一条),不是「这一组碰巧先被取回的那一行」——
// 组内按名次截断之后,第一行的发布时间不再等于组的最新发布时间。
func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
WITH scoped AS (`+latestResultsScopedSelect+`),
anchors AS (
SELECT event_id, category_id, ts AS group_ts, rid,
ROW_NUMBER() OVER (PARTITION BY event_id, category_id ORDER BY ts DESC, rid DESC) AS anchor_no
FROM scoped),
recent_groups AS (
SELECT event_id, category_id, group_ts
FROM anchors WHERE anchor_no = 1
ORDER BY group_ts DESC, rid DESC
LIMIT ?),
bounded AS (
SELECT rid, event_id, category_id,
ROW_NUMBER() OVER (PARTITION BY event_id, category_id
ORDER BY rank_value IS NULL, rank_value, rid) AS row_no
FROM scoped)
SELECT r.event_id, COALESCE(ev.code, ''), COALESCE(ev.name, ''),
e.category_id, COALESCE(cat.name, ''),
rg.group_ts,
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+`
JOIN bounded b ON b.rid = r.id
JOIN recent_groups rg ON rg.event_id = b.event_id AND rg.category_id = b.category_id
WHERE r.state = 'published' AND r.status <> 'withdrawn' AND b.row_no <= ?
ORDER BY rg.group_ts DESC,
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC`,
latestResultsGroupLimit, latestResultsRowsPerGroup)
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
// groupPublishedAt 是这一**组**的发布时间(组内最新一条),来自 SQL 的 group_ts,
// 不是这一行自己的时间——组内按名次截断后,第一行的时间不再等于组的最新时间。
groupPublishedAt string
row dto.EventResultRow
rank sql.NullInt64
value sql.NullFloat64
athleteID sql.NullInt64
athleteName string
shortName string
logoPath sql.NullString
memberNames string
isTie int
)
if err := rows.Scan(&eventID, &eventCode, &eventName, &categoryID, &categoryName, &groupPublishedAt,
&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 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.LogoPath = nilIfEmpty(logoPath.String)
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(groupPublishedAt),
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
}
}