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

246 lines
8.1 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 实现 /ws 实时同步中心。
//
// 协议以 devdocs/05-接口与实时协议.md 第 1–5 节为唯一规范:
// 版本号取自 change_log.id,客户端按 version 去重,增量补不上就回落全量快照。
package realtime
import (
"encoding/json"
"fmt"
"strings"
"time"
"unicode"
)
// 事件名与协议第 3 节的表格一一对应。
const (
EventHello = "hello"
EventSnapshot = "snapshot"
EventCatchup = "catchup"
EventChange = "change"
EventResync = "resync"
EventAck = "ack"
EventHeartbeat = "heartbeat"
EventDisplayCommand = "display:command"
)
// 房间名。admin:<userId> 由 AdminRoom 生成,用于定向通知。
//
// admin:<userId> 目前没有生产推送方(只有测试用 BroadcastTo 往它发过消息):
// 保留它是因为 devdocs/05 §1 把「admin:<userId>(定向通知)」写成了协议的一部分,
// 要删就得连着文档一起改,而文档不在本轮可改范围内——见审查清单 C36。
const (
RoomDisplay = "display"
RoomAdmin = "admin"
)
// AdminRoom 返回某个管理端用户的定向房间名。
//
// 只有带会话的管理端连接会加入这个房间(见 handleHello),身份来自会话而非客户端自报;
// 「guest 不能进管理端房间」这条鉴权语义由测试钉住,删房间会一起删掉那条覆盖。
func AdminRoom(userID int64) string { return fmt.Sprintf("admin:%d", userID) }
// 连接角色。
const (
RoleDisplay = "display"
RoleAdmin = "admin"
)
// change.action 取值。
const (
ActionUpsert = "upsert"
ActionVoid = "void"
ActionReplace = "replace"
)
// display:command 支持的 action。
const (
CmdGoto = "goto"
CmdLock = "lock"
CmdUnlock = "unlock"
CmdReload = "reload"
CmdAnnounce = "announce"
CmdShowDetail = "show-detail"
CmdHideDetail = "hide-detail"
// CmdAnnounceClear 清除当前公告:服务端同时清设置里的持久值并广播给大屏。
CmdAnnounceClear = "announce_clear"
)
var displayActions = map[string]bool{
CmdGoto: true,
CmdLock: true,
CmdUnlock: true,
CmdReload: true,
CmdAnnounce: true,
CmdAnnounceClear: true,
CmdShowDetail: true,
CmdHideDetail: true,
}
// ValidDisplayAction 判断 display:command 的 action 是否在协议清单内。
func ValidDisplayAction(action string) bool { return displayActions[action] }
// envelope 是所有消息的统一外壳:{ "event": "...", "data": { ... } }。
type envelope struct {
Event string `json:"event"`
Data json.RawMessage `json:"data,omitempty"`
}
// encode 把事件名与载荷打包成一条文本帧。
func encode(event string, data any) ([]byte, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("编码 %s 载荷: %w", event, err)
}
return json.Marshal(envelope{Event: event, Data: raw})
}
// helloPayload 是 C→S 的 hello 载荷。
//
// role 只是「申请」:服务端以会话身份为准,未登录连接申请 admin 会被拒绝,
// 因此这里没有 userId —— 定向房间的 userId 只能来自会话。
// instanceId / page / screen 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页、哪块屏」,
// 老客户端不带这几个字段也能正常工作。
type helloPayload struct {
Role string `json:"role"`
LastVersion *int64 `json:"lastVersion,omitempty"`
DisplayKey string `json:"displayKey,omitempty"`
InstanceID string `json:"instanceId,omitempty"`
Page string `json:"page,omitempty"`
// Screen 是 Console 从 URL 带下来的屏号(screen=<display.id>),局域网浏览器留空。
Screen string `json:"screen,omitempty"`
// Device 是浏览器自己拼好的设备标签(`安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`),
// DeviceRaw 是原始 UA(排障用)。两者都 omitempty:**老客户端不带这两个字段,服务端行为
// 与今天逐字一致**(管理端列表退回显示实例 id)。
//
// 服务端不解析 UA,但也不能全信这两个值:它们是客户端自报的自由文本,会直接进管理端列表。
// 长度按**字符**截断、控制字符一律清掉,收口在 sanitizeDeviceText(registerDisplay 调用)。
Device string `json:"device,omitempty"`
DeviceRaw string `json:"deviceRaw,omitempty"`
}
// 客户端自报文本的长度上限,按**字符**(rune)不是字节算:
// `安卓 13 · Chrome 120` 这种标签按字节截会切出半个汉字,显示成乱码方框。
const (
maxDeviceChars = 80
maxDeviceRawChars = 300
)
// CleanUserText 清洗客户端自报的自由文本:去掉全部控制字符(换行、制表、退格、DEL…)与首尾空白。
//
// 这些值最终要显示在管理端「在线大屏」列表里,一个带换行的 device 能把整行排版顶歪;
// 服务端是唯一的收口处,不能指望每个客户端都干净。TrimSpace 之后为空即视作「没报」,返回空串。
//
// 保留字符间的普通空格与各种可见符号:设备标签本来就长这样,不该被服务端改写。
func CleanUserText(s string) string {
if s == "" {
return ""
}
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if unicode.IsControl(r) {
continue
}
b.WriteRune(r)
}
return strings.TrimSpace(b.String())
}
// TruncateChars 按字符(rune)截断到 maxChars 个;maxChars <= 0 表示不截断。
// 会切断代理对时以整字符为界(Go 的 range 只落在字符起始字节上),不会切出半个 UTF-8 序列。
func TruncateChars(s string, maxChars int) string {
if maxChars <= 0 {
return s
}
count := 0
for i := range s {
if count == maxChars {
return s[:i]
}
count++
}
return s
}
// sanitizeDeviceText 是 hello 里设备字段的收口:先清洗再截断。
// 没有这两个字段的老客户端拿到的是空串,注册表按「没报」处理。
func sanitizeDeviceText(s string, maxChars int) string {
return TruncateChars(CleanUserText(s), maxChars)
}
// ackPayload 是 C→S 的 ack 载荷;服务端据此识别慢客户端。
type ackPayload struct {
Version int64 `json:"version"`
}
// Change 是变更的最小单元;Version 只用于服务端排序与去重,不上线路。
type Change struct {
Entity string `json:"entity"`
ID int64 `json:"id,omitempty"`
Action string `json:"action"`
Data json.RawMessage `json:"data,omitempty"`
Version int64 `json:"-"`
}
// changePayload 是 change 事件的载荷。
type changePayload struct {
Version int64 `json:"version"`
Changes []Change `json:"changes"`
}
// catchupPayload 是 catchup 事件的载荷。
type catchupPayload struct {
FromVersion int64 `json:"fromVersion"`
Version int64 `json:"version"`
Changes []Change `json:"changes"`
}
// heartbeatPayload 是 heartbeat 事件的载荷。
type heartbeatPayload struct {
Version int64 `json:"version"`
ServerTime string `json:"serverTime"`
}
// DisplayCommand 是下发给大屏的 display:command 载荷。
type DisplayCommand struct {
Action string `json:"action"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// NewDisplayCommand 构造一条大屏指令;action 不在协议清单内时返回错误。
func NewDisplayCommand(action string, payload any) (DisplayCommand, error) {
if !ValidDisplayAction(action) {
return DisplayCommand{}, fmt.Errorf("未知的大屏指令 %q", action)
}
cmd := DisplayCommand{Action: action}
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return DisplayCommand{}, fmt.Errorf("编码大屏指令 %s 的 payload: %w", action, err)
}
cmd.Payload = raw
}
return cmd, nil
}
// pageFromPayload 从 goto 指令的 payload 里取目标页名,兼容字符串与对象两种写法。
func pageFromPayload(payload json.RawMessage) string {
if len(payload) == 0 {
return ""
}
var s string
if err := json.Unmarshal(payload, &s); err == nil {
return s
}
var obj struct {
Page string `json:"page"`
}
if err := json.Unmarshal(payload, &obj); err == nil {
return obj.Page
}
return ""
}
func nowISO(t time.Time) string { return t.Format(time.RFC3339Nano) }