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:
AskaEth committed 2026-09-09 22:16:58 +08:00
1 parent 96cf181ae3
commit 13f1f03cd0
124 files changed
+5658 -1820

No files matched your search

+24 -1
View File
@@ -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():
+34 -10
View File
@@ -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
}
+152 -5
View File
@@ -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)
}
}
+39 -3
View File
@@ -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 {
+44 -18
View File
@@ -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 {
+162
View File
@@ -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])
}
}
}