Files
Scoreboard/server/internal/realtime/instances.go
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

251 lines
7.3 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
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"`
}
// instanceState 是注册表的内部记录;同一 instanceId 的多条连接合并成一个实例。
type instanceState struct {
id string
page string
screen string
connectedAt time.Time
lastHeartbeat time.Time
remoteAddr string
ackVersion int64
slow bool
conns int
}
// registerDisplay 把连接挂到大屏实例上;未带 instanceId 时由服务端生成,
// 保证「刷新页面 = 同一实例」之外的连接也能被登记和查询。
func (h *Hub) registerDisplay(c *client, id, page, screen string) string {
if id == "" {
id = "display-" + randomID()
}
now := time.Now()
c.mu.Lock()
prev := c.instanceID
c.instanceID = id
c.mu.Unlock()
// 同一连接重复 hello 或换了 instanceId 时,先归还旧实例的引用,避免计数虚高。
if prev != "" && prev != id {
h.releaseDisplayID(prev, c)
}
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 page != "" {
st.page = page
}
if screen != "" {
st.screen = screen
}
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,
})
}
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[:])
}