fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力 - realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份 - httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表 - web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换 - console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复 - docs: 用户文档三份 + README - 主代理: 破纪录加分接线(levels 未加载导致恒 0)
This commit is contained in:
1 parent
8dae63ee70
commit
7f9701c465
61 files changed
+3844
-333
No files matched your search
@@ -8,7 +8,11 @@ import (
|
||||
)
|
||||
|
||||
// currentVersion 返回服务端版本号,即 change_log 的最大 id;空表为 0。
|
||||
// NewHub 允许 db 为 nil(降级/测试),此时退回内存版本,避免空指针 panic。
|
||||
func (h *Hub) currentVersion(ctx context.Context) (int64, error) {
|
||||
if h.db == nil {
|
||||
return h.Version(), nil
|
||||
}
|
||||
var v sql.NullInt64
|
||||
if err := h.db.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil {
|
||||
return 0, fmt.Errorf("读取 change_log 版本号: %w", err)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -17,6 +18,8 @@ import (
|
||||
"github.com/coder/websocket"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -28,12 +31,22 @@ type testEnv struct {
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
// newTestEnv 建一个未登录(guest)的服务端:默认身份与大屏端一致。
|
||||
func newTestEnv(t *testing.T) *testEnv {
|
||||
t.Helper()
|
||||
return newTestEnvAs(t, &auth.User{Role: auth.RoleGuest})
|
||||
}
|
||||
|
||||
// newTestEnvAs 注入会话身份,等价于真实链路里 Authenticate 中间件写进 context 的 User。
|
||||
func newTestEnvAs(t *testing.T, u *auth.User) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
db := newTestDB(t)
|
||||
h := NewHub(db, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})))
|
||||
srv := httptest.NewServer(h.Handler())
|
||||
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)))
|
||||
}))
|
||||
|
||||
t.Cleanup(func() {
|
||||
srv.Close()
|
||||
@@ -165,6 +178,24 @@ func (c *wsClient) recvEvent(want string) json.RawMessage {
|
||||
return nil
|
||||
}
|
||||
|
||||
// expectClosed 断言连接在 d 内被服务端关闭,且关闭前没有下发任何事件。
|
||||
func (c *wsClient) expectClosed(d time.Duration) {
|
||||
c.t.Helper()
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-c.msgs:
|
||||
if !ok || msg.err != nil {
|
||||
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
|
||||
@@ -458,12 +489,13 @@ func TestDisplayInstancesRegistry(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// 房间隔离:admin:<userId> 只发给对应管理端。
|
||||
// 房间隔离:admin:<userId> 只发给对应管理端,且 userId 来自会话而非客户端自报。
|
||||
func TestAdminRoomTargeting(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env := newTestEnvAs(t, &auth.User{ID: 7, Username: "boss", Role: auth.RoleAdmin, Active: true})
|
||||
display := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
|
||||
display.recvEvent(EventSnapshot)
|
||||
admin := env.dial(t, &helloPayload{Role: RoleAdmin, UserID: 7})
|
||||
// hello 不再携带 userId:定向房间必须用会话里的 ID。
|
||||
admin := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||||
admin.recvEvent(EventSnapshot)
|
||||
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
@@ -478,6 +510,50 @@ 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)
|
||||
}
|
||||
|
||||
// guest 只能订阅 display 房间:即使 hello 里塞了 userId 也不能进入定向房间。
|
||||
func TestGuestStaysInDisplayRoom(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
c := env.dial(t, nil)
|
||||
c.send(EventHello, map[string]any{"role": RoleDisplay, "userId": 99})
|
||||
c.recvEvent(EventSnapshot)
|
||||
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
changes := loadChanges(t, env.hub, 0, 0)
|
||||
env.hub.BroadcastTo(RoomAdmin, changes)
|
||||
env.hub.BroadcastTo(AdminRoom(99), changes)
|
||||
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
|
||||
t.Fatalf("guest 不应收到管理端房间的消息,收到 %s", ev)
|
||||
}
|
||||
|
||||
// display 房间的消息仍然正常到达。
|
||||
env.hub.BroadcastTo(RoomDisplay, changes)
|
||||
if got := decode[changePayload](t, c.recvEvent(EventChange)); got.Version != 1 {
|
||||
t.Fatalf("大屏应收到 display 房间消息,版本得到 %d", got.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// recorder 会话可以进管理端房间(大屏控制/数据同步都由管理端承担)。
|
||||
func TestRecorderSessionJoinsAdminRoom(t *testing.T) {
|
||||
env := newTestEnvAs(t, &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true})
|
||||
c := env.dial(t, &helloPayload{Role: RoleAdmin})
|
||||
c.recvEvent(EventSnapshot)
|
||||
|
||||
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
env.hub.BroadcastTo(RoomAdmin, loadChanges(t, env.hub, 0, 0))
|
||||
|
||||
if got := decode[changePayload](t, c.recvEvent(EventChange)); got.Version != 1 {
|
||||
t.Fatalf("recorder 应进入管理端房间,版本得到 %d", got.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// 慢客户端:ack 落后超过阈值即被识别并断开。
|
||||
func TestSlowClientDetected(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
)
|
||||
|
||||
// 快照必须与数据库同源:版本号取自 change_log,各列表取真实表,
|
||||
@@ -16,21 +16,17 @@ func TestSnapshotContents(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
|
||||
mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `"测试运动会"`)
|
||||
mustExec(t, db, `INSERT INTO settings(key, value) VALUES ('display.theme', 'dark')`)
|
||||
mustExec(t, db, `INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T1', '第一单位', '一队', 1, 1)`)
|
||||
mustExec(t, db, `INSERT INTO sports(code, name) VALUES ('S1', '田径')`)
|
||||
mustExec(t, db, `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||||
VALUES (1, 'E1', '100米', 'individual', 'auto', 'time', 'asc', 'published')`)
|
||||
mustExec(t, db, `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`)
|
||||
mustExec(t, db, `INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`)
|
||||
mustExec(t, db, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`)
|
||||
mustExec(t, db, `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status)
|
||||
VALUES (1, 1, 1, '11.58', 11.58, 's', 'published', 'ok')`)
|
||||
mustExec(t, db, `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
|
||||
VALUES (1, 1, 1, 1, 1, 'result', 1, 9)`)
|
||||
mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `测试运动会`)
|
||||
team1 := insertTeam(t, db, "T1", "第一单位", "一队")
|
||||
event1 := insertEvent(t, db, "E1", "100米")
|
||||
cat1 := insertCategory(t, db, event1, "男子")
|
||||
athlete1 := insertAthlete(t, db, "张三", team1)
|
||||
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
|
||||
insertChannel(t, db, event1, "athlete")
|
||||
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
|
||||
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 9)
|
||||
mustExec(t, db, `INSERT INTO records(event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source)
|
||||
VALUES (1, 'meet', 11.2, '11.20', '李四', 1, '2025-09-01', 1, 'history')`)
|
||||
VALUES (?, 'meet', 11.2, '11.20', '李四', ?, '2025-09-01', 1, 'history')`, event1, team1)
|
||||
version := insertChange(t, db, "result", 1, ActionUpsert, `{"rank":1}`)
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
@@ -40,11 +36,8 @@ func TestSnapshotContents(t *testing.T) {
|
||||
if snap.Version != version {
|
||||
t.Fatalf("快照版本应为 %d,得到 %d", version, snap.Version)
|
||||
}
|
||||
if got := string(snap.Settings["event.name"]); got != `"测试运动会"` {
|
||||
t.Fatalf("settings JSON 值应透传,得到 %s", got)
|
||||
}
|
||||
if got := string(snap.Settings["display.theme"]); got != `"dark"` {
|
||||
t.Fatalf("settings 纯文本应编码为 JSON 字符串,得到 %s", got)
|
||||
if snap.Settings.Event.Name != "测试运动会" {
|
||||
t.Fatalf("settings 应聚合出嵌套 event.name,得到 %+v", snap.Settings.Event)
|
||||
}
|
||||
|
||||
if len(snap.Teams) != 1 || snap.Teams[0].Code != "T1" || !snap.Teams[0].Active {
|
||||
@@ -53,28 +46,295 @@ func TestSnapshotContents(t *testing.T) {
|
||||
if len(snap.Events) != 1 {
|
||||
t.Fatalf("小项数应为 1,得到 %d", len(snap.Events))
|
||||
}
|
||||
ev := snap.Events[0]
|
||||
if ev.Name != "100米" || ev.EventType != domain.EventTypeIndividual || !ev.Scored || ev.Compare != domain.CompareAsc {
|
||||
t.Fatalf("小项不符: %+v", ev)
|
||||
}
|
||||
|
||||
if len(snap.Standings) != 1 {
|
||||
t.Fatalf("榜单行数应为 1,得到 %d", len(snap.Standings))
|
||||
}
|
||||
row := snap.Standings[0]
|
||||
if row.TeamID != 1 || row.Points != 9 || row.Gold != 1 || row.Rank != 1 {
|
||||
t.Fatalf("榜单不符: %+v", row)
|
||||
}
|
||||
|
||||
if len(snap.Records) != 1 || snap.Records[0].HolderName != "李四" || snap.Records[0].ResultValue == nil {
|
||||
t.Fatalf("纪录不符: %+v", snap.Records)
|
||||
}
|
||||
if len(snap.LatestResults) != 1 {
|
||||
t.Fatalf("最新成绩数应为 1,得到 %d", len(snap.LatestResults))
|
||||
}
|
||||
|
||||
// 快照的线上 JSON 形状必须与 devdocs/05 第 3 节、web/src/shared/types.ts 一致:
|
||||
// settings 是嵌套对象、standings 用 totalPoints/teamCode/…、latestResults 按小项+组别分组。
|
||||
// 只断言 Go 字段会漏掉 json 标签写错这类线上事故,所以这里直接检查序列化结果。
|
||||
func TestSnapshotWireContract(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
|
||||
mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `测试运动会`)
|
||||
team1 := insertTeam(t, db, "T1", "第一单位", "一队")
|
||||
event1 := insertEvent(t, db, "E1", "男子 100 米")
|
||||
cat1 := insertCategory(t, db, event1, "青年组")
|
||||
athlete1 := insertAthlete(t, db, "张伟", team1)
|
||||
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
|
||||
insertChannel(t, db, event1, "athlete")
|
||||
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
|
||||
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 9)
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
lr := snap.LatestResults[0]
|
||||
if lr.ResultText != "11.58" || lr.TeamID != 1 || lr.AthleteID != 1 || lr.Rank != 1 || lr.State != "published" {
|
||||
t.Fatalf("最新成绩不符: %+v", lr)
|
||||
raw, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化快照: %v", err)
|
||||
}
|
||||
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &top); err != nil {
|
||||
t.Fatalf("解析快照 JSON: %v", err)
|
||||
}
|
||||
|
||||
// settings 必须是嵌套 EventSettings,不能再是点号扁平 map。
|
||||
var settings map[string]json.RawMessage
|
||||
if err := json.Unmarshal(top["settings"], &settings); err != nil {
|
||||
t.Fatalf("settings 不是对象: %s", top["settings"])
|
||||
}
|
||||
if _, flat := settings["event.name"]; flat {
|
||||
t.Fatalf("settings 仍是点号扁平 map: %s", top["settings"])
|
||||
}
|
||||
for _, key := range []string{"event", "display", "standings", "defaultProfileId", "requireApproval"} {
|
||||
if _, ok := settings[key]; !ok {
|
||||
t.Fatalf("settings 缺少嵌套字段 %s: %s", key, top["settings"])
|
||||
}
|
||||
}
|
||||
var parsed dto.EventSettings
|
||||
if err := json.Unmarshal(top["settings"], &parsed); err != nil {
|
||||
t.Fatalf("按 EventSettings 解析失败: %v", err)
|
||||
}
|
||||
if parsed.Event.Name != "测试运动会" || parsed.Display.Title != "测试运动会" {
|
||||
t.Fatalf("settings 默认值口径不符: %+v", parsed)
|
||||
}
|
||||
if parsed.Display.RotateSecond != 15 || parsed.Standings.IndividualToTeamWeight != 1 {
|
||||
t.Fatalf("settings 默认值应为 REST 的 15s / 系数 1: %+v", parsed)
|
||||
}
|
||||
if parsed.DefaultProfileID == nil || *parsed.DefaultProfileID != 1 {
|
||||
t.Fatalf("defaultProfileId 应由 scoring.default_profile 反查得到: %+v", parsed.DefaultProfileID)
|
||||
}
|
||||
|
||||
// standings 必须是 totalPoints/teamCode/teamName/medalTotal/rankChange。
|
||||
var rows []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(top["standings"], &rows); err != nil {
|
||||
t.Fatalf("standings 不是数组: %s", top["standings"])
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("榜单行数应为 1,得到 %d", len(rows))
|
||||
}
|
||||
for _, key := range []string{"teamId", "teamCode", "teamName", "totalPoints", "medalTotal", "rankChange", "gold", "rank"} {
|
||||
if _, ok := rows[0][key]; !ok {
|
||||
t.Fatalf("standings 缺少契约字段 %s: %s", key, top["standings"])
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{"points", "code", "name", "medals"} {
|
||||
if _, ok := rows[0][banned]; ok {
|
||||
t.Fatalf("standings 仍是 domain.StandingsRow 的字段 %s: %s", banned, top["standings"])
|
||||
}
|
||||
}
|
||||
var typed []dto.StandingRow
|
||||
if err := json.Unmarshal(top["standings"], &typed); err != nil {
|
||||
t.Fatalf("按 StandingRow 解析失败: %v", err)
|
||||
}
|
||||
if typed[0].TeamCode != "T1" || typed[0].TotalPoints != 9 || typed[0].MedalTotal != 1 {
|
||||
t.Fatalf("榜单内容不符: %+v", typed[0])
|
||||
}
|
||||
if typed[0].RankChange != nil {
|
||||
t.Fatalf("快照没有对比基准,rankChange 应为 null: %+v", typed[0].RankChange)
|
||||
}
|
||||
|
||||
// latestResults 必须按小项 + 组别分组,且行里带契约字段。
|
||||
var groups []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(top["latestResults"], &groups); err != nil {
|
||||
t.Fatalf("latestResults 不是数组: %s", top["latestResults"])
|
||||
}
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("分组数应为 1,得到 %d: %s", len(groups), top["latestResults"])
|
||||
}
|
||||
for _, key := range []string{"eventId", "eventCode", "eventName", "categoryId", "categoryName", "publishedAt", "rows"} {
|
||||
if _, ok := groups[0][key]; !ok {
|
||||
t.Fatalf("成绩分组缺少契约字段 %s: %s", key, top["latestResults"])
|
||||
}
|
||||
}
|
||||
var typedGroups []dto.EventResultGroup
|
||||
if err := json.Unmarshal(top["latestResults"], &typedGroups); err != nil {
|
||||
t.Fatalf("按 EventResultGroup 解析失败: %v", err)
|
||||
}
|
||||
resultRows := typedGroups[0].Rows
|
||||
if len(resultRows) != 1 || resultRows[0].EntryID != entry1 || resultRows[0].TeamCode != "T1" ||
|
||||
resultRows[0].AthleteName == nil || *resultRows[0].AthleteName != "张伟" {
|
||||
t.Fatalf("成绩行不符: %+v", resultRows)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一小项 + 组别的多条成绩必须落进同一组,不同组别/小项拆成不同组,组内按名次排序。
|
||||
func TestSnapshotLatestResultsGrouped(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", "女子 100 米")
|
||||
catA := insertCategory(t, db, event1, "青年组")
|
||||
catB := insertCategory(t, db, event1, "中年组")
|
||||
catC := insertCategory(t, db, event2, "青年组")
|
||||
|
||||
a1 := insertAthlete(t, db, "张伟", team1)
|
||||
a2 := insertAthlete(t, db, "李强", team2)
|
||||
a3 := insertAthlete(t, db, "王磊", team1)
|
||||
a4 := insertAthlete(t, db, "李娜", team2)
|
||||
|
||||
e1 := insertEntry(t, db, event1, catA, team1, a1)
|
||||
e2 := insertEntry(t, db, event1, catA, team2, a2)
|
||||
e3 := insertEntry(t, db, event1, catB, team1, a3)
|
||||
e4 := insertEntry(t, db, event2, catC, team2, a4)
|
||||
|
||||
// 故意先写名次靠后的行,验证组内会按名次重排而不是按取数顺序。
|
||||
insertResult(t, db, event1, e2, 2, "11.72", 11.72, "2026-09-09T10:00:02Z")
|
||||
insertResult(t, db, event1, e1, 1, "11.58", 11.58, "2026-09-09T10:00:01Z")
|
||||
insertResult(t, db, event1, e3, 1, "12.30", 12.30, "2026-09-09T10:00:03Z")
|
||||
insertResult(t, db, event2, e4, 1, "13.04", 13.04, "2026-09-09T10:00:04Z")
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
if len(snap.LatestResults) != 3 {
|
||||
t.Fatalf("分组数应为 3,得到 %d: %+v", len(snap.LatestResults), snap.LatestResults)
|
||||
}
|
||||
|
||||
byCategory := make(map[int64]dto.EventResultGroup, len(snap.LatestResults))
|
||||
for _, g := range snap.LatestResults {
|
||||
byCategory[g.CategoryID] = g
|
||||
}
|
||||
groupA, ok := byCategory[catA]
|
||||
if !ok {
|
||||
t.Fatalf("缺少青年组分组: %+v", snap.LatestResults)
|
||||
}
|
||||
if groupA.EventID != event1 || groupA.EventCode != "E1" || groupA.EventName != "男子 100 米" || groupA.CategoryName != "青年组" {
|
||||
t.Fatalf("分组元信息不符: %+v", groupA)
|
||||
}
|
||||
if groupA.PublishedAt == nil || *groupA.PublishedAt != "2026-09-09T10:00:02Z" {
|
||||
t.Fatalf("分组发布时间应取组内最新一条: %+v", groupA.PublishedAt)
|
||||
}
|
||||
if len(groupA.Rows) != 2 {
|
||||
t.Fatalf("青年组应有 2 行,得到 %d: %+v", len(groupA.Rows), groupA.Rows)
|
||||
}
|
||||
if groupA.Rows[0].EntryID != e1 || groupA.Rows[1].EntryID != e2 {
|
||||
t.Fatalf("组内应按名次排序: %+v", groupA.Rows)
|
||||
}
|
||||
if groupA.Rows[0].TeamCode != "T1" || groupA.Rows[0].TeamName != "第一单位" || groupA.Rows[0].ResultText != "11.58" {
|
||||
t.Fatalf("成绩行字段不符: %+v", groupA.Rows[0])
|
||||
}
|
||||
|
||||
if len(byCategory[catB].Rows) != 1 || len(byCategory[catC].Rows) != 1 {
|
||||
t.Fatalf("其余组别应各自成组: %+v", snap.LatestResults)
|
||||
}
|
||||
}
|
||||
|
||||
// 快照榜单口径必须与 REST 一致:个人流水按系数折算进单位总分。
|
||||
func TestSnapshotStandingsAppliesIndividualWeight(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
|
||||
mustExec(t, db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
|
||||
team1 := insertTeam(t, db, "T1", "第一单位", "")
|
||||
team2 := insertTeam(t, db, "T2", "第二单位", "")
|
||||
event1 := insertEvent(t, db, "E1", "100米")
|
||||
cat1 := insertCategory(t, db, event1, "男子")
|
||||
athlete1 := insertAthlete(t, db, "张三", team1)
|
||||
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
|
||||
insertChannel(t, db, event1, "athlete")
|
||||
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
|
||||
|
||||
// 团体报名项没有运动员,流水只记单位。
|
||||
cat2 := insertCategory(t, db, event1, "团体")
|
||||
entry2 := insertEntry(t, db, event1, cat2, team2, 0)
|
||||
result2 := insertResult(t, db, event1, entry2, 1, "45.20", 45.20, "2026-09-09T10:00:01Z")
|
||||
|
||||
// 个人流水 10 分按 0.5 折算成 5;仅记单位的流水不受系数影响。
|
||||
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 10)
|
||||
insertLedger(t, db, team2, 0, event1, entry2, result2, 4)
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
points := make(map[string]float64, len(snap.Standings))
|
||||
for _, row := range snap.Standings {
|
||||
points[row.TeamCode] = row.TotalPoints
|
||||
}
|
||||
if points["T1"] != 5 {
|
||||
t.Fatalf("个人流水应按系数 0.5 折算,T1 应得 5 分,得到 %v", points)
|
||||
}
|
||||
if points["T2"] != 4 {
|
||||
t.Fatalf("仅记单位的流水不应折算,T2 应得 4 分,得到 %v", points)
|
||||
}
|
||||
if len(snap.Standings) == 0 || snap.Standings[0].TeamCode != "T1" {
|
||||
t.Fatalf("按总分排序应 T1 在前: %+v", snap.Standings)
|
||||
}
|
||||
}
|
||||
|
||||
// 团体奖牌开关与 REST 一致:关闭后团体项目奖牌不计入单位奖牌榜。
|
||||
func TestSnapshotStandingsMedalSwitch(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
|
||||
team1 := insertTeam(t, db, "T1", "第一单位", "")
|
||||
event1 := insertEvent(t, db, "E1", "4x100 米接力")
|
||||
cat1 := insertCategory(t, db, event1, "男子")
|
||||
entry1 := insertEntry(t, db, event1, cat1, team1, 0)
|
||||
insertChannel(t, db, event1, "team")
|
||||
insertResult(t, db, event1, entry1, 1, "45.20", 45.20, "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 != 1 {
|
||||
t.Fatalf("默认应计入团体项目奖牌: %+v", snap.Standings)
|
||||
}
|
||||
|
||||
mustExec(t, db, `UPDATE settings SET value = 'false' WHERE key = 'standings.medal_counts_team_events'`)
|
||||
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("关闭团体奖牌后应清零: %+v", snap.Standings)
|
||||
}
|
||||
}
|
||||
|
||||
// 只统计配了计分通道的项目,且多通道不会把同一枚奖牌重复计入。
|
||||
func TestSnapshotStandingsOnlyScoredChannels(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
db := env.db
|
||||
|
||||
team1 := insertTeam(t, db, "T1", "第一单位", "")
|
||||
team2 := insertTeam(t, db, "T2", "第二单位", "")
|
||||
|
||||
// event1 没有计分通道:即使有已发布名次也不应产生奖牌。
|
||||
event1 := insertEvent(t, db, "E1", "表演项目")
|
||||
cat1 := insertCategory(t, db, event1, "男子")
|
||||
entry1 := insertEntry(t, db, event1, cat1, team1, 0)
|
||||
insertResult(t, db, event1, entry1, 1, "1.00", 1, "2026-09-09T10:00:00Z")
|
||||
|
||||
// event2 配了计分通道:名次才会计入奖牌榜。
|
||||
event2 := insertEvent(t, db, "E2", "团体项目")
|
||||
cat2 := insertCategory(t, db, event2, "男子")
|
||||
entry2 := insertEntry(t, db, event2, cat2, team2, 0)
|
||||
insertChannel(t, db, event2, "team")
|
||||
insertResult(t, db, event2, entry2, 1, "9.99", 9.99, "2026-09-09T10:00:01Z")
|
||||
|
||||
snap, err := env.hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("构建快照: %v", err)
|
||||
}
|
||||
medals := make(map[string]int, len(snap.Standings))
|
||||
for _, row := range snap.Standings {
|
||||
medals[row.TeamCode] = row.Gold
|
||||
}
|
||||
if medals["T1"] != 0 {
|
||||
t.Fatalf("无计分通道的项目不应计奖牌,得到 %v", medals)
|
||||
}
|
||||
if medals["T2"] != 1 {
|
||||
t.Fatalf("有计分通道的项目应计入奖牌,得到 %v", medals)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +353,16 @@ func TestEmptySnapshotMarshalsEmptyLists(t *testing.T) {
|
||||
t.Fatalf("空快照应包含 %s,实际 %s", want, raw)
|
||||
}
|
||||
}
|
||||
// 空库也要下发结构完整的 settings,前端才能安全地读 settings.event.name。
|
||||
var parsed struct {
|
||||
Settings dto.EventSettings `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("解析空快照: %v", err)
|
||||
}
|
||||
if parsed.Settings.Event.Name == "" || parsed.Settings.Display.RotateSecond == 0 {
|
||||
t.Fatalf("空快照 settings 应带默认值: %+v", parsed.Settings)
|
||||
}
|
||||
}
|
||||
|
||||
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
||||
@@ -101,3 +371,103 @@ func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
|
||||
t.Fatalf("执行 SQL %q: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 造数助手 ----------
|
||||
|
||||
func insertTeam(t *testing.T, db *sql.DB, code, name, shortName string) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES (?, ?, ?, 0, 1)`,
|
||||
code, name, shortName)
|
||||
if err != nil {
|
||||
t.Fatalf("插入单位 %s: %v", code, err)
|
||||
}
|
||||
return lastID(t, res)
|
||||
}
|
||||
|
||||
func insertEvent(t *testing.T, db *sql.DB, code, name string) int64 {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(`INSERT OR IGNORE INTO sports(code, name) VALUES ('S1', '田径')`); err != nil {
|
||||
t.Fatalf("插入大项: %v", err)
|
||||
}
|
||||
res, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||||
VALUES (1, ?, ?, 'individual', 'auto', 'time', 'asc', 'published')`, code, name)
|
||||
if err != nil {
|
||||
t.Fatalf("插入小项 %s: %v", code, err)
|
||||
}
|
||||
return lastID(t, res)
|
||||
}
|
||||
|
||||
func insertCategory(t *testing.T, db *sql.DB, eventID int64, name string) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(`INSERT INTO event_categories(event_id, name) VALUES (?, ?)`, eventID, name)
|
||||
if err != nil {
|
||||
t.Fatalf("插入组别 %s: %v", name, err)
|
||||
}
|
||||
return lastID(t, res)
|
||||
}
|
||||
|
||||
func insertAthlete(t *testing.T, db *sql.DB, name string, teamID int64) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(`INSERT INTO athletes(name, gender, team_id) VALUES (?, '男', ?)`, name, teamID)
|
||||
if err != nil {
|
||||
t.Fatalf("插入运动员 %s: %v", name, err)
|
||||
}
|
||||
return lastID(t, res)
|
||||
}
|
||||
|
||||
// insertEntry 在 athleteID 为 0 时写 NULL,表示团体项目报名项。
|
||||
func insertEntry(t *testing.T, db *sql.DB, eventID, categoryID, teamID, athleteID int64) int64 {
|
||||
t.Helper()
|
||||
var athlete any
|
||||
if athleteID != 0 {
|
||||
athlete = athleteID
|
||||
}
|
||||
res, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (?, ?, ?, ?, '101')`,
|
||||
eventID, categoryID, teamID, athlete)
|
||||
if err != nil {
|
||||
t.Fatalf("插入报名项: %v", err)
|
||||
}
|
||||
return lastID(t, res)
|
||||
}
|
||||
|
||||
func insertChannel(t *testing.T, db *sql.DB, eventID int64, scope string) {
|
||||
t.Helper()
|
||||
mustExec(t, db, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, ?, 'result', 1, 0)`, eventID, scope)
|
||||
}
|
||||
|
||||
func insertResult(t *testing.T, db *sql.DB, eventID, entryID int64, rank int, text string, value float64, verifiedAt string) int64 {
|
||||
t.Helper()
|
||||
res, err := db.Exec(`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status, verified_at)
|
||||
VALUES (?, ?, ?, ?, ?, 's', 'published', 'ok', ?)`, eventID, entryID, rank, text, value, verifiedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("插入成绩: %v", err)
|
||||
}
|
||||
return lastID(t, res)
|
||||
}
|
||||
|
||||
// insertLedger 写一条积分流水;athleteID 为 0 表示「仅记单位」。
|
||||
func insertLedger(t *testing.T, db *sql.DB, teamID, athleteID, eventID, entryID, resultID int64, points float64) {
|
||||
t.Helper()
|
||||
var athlete, entry, result any
|
||||
if athleteID != 0 {
|
||||
athlete = athleteID
|
||||
}
|
||||
if entryID != 0 {
|
||||
entry = entryID
|
||||
}
|
||||
if resultID != 0 {
|
||||
result = resultID
|
||||
}
|
||||
mustExec(t, db, `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
|
||||
VALUES (?, ?, ?, ?, ?, 'result', 1, ?)`, teamID, athlete, eventID, entry, result, points)
|
||||
}
|
||||
|
||||
func lastID(t *testing.T, res sql.Result) int64 {
|
||||
t.Helper()
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("读取自增 id: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
Reference in new issue
Block a user