fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修): - 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重, 通道优先级 athlete→team→id - 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一 - aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位 - 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑 - voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks - 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚 - 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400 - Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底 - /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings P1(计分口径 / 契约 / 现场): - standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验 RecordEligible 与 entry 归属;破纪录登记校验已发布成绩 - export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正 (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示 - 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/ recalculatedLedgerRows;登录审计 user_id 修正 - WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId; 实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新 - 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断; REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出 - 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端 points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护; 发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口 - Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII (已在 Meowhome 实跑验证) 自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、 node --test 24/24;console check-syntax 12 文件通过。
This commit is contained in:
1 parent
96cf181ae3
commit
13f1f03cd0
124 files changed
+5658
-1820
No files matched your search
@@ -33,13 +33,15 @@ type client struct {
|
||||
sessionRole string
|
||||
sessionUserID int64
|
||||
instanceID string
|
||||
// settingsAdmin 表示会话是否为 admin:recorder 也进管理端房间,但读不到系统配置。
|
||||
settingsAdmin bool
|
||||
|
||||
ackVersion atomic.Int64 // 客户端已确认应用到的版本
|
||||
lastSent atomic.Int64 // 服务端已发出的最大版本
|
||||
slow atomic.Bool
|
||||
}
|
||||
|
||||
func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) *client {
|
||||
func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) *client {
|
||||
return &client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
@@ -48,6 +50,7 @@ func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, ses
|
||||
done: make(chan struct{}),
|
||||
sessionRole: sessionRole,
|
||||
sessionUserID: sessionUserID,
|
||||
settingsAdmin: settingsAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +66,15 @@ func (c *client) stop(code websocket.StatusCode, reason string) {
|
||||
})
|
||||
}
|
||||
|
||||
// guardLoop 兜住读写 goroutine 里的 panic:HTTP 中间件的 recover 只覆盖 handler 栈,
|
||||
// 这两条 goroutine 不兜底会把整个进程带走。
|
||||
func (c *client) guardLoop(name string) {
|
||||
if r := recover(); r != nil {
|
||||
c.hub.log.Error("实时循环 panic,已断开连接", "loop", name, "remote", c.remoteAddr, "panic", r)
|
||||
c.stop(websocket.StatusInternalError, name+"循环异常")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *client) sendEvent(event string, data any) bool {
|
||||
msg, err := encode(event, data)
|
||||
if err != nil {
|
||||
@@ -88,8 +100,10 @@ func (c *client) sendRaw(msg []byte) bool {
|
||||
}
|
||||
|
||||
// readLoop 解析 C→S 消息;首条消息受 helloTimeout 限制,防止空连接占用资源。
|
||||
// 读写都在独立 goroutine 里跑,HTTP 中间件的 recover 覆盖不到,必须各自兜底。
|
||||
func (c *client) readLoop(ctx context.Context) {
|
||||
defer c.stop(websocket.StatusNormalClosure, "")
|
||||
defer c.guardLoop("读")
|
||||
|
||||
for {
|
||||
c.mu.Lock()
|
||||
@@ -140,6 +154,14 @@ func (c *client) handleMessage(ctx context.Context, data []byte) {
|
||||
c.hub.touchInstance(c, time.Now())
|
||||
|
||||
case EventResync:
|
||||
// hello 之前的 resync 既没有房间归属,又会提前清掉 hello 超时,直接忽略。
|
||||
c.mu.Lock()
|
||||
greeted := c.greeted
|
||||
c.mu.Unlock()
|
||||
if !greeted {
|
||||
c.hub.log.Debug("忽略 hello 之前的 resync", "remote", c.remoteAddr)
|
||||
return
|
||||
}
|
||||
c.hub.sendInitial(ctx, c, nil)
|
||||
|
||||
default:
|
||||
@@ -149,6 +171,7 @@ func (c *client) handleMessage(ctx context.Context, data []byte) {
|
||||
|
||||
// writeLoop 是唯一的写者,保证同一连接上的帧顺序。
|
||||
func (c *client) writeLoop(ctx context.Context) {
|
||||
defer c.guardLoop("写")
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -21,6 +21,7 @@ const (
|
||||
defaultSlowLag = 500
|
||||
defaultHelloTimeout = 15 * time.Second
|
||||
defaultWriteTimeout = 10 * time.Second
|
||||
defaultCloseTimeout = 2 * time.Second
|
||||
defaultSendBuffer = 64
|
||||
)
|
||||
|
||||
@@ -221,7 +222,7 @@ func (h *Hub) Handler() http.Handler {
|
||||
h.mu.Lock()
|
||||
if h.closed {
|
||||
h.mu.Unlock()
|
||||
_ = conn.CloseNow()
|
||||
_ = conn.Close(websocket.StatusGoingAway, "服务关闭")
|
||||
return
|
||||
}
|
||||
h.connWG.Add(1)
|
||||
@@ -229,25 +230,26 @@ func (h *Hub) Handler() http.Handler {
|
||||
defer h.connWG.Done()
|
||||
|
||||
// 身份只认会话:/ws 必须被 Authenticate 中间件包住,否则一律按 guest 处理。
|
||||
role, userID := wsIdentity(r.Context())
|
||||
h.serve(conn, r.RemoteAddr, role, userID)
|
||||
role, userID, settingsAdmin := wsIdentity(r.Context())
|
||||
h.serve(conn, r.RemoteAddr, role, userID, settingsAdmin)
|
||||
})
|
||||
}
|
||||
|
||||
// wsIdentity 从请求上下文取会话身份并折算成实时层角色。
|
||||
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
|
||||
func wsIdentity(ctx context.Context) (string, int64) {
|
||||
// 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
|
||||
return RoleDisplay, 0, false
|
||||
}
|
||||
return RoleAdmin, u.ID
|
||||
return RoleAdmin, u.ID, u.Role.Allows(auth.RoleAdmin)
|
||||
}
|
||||
|
||||
func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) {
|
||||
func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) {
|
||||
conn.SetReadLimit(1 << 20)
|
||||
|
||||
c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID)
|
||||
c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID, settingsAdmin)
|
||||
h.addClient(c)
|
||||
|
||||
ctx, cancel := context.WithCancel(h.ctx)
|
||||
@@ -259,11 +261,29 @@ func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessio
|
||||
go func() { defer loops.Done(); c.writeLoop(ctx) }()
|
||||
|
||||
<-c.done
|
||||
// 先做关闭握手,再取消读上下文:coder/websocket 在读上下文被取消时会直接
|
||||
// 关闭底层连接,那样对端只能看到 EOF,收不到策略性关闭码(如 1008)。
|
||||
// 写循环已因 c.done 退出,读循环会随对端回帧自然结束。
|
||||
c.mu.Lock()
|
||||
code, reason := c.closeCode, c.closeReason
|
||||
c.mu.Unlock()
|
||||
if code == 0 {
|
||||
code = websocket.StatusNormalClosure
|
||||
}
|
||||
closed := make(chan struct{})
|
||||
go func() {
|
||||
defer close(closed)
|
||||
_ = conn.Close(code, reason)
|
||||
}()
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(defaultCloseTimeout):
|
||||
// 对端不回应:cancel 会强制关闭底层连接,读循环随之退出。
|
||||
}
|
||||
|
||||
cancel()
|
||||
loops.Wait()
|
||||
h.removeClient(c)
|
||||
// 客户端会自动重连并走 catchup,这里直接断开,避免关闭握手阻塞。
|
||||
_ = conn.CloseNow()
|
||||
}
|
||||
|
||||
func (h *Hub) addClient(c *client) {
|
||||
@@ -429,6 +449,10 @@ func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) {
|
||||
c.stop(websocket.StatusInternalError, "快照不可用")
|
||||
return
|
||||
}
|
||||
// 快照对所有角色下发,但系统配置只有 admin 能看:非 admin 只保留大屏渲染所需字段。
|
||||
if !c.settingsAdmin {
|
||||
snap.Settings = snap.Settings.WithoutAdminFields()
|
||||
}
|
||||
if !c.sendEvent(EventSnapshot, snap) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -94,9 +94,10 @@ func (e *testEnv) dial(t *testing.T, hello *helloPayload) *wsClient {
|
||||
}
|
||||
|
||||
type wsMessage struct {
|
||||
event string
|
||||
data json.RawMessage
|
||||
err error
|
||||
event string
|
||||
data json.RawMessage
|
||||
err error
|
||||
closeCode websocket.StatusCode
|
||||
}
|
||||
|
||||
type wsClient struct {
|
||||
@@ -114,7 +115,7 @@ func (c *wsClient) readLoop() {
|
||||
for {
|
||||
_, data, err := c.conn.Read(c.ctx)
|
||||
if err != nil {
|
||||
c.msgs <- wsMessage{err: err}
|
||||
c.msgs <- wsMessage{err: err, closeCode: websocket.CloseStatus(err)}
|
||||
return
|
||||
}
|
||||
var env envelope
|
||||
@@ -196,6 +197,30 @@ func (c *wsClient) expectClosed(d time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// expectClosedWithCode 断言连接被服务端以指定关闭码关闭,且关闭前没有下发任何事件。
|
||||
func (c *wsClient) expectClosedWithCode(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
|
||||
}
|
||||
c.t.Fatalf("连接关闭前仍收到事件 %s", msg.event)
|
||||
case <-timer.C:
|
||||
c.t.Fatalf("等待服务端关闭连接超时")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decode[T any](t *testing.T, raw json.RawMessage) T {
|
||||
t.Helper()
|
||||
var v T
|
||||
@@ -511,11 +536,12 @@ func TestAdminRoomTargeting(t *testing.T) {
|
||||
}
|
||||
|
||||
// guest 自报 role=admin 必须被拒绝:管理端房间只能由会话授予。
|
||||
// 断开必须带上策略性关闭码,否则客户端会当成网络异常无限重连。
|
||||
func TestGuestCannotSubscribeAdminRoom(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||||
|
||||
c.expectClosed(3 * time.Second)
|
||||
c.expectClosedWithCode(3*time.Second, websocket.StatusPolicyViolation)
|
||||
}
|
||||
|
||||
// guest 只能订阅 display 房间:即使 hello 里塞了 userId 也不能进入定向房间。
|
||||
@@ -576,3 +602,124 @@ func TestSlowClientDetected(t *testing.T) {
|
||||
return len(insts) == 1 && !insts[0].Online
|
||||
})
|
||||
}
|
||||
|
||||
// 快照对所有角色下发,但系统配置是 admin-only:guest/recorder 只能拿到大屏渲染
|
||||
// 所需的 event/display/榜单开关,默认积分模板、审核开关与折算系数必须置空/清零。
|
||||
func TestSnapshotSettingsRedactedForNonAdmin(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
user *auth.User
|
||||
}{
|
||||
{"guest", &auth.User{Role: auth.RoleGuest}},
|
||||
{"recorder", &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env := newTestEnvAs(t, tc.user)
|
||||
mustExec(t, env.db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
|
||||
mustExec(t, env.db, `UPDATE settings SET value = 'true' WHERE key = 'results.review_required'`)
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: tc.name + "-1"})
|
||||
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||||
|
||||
if snap.Settings.DefaultProfileID != nil {
|
||||
t.Fatalf("非 admin 不应看到 defaultProfileId: %+v", snap.Settings.DefaultProfileID)
|
||||
}
|
||||
if snap.Settings.RequireApproval {
|
||||
t.Fatalf("非 admin 不应看到 requireApproval")
|
||||
}
|
||||
if snap.Settings.Standings.IndividualToTeamWeight != 0 {
|
||||
t.Fatalf("非 admin 不应看到 individualToTeamWeight: %v", snap.Settings.Standings.IndividualToTeamWeight)
|
||||
}
|
||||
// 大屏要用的字段必须保留,裁剪不能误伤标题/主题/榜单开关。
|
||||
if snap.Settings.Event.Name == "" || snap.Settings.Display.RotateSecond == 0 {
|
||||
t.Fatalf("event/display 不应被裁剪: %+v", snap.Settings)
|
||||
}
|
||||
if snap.Settings.Standings.RankingMode != "points" ||
|
||||
!snap.Settings.Standings.ShowIndividualBoard || !snap.Settings.Standings.ShowIndividualMedals {
|
||||
t.Fatalf("standings 展示开关不应被裁剪: %+v", snap.Settings.Standings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// admin 仍拿到完整配置,管理端才能在 WS 首包里读到默认积分模板与审核开关。
|
||||
func TestSnapshotSettingsFullForAdmin(t *testing.T) {
|
||||
env := newTestEnvAs(t, &auth.User{ID: 1, Username: "boss", Role: auth.RoleAdmin, Active: true})
|
||||
mustExec(t, env.db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
|
||||
mustExec(t, env.db, `UPDATE settings SET value = 'true' WHERE key = 'results.review_required'`)
|
||||
|
||||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||||
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
|
||||
|
||||
if snap.Settings.DefaultProfileID == nil {
|
||||
t.Fatalf("admin 应看到 defaultProfileId")
|
||||
}
|
||||
if !snap.Settings.RequireApproval {
|
||||
t.Fatalf("admin 应看到 requireApproval")
|
||||
}
|
||||
if snap.Settings.Standings.IndividualToTeamWeight != 0.5 {
|
||||
t.Fatalf("admin 应看到 individualToTeamWeight=0.5,得到 %v", snap.Settings.Standings.IndividualToTeamWeight)
|
||||
}
|
||||
}
|
||||
|
||||
// resync 在 hello 之前不可用:否则会绕过房间分配并提前清掉 hello 超时。
|
||||
func TestResyncBeforeHelloIgnored(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
c := env.dial(t, nil)
|
||||
c.send(EventResync, struct{}{})
|
||||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
|
||||
t.Fatalf("hello 之前的 resync 不应下发快照,收到事件 %s", ev)
|
||||
}
|
||||
|
||||
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||||
t.Fatalf("hello 后应收到版本 1 的快照,得到 %d", snap.Version)
|
||||
}
|
||||
c.send(EventResync, struct{}{})
|
||||
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
|
||||
t.Fatalf("hello 后 resync 应拿到快照,得到 %d", snap.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// 离线实例不能无限堆积:超过 TTL 后在下一次读取注册表时被清理。
|
||||
func TestInstanceRegistryPrunesStaleOffline(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "stale-1"})
|
||||
c.recvEvent(EventSnapshot)
|
||||
|
||||
c.close()
|
||||
waitFor(t, "实例离线", func() bool {
|
||||
insts := env.hub.Instances()
|
||||
return len(insts) == 1 && !insts[0].Online
|
||||
})
|
||||
|
||||
env.hub.mu.Lock()
|
||||
env.hub.instances["stale-1"].lastHeartbeat = time.Now().Add(-instanceTTL - time.Minute)
|
||||
env.hub.mu.Unlock()
|
||||
|
||||
if insts := env.hub.Instances(); len(insts) != 0 {
|
||||
t.Fatalf("过期离线实例应被清理,得到 %+v", insts)
|
||||
}
|
||||
}
|
||||
|
||||
// 读写 goroutine 的 panic 必须被兜住并断开该连接,而不是带走进程。
|
||||
func TestGuardLoopRecoversPanic(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
c := newClient(env.hub, nil, "test", RoleDisplay, 0, false)
|
||||
|
||||
func() {
|
||||
defer c.guardLoop("读")
|
||||
panic("boom")
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-c.done:
|
||||
default:
|
||||
t.Fatal("panic 后应关闭该连接")
|
||||
}
|
||||
if c.closeCode != websocket.StatusInternalError {
|
||||
t.Fatalf("panic 后关闭码应为 %d,得到 %d", websocket.StatusInternalError, c.closeCode)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// 注册表容量控制:离线实例保留一段时间便于排查,过期即清理;
|
||||
// 没有 displayKey 的客户端每次重连都会生成新实例,必须有上限兜底。
|
||||
const (
|
||||
instanceTTL = 10 * time.Minute
|
||||
maxInstances = 200
|
||||
)
|
||||
|
||||
// Instance 是大屏实例的对外快照,供管理端查询「谁在线、在哪一页」。
|
||||
type Instance struct {
|
||||
ID string `json:"id"`
|
||||
@@ -52,6 +59,7 @@ func (h *Hub) registerDisplay(c *client, id, page string) string {
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.pruneInstancesLocked(now)
|
||||
st := h.instances[id]
|
||||
if st == nil {
|
||||
st = &instanceState{id: id, connectedAt: now}
|
||||
@@ -68,7 +76,8 @@ func (h *Hub) registerDisplay(c *client, id, page string) string {
|
||||
return id
|
||||
}
|
||||
|
||||
// releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查。
|
||||
// releaseDisplay 断开时只减少引用计数:实例保留在注册表里并标记离线,方便事后排查;
|
||||
// 离线超过 instanceTTL 后由 pruneInstancesLocked 清理。
|
||||
func (h *Hub) releaseDisplay(c *client) {
|
||||
c.mu.Lock()
|
||||
id := c.instanceID
|
||||
@@ -126,10 +135,37 @@ func (h *Hub) setInstancePage(page string) {
|
||||
}
|
||||
}
|
||||
|
||||
// pruneInstancesLocked 清理过期离线实例并把注册表封顶到 maxInstances;
|
||||
// 调用方必须持有 h.mu。淘汰只针对离线实例,保证在线屏幕不会被误删。
|
||||
func (h *Hub) pruneInstancesLocked(now time.Time) {
|
||||
for id, st := range h.instances {
|
||||
if st.conns == 0 && now.Sub(st.lastHeartbeat) > instanceTTL {
|
||||
delete(h.instances, id)
|
||||
}
|
||||
}
|
||||
if len(h.instances) <= maxInstances {
|
||||
return
|
||||
}
|
||||
offline := make([]*instanceState, 0, len(h.instances))
|
||||
for _, st := range h.instances {
|
||||
if st.conns == 0 {
|
||||
offline = append(offline, st)
|
||||
}
|
||||
}
|
||||
sort.Slice(offline, func(i, j int) bool { return offline[i].lastHeartbeat.Before(offline[j].lastHeartbeat) })
|
||||
for _, st := range offline {
|
||||
if len(h.instances) <= maxInstances {
|
||||
break
|
||||
}
|
||||
delete(h.instances, st.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Instances 返回注册表快照,按 instanceId 排序,便于管理端稳定展示。
|
||||
func (h *Hub) Instances() []Instance {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.pruneInstancesLocked(time.Now())
|
||||
|
||||
out := make([]Instance, 0, len(h.instances))
|
||||
for _, st := range h.instances {
|
||||
|
||||
@@ -24,8 +24,8 @@ const latestResultsLimit = 20
|
||||
type Snapshot struct {
|
||||
Version int64 `json:"version"`
|
||||
Settings dto.EventSettings `json:"settings"`
|
||||
Teams []domain.Team `json:"teams"`
|
||||
Events []domain.Event `json:"events"`
|
||||
Teams []dto.Team `json:"teams"`
|
||||
Events []dto.Event `json:"events"`
|
||||
Standings []dto.StandingRow `json:"standings"`
|
||||
Records []Record `json:"records"`
|
||||
LatestResults []dto.EventResultGroup `json:"latestResults"`
|
||||
@@ -61,8 +61,8 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
snap := &Snapshot{
|
||||
Teams: []domain.Team{},
|
||||
Events: []domain.Event{},
|
||||
Teams: []dto.Team{},
|
||||
Events: []dto.Event{},
|
||||
Standings: []dto.StandingRow{},
|
||||
Records: []Record{},
|
||||
LatestResults: []dto.EventResultGroup{},
|
||||
@@ -78,13 +78,15 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
|
||||
if err := fillSettings(ctx, tx, values, snap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadTeams(ctx, tx, snap); err != nil {
|
||||
teams, err := loadTeams(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snap.Teams = dto.TeamsFromDomain(teams)
|
||||
if err := loadEvents(ctx, tx, snap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadStandings(ctx, tx, snap, values); err != nil {
|
||||
if err := loadStandings(ctx, tx, snap, teams, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadRecords(ctx, tx, snap); err != nil {
|
||||
@@ -142,29 +144,32 @@ func fillSettings(ctx context.Context, tx *sql.Tx, values map[string]string, sna
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
// loadTeams 读取全量单位表(含停用单位):榜单展示字段要与 REST 一致,
|
||||
// 停用但仍有流水的单位也必须能补出编码/名称/颜色,否则大屏会出现空白行。
|
||||
func loadTeams(ctx context.Context, tx *sql.Tx) ([]domain.Team, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
|
||||
COALESCE(color, ''), sort_order, active
|
||||
FROM teams WHERE active = 1
|
||||
FROM teams
|
||||
ORDER BY sort_order, code`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取单位: %w", err)
|
||||
return nil, fmt.Errorf("读取单位: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]domain.Team, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
t domain.Team
|
||||
active int
|
||||
)
|
||||
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil {
|
||||
return fmt.Errorf("解析单位: %w", err)
|
||||
return nil, fmt.Errorf("解析单位: %w", err)
|
||||
}
|
||||
t.Active = active != 0
|
||||
snap.Teams = append(snap.Teams, t)
|
||||
out = append(out, t)
|
||||
}
|
||||
return rows.Err()
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
@@ -204,14 +209,15 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
e.Status = domain.EventStatus(status)
|
||||
e.RecordEligible = recordEligible != 0
|
||||
e.Scored = scored != 0
|
||||
snap.Events = append(snap.Events, e)
|
||||
snap.Events = append(snap.Events, dto.EventFromDomain(e))
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// loadStandings 复用 domain 的排名口径,且输入与 REST /api/standings 完全一致:
|
||||
// 个人流水按系数折算、团体奖牌开关生效、只有配了计分通道的项目才产出名次。
|
||||
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, values map[string]string) error {
|
||||
// 排名输入只含启用单位,展示字段用全量单位表补齐;个人流水按系数折算、团体奖牌开关生效、
|
||||
// 只有配了计分通道的项目才产出名次,排序键取自规则包落库的 standings.tie_break。
|
||||
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, teams []domain.Team, values map[string]string) error {
|
||||
ledger, err := loadLedger(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -220,17 +226,36 @@ func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, values map[s
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activeTeams := make([]domain.Team, 0, len(teams))
|
||||
for _, t := range teams {
|
||||
if t.Active {
|
||||
activeTeams = append(activeTeams, t)
|
||||
}
|
||||
}
|
||||
rows := domain.BuildTeamStandings(domain.StandingsInput{
|
||||
Teams: snap.Teams,
|
||||
Teams: activeTeams,
|
||||
Ledger: ledger,
|
||||
Ranked: ranked,
|
||||
TieBreak: splitTieBreak(values["standings.tie_break"]),
|
||||
IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1),
|
||||
ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true),
|
||||
})
|
||||
snap.Standings = dto.StandingRows(rows, snap.Teams)
|
||||
snap.Standings = dto.StandingRows(rows, teams)
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitTieBreak 解析 standings.tie_break(逗号分隔的排序键);空串返回 nil,由 domain 落回默认口径。
|
||||
func splitTieBreak(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
|
||||
@@ -331,6 +356,7 @@ func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
}
|
||||
|
||||
// loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。
|
||||
// 已作废(status=withdrawn)的成绩必须排除,否则大屏会继续展示被作废的行;
|
||||
// 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。
|
||||
func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
@@ -347,7 +373,7 @@ func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
LEFT JOIN event_categories cat ON cat.id = e.category_id
|
||||
LEFT JOIN teams t ON t.id = e.team_id
|
||||
LEFT JOIN athletes a ON a.id = e.athlete_id
|
||||
WHERE r.state = 'published'
|
||||
WHERE r.state = 'published' AND r.status <> 'withdrawn'
|
||||
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC
|
||||
LIMIT ?`, latestResultsLimit)
|
||||
if err != nil {
|
||||
|
||||
@@ -471,3 +471,165 @@ func lastID(t *testing.T, res sql.Result) int64 {
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// 快照榜单排序必须读规则包落库的 standings.tie_break,不能只走硬编码默认序。
|
||||
func TestSnapshotTieBreakFromSettings(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
|
||||
team1 := insertTeam(t, db, "T1", "第一单位", "")
|
||||
team2 := insertTeam(t, db, "T2", "第二单位", "")
|
||||
event1 := insertEvent(t, db, "E1", "100米")
|
||||
event2 := insertEvent(t, db, "E2", "200米")
|
||||
cat1 := insertCategory(t, db, event1, "男子")
|
||||
cat2 := insertCategory(t, db, event2, "男子")
|
||||
athlete1 := insertAthlete(t, db, "张三", team1)
|
||||
athlete2 := insertAthlete(t, db, "李四", team2)
|
||||
|
||||
// T1:银牌 + 10 分;T2:金牌 + 0 分。默认「总分优先」T1 在前,按金牌优先则 T2 在前。
|
||||
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
|
||||
insertChannel(t, db, event1, "athlete")
|
||||
result1 := insertResult(t, db, event1, entry1, 2, "11.58", 11.58, "2026-09-09T10:00:00Z")
|
||||
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")
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
if len(snap.Standings) != 2 || snap.Standings[0].TeamCode != "T1" {
|
||||
t.Fatalf("未配置 tieBreak 时应按总分排序,T1 在前: %+v", snap.Standings)
|
||||
}
|
||||
|
||||
mustExec(t, db, `INSERT INTO settings(key, value) VALUES('standings.tie_break', 'gold')
|
||||
ON CONFLICT(key) DO UPDATE SET value = 'gold'`)
|
||||
snap, err = env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
if len(snap.Standings) != 2 || snap.Standings[0].TeamCode != "T2" {
|
||||
t.Fatalf("tieBreak=gold 时应按金牌排序,T2 在前: %+v", snap.Standings)
|
||||
}
|
||||
}
|
||||
|
||||
// 停用但有积分的单位必须像 REST 一样补出编码/名称;排名输入仍只含启用单位。
|
||||
func TestSnapshotStandingsIncludesInactiveTeamDisplayFields(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
|
||||
insertTeam(t, db, "T1", "第一单位", "")
|
||||
inactive := insertTeam(t, db, "T2", "第二单位", "")
|
||||
mustExec(t, db, `UPDATE teams SET active = 0 WHERE id = ?`, inactive)
|
||||
// 停用且无流水的单位不应出现在榜单里。
|
||||
idle := insertTeam(t, db, "T3", "第三单位", "")
|
||||
mustExec(t, db, `UPDATE teams SET active = 0 WHERE id = ?`, idle)
|
||||
|
||||
event1 := insertEvent(t, db, "E1", "100米")
|
||||
cat1 := insertCategory(t, db, event1, "男子")
|
||||
athlete2 := insertAthlete(t, db, "李四", inactive)
|
||||
entry2 := insertEntry(t, db, event1, cat1, inactive, athlete2)
|
||||
insertChannel(t, db, event1, "athlete")
|
||||
result2 := insertResult(t, db, event1, entry2, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
|
||||
insertLedger(t, db, inactive, athlete2, event1, entry2, result2, 5)
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
|
||||
// teams 是公开单位表,与 GET /api/teams 一样含停用单位。
|
||||
if len(snap.Teams) != 3 {
|
||||
t.Fatalf("快照单位表应含停用单位,得到 %+v", snap.Teams)
|
||||
}
|
||||
|
||||
byCode := make(map[string]dto.StandingRow, len(snap.Standings))
|
||||
for _, row := range snap.Standings {
|
||||
byCode[row.TeamCode] = row
|
||||
}
|
||||
row, ok := byCode["T2"]
|
||||
if !ok {
|
||||
t.Fatalf("停用但有积分的单位应上榜: %+v", snap.Standings)
|
||||
}
|
||||
if row.TeamName != "第二单位" || row.TotalPoints != 5 {
|
||||
t.Fatalf("停用单位应补出名称与积分: %+v", row)
|
||||
}
|
||||
if _, ok := byCode["T3"]; ok {
|
||||
t.Fatalf("停用且无流水的单位不应上榜: %+v", snap.Standings)
|
||||
}
|
||||
if _, ok := byCode["T1"]; !ok {
|
||||
t.Fatalf("启用单位应上榜: %+v", snap.Standings)
|
||||
}
|
||||
}
|
||||
|
||||
// 已作废(status=withdrawn)的成绩不得出现在大屏「最新成绩」。
|
||||
func TestSnapshotLatestResultsExcludesVoided(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
|
||||
team1 := insertTeam(t, db, "T1", "第一单位", "")
|
||||
event1 := insertEvent(t, db, "E1", "100米")
|
||||
cat1 := insertCategory(t, db, event1, "男子")
|
||||
athlete1 := insertAthlete(t, db, "张三", team1)
|
||||
athlete2 := insertAthlete(t, db, "李四", team1)
|
||||
|
||||
keep := insertEntry(t, db, event1, cat1, team1, athlete1)
|
||||
insertResult(t, db, event1, keep, 1, "11.58", 11.58, "2026-09-09T10:00:01Z")
|
||||
// 同一报名项只能有一条成绩,作废行用另一个报名项。
|
||||
voided := insertEntry(t, db, event1, cat1, team1, athlete2)
|
||||
voidedResult := insertResult(t, db, event1, voided, 2, "11.72", 11.72, "2026-09-09T10:00:02Z")
|
||||
mustExec(t, db, `UPDATE results SET status = 'withdrawn' WHERE id = ?`, voidedResult)
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
for _, group := range snap.LatestResults {
|
||||
for _, row := range group.Rows {
|
||||
if row.EntryID == voided {
|
||||
t.Fatalf("已作废成绩不应出现在最新成绩: %+v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(snap.LatestResults) != 1 || len(snap.LatestResults[0].Rows) != 1 ||
|
||||
snap.LatestResults[0].Rows[0].EntryID != keep {
|
||||
t.Fatalf("最新成绩应只保留未作废的一条: %+v", snap.LatestResults)
|
||||
}
|
||||
}
|
||||
|
||||
// 快照的 teams/events 必须用 DTO 序列化:可空字段显式 null、必填字段不因零值消失。
|
||||
func TestSnapshotTeamsEventsWireFieldsPresent(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
insertTeam(t, db, "T1", "第一单位", "")
|
||||
insertEvent(t, db, "E1", "100米")
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化快照: %v", err)
|
||||
}
|
||||
var top struct {
|
||||
Teams []map[string]json.RawMessage `json:"teams"`
|
||||
Events []map[string]json.RawMessage `json:"events"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &top); err != nil {
|
||||
t.Fatalf("解析快照: %v", err)
|
||||
}
|
||||
for _, key := range []string{"id", "code", "name", "shortName", "logoPath", "color", "sortOrder", "active"} {
|
||||
if _, ok := top.Teams[0][key]; !ok {
|
||||
t.Fatalf("teams 缺少契约字段 %s: %s", key, top.Teams[0])
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"id", "sportId", "code", "name", "eventType", "resultMode", "resultType",
|
||||
"compare", "recordEligible", "scored", "multiplier", "status", "scheduledAt", "venue", "sortOrder", "remark"} {
|
||||
if _, ok := top.Events[0][key]; !ok {
|
||||
t.Fatalf("events 缺少契约字段 %s: %s", key, top.Events[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user