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:
AskaEth committed 2026-09-09 23:13:20 +08:00
1 parent 045b0f5d95
commit 27bddefaad
15 files changed
+199 -31

No files matched your search

+5 -1
View File
@@ -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)。
+26
View File
@@ -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 只能拿到大屏渲染
+3 -1
View File
@@ -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'
+22
View File
@@ -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())