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

316 lines
11 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"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"unicode/utf8"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
// Realtime 是实时层的最小接口;未接入时写操作只落库,不推送。
type Realtime interface {
Broadcast(changes []realtime.Change)
LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]realtime.Change, error)
Instances() []realtime.Instance
}
// ---------- 大屏别名(机器级设置) ----------
//
// 别名与实例注册表是两套生命周期:
// - 实例注册表在**内存**里,连接断开后只留 TTL(十分钟)供排查;
// - 别名是操作员给「这台设备」起的名字,必须活过重启、活过实例过期。
//
// 所以别名存系统库 system.db 的 settings 表(与 PUT /api/system/session 的登录有效期同一套
// 读写机制,见 repo_misc.go 的 writeSessionHours),不新建表、不写迁移。
const (
// instanceAliasKey 下存一个 JSON 映射:{"<instanceId>": "<别名>"}。
instanceAliasKey = "display.instance_aliases"
// maxInstanceAliases 是映射条数上限:别名会在实例过期后继续留着(下次那台设备连上来
// 还认得出),只增不减会一路涨下去,必须有封顶。
maxInstanceAliases = 200
// maxAliasChars 是单个别名的字符数上限(按字符不是字节,中文名才存得下)。
maxAliasChars = 40
)
// displayAliasPatch 是 PUT /api/display/instances/{id}/alias 的请求体。
type displayAliasPatch struct {
// Alias 为空串或只空白 = 清除别名、回到自动设备标签。
Alias string `json:"alias"`
RequestID string `json:"requestId"`
}
// displayInstancesHandler 返回在线大屏实例列表(在线看板)。
func (s *server) displayInstancesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
if s.realtime == nil {
return []realtime.Instance{}, nil
}
list := s.realtime.Instances()
if list == nil {
list = []realtime.Instance{}
}
// 别名在这里与实例合并:注册表里只剩在线的与刚离线的,而别名可能属于一台早就
// 被清理掉的设备——那种别名留着不填(没有对应实例),等它连上来自然就填上了。
aliases, err := s.loadInstanceAliases(r.Context())
if err != nil {
// 读别名失败不该让「在线大屏」列表整个打不开:现场靠它切页、排查。
// 降级成「没有别名」(列表退回显示实例 id)并留下警告,其余字段照常返回。
s.logger.Warn("读取大屏别名失败,列表按无别名返回", "err", err)
return list, nil
}
for i := range list {
if alias := aliases[list[i].ID]; alias != "" {
list[i].Alias = alias
}
}
return list, nil
}
// updateDisplayInstanceAliasHandler 处理 PUT /api/display/instances/{id}/alias:
// 给一台设备设 / 清别名,返回该实例的最新记录(形状与列表元素一致,即 Instance DTO)。
// 权限与大屏切页同档(recorder 及以上),见 server.go 的路由注册。
func (s *server) updateDisplayInstanceAliasHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id := strings.TrimSpace(r.PathValue("id"))
var in displayAliasPatch
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
alias := realtime.CleanUserText(in.Alias)
if utf8.RuneCountInString(alias) > maxAliasChars {
return nil, badRequest(
fmt.Sprintf("别名最长 %d 个字符(当前 %d 个)", maxAliasChars, utf8.RuneCountInString(alias)),
nil)
}
inst, ok := s.findDisplayInstance(id)
if !ok {
return nil, notFound("这台大屏当前不在注册表里:它可能已经离线很久、超过了保留时间。请刷新在线列表后重试。")
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
if err := s.saveInstanceAlias(r.Context(), id, alias); err != nil {
return nil, err
}
s.audit(r.Context(), "display.instance_alias", "display", 0, nil,
map[string]any{"instanceId": id, "alias": alias})
inst.Alias = alias
return inst, nil
})
}
// findDisplayInstance 在注册表快照里按 id 找实例;找不到说明它已被清理(或压根没连过)。
func (s *server) findDisplayInstance(id string) (realtime.Instance, bool) {
if id == "" || s.realtime == nil {
return realtime.Instance{}, false
}
for _, inst := range s.realtime.Instances() {
if inst.ID == id {
return inst, true
}
}
return realtime.Instance{}, false
}
// loadInstanceAliases 读「实例 id → 别名」映射。
//
// 存量值可能被手工改过、或是别的版本写的,所以读出来还要过一遍清洗:空 id / 空别名等于
// 「没设」,直接丢掉(避免死条目一直占着条数上限),别名本身按同一个口径去控制字符与截断。
func (s *server) loadInstanceAliases(ctx context.Context) (map[string]string, error) {
return loadInstanceAliasesQ(ctx, s.sys, s.logger)
}
// loadInstanceAliasesQ 与 loadInstanceAliases 相同,但可以在给定连接 / 事务上读:
// saveInstanceAlias 要在同一个事务里「先读后写」,否则两个人同时改不同设备时,
// 后写的那个会把前一个的改动覆盖掉(两边各自读到旧映射,谁也看不见谁)。
func loadInstanceAliasesQ(ctx context.Context, q queryer, logger *slog.Logger) (map[string]string, error) {
var raw string
err := q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, instanceAliasKey).Scan(&raw)
switch {
case errors.Is(err, sql.ErrNoRows):
return map[string]string{}, nil
case err != nil:
return nil, internal(err)
}
if strings.TrimSpace(raw) == "" {
return map[string]string{}, nil
}
var stored map[string]string
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
// 值被改坏时按「没有别名」处理并留下警告:一条脏设置不该把「在线大屏」列表打不开。
logger.Warn("大屏别名设置不是合法 JSON,按无别名处理", "key", instanceAliasKey, "err", err)
return map[string]string{}, nil
}
out := make(map[string]string, len(stored))
for id, alias := range stored {
id = strings.TrimSpace(id)
alias = realtime.TruncateChars(realtime.CleanUserText(alias), maxAliasChars)
if id == "" || alias == "" {
continue
}
out[id] = alias
}
return out, nil
}
// saveInstanceAlias 写入 / 清除一个实例的别名。
//
// 清除是真的把那条**删掉**(不是存空串):实例过期后别名会一直留着,只增不减的映射会一路
// 顶到 maxInstanceAliases,之后所有设备都改不了名。
//
// 超过条数上限时**拒绝**而不是淘汰最旧的:别名是按 id 存的、没有时间戳,淘汰谁都是猜;
// 而悄悄弄丢一台设备的命名比让操作员先清几条更难查。拒绝会带中文提示(409)。
func (s *server) saveInstanceAlias(ctx context.Context, id, alias string) error {
// 读—改—写必须在一个事务里(系统库只有一条连接,事务把并发改名串起来)。
tx, err := s.sys.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
aliases, err := loadInstanceAliasesQ(ctx, tx, s.logger)
if err != nil {
return err
}
if alias == "" {
if _, ok := aliases[id]; !ok {
return nil // 本来就没有别名:不必写库
}
delete(aliases, id)
} else {
if _, exists := aliases[id]; !exists && len(aliases) >= maxInstanceAliases {
s.logger.Warn("大屏别名数量已达上限,拒绝新增",
"max", maxInstanceAliases, "instanceId", id)
return conflict(fmt.Sprintf(
"大屏别名最多 %d 条,已经存满。请先给不再使用的设备清除别名后重试。", maxInstanceAliases))
}
aliases[id] = alias
}
raw, err := json.Marshal(aliases)
if err != nil {
return internal(err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
instanceAliasKey, string(raw)); err != nil {
return internal(err)
}
if err := tx.Commit(); err != nil {
return internal(err)
}
return nil
}
// broadcastChanges 在写请求成功返回后,把本次请求写入 change_log 的变更推给实时层。
// 版本号取请求开始时的最大 id,因此只推送本次写入,不会重复补发历史变更。
func (s *server) broadcastChanges(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.realtime == nil || s.ev == nil || !isWriteMethod(r.Method) {
next.ServeHTTP(w, r)
return
}
before, ok := s.changeVersion(r.Context())
if !ok {
next.ServeHTTP(w, r)
return
}
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
if rec.status >= http.StatusBadRequest {
return
}
// 请求结束后 r.Context() 可能已被取消,推送不应因此中断。
ctx := context.WithoutCancel(r.Context())
changes, err := s.realtime.LoadChanges(ctx, before, 0)
if err != nil {
if dbClosed(err) {
// 恢复流程会在请求末尾关掉目标库再换文件,广播中间件在这之后才读 change_log,
// 必然撞上「库已关闭」。这是停机动作的一部分,不是故障——
// 记 ERROR 只会让现场以为恢复把系统搞坏了。降级成 Debug,其余错误照旧。
s.logger.Debug("跳过实时推送:库已关闭(恢复/重启中)", "err", err)
return
}
s.logger.Error("读取实时变更失败", "err", err)
return
}
if len(changes) > 0 {
s.realtime.Broadcast(changes)
}
})
}
// errDBClosed 是 database/sql 在连接池已关闭时返回的错误。
//
// 标准库里这个错误是**未导出**的(database/sql 的 errDBClosed),拿不到它的变量,
// 只能按原文认;这里独立声明一份,比对时逐层走错误链看有没有同文本的那一层。
var errDBClosed = errors.New("sql: database is closed")
// dbClosed 判定错误是否只是「库已经被关掉」。
//
// 只认原文完全相等的那一层(含被 %w 包过的),不按子串匹配:
// 别的读库失败(锁超时、SQL 写错、库损坏)都得继续当 ERROR 报出来,
// 不能被这条静默规则顺手吞掉。
func dbClosed(err error) bool {
for e := err; e != nil; e = errors.Unwrap(e) {
if e.Error() == errDBClosed.Error() {
return true
}
}
return false
}
// changeVersion 读取 change_log 当前最大 id;失败时调用方跳过本次推送。
func (s *server) changeVersion(ctx context.Context) (int64, bool) {
var v sql.NullInt64
if err := s.ev.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil {
if dbClosed(err) {
// 同 broadcastChanges:库已关闭是恢复/重启的中间状态,不是故障。
s.logger.Debug("跳过变更版本:库已关闭(恢复/重启中)", "err", err)
return 0, false
}
s.logger.Error("读取变更版本失败", "err", err)
return 0, false
}
return v.Int64, true
}
func isWriteMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return false
default:
return true
}
}
// statusRecorder 记录响应状态码,用于判断写请求是否成功。
type statusRecorder struct {
http.ResponseWriter
status int
written bool
}
func (w *statusRecorder) WriteHeader(code int) {
if !w.written {
w.status = code
w.written = true
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusRecorder) Write(b []byte) (int, error) {
if !w.written {
w.written = true
}
return w.ResponseWriter.Write(b)
}