Files
Scoreboard/server/internal/httpapi/live_events.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

410 lines
14 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 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
}