fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)

## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
This commit is contained in:
AskaEth committed 2026-09-11 21:47:07 +08:00
1 parent 53a0cb225c
commit 45edfbeb71
115 files changed
+10104 -755

No files matched your search

+44
View File
@@ -5,6 +5,8 @@ import (
"database/sql"
"encoding/json"
"fmt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
)
// currentVersion 返回服务端版本号,即 change_log 的最大 id;空表为 0。
@@ -109,3 +111,45 @@ func payloadJSON(payload string) json.RawMessage {
}
return json.RawMessage(payload)
}
// entitySettings 是赛事配置在 change_log.entity_type 里的取值;
// 它的载荷是完整的 dto.EventSettings,含只有 admin 能读的系统配置字段。
const entitySettings = "settings"
// redactChange 按连接身份裁剪一条变更的载荷,返回不与其它连接共享的新对象。
//
// 快照对非 admin 会套 WithoutAdminFields 砍掉 defaultProfileId / requireApproval /
// individualToTeamWeight,但 change 与 catchup 走的是另一条路径:不在这里做同样处理,
// 任何打开 /ws 的浏览器都能从增量里读到这三个字段,快照上的裁剪等于被旁路。
// 只动 settings 这一个实体,其余实体的载荷本来就是公开的赛事数据。
func redactChange(ch Change, settingsAdmin bool) Change {
if settingsAdmin || ch.Entity != entitySettings {
return ch
}
var settings dto.EventSettings
if err := json.Unmarshal(ch.Data, &settings); err != nil {
// 载荷不是 EventSettings 形状(历史数据 / 手工改库):宁可只下发空对象,
// 也不能把可能含系统配置的原文透给非 admin。
ch.Data = json.RawMessage(`{}`)
return ch
}
data, err := json.Marshal(settings.WithoutAdminFields())
if err != nil {
ch.Data = json.RawMessage(`{}`)
return ch
}
ch.Data = data
return ch
}
// redactChanges 批量裁剪;admin 连接原样返回,不做无谓的复制。
func redactChanges(changes []Change, settingsAdmin bool) []Change {
if settingsAdmin {
return changes
}
out := make([]Change, 0, len(changes))
for _, ch := range changes {
out = append(out, redactChange(ch, settingsAdmin))
}
return out
}
+42 -6
View File
@@ -33,25 +33,37 @@ type client struct {
userID int64
sessionRole string
sessionUserID int64
instanceID string
// sessionID 是握手时的会话 id(未登录为空):会话撤销后要据此复查并断开本连接。
sessionID string
instanceID string
// settingsAdmin 表示会话是否为 admin:recorder 也进管理端房间,但读不到系统配置。
settingsAdmin bool
ackVersion atomic.Int64 // 客户端已确认应用到的版本
lastSent atomic.Int64 // 服务端已发出的最大版本
slow atomic.Bool
// snapshotAt 是本连接最近一次发出整库快照的时间(unix 纳秒)。
// 它是「整库快照窗口」的记账点:resync 与不带 lastVersion 的 hello 都会走到发快照,
// 两者共用一个窗口,不能靠交替发把限流绕过去。
snapshotAt atomic.Int64
// snapshotPending 表示「有请求要过整库快照,但被窗口挡下了」:
// 心跳循环会在窗口过后补发一份,见 flushPendingSnapshot。
// 不能把被挡下的请求直接丢掉——大屏收到它不认识的变更时会主动 resync,
// 丢了就可能让那块屏一直停在旧数据上,直到下一条变更到来。
snapshotPending atomic.Bool
slow atomic.Bool
}
func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) *client {
func newClient(h *Hub, conn *websocket.Conn, remoteAddr string, id wsIdentity) *client {
return &client{
hub: h,
conn: conn,
remoteAddr: remoteAddr,
send: make(chan []byte, defaultSendBuffer),
done: make(chan struct{}),
sessionRole: sessionRole,
sessionUserID: sessionUserID,
settingsAdmin: settingsAdmin,
sessionRole: id.Role,
sessionUserID: id.UserID,
sessionID: id.SessionID,
settingsAdmin: id.SettingsAdmin,
}
}
@@ -85,6 +97,29 @@ func (c *client) sendEvent(event string, data any) bool {
return c.sendRaw(msg)
}
// allowSnapshot 按连接给「整库快照」记账:窗口内只放行一次。
//
// 触发整库快照的入口有两个(resync,以及不带 lastVersion 的 hello),它们共用这一个窗口:
// 一条 15 字节的帧就是一次 DB 事务 + 8 条查询,而赛事库是单连接(SetMaxOpenConns(1)),
// 快照读取还在全局发送锁里——不限流的话,一个写坏的客户端就能把库连接与所有人的推送一起堵住。
// 窗口(defaultSnapshotMinInterval)比客户端自己的 3s 冷却短,正常客户端按自己的节奏回来时
// 窗口已经过期,重连补齐行为因此不受影响。
func (c *client) allowSnapshot(minInterval time.Duration) bool {
if minInterval <= 0 {
return true
}
now := time.Now().UnixNano()
for {
last := c.snapshotAt.Load()
if last != 0 && now-last < int64(minInterval) {
return false
}
if c.snapshotAt.CompareAndSwap(last, now) {
return true
}
}
}
func (c *client) sendRaw(msg []byte) bool {
select {
case <-c.done:
@@ -163,6 +198,7 @@ func (c *client) handleMessage(ctx context.Context, data []byte) {
c.hub.log.Debug("忽略 hello 之前的 resync", "remote", c.remoteAddr)
return
}
// 整库快照的限流统一在 sendInitial 里判定(resync 与重复 hello 共用同一个窗口)。
c.hub.sendInitial(ctx, c, nil)
default:
+225 -32
View File
@@ -26,6 +26,17 @@ const (
defaultWriteTimeout = 10 * time.Second
defaultCloseTimeout = 2 * time.Second
defaultSendBuffer = 64
// defaultSnapshotMinInterval 是同一连接两次「整库快照」之间的最小间隔。
//
// 取 2.5s,比客户端自己的 3s 冷却(web/src/shared/realtime.ts 的 resyncCooldownMs)短半秒:
// 客户端按自己的节奏回来时(≥ 它的上次发送 + 3s),服务端的窗口必须已经过期,
// 否则两个等长的窗口会因收发延迟抖动而被随机判定成「超频」,把正常的补齐请求挡掉。
// 反过来,2.5s 也已经把放大面压到「每个连接每 2.5 秒最多一份整库快照」
// (一份 = 一个事务 + 8 条查询,赛事库还是单连接)。
defaultSnapshotMinInterval = 2500 * time.Millisecond
// sessionCheckTimeout 是会话复查查询的超时:系统库同样只有一条连接,
// 被长操作占住时不能把心跳循环一起卡住。
sessionCheckTimeout = 2 * time.Second
)
// slowCloseCode 是踢掉慢客户端时使用的自定义关闭码。
@@ -63,6 +74,16 @@ type Hub struct {
helloTO time.Duration
writeTO time.Duration
allowedOrigins []string
// snapshotMinInterval 是同一连接两次整库快照之间的最小间隔;<=0 表示不限流。
snapshotMinInterval time.Duration
// sessionChecker 复查长连接背后的会话是否还有效;未注入时只在握手时校验一次。
sessionChecker SessionChecker
}
// SessionChecker 判定一条会话当前是否仍然有效(未被撤销 / 未过期 / 用户未停用)。
// auth.Service 满足这个接口;测试可注入桩实现。
type SessionChecker interface {
SessionActive(ctx context.Context, sessionID string) (bool, error)
}
// NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。
@@ -73,19 +94,20 @@ func NewHub(db *sql.DB, log *slog.Logger) *Hub {
}
ctx, cancel := context.WithCancel(context.Background())
h := &Hub{
db: db,
log: log,
rooms: make(map[string]map[*client]struct{}),
clients: make(map[*client]struct{}),
instances: make(map[string]*instanceState),
ctx: ctx,
cancel: cancel,
heartbeatInterval: defaultHeartbeatInterval,
heartbeatReset: make(chan struct{}, 1),
maxCatchup: defaultMaxCatchup,
slowLag: defaultSlowLag,
helloTO: defaultHelloTimeout,
writeTO: defaultWriteTimeout,
db: db,
log: log,
rooms: make(map[string]map[*client]struct{}),
clients: make(map[*client]struct{}),
instances: make(map[string]*instanceState),
ctx: ctx,
cancel: cancel,
heartbeatInterval: defaultHeartbeatInterval,
heartbeatReset: make(chan struct{}, 1),
maxCatchup: defaultMaxCatchup,
slowLag: defaultSlowLag,
helloTO: defaultHelloTimeout,
writeTO: defaultWriteTimeout,
snapshotMinInterval: defaultSnapshotMinInterval,
}
if db != nil {
if v, err := h.currentVersion(context.Background()); err == nil {
@@ -177,6 +199,38 @@ func (h *Hub) SetAllowedOrigins(origins []string) {
h.mu.Unlock()
}
// SetSessionChecker 注入会话复查器:连接建立后周期性复查会话是否仍然有效,失效即断开。
//
// /ws 的身份在握手时固化,而会话随时可能被撤销(登出、改密、管理员重置密码、停用用户)。
// 不复查的话,那条长连接会一直以原角色收推送——被停用的人只要不关页面,管理端就还挂着。
func (h *Hub) SetSessionChecker(c SessionChecker) {
h.mu.Lock()
h.sessionChecker = c
h.mu.Unlock()
}
// SetSnapshotMinInterval 设置同一连接两次整库快照之间的最小间隔;<= 0 表示不限流(仅测试用)。
//
// 撞到窗口的请求不会拿到第二份全量:客户端本来就刚拿到过一份,心跳里的版本号会让它
// 在需要时重新发起;越过窗口后一切照旧。
func (h *Hub) SetSnapshotMinInterval(d time.Duration) {
h.mu.Lock()
h.snapshotMinInterval = d
h.mu.Unlock()
}
func (h *Hub) sessionCheckerValue() SessionChecker {
h.mu.RLock()
defer h.mu.RUnlock()
return h.sessionChecker
}
func (h *Hub) snapshotMinIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.snapshotMinInterval
}
func (h *Hub) heartbeatIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -237,26 +291,45 @@ func (h *Hub) Handler() http.Handler {
defer h.connWG.Done()
// 身份只认会话:/ws 必须被 Authenticate 中间件包住,否则一律按 guest 处理。
role, userID, settingsAdmin := wsIdentity(r.Context())
h.serve(conn, r.RemoteAddr, role, userID, settingsAdmin)
h.serve(conn, r.RemoteAddr, wsIdentityFromContext(r.Context()))
})
}
// wsIdentity 从请求上下文取会话身份并折算成实时层角色。
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
// settingsAdmin 单独返回真实的 admin 判定:recorder 能进管理端房间,但读不到系统配置。
func wsIdentity(ctx context.Context) (string, int64, bool) {
u := middleware.User(ctx)
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
return RoleDisplay, 0, false
}
return RoleAdmin, u.ID, u.Role.Allows(auth.RoleAdmin)
// wsIdentity 是握手时从会话取到的身份;整条连接的生命周期都用它。
//
// sessionID 不进协议,只为会话复查保留:会话被撤销后要据此找到并断开对应的连接。
type wsIdentity struct {
Role string
UserID int64
SessionID string
SettingsAdmin bool
}
func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) {
// wsIdentityFromContext 从请求上下文取会话身份并折算成实时层角色。
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
// settingsAdmin 单独返回真实的 admin 判定:recorder 能进管理端房间,但读不到系统配置。
func wsIdentityFromContext(ctx context.Context) wsIdentity {
id := wsIdentity{}
if sess := middleware.Session(ctx); sess != nil {
id.SessionID = sess.ID
}
u := middleware.User(ctx)
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
// 会话无效时 Authenticate 已经降级成 guest,这里拿不到会话 id,
// 因此降级后的连接不会被会话复查误伤。
id.Role = RoleDisplay
return id
}
id.Role = RoleAdmin
id.UserID = u.ID
id.SettingsAdmin = u.Role.Allows(auth.RoleAdmin)
return id
}
func (h *Hub) serve(conn *websocket.Conn, remoteAddr string, id wsIdentity) {
conn.SetReadLimit(1 << 20)
c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID, settingsAdmin)
c := newClient(h, conn, remoteAddr, id)
h.addClient(c)
ctx, cancel := context.WithCancel(h.ctx)
@@ -402,6 +475,8 @@ func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) {
// sendInitial 决定首包是 snapshot 还是 catchup:
// 没有 lastVersion、版本超前、或增量条数超阈值,一律回落全量快照。
//
// 两个入口共用这一条路径:hello(首包 / 翻页补的那条)与 resync。
func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) {
h.sendMu.Lock()
defer h.sendMu.Unlock()
@@ -419,34 +494,91 @@ func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) {
h.setVersion(cur)
if lastVersion == nil || *lastVersion <= 0 || *lastVersion > cur {
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
n, err := h.countChangesSince(ctx, *lastVersion)
if err != nil {
h.log.Warn("统计增量失败,回落快照", "err", err)
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
if n > h.maxCatchupValue() {
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
changes, err := h.changesSince(ctx, *lastVersion, 0)
if err != nil {
h.log.Warn("读取增量失败,回落快照", "err", err)
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
if !c.sendEvent(EventCatchup, catchupPayload{FromVersion: *lastVersion, Version: cur, Changes: changes}) {
// catchup 与 change 一样按连接身份裁剪:重连补齐不能成为读系统配置的后门。
payload := catchupPayload{
FromVersion: *lastVersion,
Version: cur,
Changes: redactChanges(changes, c.settingsAdmin),
}
if !c.sendEvent(EventCatchup, payload) {
return
}
c.lastSent.Store(cur)
c.ackVersion.Store(cur)
}
// sendSnapshotGuarded 是「要发整库快照」的唯一出口(调用方须持有 sendMu,同 sendSnapshotLocked)。
//
// 整库快照的代价是一个 DB 事务 + 8 条查询,而赛事库是单连接(SetMaxOpenConns(1)),
// 读取还在全局发送锁里:不限流的话,一条 15 字节的帧(resync,或重复的不带 lastVersion 的
// hello)就能反复触发,把库连接与所有人的推送一起堵住。这里按连接记账,窗口内已经发过
// 整库快照就不再发第二份。
//
// 只拦「整库快照」这一种:带 lastVersion 的 hello 走 catchup(增量)不受影响;
// 每次断线重连都是新连接(新的 client,窗口为空),第一份快照照发。
//
// 被挡下的请求不丢弃,只在连接上挂一个标记,由心跳循环补发(见 flushPendingSnapshot):
// 请求方拿到的是「稍微晚一点的一份」,而不是「什么都没有」。
func (h *Hub) sendSnapshotGuarded(ctx context.Context, c *client) bool {
interval := h.snapshotMinIntervalValue()
if !c.allowSnapshot(interval) {
c.snapshotPending.Store(true)
h.log.Debug("整库快照窗口内,改为心跳补发", "remote", c.remoteAddr, "interval", interval.String())
return false
}
// 这一份已经满足了挂起的请求(否则它会在下个心跳里变成多余的第三份)。
c.snapshotPending.Store(false)
h.sendSnapshotLocked(ctx, c)
return true
}
// flushPendingSnapshot 在心跳里补发被窗口挡下的整库快照;返回 true 表示补发了一份。
//
// 规则:只有在「有待发标记」且「窗口确实已过」时才发,发完清标记——
// 不能做成每次心跳都发一份(那等于把限流从 2.5s 放大成心跳频率)。
// 最迟一个心跳周期(默认 10s)内一定补上,正常客户端感知不到差别。
func (h *Hub) flushPendingSnapshot(ctx context.Context, c *client) bool {
if !c.snapshotPending.Load() {
return false
}
h.sendMu.Lock()
defer h.sendMu.Unlock()
// 加锁后再看一次:期间可能有别的路径已经发过(那时标记已被清掉)。
if !c.snapshotPending.Load() {
return false
}
if !c.allowSnapshot(h.snapshotMinIntervalValue()) {
// 窗口还没过(刚有别的请求发过一份):留给下一个心跳。
return false
}
c.snapshotPending.Store(false)
h.log.Debug("心跳补发挂起的整库快照", "remote", c.remoteAddr)
h.sendSnapshotLocked(ctx, c)
return true
}
// sendSnapshotLocked 必须在持有 sendMu 时调用:快照读取与「已发送版本」的推进
// 必须原子完成,否则并发的 change 会插到快照前面。
func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) {
@@ -503,12 +635,13 @@ func (h *Hub) fanout(room string, changes []Change) {
}
last := c.lastSent.Load()
settingsAdmin := c.settingsAdmin
filtered := make([]Change, 0, len(changes))
for _, ch := range changes {
if ch.Version > 0 && ch.Version <= last {
continue
}
filtered = append(filtered, ch)
filtered = append(filtered, redactChange(ch, settingsAdmin))
}
if len(filtered) == 0 {
continue
@@ -671,6 +804,9 @@ func (h *Hub) beat() {
now := time.Now()
data := heartbeatPayload{Version: version, ServerTime: nowISO(now)}
// 挂起补发单独收集,等心跳全部发完再做:补发要现建一份快照(慢,且要抢 sendMu),
// 不能让排在后面的大屏连心跳都跟着等(落线判定的看门狗就在客户端那边盯着心跳)。
var pending []*client
for _, c := range h.roomClients("") {
c.mu.Lock()
greeted := c.greeted
@@ -678,8 +814,65 @@ func (h *Hub) beat() {
if !greeted {
continue
}
// 会话失效(撤销 / 过期 / 用户停用)的连接先断开,不再给它下发心跳。
if h.sessionRevoked(c) {
continue
}
c.sendEvent(EventHeartbeat, data)
h.touchInstance(c, now)
h.checkSlow(c, version)
if c.snapshotPending.Load() {
pending = append(pending, c)
}
}
for _, c := range pending {
h.flushPendingSnapshot(h.ctx, c)
}
}
// sessionRevoked 复查连接背后的会话是否仍然有效,失效即发起断开;返回 true 表示已断开。
//
// 会话撤销(登出 / 改密 / 管理员重置密码 / 停用用户)会删掉会话行或停用用户,
// 但 /ws 的身份在握手时就固化了:不复查的话,那条长连接会一直以原角色收推送,
// 只有写操作被 HTTP 侧拦住。复查挂在心跳上,最迟一个心跳周期(默认 10s)内断开。
//
// 只有带会话的连接需要复查:guest 大屏连接没有会话,撤销语义不适用。
// 查询失败(库忙 / 系统库被长操作占住)一律按「仍有效」处理并留痕——
// 一次查询抖动不该把正在比赛的大屏整片踢下线。
func (h *Hub) sessionRevoked(c *client) bool {
checker := h.sessionCheckerValue()
if checker == nil {
return false
}
c.mu.Lock()
sessionID, role := c.sessionID, c.role
c.mu.Unlock()
if sessionID == "" {
return false
}
ctx, cancel := context.WithTimeout(h.ctx, sessionCheckTimeout)
defer cancel()
active, err := checker.SessionActive(ctx, sessionID)
if err != nil {
h.log.Warn("复查会话失败,本次保留连接", "session", sessionID, "remote", c.remoteAddr, "err", err)
return false
}
if active {
return false
}
// 管理端连接按「登录失效」关闭:前端对所有 1008 会停止重连并引导重新登录,
// 这正是被撤销的会话应有的表现。大屏连接改用 1001 让它自动重连——
// 重连时握手会按会话失效降级成 guest(HTTP 侧本来也是这样),
// 现场大屏不该因为操作员登出而永久冻结。
code, reason := websocket.StatusGoingAway, "会话已失效,按游客身份重连"
if role == RoleAdmin {
code, reason = websocket.StatusPolicyViolation, "会话已失效,请重新登录"
}
h.log.Info("会话已失效,断开实时连接", "session", sessionID, "role", role, "remote", c.remoteAddr)
c.stop(code, reason)
return true
}
+493 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -12,6 +13,7 @@ import (
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
@@ -19,6 +21,7 @@ import (
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
@@ -40,12 +43,23 @@ func newTestEnv(t *testing.T) *testEnv {
// newTestEnvAs 注入会话身份,等价于真实链路里 Authenticate 中间件写进 context 的 User。
func newTestEnvAs(t *testing.T, u *auth.User) *testEnv {
t.Helper()
return newTestEnvAsSession(t, u, "")
}
// newTestEnvAsSession 在身份之外再注入会话 id,等价于 Authenticate 中间件写进 context 的
// *auth.Session:会话复查(撤销即断开)要按会话 id 判定,测试必须能给出这一条。
func newTestEnvAsSession(t *testing.T, u *auth.User, sessionID string) *testEnv {
t.Helper()
db := newTestDB(t)
h := NewHub(db, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})))
handler := h.Handler()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler.ServeHTTP(w, r.WithContext(middleware.WithUser(r.Context(), u)))
ctx := middleware.WithUser(r.Context(), u)
if sessionID != "" {
ctx = middleware.WithSession(ctx, &auth.Session{ID: sessionID, UserID: u.ID})
}
handler.ServeHTTP(w, r.WithContext(ctx))
}))
t.Cleanup(func() {
@@ -179,6 +193,37 @@ func (c *wsClient) recvEvent(want string) json.RawMessage {
return nil
}
// awaitSnapshot 在 d 内等待一份整库快照(心跳等无关消息跳过);等到返回 true。
func (c *wsClient) awaitSnapshot(d time.Duration) bool {
c.t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
ev, _, ok := c.recvWithin(time.Until(deadline))
if !ok {
return false
}
if ev == EventSnapshot {
return true
}
}
return false
}
// noSnapshotWithin 断言 d 内没有整库快照;心跳等无关消息不算(连接断开则直接失败)。
func (c *wsClient) noSnapshotWithin(d time.Duration) {
c.t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
ev, _, ok := c.recvWithin(time.Until(deadline))
if !ok {
return
}
if ev == EventSnapshot {
c.t.Fatalf("这段时间内不该有整库快照")
}
}
}
// expectClosed 断言连接在 d 内被服务端关闭,且关闭前没有下发任何事件。
func (c *wsClient) expectClosed(d time.Duration) {
c.t.Helper()
@@ -459,9 +504,11 @@ func TestDuplicateDeliveryIgnored(t *testing.T) {
}
// 心跳携带版本号与服务器时间;版本落后时客户端 resync 能拿到最新快照。
// 本用例测的是协议本身(请求 → 响应),整库快照窗口另有专门用例,这里关闭限流。
func TestHeartbeatAndResync(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(0)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
@@ -486,8 +533,10 @@ func TestHeartbeatAndResync(t *testing.T) {
}
// 大屏实例注册表:instanceId、在线状态、当前页、最后心跳可查。
// 重复 hello 会回一份快照(窗口内属于限流范围,这里关掉限流以保持用例只关心注册表)。
func TestDisplayInstancesRegistry(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(0)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1", Page: "standings"})
c.recvEvent(EventSnapshot)
@@ -691,6 +740,7 @@ func TestSnapshotSettingsFullForAdmin(t *testing.T) {
// resync 在 hello 之前不可用:否则会绕过房间分配并提前清掉 hello 超时。
func TestResyncBeforeHelloIgnored(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(200 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, nil)
@@ -703,6 +753,9 @@ func TestResyncBeforeHelloIgnored(t *testing.T) {
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("hello 后应收到版本 1 的快照,得到 %d", snap.Version)
}
// hello 之后 resync 仍然可用,只是要与刚发出去的整库快照共用同一个窗口
// (生产默认窗口与客户端 3s 冷却一致;这里把它调短,免得测试睡 3 秒)。
time.Sleep(250 * time.Millisecond)
c.send(EventResync, struct{}{})
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("hello 后 resync 应拿到快照,得到 %d", snap.Version)
@@ -733,7 +786,7 @@ func TestInstanceRegistryPrunesStaleOffline(t *testing.T) {
// 读写 goroutine 的 panic 必须被兜住并断开该连接,而不是带走进程。
func TestGuardLoopRecoversPanic(t *testing.T) {
env := newTestEnv(t)
c := newClient(env.hub, nil, "test", RoleDisplay, 0, false)
c := newClient(env.hub, nil, "test", wsIdentity{Role: RoleDisplay})
func() {
defer c.guardLoop("读")
@@ -908,3 +961,441 @@ func TestDisplayInstancesReportScreen(t *testing.T) {
t.Fatalf("局域网浏览器不带屏号,应为空串,得到 %q", got)
}
}
// sessionCheckerFunc 把桩会话复查器写成普通函数:用例里只需回答「这条会话还在不在」。
type sessionCheckerFunc func(ctx context.Context, sessionID string) (bool, error)
func (f sessionCheckerFunc) SessionActive(ctx context.Context, sessionID string) (bool, error) {
return f(ctx, sessionID)
}
// ---------- 会话撤销:长连接必须跟着断开 ----------
// 会话被撤销(登出 / 改密 / 管理员重置密码 / 停用用户)后,已经建立的 /ws 连接必须被断开,
// 不能再以原角色继续收推送;管理端连接按 1008 关闭,前端据此停止重连并引导重新登录。
func TestSessionRevokedClosesAdminConnection(t *testing.T) {
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-1")
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
var revoked atomic.Bool
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
return !revoked.Load(), nil
}))
c := env.dial(t, &helloPayload{Role: RoleAdmin})
c.recvEvent(EventSnapshot)
// 会话仍有效时照常收推送:复查不能误伤在线用户。
insertChange(t, env.db, "teams", 1, ActionUpsert, `{"id":1}`)
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
c.recvEvent(EventChange)
// 撤销之后:最迟一个心跳周期内断开,关闭码为「登录失效」。
revoked.Store(true)
c.expectClosedWithCodeDraining(3*time.Second, websocket.StatusPolicyViolation)
// 断开后这条连接不再是任何广播的投递对象:后续变更推不到它。
waitFor(t, "失效连接退出", func() bool { return len(env.hub.roomClients("")) == 0 })
insertChange(t, env.db, "teams", 2, ActionUpsert, `{"id":2}`)
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
if n := len(env.hub.roomClients("")); n != 0 {
t.Fatalf("会话失效后不应再有投递对象,得到 %d 条连接", n)
}
}
// 大屏连接(role=display 但带会话)同样要断开,但用 1001:客户端会自动重连,
// 重连时握手按会话失效降级成 guest,现场大屏不该因为操作员登出而永久冻结。
func TestSessionRevokedClosesDisplayConnectionAsGoingAway(t *testing.T) {
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-2")
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
var revoked atomic.Bool
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
return !revoked.Load(), nil
}))
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1"})
c.recvEvent(EventSnapshot)
revoked.Store(true)
c.expectClosedWithCodeDraining(3*time.Second, websocket.StatusGoingAway)
}
// 没有会话的连接(局域网大屏)不做复查:撤销语义不适用于 guest,也不该白查一次库。
func TestSessionCheckSkippedWithoutSession(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
return false, nil // 一旦真被问到就会判死,但 guest 连接根本不该被问
}))
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "lan-1"})
c.recvEvent(EventSnapshot)
if ev, _, ok := c.recvWithin(300 * time.Millisecond); !ok || ev != EventHeartbeat {
t.Fatalf("guest 连接应继续收心跳,得到 %q ok=%v", ev, ok)
}
}
// 复查失败(系统库忙 / 被长操作占住)按「仍有效」处理:一次查询抖动不该把大屏整片踢下线。
func TestSessionCheckFailureKeepsConnection(t *testing.T) {
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-3")
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
return false, errors.New("库忙")
}))
c := env.dial(t, &helloPayload{Role: RoleAdmin})
c.recvEvent(EventSnapshot)
if ev, _, ok := c.recvWithin(300 * time.Millisecond); !ok || ev != EventHeartbeat {
t.Fatalf("复查失败时应保留连接并继续心跳,得到 %q ok=%v", ev, ok)
}
}
// ---------- 增量载荷按角色裁剪 ----------
// fullSettingsPayload 是 settings 实体的变更载荷:既有 admin 专属的系统配置,
// 也有大屏要用的展示字段(公告、标题、榜单开关)。
const fullSettingsPayload = `{"event":{"name":"测试运动会","subtitle":null,"host":null,"logoPath":null,"primaryColor":null},` +
`"display":{"background":null,"logo":null,"title":"测试运动会","subtitle":null,"primaryColor":null,` +
`"rotateSeconds":15,"announce":"请注意检录","fontTitle":null,"fontBody":null,"liveEvents":[],"liveSource":"auto"},` +
`"standings":{"individualToTeamWeight":0.5,"rankingMode":"points","showIndividualBoard":true,` +
`"showIndividualMedals":true,"liveOnPublish":true},"defaultProfileId":1,"requireApproval":true}`
// assertSettingsPayload 校验一条 settings 载荷是否按身份裁过:
// 非 admin 不得出现默认积分模板 / 审核开关 / 个人折算系数,但公告与标题这类大屏字段必须保留。
func assertSettingsPayload(t *testing.T, raw json.RawMessage, wantFull bool) {
t.Helper()
var got dto.EventSettings
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("settings 载荷无法解析: %s", raw)
}
if wantFull {
if got.DefaultProfileID == nil || !got.RequireApproval || got.Standings.IndividualToTeamWeight != 0.5 {
t.Fatalf("admin 应拿到完整系统配置: %s", raw)
}
} else {
if got.DefaultProfileID != nil {
t.Fatalf("非 admin 不应看到 defaultProfileId: %s", raw)
}
if got.RequireApproval {
t.Fatalf("非 admin 不应看到 requireApproval: %s", raw)
}
if got.Standings.IndividualToTeamWeight != 0 {
t.Fatalf("非 admin 不应看到 individualToTeamWeight: %s", raw)
}
}
if got.Event.Name != "测试运动会" || got.Display.Announce == nil || *got.Display.Announce != "请注意检录" {
t.Fatalf("大屏展示字段不该被裁剪: %s", raw)
}
if got.Standings.RankingMode != "points" || !got.Standings.ShowIndividualBoard || !got.Standings.LiveOnPublish {
t.Fatalf("榜单展示开关不该被裁剪: %s", raw)
}
}
// 快照裁剪之外,change 与 catchup 两条增量路径也必须按角色裁剪:
// 否则任何打开 /ws 的浏览器都能从增量里读到系统配置,快照上的裁剪等于被旁路。
func TestSettingsChangeRedactedByRole(t *testing.T) {
for _, tc := range []struct {
name string
user *auth.User
role string
full bool
}{
{"guest", &auth.User{Role: auth.RoleGuest}, RoleDisplay, false},
{"recorder", &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true}, RoleAdmin, false},
{"admin", &auth.User{ID: 1, Username: "boss", Role: auth.RoleAdmin, Active: true}, RoleAdmin, true},
} {
t.Run(tc.name, func(t *testing.T) {
env := newTestEnvAs(t, tc.user)
// 一条历史变更:用来构造 catchup(重连补齐)。先垫一条无关变更,
// 让下面的 lastVersion 落在增量区间内(0 会走全量快照)。
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
old := insertChange(t, env.db, entitySettings, 0, ActionUpsert, fullSettingsPayload)
// change:在线连接收到一条新的 settings 变更。
live := env.dial(t, &helloPayload{Role: tc.role, InstanceID: tc.name + "-live"})
live.recvEvent(EventSnapshot)
insertChange(t, env.db, entitySettings, 0, ActionUpsert, fullSettingsPayload)
sent := loadChanges(t, env.hub, old, 0)
env.hub.Broadcast(sent)
got := decode[changePayload](t, live.recvEvent(EventChange))
if len(got.Changes) != 1 || got.Changes[0].Entity != entitySettings {
t.Fatalf("应收到一条 settings 变更,得到 %+v", got.Changes)
}
assertSettingsPayload(t, got.Changes[0].Data, tc.full)
// 裁剪必须产出新对象:就地改写共享的 Change 会让 admin 连接也拿到裁剪版。
if string(sent[0].Data) != fullSettingsPayload {
t.Fatalf("裁剪不应改写共享的 Change: %s", sent[0].Data)
}
// catchup:重连连接按 lastVersion 补齐,同样不能漏出系统配置。
catchupClient := env.dial(t, &helloPayload{
Role: tc.role, InstanceID: tc.name + "-catchup", LastVersion: ptrInt64(old - 1),
})
cp := decode[catchupPayload](t, catchupClient.recvEvent(EventCatchup))
var found bool
for _, ch := range cp.Changes {
if ch.Entity != entitySettings {
continue
}
found = true
assertSettingsPayload(t, ch.Data, tc.full)
}
if !found {
t.Fatalf("catchup 应包含 settings 变更,得到 %+v", cp.Changes)
}
})
}
}
// 载荷不是 EventSettings 形状时宁可只下发空对象,也不能把可能含系统配置的原文透给非 admin。
func TestSettingsChangeUnknownShapeRedacted(t *testing.T) {
env := newTestEnv(t)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
insertChange(t, env.db, entitySettings, 0, ActionUpsert, `"旧版扁平配置"`)
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
got := decode[changePayload](t, c.recvEvent(EventChange))
if string(got.Changes[0].Data) != `{}` {
t.Fatalf("无法识别的 settings 载荷应降级成空对象,得到 %s", got.Changes[0].Data)
}
}
// ---------- 整库快照限流 ----------
//
// 整库快照有两个入口:resync,和不带 lastVersion 的 hello(首包 / 翻页补的那条)。
// 两者在 sendInitial 里共用同一个 per-connection 窗口,所以「交替发」绕不过去。
// 窗口内连发多次 resync 只出一份整库快照;越过窗口后恢复正常,
// 客户端「落后了要一次全量」的补齐行为不受影响。
func TestSnapshotThrottleCoversResync(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(300 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
// 新连接的第一份快照照发,并且它就是本窗口里的那一份。
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("新连接应收到版本 1 的快照,得到 %d", snap.Version)
}
for i := 0; i < 5; i++ {
c.send(EventResync, struct{}{})
}
if ev, _, ok := c.recvWithin(150 * time.Millisecond); ok {
t.Fatalf("同一窗口内不该有第二份整库快照,收到 %s", ev)
}
// 越过窗口后再连发 5 次:只放行一份。
time.Sleep(350 * time.Millisecond)
for i := 0; i < 5; i++ {
c.send(EventResync, struct{}{})
}
decode[Snapshot](t, c.recvEvent(EventSnapshot))
if ev, _, ok := c.recvWithin(150 * time.Millisecond); ok {
t.Fatalf("一个窗口只应放行一份整库快照,收到 %s", ev)
}
}
// 不带 lastVersion 的重复 hello 与 resync 等价:也是要一份整库快照,同样按窗口限流。
func TestSnapshotThrottleCoversRepeatHello(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(300 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("新连接应收到版本 1 的快照,得到 %d", snap.Version)
}
for i := 0; i < 5; i++ {
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
}
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
t.Fatalf("窗口内重复 hello 只应回一份快照(首包那份),收到 %s", ev)
}
// 越过窗口后恢复:真实节奏下翻页补的那条 hello 远晚于窗口,照旧生效。
time.Sleep(350 * time.Millisecond)
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
decode[Snapshot](t, c.recvEvent(EventSnapshot))
}
// hello 与 resync 交替发也绕不过限流:两者共用一个窗口,而不是各记各的。
func TestSnapshotThrottleSharedByHelloAndResync(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(time.Minute)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
decode[Snapshot](t, c.recvEvent(EventSnapshot))
resync := func() { c.send(EventResync, struct{}{}) }
hello := func() { c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"}) }
resync()
hello()
resync()
hello()
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
t.Fatalf("交替 hello / resync 不应绕过整库快照窗口,收到 %s", ev)
}
}
// 不变式:带 lastVersion 的 hello 走 catchup(增量),与整库快照无关——
// 刚发过快照也不能把它挡住,而且它不会留下「待发快照」的挂起标记。
func TestSnapshotThrottleDoesNotAffectCatchup(t *testing.T) {
env := newTestEnv(t)
// 窗口与心跳都调短:万一 catchup 请求被误当成「要整库快照」,下面的
// noSnapshotWithin 立刻就能看见那次多余的补发。
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
// 窗口内的第二条 hello 带 lastVersion:必须立刻拿到增量。
insertChange(t, env.db, "result", 2, ActionUpsert, `{"rank":2}`)
c.send(EventHello, helloPayload{
Role: RoleDisplay, InstanceID: "d1", LastVersion: ptrInt64(snap.Version),
})
cp := decode[catchupPayload](t, c.recvEvent(EventCatchup))
if cp.FromVersion != snap.Version || cp.Version != snap.Version+1 {
t.Fatalf("catchup 应为 %d → %d,得到 %d → %d",
snap.Version, snap.Version+1, cp.FromVersion, cp.Version)
}
if len(cp.Changes) != 1 || cp.Changes[0].ID != 2 {
t.Fatalf("catchup 应补上那一条新变更,得到 %+v", cp.Changes)
}
// catchup 不是整库快照:不该挂起、也不该在窗口过后补发。
c.noSnapshotWithin(400 * time.Millisecond)
}
// 不变式:窗口按连接记账。一条连接把自己的窗口占满,不影响别的连接的第一份快照;
// 断线重连也是新连接(新的 client,窗口为空),第一份快照同样照发。
func TestSnapshotThrottleIsScopedToConnection(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(time.Minute)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
// A 连发 resync 把自己的窗口占满。
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
a.recvEvent(EventSnapshot)
for i := 0; i < 3; i++ {
a.send(EventResync, struct{}{})
}
if ev, _, ok := a.recvWithin(200 * time.Millisecond); ok {
t.Fatalf("A 的窗口内不该再有整库快照,收到 %s", ev)
}
// B 是新连接:第一份快照照发,不受 A 影响。
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "b"})
if snap := decode[Snapshot](t, b.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("B 作为新连接应立刻拿到快照,得到版本 %d", snap.Version)
}
// 断线重连同样是新连接:现场刷新页面 / 网络抖动都不该被限流影响。
a.close()
reconnected := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
if snap := decode[Snapshot](t, reconnected.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("重连应立刻拿到快照,得到版本 %d", snap.Version)
}
}
// ---------- 被窗口挡下的整库快照:挂起 + 心跳补发 ----------
// 窗口内连发多次 resync:当下不回(否则等于没限流),但请求被挂起,
// 窗口过后由心跳补一份——大屏不会因为限流静默停在旧数据上。
func TestPendingSnapshotFlushedByHeartbeat(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot) // 首包即时发,窗口从这一份开始记账
for i := 0; i < 3; i++ {
c.send(EventResync, struct{}{})
}
c.noSnapshotWithin(100 * time.Millisecond)
if !c.awaitSnapshot(2 * time.Second) {
t.Fatal("窗口过后应由心跳补发一份整库快照")
}
// 补发即清标记:之后的心跳不再重复发(否则限流就退化成心跳频率了)。
c.noSnapshotWithin(300 * time.Millisecond)
}
// 不带 lastVersion 的重复 hello 与 resync 走同一条挂起路径:也只补一份。
func TestPendingSnapshotFromRepeatHelloFlushedOnce(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
for i := 0; i < 5; i++ {
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
}
c.noSnapshotWithin(100 * time.Millisecond)
if !c.awaitSnapshot(2 * time.Second) {
t.Fatal("窗口过后应由心跳补发一份整库快照")
}
c.noSnapshotWithin(300 * time.Millisecond)
}
// hello 与 resync 交替触发时共用一个挂起标记:补发仍然只有一份。
func TestPendingSnapshotSharedByHelloAndResync(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
for i := 0; i < 3; i++ {
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.send(EventResync, struct{}{})
}
c.noSnapshotWithin(100 * time.Millisecond)
if !c.awaitSnapshot(2 * time.Second) {
t.Fatal("窗口过后应由心跳补发一份整库快照")
}
c.noSnapshotWithin(300 * time.Millisecond)
}
// 补发的量级仍然是「每窗口一份」:两条连接各自挂起,各自只补一份。
func TestPendingSnapshotFlushIsPerConnection(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "b"})
a.recvEvent(EventSnapshot)
b.recvEvent(EventSnapshot)
a.send(EventResync, struct{}{})
b.send(EventResync, struct{}{})
// 只对 A 做一次「当下不回」的断言:两条连接的静默窗口不能串行等待,
// 后一条会把自己的补发也等进去(B 的那份本来就该在窗口过后到)。
a.noSnapshotWithin(100 * time.Millisecond)
if !a.awaitSnapshot(2*time.Second) || !b.awaitSnapshot(2*time.Second) {
t.Fatal("两条挂起的连接都应在窗口过后各补一份快照")
}
a.noSnapshotWithin(300 * time.Millisecond)
b.noSnapshotWithin(300 * time.Millisecond)
}
+30 -21
View File
@@ -283,21 +283,22 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
return out, rows.Err()
}
// loadRanked 与 REST 的 buildRanked 同源:必须 join event_score_channels,
// 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌;
// loadRanked 与 REST 的 buildRanked、导出的 loadRanked 同源:数据源是发布/重算时
// 按通道全量重建的 channel_ranks,一条名次一行(多通道项目因此不会把一枚奖牌算成多枚,
// 没配通道的项目也不会有名次行)。三个奖牌开关随行携带,domain 这边不必回查库;
// 同时排除只排名不计分(scored=0)的项目,与导出侧口径一致。
func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) {
rows, err := tx.QueryContext(ctx, `
SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status,
e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id
FROM results r
JOIN entries e ON e.id = r.entry_id
JOIN events ev ON ev.id = r.event_id AND ev.scored = 1
JOIN event_score_channels c ON c.event_id = r.event_id
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)
ORDER BY r.id, c.sort_order, c.id`)
SELECT COALESCE(cr.result_id, 0), cr.event_id, cr.category_id, cr.rank, cr.result_value, cr.status,
COALESCE(cr.entry_id, 0), COALESCE(cr.team_id, 0), COALESCE(cr.athlete_id, 0),
COALESCE(a.name, ''), cr.scope, cr.channel_id, cr.basis, cr.subject_key,
ev.award_medals, c.count_to_medals, c.count_to_team_medals
FROM channel_ranks cr
JOIN events ev ON ev.id = cr.event_id AND ev.scored = 1
JOIN event_score_channels c ON c.id = cr.channel_id
LEFT JOIN athletes a ON a.id = cr.athlete_id
WHERE cr.rank > 0
ORDER BY cr.event_id, cr.category_id, c.sort_order, c.id, cr.rank, cr.id`)
if err != nil {
return nil, fmt.Errorf("读取奖牌名次: %w", err)
}
@@ -306,22 +307,30 @@ func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error)
var out []domain.RankedResult
for rows.Next() {
var (
rr domain.RankedResult
value sql.NullFloat64
athleteID sql.NullInt64
rank int
rr domain.RankedResult
value sql.NullFloat64
status sql.NullString
awardMedals int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rr.Rank, &value, &status,
&rr.EntryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID,
&rr.Basis, &rr.SubjectKey, &awardMedals, &countToMedals, &countToTeamMedals); err != nil {
return nil, fmt.Errorf("解析奖牌名次: %w", err)
}
rr.Rank = rank
rr.Status = domain.ResultStatus(status.String)
if value.Valid {
v := value.Float64
rr.ResultValue = &v
}
rr.AthleteID = athleteID.Int64
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
// 与 REST 同口径:份额只有「主单位独占 100%」一种,team_id 为空时不造份额。
if rr.TeamID != 0 {
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
}
rr.AwardMedals = awardMedals != 0
rr.CountToMedals = countToMedals != 0
rr.CountToTeamMedals = countToTeamMedals != 0
out = append(out, rr)
}
return out, rows.Err()
+49 -10
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"testing"
@@ -65,8 +66,9 @@ func TestSnapshotWireContract(t *testing.T) {
cat1 := insertCategory(t, db, event1, "青年组")
athlete1 := insertAthlete(t, db, "张伟", team1)
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
insertChannel(t, db, event1, "athlete")
ch1 := insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertChannelRank(t, db, event1, cat1, ch1, entry1, result1, team1, athlete1, "athlete", "result", 1, 11.58)
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 9)
snap, err := env.hub.Snapshot(context.Background())
@@ -307,8 +309,9 @@ func TestSnapshotStandingsMedalSwitch(t *testing.T) {
event1 := insertEvent(t, db, "E1", "4x100 米接力")
cat1 := insertCategory(t, db, event1, "男子")
entry1 := insertEntry(t, db, event1, cat1, team1, 0)
insertChannel(t, db, event1, "team")
insertResult(t, db, event1, entry1, 1, "45.20", 45.20, "2026-09-09T10:00:00Z")
ch1 := insertChannel(t, db, event1, "team")
r1 := insertResult(t, db, event1, entry1, 1, "45.20", 45.20, "2026-09-09T10:00:00Z")
insertChannelRank(t, db, event1, cat1, ch1, entry1, r1, team1, 0, "team", "result", 1, 45.20)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
@@ -346,8 +349,9 @@ func TestSnapshotStandingsOnlyScoredChannels(t *testing.T) {
event2 := insertEvent(t, db, "E2", "团体项目")
cat2 := insertCategory(t, db, event2, "男子")
entry2 := insertEntry(t, db, event2, cat2, team2, 0)
insertChannel(t, db, event2, "team")
insertResult(t, db, event2, entry2, 1, "9.99", 9.99, "2026-09-09T10:00:01Z")
ch2 := insertChannel(t, db, event2, "team")
r2 := insertResult(t, db, event2, entry2, 1, "9.99", 9.99, "2026-09-09T10:00:01Z")
insertChannelRank(t, db, event2, cat2, ch2, entry2, r2, team2, 0, "team", "result", 1, 9.99)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
@@ -479,10 +483,43 @@ func insertEntry(t *testing.T, db *sql.DB, eventID, categoryID, teamID, athleteI
return lastID(t, res)
}
func insertChannel(t *testing.T, db *sql.DB, eventID int64, scope string) {
// insertChannel 建一条按成绩排名的通道,返回通道 id(写通道名次时要用)。
func insertChannel(t *testing.T, db *sql.DB, eventID int64, scope string) int64 {
t.Helper()
mustExec(t, db, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
res, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, ?, 'result', 1, 0)`, eventID, scope)
if err != nil {
t.Fatalf("插入计分通道: %v", err)
}
return lastID(t, res)
}
// insertChannelRank 写一条通道名次:大屏奖牌榜的读取来源(loadRanked 读 channel_ranks)。
// entryID 为 0 时主体是单位(subject_key 用 t:<teamID>),否则是报名项的 e:<entryID>。
func insertChannelRank(t *testing.T, db *sql.DB, eventID, categoryID, channelID, entryID, resultID, teamID, athleteID int64,
scope, basis string, rank int, value float64) {
t.Helper()
subject := fmt.Sprintf("e:%d", entryID)
if entryID == 0 {
subject = fmt.Sprintf("t:%d", teamID)
}
var entry, result, athlete, team any
if entryID != 0 {
entry = entryID
}
if resultID != 0 {
result = resultID
}
if athleteID != 0 {
athlete = athleteID
}
if teamID != 0 {
team = teamID
}
mustExec(t, db, `INSERT INTO channel_ranks (event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, team_id, rank, points, result_value, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 'ok')`,
eventID, categoryID, channelID, basis, scope, subject, entry, result, athlete, team, rank, value)
}
func insertResult(t *testing.T, db *sql.DB, eventID, entryID int64, rank int, text string, value float64, verifiedAt string) int64 {
@@ -537,13 +574,15 @@ func TestSnapshotTieBreakFromSettings(t *testing.T) {
// T1:银牌 + 10 分;T2:金牌 + 0 分。默认「总分优先」T1 在前,按金牌优先则 T2 在前。
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
insertChannel(t, db, event1, "athlete")
ch1 := insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 2, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertChannelRank(t, db, event1, cat1, ch1, entry1, result1, team1, athlete1, "athlete", "result", 2, 11.58)
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 10)
entry2 := insertEntry(t, db, event2, cat2, team2, athlete2)
insertChannel(t, db, event2, "athlete")
insertResult(t, db, event2, entry2, 1, "22.10", 22.10, "2026-09-09T10:00:01Z")
ch2 := insertChannel(t, db, event2, "athlete")
result2 := insertResult(t, db, event2, entry2, 1, "22.10", 22.10, "2026-09-09T10:00:01Z")
insertChannelRank(t, db, event2, cat2, ch2, entry2, result2, team2, athlete2, "athlete", "result", 1, 22.10)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {