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

+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)
}