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