本批七项(三处是现场报的 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 与两处前端注释的旧窗口说法一并订正。
410 lines
14 KiB
Go
410 lines
14 KiB
Go
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
|
||
}
|