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:
AskaEth committed 2026-09-11 04:05:02 +08:00
1 parent e5e0944d26
commit 08a5740fb2
64 files changed
+4968 -270

No files matched your search

+24 -15
View File
@@ -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)
}
}
}
+268
View File
@@ -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 {
+380
View File
@@ -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)
}
}
}
+205
View File
@@ -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)
}
+12
View File
@@ -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 {
+82 -7
View File
@@ -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
}
+5 -5
View File
@@ -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
}
+31
View File
@@ -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 {
+14 -1
View File
@@ -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 = ?)`
+4
View File
@@ -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))
+1 -1
View File
@@ -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"})