## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发) - 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、 手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。 - 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错 (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。 ## 实时赛况(新大屏页 + 定向接口) - 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩 (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。 设置键 display.live_events / display.live_source。 - 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面 (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。 - 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only), 故对非管理员整组置灰 + 提示条(不新增角色)。 ## 显式批次层(组别之下) - 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果 完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 + 一键均分(按 N 人切片,已有批次号接着往后排)。 ## 成绩输入框实时悬浮提示 - 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻; 合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文; 与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。 ## 窄屏 / 手机端布局 - 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针, 四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框 塌成 22px 这类真问题。 ## 版本号单一真相 - 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json 的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。 测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。 docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
381 lines
12 KiB
Go
381 lines
12 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
|
|
}
|
|
|
|
// liveResultGroups 取某个小项已发布的成绩,按「小项 + 组别」聚合成组。
|
|
//
|
|
// 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序),
|
|
// 但范围固定在这一个小项、且不设条数上限——当前项目要显示完整名单,不能被 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, r.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
|
|
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
|
|
}
|