fix: 上线前快修(大屏自愈 / 奖牌口径 / 聚合作废 / 密码可发现性)
- P0 慢客户端关闭码 1008 → 4000:前端把所有 1008 当「登录失效」并永久停止重连, 大屏被踢后再也不自愈(服务端 markSlow 改用自定义码,前端逻辑不动) - P1 REST 与 WS 的奖牌榜补 events.scored = 1 过滤,与导出成绩册口径一致 (否则「只排名不计分」项目只要挂着通道就会在大屏凭空出奖牌) - P1 聚合流水作废去掉 source_id 子查询:改通道会重建通道 id,旧聚合流水 永远匹配不上 → 再插一份 → 分数翻倍 - P1 审核页/历史页的折算系数兜底由 ?? 1 改为 >0 ? : 1:/ws 快照对非 admin 会把 individualToTeamWeight 置 0,recorder 看到的个人折算分全是 0 - P2 删掉 signature.local.txt 后清理残留的 signature.build.txt,避免继续注入旧署名 - 初始管理员密码:banner 加纯 ASCII 锚 INITIAL ADMIN CREATED,文档改为搜它 (原来教的 findstr "password=" 已经失效,且中文 findstr 本身不可靠) 自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 24/24;console 语法通过。
This commit is contained in:
1 parent
045b0f5d95
commit
27bddefaad
15 files changed
+199
-31
No files matched your search
@@ -25,6 +25,10 @@ const (
|
||||
defaultSendBuffer = 64
|
||||
)
|
||||
|
||||
// slowCloseCode 是踢掉慢客户端时使用的自定义关闭码。
|
||||
// 不能用 1008:前端把所有 1008 当「登录失效」并永久停止重连,慢客户端被踢后大屏会冻结。
|
||||
const slowCloseCode = websocket.StatusCode(4000)
|
||||
|
||||
// Hub 是实时同步中心:维护房间、连接、大屏实例注册表与版本号。
|
||||
//
|
||||
// 版本号等于 change_log 的最大 id;写入方提交事务后调用 Broadcast 推送变更,
|
||||
@@ -568,7 +572,7 @@ func (h *Hub) markSlow(c *client) {
|
||||
|
||||
h.log.Warn("慢客户端:ack 长时间落后,断开后由重连补齐",
|
||||
"instance", id, "ackVersion", c.ackVersion.Load(), "remote", c.remoteAddr)
|
||||
c.stop(websocket.StatusPolicyViolation, "ack 落后")
|
||||
c.stop(slowCloseCode, "ack 落后")
|
||||
}
|
||||
|
||||
// heartbeatLoop 每 10s 下发一次心跳(version + serverTime)。
|
||||
|
||||
@@ -221,6 +221,30 @@ func (c *wsClient) expectClosedWithCode(d time.Duration, want websocket.StatusCo
|
||||
}
|
||||
}
|
||||
|
||||
// expectClosedWithCodeDraining 断言连接最终被服务端以指定关闭码关闭。
|
||||
// 关闭前已下发的正常事件会被丢弃:慢客户端被踢属于「先推后关」,不能要求关闭前零事件。
|
||||
func (c *wsClient) expectClosedWithCodeDraining(d time.Duration, want websocket.StatusCode) {
|
||||
c.t.Helper()
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.msgs:
|
||||
if !ok {
|
||||
c.t.Fatalf("连接已关闭但没有收到关闭帧")
|
||||
}
|
||||
if msg.err != nil {
|
||||
if msg.closeCode != want {
|
||||
c.t.Fatalf("关闭码应为 %d,得到 %d (%v)", want, msg.closeCode, msg.err)
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-timer.C:
|
||||
c.t.Fatalf("等待服务端关闭连接超时")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decode[T any](t *testing.T, raw json.RawMessage) T {
|
||||
t.Helper()
|
||||
var v T
|
||||
@@ -601,6 +625,8 @@ func TestSlowClientDetected(t *testing.T) {
|
||||
insts := env.hub.Instances()
|
||||
return len(insts) == 1 && !insts[0].Online
|
||||
})
|
||||
// 必须用自定义关闭码:前端把所有 1008 当登录失效并永久停重连,大屏会就此冻结。
|
||||
c.expectClosedWithCodeDraining(3*time.Second, slowCloseCode)
|
||||
}
|
||||
|
||||
// 快照对所有角色下发,但系统配置是 admin-only:guest/recorder 只能拿到大屏渲染
|
||||
|
||||
@@ -282,13 +282,15 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
|
||||
}
|
||||
|
||||
// loadRanked 与 REST 的 buildRanked 同源:必须 join event_score_channels,
|
||||
// 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌。
|
||||
// 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌;
|
||||
// 同时排除只排名不计分(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'
|
||||
|
||||
@@ -338,6 +338,28 @@ func TestSnapshotStandingsOnlyScoredChannels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 只排名不计分(scored=0)的项目即使配了计分通道也不计入快照奖牌榜,与导出侧同口径。
|
||||
func TestSnapshotStandingsExcludesUnscoredEvents(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
|
||||
team1 := insertTeam(t, db, "T1", "第一单位", "")
|
||||
event1 := insertEvent(t, db, "E1", "表演项目")
|
||||
mustExec(t, db, `UPDATE events SET scored = 0 WHERE id = ?`, event1)
|
||||
cat1 := insertCategory(t, db, event1, "男子")
|
||||
entry1 := insertEntry(t, db, event1, cat1, team1, 0)
|
||||
insertChannel(t, db, event1, "team")
|
||||
insertResult(t, db, event1, entry1, 1, "9.99", 9.99, "2026-09-09T10:00:00Z")
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
if len(snap.Standings) != 1 || snap.Standings[0].Gold != 0 || snap.Standings[0].MedalTotal != 0 {
|
||||
t.Fatalf("scored=0 的项目不应产出奖牌: %+v", snap.Standings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySnapshotMarshalsEmptyLists(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
|
||||
Reference in new issue
Block a user