本批七项(三处是现场报的 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 与两处前端注释的旧窗口说法一并订正。
292 lines
9.5 KiB
Go
292 lines
9.5 KiB
Go
package realtime
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 注册表容量控制:离线实例保留一段时间便于排查,过期即清理;
|
||
// 没有 displayKey 的客户端每次重连都会生成新实例,必须有上限兜底。
|
||
const (
|
||
instanceTTL = 10 * time.Minute
|
||
maxInstances = 200
|
||
)
|
||
|
||
// Instance 是大屏实例的对外快照,供管理端查询「谁在线、在哪一页」。
|
||
type Instance struct {
|
||
ID string `json:"id"`
|
||
Online bool `json:"online"`
|
||
Page string `json:"page,omitempty"`
|
||
LastHeartbeat time.Time `json:"lastHeartbeat"`
|
||
ConnectedAt time.Time `json:"connectedAt"`
|
||
RemoteAddr string `json:"remoteAddr,omitempty"`
|
||
AckVersion int64 `json:"ackVersion"`
|
||
Slow bool `json:"slow"`
|
||
// Screen 是 Console 分配的屏号(URL 里的 screen=<display.id>),
|
||
// 管理端靠它把「本机显示器」与「在线实例」对上;局域网浏览器直接开 /display 时为空串。
|
||
// 用字符串而不是数字:显示器的 id 由 Electron 给,不保证是小的正整数。
|
||
Screen string `json:"screen"`
|
||
// Device 是浏览器报上来的设备标签(`安卓 13 · Chrome 120 · 1080×2400 @3x · 手机`),
|
||
// DeviceRaw 是原始 UA。老客户端不报时**整个键不出现**(omitempty),管理端列表退回显示 id。
|
||
Device string `json:"device,omitempty"`
|
||
DeviceRaw string `json:"deviceRaw,omitempty"`
|
||
// Alias 是操作员手动起的名字。它**不住在注册表里**:注册表是内存的、实例过期即清理,
|
||
// 而别名是机器级的、必须活过重启与实例过期,所以由 httpapi 从系统库设置里读出来,
|
||
// 在返回列表时按实例 id 填进来(见 httpapi 的 loadInstanceAliases)。
|
||
Alias string `json:"alias,omitempty"`
|
||
}
|
||
|
||
// instanceState 是注册表的内部记录;同一 instanceId 的多条连接合并成一个实例。
|
||
type instanceState struct {
|
||
id string
|
||
page string
|
||
screen string
|
||
device string
|
||
deviceRaw string
|
||
connectedAt time.Time
|
||
lastHeartbeat time.Time
|
||
remoteAddr string
|
||
ackVersion int64
|
||
slow bool
|
||
conns int
|
||
}
|
||
|
||
// displayRegistration 是登记一个实例要用的 hello 字段。
|
||
// 单独一个结构体而不是直接传 helloPayload:注册表只关心「在哪一页、哪块屏、什么设备」,
|
||
// role / lastVersion 那些与它无关。
|
||
type displayRegistration struct {
|
||
page string
|
||
screen string
|
||
device string
|
||
deviceRaw string
|
||
}
|
||
|
||
// registerDisplay 把连接挂到大屏实例上。
|
||
//
|
||
// id 为空(局域网浏览器直接开 /display,URL 上没有 instance / screen)时**优先复用这条连接
|
||
// 已经在用的实例 id**,只有从没用过才现生成一个:客户端翻页(setPage)与补齐设备标签都会
|
||
// 再发一次 hello,而每次 hello 都现生成 `display-<随机>` 的话,老实例会永远留在注册表里
|
||
// 变成一条「离线条目」(TTL 十分钟才清理),管理端「在线大屏」底下就凭空多出一条同一台
|
||
// 设备的离线记录。刷新页面等新连接仍然各自登记,不会因此查不到。
|
||
func (h *Hub) registerDisplay(c *client, id string, reg displayRegistration) string {
|
||
c.mu.Lock()
|
||
if id == "" {
|
||
id = c.instanceID
|
||
}
|
||
if id == "" {
|
||
id = "display-" + randomID()
|
||
}
|
||
prev := c.instanceID
|
||
c.instanceID = id
|
||
c.mu.Unlock()
|
||
|
||
// 同一连接重复 hello 或换了 instanceId 时,先归还旧实例的引用,避免计数虚高。
|
||
// 复用同一条 id 时 prev == id,引用计数不动——这正是「重复 hello 不许产生新实例」的一半。
|
||
if prev != "" && prev != id {
|
||
h.releaseDisplayID(prev, c)
|
||
}
|
||
|
||
device := sanitizeDeviceText(reg.device, maxDeviceChars)
|
||
deviceRaw := sanitizeDeviceText(reg.deviceRaw, maxDeviceRawChars)
|
||
|
||
now := time.Now()
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
h.pruneInstancesLocked(now)
|
||
st := h.instances[id]
|
||
if st == nil {
|
||
st = &instanceState{id: id, connectedAt: now}
|
||
h.instances[id] = st
|
||
}
|
||
if prev != id {
|
||
st.conns++
|
||
}
|
||
if reg.page != "" {
|
||
st.page = reg.page
|
||
}
|
||
if reg.screen != "" {
|
||
st.screen = reg.screen
|
||
}
|
||
// 设备标签只在这次报了非空值时覆盖:老客户端(以及 localStorage 不可用的客户端)
|
||
// 不带这两个字段,不该把上一次 hello 报上来的标签抹掉。
|
||
if device != "" {
|
||
st.device = device
|
||
}
|
||
if deviceRaw != "" {
|
||
st.deviceRaw = deviceRaw
|
||
}
|
||
st.lastHeartbeat = now
|
||
st.remoteAddr = c.remoteAddr
|
||
return id
|
||
}
|
||
|
||
// releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查;
|
||
// 离线超过 instanceTTL 后由 pruneInstancesLocked 清理。
|
||
func (h *Hub) releaseDisplay(c *client) {
|
||
c.mu.Lock()
|
||
id := c.instanceID
|
||
c.mu.Unlock()
|
||
if id == "" {
|
||
return
|
||
}
|
||
h.releaseDisplayID(id, c)
|
||
}
|
||
|
||
func (h *Hub) releaseDisplayID(id string, c *client) {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
st := h.instances[id]
|
||
if st == nil {
|
||
return
|
||
}
|
||
if st.conns > 0 {
|
||
st.conns--
|
||
}
|
||
st.lastHeartbeat = time.Now()
|
||
st.ackVersion = c.ackVersion.Load()
|
||
st.slow = c.slow.Load()
|
||
}
|
||
|
||
// touchInstance 在收到客户端消息或探测到 pong 时刷新「最后活跃」。
|
||
// now 必须是**收到对端帧的时间**,不能是服务端自己想盖的章:这个字段既决定注册表
|
||
// 里的「最后心跳」,也是离线实例清理的计时起点(见 pruneInstancesLocked)。
|
||
func (h *Hub) touchInstance(c *client, now time.Time) {
|
||
c.mu.Lock()
|
||
id := c.instanceID
|
||
c.mu.Unlock()
|
||
if id == "" {
|
||
return
|
||
}
|
||
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
if st := h.instances[id]; st != nil {
|
||
st.lastHeartbeat = now
|
||
st.ackVersion = c.ackVersion.Load()
|
||
st.slow = c.slow.Load()
|
||
}
|
||
}
|
||
|
||
// syncInstance 只把连接的 ack / 慢标记同步到注册表,**不碰 lastHeartbeat**。
|
||
// 心跳循环用它:服务端自己发过心跳不能算「对端还活着」的证据,
|
||
// 否则一块拔了网线的屏会一直被刷成「刚刚活跃」。
|
||
func (h *Hub) syncInstance(c *client) {
|
||
c.mu.Lock()
|
||
id := c.instanceID
|
||
c.mu.Unlock()
|
||
if id == "" {
|
||
return
|
||
}
|
||
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
if st := h.instances[id]; st != nil {
|
||
st.ackVersion = c.ackVersion.Load()
|
||
st.slow = c.slow.Load()
|
||
}
|
||
}
|
||
|
||
// setInstancePage 记录服务端刚下发的目标页,让注册表里的「当前页」跟得上 goto 指令。
|
||
// targets 为空表示广播(所有在线实例),否则只更新被点名的那几个实例——定向切页后
|
||
// 每块屏的「当前页」必须各算各的。
|
||
//
|
||
// targets 在这里再 TrimSpace 一次是防御性的:调用方(SendCommandToInstances)已经
|
||
// 归一化过,但只要有第二个调用方忘了,注册表里就会出现「id 带空格」这类永远匹配不上的
|
||
// 死条目,而症状是「指令发出去了、界面上的当前页不变」——很难查。
|
||
func (h *Hub) setInstancePage(page string, targets []string) {
|
||
if page == "" {
|
||
return
|
||
}
|
||
var picked map[string]bool
|
||
if len(targets) > 0 {
|
||
picked = make(map[string]bool, len(targets))
|
||
for _, id := range targets {
|
||
if id = strings.TrimSpace(id); id != "" {
|
||
picked[id] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
for _, st := range h.instances {
|
||
if st.conns == 0 {
|
||
continue
|
||
}
|
||
if picked != nil && !picked[st.id] {
|
||
continue
|
||
}
|
||
st.page = page
|
||
}
|
||
}
|
||
|
||
// pruneInstancesLocked 清理过期离线实例并把注册表封顶到 maxInstances;
|
||
// 调用方必须持有 h.mu。淘汰只针对离线实例,保证在线屏幕不会被误删。
|
||
func (h *Hub) pruneInstancesLocked(now time.Time) {
|
||
for id, st := range h.instances {
|
||
if st.conns == 0 && now.Sub(st.lastHeartbeat) > instanceTTL {
|
||
delete(h.instances, id)
|
||
}
|
||
}
|
||
if len(h.instances) <= maxInstances {
|
||
return
|
||
}
|
||
offline := make([]*instanceState, 0, len(h.instances))
|
||
for _, st := range h.instances {
|
||
if st.conns == 0 {
|
||
offline = append(offline, st)
|
||
}
|
||
}
|
||
sort.Slice(offline, func(i, j int) bool { return offline[i].lastHeartbeat.Before(offline[j].lastHeartbeat) })
|
||
for _, st := range offline {
|
||
if len(h.instances) <= maxInstances {
|
||
break
|
||
}
|
||
delete(h.instances, st.id)
|
||
}
|
||
}
|
||
|
||
// Instances 返回注册表快照,按 instanceId 排序,便于管理端稳定展示。
|
||
//
|
||
// online = 「还有连接挂着」。这里刻意不叠加 TTL 判断:只要连接还在,客户端就是那条
|
||
// 连接上的证据——而连接本身已经被 livenessLoop 用 ping/pong 求证过(不回 pong 的连接
|
||
// 会先被断开、引用计数归零)。反过来,若在这里按 lastHeartbeat 再判一次,
|
||
// 一块正常但不发上行帧的大屏(数据没变时它只在收心跳)就会被误判成离线。
|
||
func (h *Hub) Instances() []Instance {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
h.pruneInstancesLocked(time.Now())
|
||
|
||
out := make([]Instance, 0, len(h.instances))
|
||
for _, st := range h.instances {
|
||
out = append(out, Instance{
|
||
ID: st.id,
|
||
Online: st.conns > 0,
|
||
Page: st.page,
|
||
LastHeartbeat: st.lastHeartbeat,
|
||
ConnectedAt: st.connectedAt,
|
||
RemoteAddr: st.remoteAddr,
|
||
AckVersion: st.ackVersion,
|
||
Slow: st.slow,
|
||
Screen: st.screen,
|
||
Device: st.device,
|
||
DeviceRaw: st.deviceRaw,
|
||
})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||
return out
|
||
}
|
||
|
||
func randomID() string {
|
||
var b [8]byte
|
||
if _, err := rand.Read(b[:]); err != nil {
|
||
// 随机源不可用时退回时间戳,实例 id 只需唯一,不承担安全职责。
|
||
return strconv.FormatInt(time.Now().UnixNano(), 16)
|
||
}
|
||
return hex.EncodeToString(b[:])
|
||
}
|