feat: 分批发布 + 实时赛况页 + 显式批次层 + 成绩框实时提示 + 窄屏适配 + 版本号单一真相
## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发) - 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、 手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。 - 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错 (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。 ## 实时赛况(新大屏页 + 定向接口) - 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩 (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。 设置键 display.live_events / display.live_source。 - 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面 (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。 - 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only), 故对非管理员整组置灰 + 提示条(不新增角色)。 ## 显式批次层(组别之下) - 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果 完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 + 一键均分(按 N 人切片,已有批次号接着往后排)。 ## 成绩输入框实时悬浮提示 - 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻; 合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文; 与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。 ## 窄屏 / 手机端布局 - 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针, 四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框 塌成 22px 这类真问题。 ## 版本号单一真相 - 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json 的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。 测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。 docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
This commit is contained in:
1 parent
e5e0944d26
commit
08a5740fb2
64 files changed
+4968
-270
No files matched your search
@@ -100,21 +100,23 @@ type profileDTO struct {
|
||||
}
|
||||
|
||||
type entryDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"eventId"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
AthleteID *int64 `json:"athleteId"`
|
||||
MemberNames []string `json:"memberNames"`
|
||||
Bib *string `json:"bib"`
|
||||
Seed *string `json:"seed"`
|
||||
Status string `json:"status"`
|
||||
Remark *string `json:"remark"`
|
||||
AthleteName *string `json:"athleteName,omitempty"`
|
||||
TeamCode string `json:"teamCode,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
ShortName *string `json:"shortName,omitempty"`
|
||||
CategoryName string `json:"categoryName,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"eventId"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
AthleteID *int64 `json:"athleteId"`
|
||||
MemberNames []string `json:"memberNames"`
|
||||
Bib *string `json:"bib"`
|
||||
Seed *string `json:"seed"`
|
||||
// Heat 是录入 / 展示用的批次号,null 表示「未分批」;批次不参与算分。
|
||||
Heat *int64 `json:"heat"`
|
||||
Status string `json:"status"`
|
||||
Remark *string `json:"remark"`
|
||||
AthleteName *string `json:"athleteName,omitempty"`
|
||||
TeamCode string `json:"teamCode,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
ShortName *string `json:"shortName,omitempty"`
|
||||
CategoryName string `json:"categoryName,omitempty"`
|
||||
}
|
||||
|
||||
type entryWithResultDTO struct {
|
||||
@@ -122,6 +124,13 @@ type entryWithResultDTO struct {
|
||||
Result *resultDTO `json:"result"`
|
||||
}
|
||||
|
||||
// entryHeatResultDTO 是批量设置批次的结果:本批条目当前统一的批次号(null = 未分批)。
|
||||
type entryHeatResultDTO struct {
|
||||
Updated int `json:"updated"`
|
||||
Heat *int64 `json:"heat"`
|
||||
EntryIDs []int64 `json:"entryIds"`
|
||||
}
|
||||
|
||||
type resultDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"eventId"`
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// scoringSnapshot 是「发布完全部成绩」后的计分快照:名次 + 每条报名的积分 + 单位总分。
|
||||
//
|
||||
// entries 的 id 由 seed / seedBatch 按固定顺序插入决定,两个库里完全一致,
|
||||
// 所以可以直接按 entryID 比对两份快照。
|
||||
type scoringSnapshot struct {
|
||||
ranks map[int64]int
|
||||
entryPoints map[int64]float64
|
||||
teamPoints map[int64]float64
|
||||
}
|
||||
|
||||
func entryRank(t *testing.T, env *testEnv, id int64) int {
|
||||
t.Helper()
|
||||
var rank int
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank); err != nil {
|
||||
t.Fatalf("读名次: %v", err)
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
// publishEightWithHeats 造一个 8 人的组别,可选地先分好批次,再全量发布,返回计分快照。
|
||||
func publishEightWithHeats(t *testing.T, assignHeats func(env *testEnv, client *http.Client, entries []int64)) scoringSnapshot {
|
||||
t.Helper()
|
||||
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 8)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
if assignHeats != nil {
|
||||
assignHeats(env, client, entries)
|
||||
}
|
||||
|
||||
times := make([]float64, len(entries))
|
||||
for i := range times {
|
||||
times[i] = 12.0 + float64(i)*0.1
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": batchDraft(entries, len(entries), 0, times),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
snap := scoringSnapshot{
|
||||
ranks: make(map[int64]int, len(entries)),
|
||||
entryPoints: make(map[int64]float64),
|
||||
teamPoints: make(map[int64]float64),
|
||||
}
|
||||
for _, id := range entries {
|
||||
snap.ranks[id] = entryRank(t, env, id)
|
||||
}
|
||||
rows, err := env.ev.Query(
|
||||
`SELECT entry_id, team_id, SUM(points) FROM score_ledger
|
||||
WHERE event_id = ? AND voided = 0 AND source_type = 'result'
|
||||
GROUP BY entry_id, team_id`, ids.event)
|
||||
if err != nil {
|
||||
t.Fatalf("读积分流水: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID int64
|
||||
teamID int64
|
||||
points float64
|
||||
)
|
||||
if err := rows.Scan(&entryID, &teamID, &points); err != nil {
|
||||
t.Fatalf("扫描积分流水: %v", err)
|
||||
}
|
||||
snap.entryPoints[entryID] = points
|
||||
snap.teamPoints[teamID] += points
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历积分流水: %v", err)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// 批次只做录入 / 展示分组:同一个组别分两批之后,发布出来的名次与积分必须与「没分批」完全一致。
|
||||
// 这条断言是防止后人把批次当成分组参与算分的护栏。
|
||||
func TestEntryHeatDoesNotAffectScoring(t *testing.T) {
|
||||
plain := publishEightWithHeats(t, nil)
|
||||
|
||||
batched := publishEightWithHeats(t, func(env *testEnv, client *http.Client, entries []int64) {
|
||||
status, e := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": entries[:4],
|
||||
"heat": 1,
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("给前 4 人设第 1 批应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
status, e = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": entries[4:],
|
||||
"heat": 2,
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("给后 4 人设第 2 批应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
// 确认批次真的落库、且确实分成了两批,否则这条用例会假通过。
|
||||
assertCount(t, env, `SELECT COUNT(DISTINCT heat) FROM entries WHERE heat IS NOT NULL`, 2)
|
||||
})
|
||||
|
||||
if !reflect.DeepEqual(plain, batched) {
|
||||
t.Fatalf("分了批次的计分快照与未分批不一致:\n未分批 %+v\n分批次 %+v", plain, batched)
|
||||
}
|
||||
|
||||
// 逐项显式断言,失败时给出可读的差异,而不是只有一个 DeepEqual。
|
||||
// seed / seedBatch 依次插入,前 8 条报名的 id 就是 1..8。
|
||||
for i, points := range []float64{10, 8, 6, 3, 2, 1} {
|
||||
id := int64(i + 1)
|
||||
if got := batched.ranks[id]; got != i+1 {
|
||||
t.Fatalf("entry %d 名次 = %d,期望 %d", id, got, i+1)
|
||||
}
|
||||
if got := batched.entryPoints[id]; got != points {
|
||||
t.Fatalf("entry %d 积分 = %v,期望 %v", id, got, points)
|
||||
}
|
||||
}
|
||||
// 第 7、8 名没有积分;总分仍是 10+8+6+3+2+1。
|
||||
if _, ok := batched.entryPoints[7]; ok {
|
||||
t.Fatalf("第 7 名不该有积分流水: %+v", batched.entryPoints)
|
||||
}
|
||||
var total float64
|
||||
for _, points := range batched.teamPoints {
|
||||
total += points
|
||||
}
|
||||
if total != 30 {
|
||||
t.Fatalf("单位总分合计 = %v,期望 30", total)
|
||||
}
|
||||
t.Logf("分两批发布后:名次 %v,每条报名积分 %v,单位总分 %+v", batched.ranks, batched.entryPoints, batched.teamPoints)
|
||||
}
|
||||
|
||||
// 批次号大小不影响名次:同一批次里的人名次照常由成绩决定,慢的那个仍排后面。
|
||||
func TestEntryHeatKeepsRanksByResult(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 4)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
if status, e := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{entries[0], entries[1]},
|
||||
"heat": 7,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("设置批次应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
// 第 1、2 人同属第 7 批,但成绩比第 3、4 人慢。
|
||||
times := []float64{13.0, 12.0, 11.0, 10.0}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": batchDraft(entries, 4, 0, times),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
// 名次只看成绩(用时越短越好):13.0 → 第 4、12.0 → 第 3、11.0 → 第 2、10.0 → 第 1。
|
||||
for i, want := range []int{4, 3, 2, 1} {
|
||||
if got := entryRank(t, env, entries[i]); got != want {
|
||||
t.Fatalf("entry %d 名次 = %d,期望 %d(批次号不参与名次)", entries[i], got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// importHeaderHeat 是带批次列的 12 列表头(与 ImportSection 模板一致)。
|
||||
const importHeaderHeat = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
|
||||
|
||||
// 批量设置批次:成功写库、GET 下发、再传 null 清除;重复提交幂等。
|
||||
func TestSetEntriesHeatSetsAndClears(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA, ids.entryB},
|
||||
"heat": 2,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("设置批次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["updated"] != float64(2) || result["heat"] != float64(2) {
|
||||
t.Fatalf("批量设置结果不对: %+v", result)
|
||||
}
|
||||
|
||||
var heat int
|
||||
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&heat); err != nil {
|
||||
t.Fatalf("读 entries.heat: %v", err)
|
||||
}
|
||||
if heat != 2 {
|
||||
t.Fatalf("entryA 的批次应为 2,得到 %d", heat)
|
||||
}
|
||||
|
||||
// 下发路径也要带上 heat:录入页靠 GET /api/events/{id}/entries 拿批次。
|
||||
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", ids.event), nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读报名列表应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
items := decodeData[[]map[string]any](t, env2)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("报名列表应有 2 条,得到 %d", len(items))
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["heat"] != float64(2) {
|
||||
t.Fatalf("报名列表应下发 heat=2,得到 %+v", item["heat"])
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:同一批再设一次批次号,结果一致。
|
||||
status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA, ids.entryB},
|
||||
"heat": 2,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("重复设置同一批次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if again := decodeData[map[string]any](t, env2); again["updated"] != float64(2) {
|
||||
t.Fatalf("重复设置应同样更新 2 条,得到 %+v", again)
|
||||
}
|
||||
|
||||
// 传 null 清除为「未分批」。
|
||||
status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA, ids.entryB},
|
||||
"heat": nil,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("清除批次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if cleared := decodeData[map[string]any](t, env2); cleared["heat"] != nil {
|
||||
t.Fatalf("清除后 heat 应为 null,得到 %+v", cleared["heat"])
|
||||
}
|
||||
var nullHeat any
|
||||
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&nullHeat); err != nil {
|
||||
t.Fatalf("读 entries.heat: %v", err)
|
||||
}
|
||||
if nullHeat != nil {
|
||||
t.Fatalf("清除后 entries.heat 应为 NULL,得到 %v", nullHeat)
|
||||
}
|
||||
}
|
||||
|
||||
// 跨小项批量设置一定是选错行:必须拒绝,且一条都不能改。
|
||||
func TestSetEntriesHeatRejectsCrossEvent(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
otherEvent := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
||||
otherCategory := env.insert(t,
|
||||
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, otherEvent)
|
||||
otherEntry := env.insert(t,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`,
|
||||
otherEvent, otherCategory, ids.teamB, ids.athB)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA, otherEntry},
|
||||
"heat": 1,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("跨小项设置批次应 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
assertNoHeat(t, env, ids.entryA)
|
||||
assertNoHeat(t, env, otherEntry)
|
||||
}
|
||||
|
||||
// entryIds 为空 / 全部非法、以及 heat 越界都必须被拒。
|
||||
func TestSetEntriesHeatValidatesInput(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"entryIds 为空", map[string]any{"entryIds": []int64{}, "heat": 1}},
|
||||
{"entryIds 缺失", map[string]any{"heat": 1}},
|
||||
{"entryIds 全是非法 id", map[string]any{"entryIds": []int64{0, -3}, "heat": 1}},
|
||||
{"heat 为 0", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 0}},
|
||||
{"heat 为负", map[string]any{"entryIds": []int64{ids.entryA}, "heat": -1}},
|
||||
{"heat 超上限", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 100000}},
|
||||
{"报名项不存在", map[string]any{"entryIds": []int64{ids.entryA, 999999}, "heat": 1}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", tc.body, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Errorf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
|
||||
continue
|
||||
}
|
||||
if env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Errorf("%s 应返回 VALIDATION_FAILED,得到 %+v", tc.name, env2.Error)
|
||||
}
|
||||
}
|
||||
assertNoHeat(t, env, ids.entryA)
|
||||
}
|
||||
|
||||
// 权限:guest 未登录 401;recorder 放行;批次写操作进审计。
|
||||
func TestSetEntriesHeatPermissionAndAudit(t *testing.T) {
|
||||
env, ids, _ := recorderWithSeed(t)
|
||||
|
||||
guest, _ := env.client()
|
||||
status, env2 := env.do(guest, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA}, "heat": 1,
|
||||
}, false)
|
||||
if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
|
||||
t.Fatalf("guest 设置批次应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
assertNoHeat(t, env, ids.entryA)
|
||||
|
||||
recorder, _ := env.client()
|
||||
env.login(recorder, "recorder", "recorder-pass")
|
||||
status, env2 = env.do(recorder, http.MethodPut, "/api/entries/heat", map[string]any{
|
||||
"entryIds": []int64{ids.entryA}, "heat": 3,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("recorder 设置批次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
var (
|
||||
action, after string
|
||||
)
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT action, after_json FROM audit_logs WHERE action = 'entry.heat' ORDER BY id DESC LIMIT 1`).
|
||||
Scan(&action, &after); err != nil {
|
||||
t.Fatalf("查批次审计: %v", err)
|
||||
}
|
||||
if !strings.Contains(after, `"heat":3`) {
|
||||
t.Fatalf("审计里应记下批次号 3,得到 %s", after)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoHeat(t *testing.T, env *testEnv, entryID int64) {
|
||||
t.Helper()
|
||||
var heat any
|
||||
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, entryID).Scan(&heat); err != nil {
|
||||
t.Fatalf("读 entries.heat: %v", err)
|
||||
}
|
||||
if heat != nil {
|
||||
t.Fatalf("entry %d 的批次应保持未分批,得到 %v", entryID, heat)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入带 heat 列:正常值入库、留空 = 未分批、非法值走行级拦截且其余行照常导入。
|
||||
func TestImportEntriesWithHeatColumn(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeaderHeat + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,,,1,registered,\n" +
|
||||
"M100,男子,A,李强,110101199001011235,男,,,,2,registered,\n" +
|
||||
"M100,男子,B,王芳,110101199001011236,女,,,,3,registered,\n" +
|
||||
"M100,男子,A,赵六,110101199001011237,男,,,,abc,registered,\n" +
|
||||
"M100,男子,B,孙七,110101199001011238,男,,,,0,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(3) || result["skipped"] != float64(2) {
|
||||
t.Fatalf("应新建 3 条、跳过 2 条,得到 %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
if len(errs) != 2 {
|
||||
t.Fatalf("应有 2 条行级错误,得到 %+v", errs)
|
||||
}
|
||||
for _, raw := range errs {
|
||||
text, _ := raw.(string)
|
||||
if !strings.Contains(text, "heat") {
|
||||
t.Errorf("行级错误应说明 heat 非法,得到 %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// 正常值入库;heat 非法的两行被拦,不影响同一份表里的其余行。
|
||||
for idCard, want := range map[string]int64{
|
||||
"110101199001011234": 1,
|
||||
"110101199001011235": 2,
|
||||
"110101199001011236": 3,
|
||||
} {
|
||||
var heat int64
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = ?`, idCard).
|
||||
Scan(&heat); err != nil {
|
||||
t.Fatalf("读 %s 的报名批次: %v", idCard, err)
|
||||
}
|
||||
if heat != want {
|
||||
t.Errorf("%s 的批次应为 %d,得到 %d", idCard, want, heat)
|
||||
}
|
||||
}
|
||||
// 非法值的两行没有落库。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM athletes WHERE id_card IN ('110101199001011237','110101199001011238')`, 0)
|
||||
// 表头没有 heat 列时(老模板)照常导入,批次为未分批。
|
||||
status, env2 = env.postImport(t, client, "/api/entries/import", importHeader+
|
||||
"\nM100,男子,A,周八,110101199001011239,男,,,,registered,\n", "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("无 heat 列的老模板应照常导入,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
var heat any
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011239'`).
|
||||
Scan(&heat); err != nil {
|
||||
t.Fatalf("读老模板导入的报名批次: %v", err)
|
||||
}
|
||||
if heat != nil {
|
||||
t.Fatalf("无 heat 列时应为未分批,得到 %v", heat)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入留空 = 未分批:显式带 heat 列但留空,写入 NULL。
|
||||
func TestImportEntriesHeatBlankMeansUnbatched(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeaderHeat + "\nM100,男子,A,张三,110101199001011234,男,,,,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if result := decodeData[map[string]any](t, env2); result["created"] != float64(1) {
|
||||
t.Fatalf("应新建 1 条,得到 %+v", result)
|
||||
}
|
||||
var heat any
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011234'`).
|
||||
Scan(&heat); err != nil {
|
||||
t.Fatalf("读报名批次: %v", err)
|
||||
}
|
||||
if heat != nil {
|
||||
t.Fatalf("留空应为未分批(NULL),得到 %v", heat)
|
||||
}
|
||||
}
|
||||
@@ -690,6 +690,21 @@ func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// setEntriesHeatHandler 批量设置 / 清除一批报名项的批次(PUT /api/entries/heat)。
|
||||
func (s *server) setEntriesHeatHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in entryHeatPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.setEntriesHeat(r.Context(), in.EntryIDs, in.Heat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "entry.heat", "entry", 0, nil,
|
||||
map[string]any{"entryIds": result.EntryIDs, "heat": result.Heat, "updated": result.Updated})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".xlsx":
|
||||
@@ -828,6 +843,14 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
}
|
||||
// heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。
|
||||
heat, ok := parseImportHeat(cell("heat"))
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line,
|
||||
"heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat))
|
||||
continue
|
||||
}
|
||||
|
||||
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
|
||||
if err != nil {
|
||||
@@ -931,6 +954,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
MemberNames: splitNames(cell("member_names")),
|
||||
Bib: nilIfEmpty(cell("bib")),
|
||||
Seed: nilIfEmpty(cell("seed")),
|
||||
Heat: heat,
|
||||
Status: status,
|
||||
})
|
||||
switch {
|
||||
@@ -958,6 +982,18 @@ func parseImportAge(raw string) (*int, bool) {
|
||||
return &n, true
|
||||
}
|
||||
|
||||
// parseImportHeat 解析可选的 heat 列:留空表示未分批(nil),非整数或超出 1-maxEntryHeat 视为非法。
|
||||
func parseImportHeat(raw string) (*int64, bool) {
|
||||
if raw == "" {
|
||||
return nil, true
|
||||
}
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || n < 1 || n > maxEntryHeat {
|
||||
return nil, false
|
||||
}
|
||||
return &n, true
|
||||
}
|
||||
|
||||
// normalizeGender 只认 男/女,其它值(含空)一律忽略。
|
||||
func normalizeGender(raw string) string {
|
||||
switch raw {
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
)
|
||||
|
||||
// 「实时赛况」大屏页的当前项目判定。
|
||||
//
|
||||
// 现场节奏:一个项目跑完一批就发布一批,下一场开始前大屏要一直显示上一场。
|
||||
// 判定顺序(display.live_source=auto 时):
|
||||
// 1. 人工指定了 display.live_events → 用人工指定的(最多 2 个);
|
||||
// 2. 否则取 scheduled_at 落在「现在 ± liveEventWindow」内的小项,按离现在多近排序,最多 2 个;
|
||||
// 3. 都没有 → 取最近一次发布过成绩的小项(用户要求的「下一场开始前持续显示上一场」)。
|
||||
//
|
||||
// manual 时只认人工指定,不做任何回退。
|
||||
const (
|
||||
// liveEventWindow 是排期窗口的半径:项目开始时间前后各 30 分钟都算「正在进行」。
|
||||
// 取 30 分钟是因为一个田径小项(含多轮次)通常不会超过一小时;
|
||||
// 想更精确可以后续做成设置项,这里先给一个现场够用的固定值。
|
||||
liveEventWindow = 30 * time.Minute
|
||||
|
||||
liveReasonManual = "manual"
|
||||
liveReasonScheduled = "scheduled"
|
||||
liveReasonRecent = "recent"
|
||||
)
|
||||
|
||||
// liveScheduledCandidate 是排期命中的候选小项。
|
||||
type liveScheduledCandidate struct {
|
||||
ID int64
|
||||
At time.Time
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// liveSelection 是判定结果:id 列表与每个 id 的入选原因。
|
||||
type liveSelection struct {
|
||||
Source string
|
||||
IDs []int64
|
||||
Reasons map[int64]string
|
||||
}
|
||||
|
||||
// pickLiveEvents 是纯函数形式的回退链,方便单测覆盖各种组合。
|
||||
// scheduled 传入的候选必须已经按窗口筛过。
|
||||
func pickLiveEvents(source string, manual []int64, scheduled []liveScheduledCandidate, recent int64, now time.Time) liveSelection {
|
||||
out := liveSelection{Source: source, IDs: make([]int64, 0, dto.MaxLiveEvents), Reasons: map[int64]string{}}
|
||||
take := func(ids []int64, reason string) {
|
||||
for _, id := range ids {
|
||||
if id <= 0 || len(out.IDs) >= dto.MaxLiveEvents {
|
||||
return
|
||||
}
|
||||
if _, ok := out.Reasons[id]; ok {
|
||||
continue
|
||||
}
|
||||
out.IDs = append(out.IDs, id)
|
||||
out.Reasons[id] = reason
|
||||
}
|
||||
}
|
||||
|
||||
if source == dto.LiveSourceManual {
|
||||
take(manual, liveReasonManual)
|
||||
return out
|
||||
}
|
||||
if len(manual) > 0 {
|
||||
take(manual, liveReasonManual)
|
||||
return out
|
||||
}
|
||||
|
||||
if len(scheduled) > 0 {
|
||||
sort.SliceStable(scheduled, func(i, j int) bool {
|
||||
di := absDuration(scheduled[i].At.Sub(now))
|
||||
dj := absDuration(scheduled[j].At.Sub(now))
|
||||
if di != dj {
|
||||
return di < dj
|
||||
}
|
||||
if scheduled[i].SortOrder != scheduled[j].SortOrder {
|
||||
return scheduled[i].SortOrder < scheduled[j].SortOrder
|
||||
}
|
||||
return scheduled[i].ID < scheduled[j].ID
|
||||
})
|
||||
ids := make([]int64, 0, len(scheduled))
|
||||
for _, c := range scheduled {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
take(ids, liveReasonScheduled)
|
||||
return out
|
||||
}
|
||||
|
||||
if recent > 0 {
|
||||
take([]int64{recent}, liveReasonRecent)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func absDuration(d time.Duration) time.Duration {
|
||||
if d < 0 {
|
||||
return -d
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// resolveLiveEvents 读取设置与赛事库,判定当前项目。
|
||||
func (s *server) resolveLiveEvents(ctx context.Context, now time.Time) (liveSelection, error) {
|
||||
values, err := loadSettingsMap(ctx, s.ev)
|
||||
if err != nil {
|
||||
return liveSelection{}, err
|
||||
}
|
||||
source := dto.NormalizeLiveSource(values[dto.SettingKeyLiveSource])
|
||||
|
||||
manual, err := s.existingEventIDs(ctx, dto.ParseLiveEventIDs(values[dto.SettingKeyLiveEvents]))
|
||||
if err != nil {
|
||||
return liveSelection{}, err
|
||||
}
|
||||
// manual 模式不必查排期与最近发布,省掉两次查询。
|
||||
if source == dto.LiveSourceManual {
|
||||
return pickLiveEvents(source, manual, nil, 0, now), nil
|
||||
}
|
||||
scheduled, err := s.scheduledLiveCandidates(ctx, now)
|
||||
if err != nil {
|
||||
return liveSelection{}, err
|
||||
}
|
||||
recent, err := s.mostRecentPublishedEvent(ctx)
|
||||
if err != nil {
|
||||
return liveSelection{}, err
|
||||
}
|
||||
return pickLiveEvents(source, manual, scheduled, recent, now), nil
|
||||
}
|
||||
|
||||
// existingEventIDs 丢掉已不存在的小项 id:人工指定的小项被删除后,
|
||||
// 大屏不该因为一个残留 id 报错或整块空白。
|
||||
func (s *server) existingEventIDs(ctx context.Context, ids []int64) ([]int64, error) {
|
||||
out := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
var one int
|
||||
err := s.ev.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, id).Scan(&one)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
continue
|
||||
case err != nil:
|
||||
return nil, internal(err)
|
||||
default:
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scheduledLiveCandidates 取 scheduled_at 落在当前窗口内的小项。
|
||||
func (s *server) scheduledLiveCandidates(ctx context.Context, now time.Time) ([]liveScheduledCandidate, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id, scheduled_at, sort_order FROM events
|
||||
WHERE scheduled_at IS NOT NULL AND TRIM(scheduled_at) <> ''`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]liveScheduledCandidate, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
c liveScheduledCandidate
|
||||
raw string
|
||||
sortOrd int64
|
||||
)
|
||||
if err := rows.Scan(&c.ID, &raw, &sortOrd); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
at, ok := parseScheduledAt(raw)
|
||||
if !ok || absDuration(at.Sub(now)) > liveEventWindow {
|
||||
continue
|
||||
}
|
||||
c.At = at
|
||||
c.SortOrder = int(sortOrd)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// mostRecentPublishedEvent 取最近一次发布过成绩的小项,取不到时返回 0(全新赛事)。
|
||||
func (s *server) mostRecentPublishedEvent(ctx context.Context) (int64, error) {
|
||||
var id int64
|
||||
err := s.ev.QueryRowContext(ctx, `
|
||||
SELECT r.event_id
|
||||
FROM results r
|
||||
JOIN events ev ON ev.id = r.event_id
|
||||
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 1`).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, internal(err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// parseScheduledAt 解析 events.scheduled_at。
|
||||
//
|
||||
// 管理端存的是 datetime-local 转出的 RFC3339(UTC),但手工改库或早期数据可能是
|
||||
// 其它几种常见写法;认不出来的一律当成「没有排期」,不让一个大屏因为格式怪就整块不显示。
|
||||
func parseScheduledAt(raw string) (time.Time, bool) {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.999999999",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if at, err := time.Parse(layout, v); err == nil {
|
||||
return at, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// dedupeIDs 去重并丢掉非法 id(0 / 负数),保持原顺序。
|
||||
func dedupeIDs(ids []int64) []int64 {
|
||||
out := make([]int64, 0, len(ids))
|
||||
seen := make(map[int64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
if id <= 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------- 接口 ----------
|
||||
|
||||
// liveEventsHandler 是 GET /api/display/live:大屏「实时赛况」页拉当前项目与已发布成绩。
|
||||
// 同样对 guest 开放——局域网大屏不带会话,判定结论本身不含任何管理配置。
|
||||
func (s *server) liveEventsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
ctx := r.Context()
|
||||
sel, err := s.resolveLiveEvents(ctx, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := dto.LiveEvents{Source: sel.Source, EventIDs: sel.IDs, Events: make([]dto.LiveEvent, 0, len(sel.IDs))}
|
||||
for _, id := range sel.IDs {
|
||||
ev, err := loadEventQ(ctx, s.ev, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups, err := s.liveResultGroups(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.Events = append(out.Events, dto.LiveEvent{
|
||||
Event: dto.EventFromDomain(ev),
|
||||
Reason: sel.Reasons[id],
|
||||
Groups: groups,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// liveResultGroups 取某个小项已发布的成绩,按「小项 + 组别」聚合成组。
|
||||
//
|
||||
// 与实时层 snapshot.latestResults 的口径一致(排除 withdrawn、组内按名次排序),
|
||||
// 但范围固定在这一个小项、且不设条数上限——当前项目要显示完整名单,不能被 20 条截断。
|
||||
func (s *server) liveResultGroups(ctx context.Context, eventID int64) ([]dto.EventResultGroup, error) {
|
||||
rows, err := s.ev.QueryContext(ctx, `
|
||||
SELECT COALESCE(ev.code, ''), COALESCE(ev.name, ''),
|
||||
e.category_id, COALESCE(cat.name, ''),
|
||||
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''),
|
||||
r.entry_id, r.rank, e.team_id,
|
||||
COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''), t.logo_path,
|
||||
e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''),
|
||||
COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status
|
||||
FROM results r
|
||||
JOIN entries e ON e.id = r.entry_id
|
||||
JOIN events ev ON ev.id = r.event_id
|
||||
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.event_id = ? AND 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`, eventID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
index := make(map[int64]int)
|
||||
out := make([]dto.EventResultGroup, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
eventCode, eventName string
|
||||
categoryID int64
|
||||
categoryName string
|
||||
publishedAt string
|
||||
row dto.EventResultRow
|
||||
rank sql.NullInt64
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
athleteName string
|
||||
shortName string
|
||||
logoPath sql.NullString
|
||||
memberNames sql.NullString
|
||||
isTie int
|
||||
)
|
||||
if err := rows.Scan(&eventCode, &eventName, &categoryID, &categoryName, &publishedAt,
|
||||
&row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName, &logoPath,
|
||||
&athleteID, &athleteName, &memberNames,
|
||||
&row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
if rank.Valid {
|
||||
v := int(rank.Int64)
|
||||
row.Rank = &v
|
||||
}
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
row.ResultValue = &v
|
||||
}
|
||||
if athleteID.Valid && athleteID.Int64 != 0 {
|
||||
v := athleteID.Int64
|
||||
row.AthleteID = &v
|
||||
}
|
||||
row.AthleteName = nilIfEmpty(athleteName)
|
||||
row.ShortName = nilIfEmpty(shortName)
|
||||
row.LogoPath = nilIfEmpty(logoPath.String)
|
||||
row.MemberNames = parseNames(memberNames)
|
||||
row.IsTie = isTie != 0
|
||||
|
||||
key := categoryID
|
||||
i, ok := index[key]
|
||||
if !ok {
|
||||
i = len(out)
|
||||
index[key] = i
|
||||
out = append(out, dto.EventResultGroup{
|
||||
EventID: eventID,
|
||||
EventCode: eventCode,
|
||||
EventName: eventName,
|
||||
CategoryID: categoryID,
|
||||
CategoryName: categoryName,
|
||||
PublishedAt: nilIfEmpty(publishedAt),
|
||||
Rows: []dto.EventResultRow{},
|
||||
})
|
||||
}
|
||||
out[i].Rows = append(out[i].Rows, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
|
||||
// 行是按发布时间倒序取回的,组内要改回名次顺序;无名次的排最后。
|
||||
for i := range out {
|
||||
sort.SliceStable(out[i].Rows, func(a, b int) bool {
|
||||
ra, rb := out[i].Rows[a], out[i].Rows[b]
|
||||
switch {
|
||||
case ra.Rank == nil && rb.Rank == nil:
|
||||
return ra.EntryID < rb.EntryID
|
||||
case ra.Rank == nil:
|
||||
return false
|
||||
case rb.Rank == nil:
|
||||
return true
|
||||
default:
|
||||
return *ra.Rank < *rb.Rank
|
||||
}
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func liveCandidate(id int64, offset time.Duration, order int) liveScheduledCandidate {
|
||||
return liveScheduledCandidate{ID: id, At: time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC).Add(offset), SortOrder: order}
|
||||
}
|
||||
|
||||
func liveNow() time.Time {
|
||||
return time.Date(2026, 9, 11, 10, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// manual 只认人工指定:人工没指定就是空,绝不回退。
|
||||
func TestPickLiveEventsManualOnly(t *testing.T) {
|
||||
now := liveNow()
|
||||
sel := pickLiveEvents("manual", []int64{7, 9, 11}, []liveScheduledCandidate{liveCandidate(1, 0, 0)}, 3, now)
|
||||
if sel.Source != "manual" {
|
||||
t.Fatalf("source 应原样保留,得到 %q", sel.Source)
|
||||
}
|
||||
if len(sel.IDs) != 2 || sel.IDs[0] != 7 || sel.IDs[1] != 9 {
|
||||
t.Fatalf("manual 应取人工指定且最多 2 个,得到 %+v", sel.IDs)
|
||||
}
|
||||
if sel.Reasons[7] != "manual" || sel.Reasons[9] != "manual" {
|
||||
t.Fatalf("manual 模式的入选原因应为 manual,得到 %+v", sel.Reasons)
|
||||
}
|
||||
|
||||
empty := pickLiveEvents("manual", nil, nil, 3, now)
|
||||
if len(empty.IDs) != 0 {
|
||||
t.Fatalf("manual 未指定时不应回退,得到 %+v", empty.IDs)
|
||||
}
|
||||
}
|
||||
|
||||
// auto:人工指定优先,其余候选取最近的,最多 2 个。
|
||||
func TestPickLiveEventsAutoChain(t *testing.T) {
|
||||
now := liveNow()
|
||||
|
||||
manualFirst := pickLiveEvents("auto", []int64{4}, []liveScheduledCandidate{liveCandidate(1, 0, 0)}, 9, now)
|
||||
if len(manualFirst.IDs) != 1 || manualFirst.IDs[0] != 4 || manualFirst.Reasons[4] != "manual" {
|
||||
t.Fatalf("auto 下人工指定应优先,得到 %+v / %+v", manualFirst.IDs, manualFirst.Reasons)
|
||||
}
|
||||
|
||||
// 三个排期候选:按离现在的远近排序,取最近的两个。
|
||||
scheduled := pickLiveEvents("auto", nil, []liveScheduledCandidate{
|
||||
liveCandidate(11, 20*time.Minute, 0),
|
||||
liveCandidate(12, -5*time.Minute, 2),
|
||||
liveCandidate(13, 3*time.Minute, 1),
|
||||
}, 9, now)
|
||||
if len(scheduled.IDs) != 2 || scheduled.IDs[0] != 13 || scheduled.IDs[1] != 12 {
|
||||
t.Fatalf("排期应按离现在远近取前 2 个,得到 %+v", scheduled.IDs)
|
||||
}
|
||||
if scheduled.Reasons[13] != "scheduled" {
|
||||
t.Fatalf("排期命中的原因应为 scheduled,得到 %+v", scheduled.Reasons)
|
||||
}
|
||||
|
||||
// 距离相同时按 sort_order / id 稳定排序。
|
||||
tie := pickLiveEvents("auto", nil, []liveScheduledCandidate{
|
||||
liveCandidate(21, 5*time.Minute, 1),
|
||||
liveCandidate(22, -5*time.Minute, 0),
|
||||
}, 0, now)
|
||||
if len(tie.IDs) != 2 || tie.IDs[0] != 22 || tie.IDs[1] != 21 {
|
||||
t.Fatalf("距离相同应按 sort_order 排序,得到 %+v", tie.IDs)
|
||||
}
|
||||
|
||||
recent := pickLiveEvents("auto", nil, nil, 9, now)
|
||||
if len(recent.IDs) != 1 || recent.IDs[0] != 9 || recent.Reasons[9] != "recent" {
|
||||
t.Fatalf("都不命中时应回退到最近发布过成绩的项目,得到 %+v / %+v", recent.IDs, recent.Reasons)
|
||||
}
|
||||
|
||||
none := pickLiveEvents("auto", nil, nil, 0, now)
|
||||
if len(none.IDs) != 0 {
|
||||
t.Fatalf("空赛事不应选出任何项目,得到 %+v", none.IDs)
|
||||
}
|
||||
if none.Source != "auto" {
|
||||
t.Fatalf("source 应原样保留,得到 %q", none.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScheduledAt(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
ok bool
|
||||
want int // 期望的 UTC 小时
|
||||
}{
|
||||
{"2026-09-11T10:30:00.000Z", true, 10},
|
||||
{"2026-09-11T10:30:00+08:00", true, 2},
|
||||
{"2026-09-11T10:30:00", true, 10},
|
||||
{"2026-09-11T10:30", true, 10},
|
||||
{"2026-09-11 10:30:00", true, 10},
|
||||
{"2026-09-11", true, 0},
|
||||
{"", false, 0},
|
||||
{"待定", false, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := parseScheduledAt(tc.raw)
|
||||
if ok != tc.ok {
|
||||
t.Fatalf("parseScheduledAt(%q) ok=%v,期望 %v", tc.raw, ok, tc.ok)
|
||||
}
|
||||
if ok && got.UTC().Hour() != tc.want {
|
||||
t.Fatalf("parseScheduledAt(%q) = %v,期望 UTC %d 点", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type liveEventsBody struct {
|
||||
Source string `json:"source"`
|
||||
EventIDs []int64 `json:"eventIds"`
|
||||
Events []struct {
|
||||
Event struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"event"`
|
||||
Reason string `json:"reason"`
|
||||
Groups []struct {
|
||||
EventID int64 `json:"eventId"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
CategoryName string
|
||||
Rows []struct {
|
||||
EntryID int64 `json:"entryId"`
|
||||
Rank *int `json:"rank"`
|
||||
ResultText string `json:"resultText"`
|
||||
} `json:"rows"`
|
||||
} `json:"groups"`
|
||||
} `json:"events"`
|
||||
}
|
||||
|
||||
type settingsBody struct {
|
||||
Display struct {
|
||||
LiveEvents []int64 `json:"liveEvents"`
|
||||
LiveSource string `json:"liveSource"`
|
||||
} `json:"display"`
|
||||
}
|
||||
|
||||
// 设置读写:display.live_events / display.live_source 要能存取,非法值必须当场拒绝。
|
||||
func TestSettingsLiveEventsRoundTrip(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||
"display": map[string]any{"liveEvents": []int64{ids.event}, "liveSource": "manual"},
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存实时赛况设置应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
status, e := env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("读取设置应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
got := decodeData[settingsBody](t, e)
|
||||
if got.Display.LiveSource != "manual" || len(got.Display.LiveEvents) != 1 || got.Display.LiveEvents[0] != ids.event {
|
||||
t.Fatalf("设置应原样读回,得到 %+v", got.Display)
|
||||
}
|
||||
|
||||
// 库里以逗号分隔的 id 文本存储,随赛事包一起走。
|
||||
var raw string
|
||||
if err := env.ev.QueryRow(`SELECT value FROM settings WHERE key = 'display.live_events'`).Scan(&raw); err != nil {
|
||||
t.Fatalf("读取存储值: %v", err)
|
||||
}
|
||||
if raw != strconv.FormatInt(ids.event, 10) {
|
||||
t.Fatalf("display.live_events 应存逗号分隔 id,得到 %q", raw)
|
||||
}
|
||||
|
||||
// 清空人工指定:传空数组。
|
||||
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||
"display": map[string]any{"liveEvents": []int64{}},
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("清空人工指定应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
_, e = env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||||
if cleared := decodeData[settingsBody](t, e); len(cleared.Display.LiveEvents) != 0 {
|
||||
t.Fatalf("清空后应回空数组,得到 %+v", cleared.Display.LiveEvents)
|
||||
}
|
||||
|
||||
// 非法取值全部拒绝。
|
||||
for _, patch := range []map[string]any{
|
||||
{"display": map[string]any{"liveSource": "sometimes"}},
|
||||
{"display": map[string]any{"liveEvents": []int64{ids.event, ids.event + 1, ids.event + 2}}},
|
||||
{"display": map[string]any{"liveEvents": []int64{999999}}},
|
||||
} {
|
||||
status, e := env.do(client, http.MethodPut, "/api/settings", patch, true)
|
||||
if status != http.StatusBadRequest || e.Error == nil || e.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("非法设置应 400 VALIDATION_FAILED,得到 %d %+v(%+v)", status, e.Error, patch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 当前项目的判定:manual 认人工指定;auto 先排期命中;都不命中回退到最近发布过成绩的项目;空赛事不炸。
|
||||
func TestDisplayLiveEndpoint(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
adminClient, _ := env.client()
|
||||
env.login(adminClient, "admin", "admin-pass")
|
||||
|
||||
// 第二、三个小项:只用来验证人工指定(最多 2 个)。
|
||||
other := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport)
|
||||
other2 := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M400', '400米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport)
|
||||
|
||||
guest, _ := env.client()
|
||||
live := func() liveEventsBody {
|
||||
t.Helper()
|
||||
status, e := env.do(guest, http.MethodGet, "/api/display/live", nil, false)
|
||||
if status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("读取实时赛况应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
return decodeData[liveEventsBody](t, e)
|
||||
}
|
||||
setDisplay := func(patch map[string]any) {
|
||||
t.Helper()
|
||||
if status, e := env.do(adminClient, http.MethodPut, "/api/settings",
|
||||
map[string]any{"display": patch}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存设置应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 1) manual 只认人工指定(两个)。
|
||||
setDisplay(map[string]any{"liveSource": "manual", "liveEvents": []int64{other, other2}})
|
||||
got := live()
|
||||
if got.Source != "manual" || len(got.EventIDs) != 2 || got.EventIDs[0] != other || got.EventIDs[1] != other2 {
|
||||
t.Fatalf("manual 应只认人工指定的两个项目 %d/%d,得到 %+v", other, other2, got)
|
||||
}
|
||||
if len(got.Events) != 2 || got.Events[0].Reason != "manual" || got.Events[0].Event.ID != other {
|
||||
t.Fatalf("manual 命中原因应为 manual,得到 %+v", got.Events)
|
||||
}
|
||||
if len(got.Events[0].Groups) != 0 {
|
||||
t.Fatalf("还没发布成绩时应回空组,得到 %+v", got.Events[0].Groups)
|
||||
}
|
||||
|
||||
// 2) auto:排期落在当前窗口内的小项命中。
|
||||
scheduled := time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339)
|
||||
env.exec(t, `UPDATE events SET scheduled_at = ? WHERE id = ?`, scheduled, ids.event)
|
||||
setDisplay(map[string]any{"liveSource": "auto", "liveEvents": []int64{}})
|
||||
got = live()
|
||||
if got.Source != "auto" || len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event {
|
||||
t.Fatalf("auto 应命中排期窗口内的小项 %d,得到 %+v", ids.event, got)
|
||||
}
|
||||
if got.Events[0].Reason != "scheduled" {
|
||||
t.Fatalf("排期命中的原因应为 scheduled,得到 %+v", got.Events[0])
|
||||
}
|
||||
|
||||
// 3) auto:排期都不命中时回退到最近发布过成绩的小项。
|
||||
env.exec(t, `UPDATE events SET scheduled_at = NULL WHERE id = ?`, ids.event)
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.5, "resultText": "11.50", "unit": "s", "status": "ok"},
|
||||
{"entryId": ids.entryB, "status": "ok"},
|
||||
},
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
got = live()
|
||||
if len(got.EventIDs) != 1 || got.EventIDs[0] != ids.event {
|
||||
t.Fatalf("无排期命中时应回退到最近发布的小项 %d,得到 %+v", ids.event, got)
|
||||
}
|
||||
if got.Events[0].Reason != "recent" {
|
||||
t.Fatalf("回退的原因应为 recent,得到 %+v", got.Events[0])
|
||||
}
|
||||
if len(got.Events[0].Groups) != 1 || len(got.Events[0].Groups[0].Rows) != 1 {
|
||||
t.Fatalf("最近发布的小项应带上已发布成绩(1 条),得到 %+v", got.Events[0].Groups)
|
||||
}
|
||||
if rank := got.Events[0].Groups[0].Rows[0].Rank; rank == nil || *rank != 1 {
|
||||
t.Fatalf("已发布成绩应带名次,得到 %+v", got.Events[0].Groups[0].Rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 全新赛事(一个小项都没有)时不能报错。
|
||||
func TestDisplayLiveEmptyEvent(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
client, _ := env.client()
|
||||
status, e := env.do(client, http.MethodGet, "/api/display/live", nil, false)
|
||||
if status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("空赛事读取实时赛况应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
got := decodeData[liveEventsBody](t, e)
|
||||
if len(got.EventIDs) != 0 || len(got.Events) != 0 {
|
||||
t.Fatalf("空赛事应回空列表,得到 %+v", got)
|
||||
}
|
||||
if got.Source != "auto" {
|
||||
t.Fatalf("未配置时默认 auto,得到 %q", got.Source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedBatch 在 seed 的基础上补齐到 30 条报名项(第 1、2 条来自 seed),
|
||||
// 供「现场分批比赛」场景使用:跑道只有 8 条,一次只跑完一批。
|
||||
func seedBatch(t *testing.T, env *testEnv, ids seedIDs, total int) []int64 {
|
||||
t.Helper()
|
||||
entries := []int64{ids.entryA, ids.entryB}
|
||||
for i := len(entries) + 1; i <= total; i++ {
|
||||
team := ids.teamA
|
||||
if i%2 == 0 {
|
||||
team = ids.teamB
|
||||
}
|
||||
athlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
|
||||
fmt.Sprintf("运动员%02d", i), team)
|
||||
entries = append(entries, env.insert(t,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, team, athlete))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// 批次草稿:前 filled 条填成绩,其余留空,全量发给服务端(与录入页 buildDraftPayload 一致)。
|
||||
func batchDraft(entries []int64, filled, from int, times []float64) []map[string]any {
|
||||
rows := make([]map[string]any, 0, len(entries)-from)
|
||||
for i := from; i < len(entries); i++ {
|
||||
row := map[string]any{"entryId": entries[i], "status": "ok"}
|
||||
if i < filled {
|
||||
value := times[i]
|
||||
row["resultValue"] = value
|
||||
row["resultText"] = fmt.Sprintf("%.2f", value)
|
||||
row["unit"] = "s"
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, env *testEnv, query string, want int, args ...any) {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := env.ev.QueryRow(query, args...).Scan(&got); err != nil {
|
||||
t.Fatalf("统计失败: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("期望 %d,得到 %d(%s)", want, got, query)
|
||||
}
|
||||
}
|
||||
|
||||
func rankOfEntry(t *testing.T, env *testEnv, entryID int64) int {
|
||||
t.Helper()
|
||||
var rank int
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil {
|
||||
t.Fatalf("读取名次: %v", err)
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
// 现场分批比赛:30 人报名,先跑完 8 人发布,再跑完 8 人发布。
|
||||
// 关键要求:没跑/没录的行不阻塞发布、不被发布;已发布的先算名次积分,补发后重算要纳入先前批次。
|
||||
func TestPartialPublishKeepsUnfinishedRowsDraft(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 30)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
// 第一批:第 1–8 人跑完,成绩由慢到快;其余 22 人留空。
|
||||
first := make([]float64, 30)
|
||||
for i := range first {
|
||||
first[i] = 20.0 + float64(i)*0.1
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": batchDraft(entries, 8, 0, first),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存第一批草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("缺 22 条成绩时发布应成功(不报错),得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
// 只有 8 条被发布,其余 22 条仍是草稿。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 8, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 22, ids.event)
|
||||
// 未录行必须保持空值:发布不能给它们塞任何成绩。
|
||||
assertCount(t, env,
|
||||
`SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft' AND result_value IS NULL AND rank IS NULL`,
|
||||
22, ids.event)
|
||||
for i := 0; i < 8; i++ {
|
||||
if got := rankOfEntry(t, env, entries[i]); got != i+1 {
|
||||
t.Fatalf("第一批第 %d 人应第 %d 名,得到 %d", i+1, i+1, got)
|
||||
}
|
||||
}
|
||||
// 积分只按已发布的 8 人算:前六名 10+8+6+3+2+1=30 分。
|
||||
assertCount(t, env,
|
||||
`SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger WHERE event_id = ? AND voided = 0 AND source_type = 'result'`,
|
||||
30, ids.event)
|
||||
|
||||
// 第二批:第 9–16 人跑完且明显更快。已发布的 1–8 不能再存草稿,只补未发布的行。
|
||||
second := make([]float64, 30)
|
||||
for i := range second {
|
||||
second[i] = 10.0 + float64(i)*0.1
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": batchDraft(entries, 16, 8, second),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("补录第二批草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("第二批发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 16, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 14, ids.event)
|
||||
// 先前的 8 人必须被重算纳入:第二批更快,第一批整体退到第 9–16 名。
|
||||
if got := rankOfEntry(t, env, entries[0]); got != 9 {
|
||||
t.Fatalf("补发后第一批第一名应退到第 9 名(证明重算纳入了先前批次),得到 %d", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, entries[8]); got != 1 {
|
||||
t.Fatalf("第二批最快者应第 1 名,得到 %d", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, entries[15]); got != 8 {
|
||||
t.Fatalf("第二批最慢者应第 8 名,得到 %d", got)
|
||||
}
|
||||
// 16 人里仍只有前六名得分:30 分;流水不能因为重算而翻倍。
|
||||
assertCount(t, env,
|
||||
`SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger WHERE event_id = ? AND voided = 0 AND source_type = 'result'`,
|
||||
30, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
|
||||
}
|
||||
|
||||
// 一条有效成绩 + 29 条空成绩:发布必须成功,且只有那一条上屏。
|
||||
// 这也是「服务端是否会因缺成绩报错」的回归断言。
|
||||
func TestPublishWithSingleFilledRow(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 30)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
times := make([]float64, 30)
|
||||
for i := range times {
|
||||
times[i] = 12.0 + float64(i)*0.1
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": batchDraft(entries, 1, 0, times),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true)
|
||||
if status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("缺 29 条成绩不是错误,发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 29, ids.event)
|
||||
if got := rankOfEntry(t, env, entries[0]); got != 1 {
|
||||
t.Fatalf("唯一有效成绩应第 1 名,得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// DNS / DQ 这类「没有成绩但确实是现场结果」的行必须能发布(不能被当成空行留在草稿)。
|
||||
func TestPublishKeepsNonOKStatusWithoutValue(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.5, "resultText": "11.50", "unit": "s", "status": "ok"},
|
||||
{"entryId": ids.entryB, "status": "dns"},
|
||||
},
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("含 DNS 的组应能发布,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 2, ids.event)
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 0, ids.event)
|
||||
}
|
||||
@@ -38,6 +38,13 @@ type voidResultPayload struct {
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
// entryHeatPayload 是批量设置批次的请求体:heat 为 null / 缺省表示清除批次(未分批)。
|
||||
type entryHeatPayload struct {
|
||||
EntryIDs []int64 `json:"entryIds"`
|
||||
Heat *int64 `json:"heat"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type adjustmentInput struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Delta float64 `json:"delta"`
|
||||
@@ -98,6 +105,11 @@ type settingsDisplayPatch struct {
|
||||
// FontTitle / FontBody 是字体 id;null 表示不改动(清空要传空串)。
|
||||
FontTitle *string `json:"fontTitle"`
|
||||
FontBody *string `json:"fontBody"`
|
||||
// LiveEvents 是「实时赛况」人工指定的当前项目 id(最多 2 个)。
|
||||
// nil 表示不改动;传 [] 表示清空人工指定。
|
||||
LiveEvents *[]int64 `json:"liveEvents"`
|
||||
// LiveSource 是当前项目的判定方式(manual / auto);nil 表示不改动。
|
||||
LiveSource *string `json:"liveSource"`
|
||||
}
|
||||
|
||||
type settingsStandingsPatch struct {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
@@ -385,7 +386,7 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
|
||||
// ---------- entries ----------
|
||||
|
||||
const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
|
||||
e.bib, e.seed, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name`
|
||||
e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name`
|
||||
|
||||
func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
|
||||
var (
|
||||
@@ -393,6 +394,7 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
|
||||
athleteID sql.NullInt64
|
||||
memberNames sql.NullString
|
||||
bib, seed, rem sql.NullString
|
||||
heat sql.NullInt64
|
||||
athleteName sql.NullString
|
||||
teamCode sql.NullString
|
||||
teamName sql.NullString
|
||||
@@ -400,13 +402,14 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
|
||||
categoryName sql.NullString
|
||||
)
|
||||
if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames,
|
||||
&bib, &seed, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil {
|
||||
&bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil {
|
||||
return e, err
|
||||
}
|
||||
e.AthleteID = i64Ptr(athleteID)
|
||||
e.MemberNames = parseNames(memberNames)
|
||||
e.Bib = strPtr(bib)
|
||||
e.Seed = strPtr(seed)
|
||||
e.Heat = i64Ptr(heat)
|
||||
e.Remark = strPtr(rem)
|
||||
e.AthleteName = strPtr(athleteName)
|
||||
e.TeamCode = teamCode.String
|
||||
@@ -483,10 +486,10 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error)
|
||||
return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil)
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark))
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark))
|
||||
if isUniqueViolation(err) {
|
||||
return entryDTO{}, conflict("该报名项已存在")
|
||||
}
|
||||
@@ -506,9 +509,9 @@ func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error)
|
||||
func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) {
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?,
|
||||
bib = ?, seed = ?, status = ?, remark = ? WHERE id = ?`,
|
||||
bib = ?, seed = ?, heat = ?, status = ?, remark = ? WHERE id = ?`,
|
||||
in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark), id)
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered"), nullStr(in.Remark), id)
|
||||
if isUniqueViolation(err) {
|
||||
return entryDTO{}, conflict("该报名项已存在")
|
||||
}
|
||||
@@ -532,3 +535,75 @@ func (s *server) deleteEntry(ctx context.Context, id int64) error {
|
||||
}
|
||||
return requireChanges(res, "报名项不存在")
|
||||
}
|
||||
|
||||
// maxEntryHeat 是批次号上限:批次只是现场录入的编排分组,四位以上没有实际意义;
|
||||
// 设上限同时挡住误填的大数(例如把成绩错填进批次号)。
|
||||
const maxEntryHeat = 999
|
||||
|
||||
// setEntriesHeat 给一批报名项统一设置批次(heat 为 nil 表示清除批次 / 未分批)。
|
||||
//
|
||||
// 只改 entries.heat,不碰任何成绩与积分:批次不参与算分,排名与积分仍按「小项 + 组别」算。
|
||||
// 幂等:重复提交同一个批次号结果一致。要求所有报名项属于同一个小项(跨小项批量设置是误操作)。
|
||||
func (s *server) setEntriesHeat(ctx context.Context, entryIDs []int64, heat *int64) (entryHeatResultDTO, error) {
|
||||
ids := dedupeIDs(entryIDs)
|
||||
if len(ids) == 0 {
|
||||
return entryHeatResultDTO{}, badRequest("entryIds 不能为空", nil)
|
||||
}
|
||||
if heat != nil && (*heat < 1 || *heat > maxEntryHeat) {
|
||||
return entryHeatResultDTO{}, badRequest(fmt.Sprintf("heat 需要是 1-%d 的整数,或传 null 清除批次", maxEntryHeat), nil)
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id, event_id FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...)
|
||||
if err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
found := make(map[int64]bool, len(ids))
|
||||
events := make(map[int64]bool)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID int64
|
||||
eventID int64
|
||||
)
|
||||
if err := rows.Scan(&entryID, &eventID); err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
found[entryID] = true
|
||||
events[eventID] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
|
||||
missing := make([]int64, 0)
|
||||
for _, id := range ids {
|
||||
if !found[id] {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return entryHeatResultDTO{}, badRequest("部分报名项不存在", missing)
|
||||
}
|
||||
// 批次挂在「小项 + 组别」之下,批量设置跨小项一定是选错了行。
|
||||
if len(events) > 1 {
|
||||
return entryHeatResultDTO{}, badRequest("不能跨小项批量设置批次,请在同一小项内操作", nil)
|
||||
}
|
||||
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET heat = ? WHERE id IN (`+placeholders(len(ids))+`)`,
|
||||
append([]any{nullI64(heat)}, args...)...)
|
||||
if err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
updated, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return entryHeatResultDTO{}, internal(err)
|
||||
}
|
||||
return entryHeatResultDTO{Updated: int(updated), Heat: heat, EntryIDs: ids}, nil
|
||||
}
|
||||
@@ -287,10 +287,10 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, er
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered")); err != nil {
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered")); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
@@ -298,8 +298,8 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, er
|
||||
return false, err
|
||||
default:
|
||||
if _, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET member_names = ?, bib = ?, seed = ?, status = ? WHERE id = ?`,
|
||||
marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed),
|
||||
`UPDATE entries SET member_names = ?, bib = ?, seed = ?, heat = ?, status = ? WHERE id = ?`,
|
||||
marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat),
|
||||
orDefault(in.Status, "registered"), existing); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -147,6 +148,36 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
}
|
||||
// 实时赛况的当前项目:最多 2 个,且必须是真实存在的小项。
|
||||
// 存不存在的 id 只会让大屏整块空白,现场还得回管理端排查,不如写入时就拒绝。
|
||||
if p.LiveEvents != nil {
|
||||
if len(*p.LiveEvents) > dto.MaxLiveEvents {
|
||||
return dto.EventSettings{}, badRequest(fmt.Sprintf("display.liveEvents 最多 %d 个", dto.MaxLiveEvents), len(*p.LiveEvents))
|
||||
}
|
||||
ids := dedupeIDs(*p.LiveEvents)
|
||||
for _, id := range ids {
|
||||
var exists int
|
||||
err := tx.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, id).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return dto.EventSettings{}, badRequest("display.liveEvents 引用了不存在的小项", id)
|
||||
}
|
||||
if err != nil {
|
||||
return dto.EventSettings{}, internal(err)
|
||||
}
|
||||
}
|
||||
if err := set(dto.SettingKeyLiveEvents, dto.FormatLiveEventIDs(ids)); err != nil {
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
}
|
||||
if p.LiveSource != nil {
|
||||
source := dto.NormalizeLiveSource(*p.LiveSource)
|
||||
if source == "" {
|
||||
return dto.EventSettings{}, badRequest("display.liveSource 只能是 manual / auto", *p.LiveSource)
|
||||
}
|
||||
if err := set(dto.SettingKeyLiveSource, source); err != nil {
|
||||
return dto.EventSettings{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if p := patch.Standings; p != nil {
|
||||
if p.IndividualToTeamWeight != nil {
|
||||
|
||||
@@ -435,6 +435,16 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
|
||||
|
||||
// ---------- 发布 / 撤销 / 作废 ----------
|
||||
|
||||
// publishableResultCond 判断一条 results 行是否承载了「可以上屏的成绩」:
|
||||
// - 有解析出来的成绩值(计时 / 距离 / 计数 / 评分项目);
|
||||
// - 有手工名次(resultMode=manual 或 compare=none 的项目,名次本身就是成绩);
|
||||
// - 状态不是 ok(DNS / DQ / DNF / 退赛 也是现场明确给出的结果,要能上屏)。
|
||||
//
|
||||
// 空且状态为 ok 的行只是「还没跑 / 还没录」,发布时必须留在 draft:
|
||||
// 分批比赛时若把没跑的人一起置为 published,大屏的最新成绩会出现空行,
|
||||
// 而且编辑页会把整组当成「已发布」锁死,补录无门。
|
||||
const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')`
|
||||
|
||||
func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
|
||||
if in.EventID == 0 {
|
||||
return nil, badRequest("eventId 必填", nil)
|
||||
@@ -458,9 +468,12 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 只发布「已有内容」的成绩行:现场分批比赛时(如 100 米预赛分 4 组、每组 8 条跑道),
|
||||
// 只跑完一组就要先把这一组上屏,没跑/没录的行必须留在 draft,不能被一起发布。
|
||||
// 判据见 publishableResultCond。名次与积分由下方重算按已发布部分计算,后续再发一批会自动纳入。
|
||||
setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'),
|
||||
updated_at = datetime('now')
|
||||
WHERE event_id = ?`
|
||||
WHERE event_id = ? AND ` + publishableResultCond
|
||||
args := []any{nullID(userID), in.EventID}
|
||||
if catID > 0 {
|
||||
setPublished += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
|
||||
|
||||
@@ -215,6 +215,8 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
|
||||
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
|
||||
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))
|
||||
// 批次(heat)是录入 / 展示分组,不参与算分,所以没有独立的「发布」概念。
|
||||
mux.HandleFunc("PUT /api/entries/heat", s.write(auth.RoleRecorder, s.setEntriesHeatHandler))
|
||||
|
||||
// 成绩
|
||||
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
|
||||
@@ -239,6 +241,8 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
// 大屏控制与运维
|
||||
mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler))
|
||||
mux.HandleFunc("GET /api/display/instances", s.read(auth.RoleRecorder, s.displayInstancesHandler))
|
||||
// 「实时赛况」页的当前项目与已发布成绩;大屏(guest)要能直接拉,故对外只读开放。
|
||||
mux.HandleFunc("GET /api/display/live", s.public(s.liveEventsHandler))
|
||||
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
|
||||
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
|
||||
assertKeys(t, "settings.event", asMap(t, settings["event"]),
|
||||
[]string{"name", "subtitle", "host", "logoPath", "primaryColor"})
|
||||
assertKeys(t, "settings.display", asMap(t, settings["display"]),
|
||||
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody"})
|
||||
[]string{"background", "logo", "title", "subtitle", "primaryColor", "rotateSeconds", "announce", "fontTitle", "fontBody", "liveEvents", "liveSource"})
|
||||
assertKeys(t, "settings.standings", asMap(t, settings["standings"]),
|
||||
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals"})
|
||||
|
||||
|
||||
Reference in new issue
Block a user