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:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user