feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)

用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
This commit is contained in:
AskaEth committed 2026-09-11 17:14:02 +08:00
1 parent 8dd599bd97
commit 631c8dd8e1
80 files changed
+12468 -519

No files matched your search

@@ -0,0 +1,241 @@
package httpapi_test
import (
"net/http"
"testing"
"time"
)
// batchData 解一次批量动作的响应体。
type batchItemView struct {
ID int64 `json:"id"`
OK bool `json:"ok"`
Code string `json:"code"`
Message string `json:"message"`
}
type batchResultView struct {
Action string `json:"action"`
DryRun bool `json:"dryRun"`
Requested int `json:"requested"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Items []batchItemView `json:"items"`
Purge *struct {
DeletedEntries int `json:"deletedEntries"`
DeletedResults int `json:"deletedResults"`
DeletedLedger int `json:"deletedLedger"`
DeletedBreaks int `json:"deletedBreaks"`
RecomputedEvent []int64 `json:"recomputedEvents"`
} `json:"purge"`
}
func doBatch(t *testing.T, env *testEnv, client *http.Client, body map[string]any) batchResultView {
t.Helper()
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", body, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("批量动作应 200,得到 %d %+v", status, env2.Error)
}
return decodeData[batchResultView](t, env2)
}
// auditCount 数某个 action 的审计条数;审计在响应之后落库,给一点重试窗口。
func auditCount(t *testing.T, env *testEnv, action string) int {
t.Helper()
for i := 0; i < 100; i++ {
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = ?`, action); n > 0 {
return n
}
time.Sleep(10 * time.Millisecond)
}
return 0
}
// 一次请求停用 5 人:库内 5 行 active = 0,响应逐条成功,且只产生一条审计。
func TestBatchDisableAthletesOneRequest(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := []int64{ids.athleteA, ids.athB}
for _, name := range []string{"赵一", "钱二", "孙三"} {
targets = append(targets, env.insert(t,
`INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, name, ids.teamA))
}
result := doBatch(t, env, client, map[string]any{"action": "disable", "ids": targets})
if result.Action != "disable" || result.Requested != 5 || result.Succeeded != 5 || result.Failed != 0 {
t.Fatalf("响应应为 5 成功 0 失败,得到 %+v", result)
}
for _, item := range result.Items {
if !item.OK {
t.Fatalf("每条都该成功,得到 %+v", item)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE active = 0`); n != 5 {
t.Fatalf("应有 5 人处于停用,得到 %d", n)
}
// 停用是软删除:报名与成绩一条不动。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, ids.athleteA); n != 1 {
t.Fatalf("停用不该动报名,得到 %d 条", n)
}
if n := auditCount(t, env, "athlete.batch.disable"); n != 1 {
t.Fatalf("整批只应写 1 条审计,得到 %d 条", n)
}
}
// 混入不存在的 id:逐条回报,整批仍然成功,重复 id 只算一次。
func TestBatchDisableReportsMissingIDs(t *testing.T) {
env, ids, client := recorderWithSeed(t)
result := doBatch(t, env, client, map[string]any{
"action": "disable", "ids": []int64{ids.athleteA, ids.athleteA, 999999},
})
if result.Requested != 2 || result.Succeeded != 1 || result.Failed != 1 {
t.Fatalf("重复 id 应去重、缺失 id 应逐条失败,得到 %+v", result)
}
if len(result.Items) != 2 {
t.Fatalf("应逐条回报 2 条,得到 %+v", result.Items)
}
missing := result.Items[1]
if missing.ID != 999999 || missing.OK || missing.Code != "NOT_FOUND" || missing.Message != "运动员不存在" {
t.Fatalf("缺失 id 应回报 运动员不存在,得到 %+v", missing)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 1 {
t.Fatalf("存在的那个人应被停用,得到 %d", n)
}
}
// 批量彻底删除:删 2 人、重算一次受影响组别,第三个人升到第一并拿到 10 分;
// 缺失的 id 不影响其余两人被删掉。
func TestBatchPurgeRecomputesAndReportsPerItem(t *testing.T) {
env, ids, client := recorderWithSeed(t)
athC := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('王芳', '女', ?, 1)`, ids.teamA)
entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, athC)
publishResults(t, env, client, ids.event, "batch-purge",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
)
// 前置:A 第一 10 分,C 第三 6 分。
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) {
t.Fatalf("前置条件:C 应是 6 分,得到 %v", got)
}
result := doBatch(t, env, client, map[string]any{
"action": "purge", "ids": []int64{ids.athleteA, ids.athB, 999999},
})
if result.Succeeded != 2 || result.Failed != 1 {
t.Fatalf("应 2 成功 1 失败,得到 %+v", result)
}
if result.Purge == nil {
t.Fatalf("purge 应带回删除规模,得到 %+v", result)
}
if result.Purge.DeletedEntries != 2 || result.Purge.DeletedResults != 2 || result.Purge.DeletedLedger != 2 {
t.Fatalf("删除规模应为 报名2 / 成绩2 / 流水2,得到 %+v", result.Purge)
}
if len(result.Purge.RecomputedEvent) != 1 || result.Purge.RecomputedEvent[0] != ids.event {
t.Fatalf("应重算 1 个受影响小项,得到 %+v", result.Purge.RecomputedEvent)
}
// 被删的两人痕迹全无,第三人原样保留。
for _, id := range []int64{ids.athleteA, ids.athB} {
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ?`, id); n != 0 {
t.Fatalf("运动员 %d 应被删掉,得到 %d", id, n)
}
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE athlete_id = ?`, athC); n != 1 {
t.Fatalf("第三个人的报名不该被删,得到 %d", n)
}
// 别人的名次被重算:C 从第三升到第一,得分 6 → 10,单位总分跟着变。
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{10}) {
t.Fatalf("C 应随新名次重算成 10 分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
t.Fatalf("B 单位删空后总分应归零,得到 %v", got)
}
// 榜单也重算了:C 所在的 A 单位仍是 10 分(A 的 10 分换成了 C 的 10 分)。
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
t.Fatalf("A 单位应保持 10 分,得到 %v", got)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n == 0 {
t.Fatalf("重算应把旧流水作废留下来")
}
assertNoForeignKeyViolation(t, env)
if n := auditCount(t, env, "athlete.batch.purge"); n != 1 {
t.Fatalf("整批只应写 1 条审计,得到 %d 条", n)
}
}
// dryRun 只统计不改库:预览数字与真删同源,但一行都不能动,也不该留审计。
func TestBatchPurgeDryRunChangesNothing(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "batch-dry",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
)
before := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) +
tableDump(t, env, `SELECT * FROM entries ORDER BY id`) +
tableDump(t, env, `SELECT * FROM results ORDER BY id`) +
tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`)
result := doBatch(t, env, client, map[string]any{
"action": "purge", "ids": []int64{ids.athleteA}, "dryRun": true,
})
if !result.DryRun || result.Succeeded != 1 {
t.Fatalf("预览应逐条成功并标记 dryRun,得到 %+v", result)
}
if result.Purge == nil || result.Purge.DeletedEntries != 1 || result.Purge.DeletedResults != 1 {
t.Fatalf("预览应报出会删掉什么,得到 %+v", result.Purge)
}
if len(result.Purge.RecomputedEvent) != 1 {
t.Fatalf("预览应报出会重算哪个小项,得到 %+v", result.Purge.RecomputedEvent)
}
after := tableDump(t, env, `SELECT * FROM athletes ORDER BY id`) +
tableDump(t, env, `SELECT * FROM entries ORDER BY id`) +
tableDump(t, env, `SELECT * FROM results ORDER BY id`) +
tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`)
if before != after {
t.Fatalf("预览不该改动任何数据:\n%s\n---\n%s", before, after)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action LIKE 'athlete.batch%'`); n != 0 {
t.Fatalf("预览不该写审计,得到 %d 条", n)
}
}
// 非法入参:action 只认 disable / purge,ids 去重后不能为空。
func TestBatchRejectsBadInput(t *testing.T) {
env, ids, client := recorderWithSeed(t)
for _, tc := range []struct {
name string
body map[string]any
want string
}{
{"action 非法", map[string]any{"action": "delete", "ids": []int64{ids.athleteA}}, "action 只能是 disable / purge"},
{"ids 为空", map[string]any{"action": "disable", "ids": []int64{}}, "ids 不能为空"},
{"ids 全非法", map[string]any{"action": "purge", "ids": []int64{0, -3}}, "ids 不能为空"},
} {
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch", tc.body, true)
if status != http.StatusBadRequest {
t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
}
if env2.Error == nil || env2.Error.Message != tc.want {
t.Fatalf("%s 文案应为 %q,得到 %+v", tc.name, tc.want, env2.Error)
}
}
}
// 未登录的写操作一律 401,且不改动任何数据。
func TestBatchRequiresLogin(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client, _ := env.client()
status, env2 := env.do(client, http.MethodPost, "/api/athletes/batch",
map[string]any{"action": "disable", "ids": []int64{ids.athleteA}}, false)
if status != http.StatusUnauthorized {
t.Fatalf("未登录应 401,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE id = ? AND active = 0`, ids.athleteA); n != 0 {
t.Fatalf("被拒后不该改动数据,得到 %d", n)
}
}
@@ -0,0 +1,368 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"strings"
"testing"
)
// rawData 发一次请求并把 data 原样作为字符串返回。
// 逐字节比较需要的是服务端的原始字节,不是 decode 再 encode 的结果。
func rawData(t *testing.T, env *testEnv, client *http.Client, path string) string {
t.Helper()
status, resp := env.do(client, http.MethodGet, path, nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("GET %s 应 200,得到 %d %+v", path, status, resp.Error)
}
return string(resp.Data)
}
// tableDump 把查询结果按原始列顺序 dump 成文本。
// 逐字节比较要的是存储层的事实,而不是经过 join 加工后的展示字段。
func tableDump(t *testing.T, env *testEnv, query string) string {
t.Helper()
rows, err := env.ev.Query(query)
if err != nil {
t.Fatalf("dump %s: %v", query, err)
}
defer func() { _ = rows.Close() }()
cols, err := rows.Columns()
if err != nil {
t.Fatalf("读列名: %v", err)
}
var b strings.Builder
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
t.Fatalf("扫描 %s: %v", query, err)
}
for i, v := range vals {
if raw, ok := v.([]byte); ok {
v = string(raw)
}
fmt.Fprintf(&b, "%s=%v ", cols[i], v)
}
b.WriteString("\n")
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历 %s: %v", query, err)
}
return b.String()
}
// categorySnapshot 抓下「报名 / 成绩 / 流水 / 成绩接口 / 积分榜」五份原始数据。
// 改个组别名不该让它们有任何变化,连一个字节都不行。
//
// 不含 GET /events/{id}/entries:它 join 了组别名,categoryName 跟着变是应该的,
// 这条由下面的断言单独检查。
func categorySnapshot(t *testing.T, env *testEnv, client *http.Client, eventID int64) string {
t.Helper()
return strings.Join([]string{
tableDump(t, env, fmt.Sprintf(`SELECT * FROM entries WHERE event_id = %d ORDER BY id`, eventID)),
tableDump(t, env, fmt.Sprintf(`SELECT * FROM results WHERE event_id = %d ORDER BY id`, eventID)),
tableDump(t, env, `SELECT * FROM score_ledger ORDER BY id`),
rawData(t, env, client, fmt.Sprintf("/api/results?eventId=%d&size=500", eventID)),
rawData(t, env, client, "/api/standings"),
}, "\n")
}
// 有报名数据的小项必须能改组别名,且报名 / 成绩 / 榜单逐字节不变——这是本次修复的核心诉求。
func TestUpdateCategoryRenamesWithEntriesKeepingData(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "cat-rename",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
before := categorySnapshot(t, env, client, ids.event)
status, env2 := env.do(client, http.MethodPut,
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
map[string]any{
"id": ids.category, "eventId": ids.event, "name": "男子甲组",
"multiplier": 1, "gender": "男", "ageMin": 18, "ageMax": 35, "sortOrder": 0,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
}
// 响应回的是该小项最新的组别列表,前端据此刷新。
cats := decodeData[[]map[string]any](t, env2)
found := false
for _, c := range cats {
if c["id"] == float64(ids.category) {
found = true
if c["name"] != "男子甲组" {
t.Fatalf("响应里的组别名应为 男子甲组,得到 %+v", c["name"])
}
}
}
if !found {
t.Fatalf("响应里应有组别 %d,得到 %+v", ids.category, cats)
}
var (
name string
multiplier float64
gender string
ageMin, ageMax int
)
if err := env.ev.QueryRow(`SELECT name, multiplier, gender, age_min, age_max FROM event_categories WHERE id = ?`,
ids.category).Scan(&name, &multiplier, &gender, &ageMin, &ageMax); err != nil {
t.Fatalf("读组别: %v", err)
}
if name != "男子甲组" || multiplier != 1 || gender != "男" || ageMin != 18 || ageMax != 35 {
t.Fatalf("组别字段应就地更新,得到 name=%s mult=%v gender=%s age=%d-%d", name, multiplier, gender, ageMin, ageMax)
}
// 组别 id 没变(整表替换会换 id,报名就跟着断链)。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE category_id = ?`, ids.category); n != 2 {
t.Fatalf("两条报名应仍挂在原组别上,得到 %d 条", n)
}
after := categorySnapshot(t, env, client, ids.event)
if before != after {
t.Fatalf("改名不该动报名 / 成绩 / 流水 / 榜单:\n--- before ---\n%s\n--- after ---\n%s", before, after)
}
// 改名不参与算分:一条流水都不该被作废或重建。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 0 {
t.Fatalf("改名不该触发重算,得到 %d 条作废流水", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger`); n != 2 {
t.Fatalf("流水条数不该变,得到 %d 条", n)
}
if audit := waitAudit(t, env, "event.category.update", ids.category); !strings.Contains(audit, `"recomputedLedger":false`) {
t.Fatalf("审计应标出没有重算,得到 %s", audit)
}
// 展示层该跟着变:报名接口里的 categoryName 是新名字(这就是用户想看到的结果)。
entries := rawData(t, env, client, fmt.Sprintf("/api/events/%d/entries", ids.event))
if !strings.Contains(entries, `"categoryName":"男子甲组"`) || strings.Contains(entries, `"categoryName":"男子"`) {
t.Fatalf("报名列表应显示新组别名,得到 %s", entries)
}
}
// 改权重同样是就地更新:不重算已有流水,榜单在改名前后必须逐字节一致。
// 改权重要立刻重算该组已发布成绩:权重进算分公式,不重算就会让积分与榜单静默不一致。
func TestUpdateCategoryWeightChangeRecomputesLedger(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "cat-mult",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
// 前置:A 第一 10 分、B 第二 8 分,单位总分 10 / 8。
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
t.Fatalf("前置条件:A 应是 10 分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamA); got != 10 {
t.Fatalf("前置条件:A 单位应是 10 分,得到 %v", got)
}
before := rawData(t, env, client, "/api/standings")
status, env2 := env.do(client, http.MethodPut,
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
map[string]any{"name": "男子", "multiplier": 2, "sortOrder": 0}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改权重应 200,得到 %d %+v", status, env2.Error)
}
// 旧流水被作废保留痕迹,新流水按 ×2 重建:10 → 20、8 → 16。
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{20}) {
t.Fatalf("A 应重算成 20 分,得到 %v", got)
}
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{16}) {
t.Fatalf("B 应重算成 16 分,得到 %v", got)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 2 {
t.Fatalf("两条旧流水都应被作废,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger`); n != 4 {
t.Fatalf("应是「2 条作废 + 2 条有效」,得到 %d 条", n)
}
// 名次不变(权重不改排序),但分数与榜单必须跟着变。
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE rank = 1`); n != 1 {
t.Fatalf("名次不该被改乱,得到 %d 个第一", n)
}
if after := rawData(t, env, client, "/api/standings"); before == after {
t.Fatalf("榜单必须随新权重重算,但仍与改前一致:%s", after)
}
if got := teamPoints(t, env, client, ids.teamA); got != 20 {
t.Fatalf("改权重后 A 单位应是 20 分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 16 {
t.Fatalf("改权重后 B 单位应是 16 分,得到 %v", got)
}
// 审计要能看出这次动了权重并触发重算。
after := waitAudit(t, env, "event.category.update", ids.category)
if !strings.Contains(after, `"recomputedLedger":true`) {
t.Fatalf("审计应标出触发了重算,得到 %s", after)
}
}
// 权重原样提交(浮点比较的容差)不该白重算一次全组积分。
func TestUpdateCategorySameMultiplierDoesNotRecompute(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "cat-same",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
)
var logID int64
if err := env.ev.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM score_ledger`).Scan(&logID); err != nil {
t.Fatalf("读流水: %v", err)
}
status, env2 := env.do(client, http.MethodPut,
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
// 1 与 1+1e-12 在容差内视为同一个权重。
map[string]any{"name": "男子甲组", "multiplier": 1.000000000001, "sortOrder": 0}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE id > ?`, logID); n != 0 {
t.Fatalf("权重没变不该产生新流水,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n != 0 {
t.Fatalf("权重没变不该作废流水,得到 %d 条", n)
}
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
t.Fatalf("分值不该变,得到 %v", got)
}
after := waitAudit(t, env, "event.category.update", ids.category)
if !strings.Contains(after, `"recomputedLedger":false`) {
t.Fatalf("审计应标出没有重算,得到 %s", after)
}
}
// 同小项内重名必须 409,且文案点名是哪一个名字。
func TestUpdateCategoryRejectsDuplicateName(t *testing.T) {
env, ids, client := recorderWithSeed(t)
cat2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '女子', 1, 1)`, ids.event)
status, env2 := env.do(client, http.MethodPut,
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, cat2),
map[string]any{"name": "男子", "multiplier": 1, "sortOrder": 1}, true)
if status != http.StatusConflict {
t.Fatalf("重名应 409,得到 %d %+v", status, env2.Error)
}
if env2.Error == nil || !strings.Contains(env2.Error.Message, "组别名称重复:男子") {
t.Fatalf("重名文案应含「组别名称重复:男子」,得到 %+v", env2.Error)
}
// 原行没被改坏。
var name string
if err := env.ev.QueryRow(`SELECT name FROM event_categories WHERE id = ?`, cat2).Scan(&name); err != nil {
t.Fatalf("读组别: %v", err)
}
if name != "女子" {
t.Fatalf("冲突时不该改动原组别,得到 %s", name)
}
}
// 名称必填、性别枚举、年龄范围都按新接口的口径给 400,而不是让数据库约束冒成 500。
func TestUpdateCategoryValidatesFields(t *testing.T) {
env, ids, client := recorderWithSeed(t)
path := fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category)
for _, tc := range []struct {
name string
body map[string]any
want string
}{
{"名称必填", map[string]any{"name": " ", "multiplier": 1}, "组别 name 必填"},
{"性别枚举", map[string]any{"name": "男子", "multiplier": 1, "gender": "其他"}, "组别 gender 只能是 男 / 女"},
{"年龄范围", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 130}, "组别年龄需要在 0-120 之间"},
{"年龄区间", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 30, "ageMax": 20}, "组别年龄下限不能大于上限"},
} {
status, env2 := env.do(client, http.MethodPut, path, tc.body, true)
if status != http.StatusBadRequest {
t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
}
if env2.Error == nil || env2.Error.Message != tc.want {
t.Fatalf("%s 文案应为 %q,得到 %+v", tc.name, tc.want, env2.Error)
}
}
}
// 组别不存在、或 categoryId 属于别的小项,都按 404「组别不存在」处理,不给跨小项改名。
func TestUpdateCategoryMissingReturns404(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)
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '女子', 1, 0)`, otherEvent)
for _, tc := range []struct {
name string
path string
}{
{"组别不存在", fmt.Sprintf("/api/events/%d/categories/999999", ids.event)},
{"组别属于别的小项", fmt.Sprintf("/api/events/%d/categories/%d", ids.event, otherCat)},
} {
status, env2 := env.do(client, http.MethodPut, tc.path, map[string]any{"name": "男子", "multiplier": 1}, true)
if status != http.StatusNotFound || env2.Error == nil || env2.Error.Message != "组别不存在" {
t.Fatalf("%s 应 404 组别不存在,得到 %d %+v", tc.name, status, env2.Error)
}
}
// 别的小项的组别没被改动。
var name string
if err := env.ev.QueryRow(`SELECT name FROM event_categories WHERE id = ?`, otherCat).Scan(&name); err != nil {
t.Fatalf("读组别: %v", err)
}
if name != "女子" {
t.Fatalf("跨小项改名必须被拒,得到 %s", name)
}
}
// 老路径(整表替换)不能被改坏:有报名数据时仍然拒绝,但文案要说清是「不能增删」。
func TestReplaceCategoriesStillRejectsWithEntries(t *testing.T) {
env, ids, client := recorderWithSeed(t)
status, env2 := env.do(client, http.MethodPut,
fmt.Sprintf("/api/events/%d/categories", ids.event),
map[string]any{"categories": []map[string]any{{"name": "男子", "multiplier": 1}}}, true)
if status != http.StatusConflict {
t.Fatalf("有报名数据时整表替换应 409,得到 %d %+v", status, env2.Error)
}
if env2.Error == nil || !strings.Contains(env2.Error.Message, "不能新增或删除组别") {
t.Fatalf("文案应说清是「不能增删组别」,得到 %+v", env2.Error)
}
// 拒绝时不许留下半截改动。
if n := countRows(t, env, `SELECT COUNT(*) FROM event_categories WHERE event_id = ?`, ids.event); n != 1 {
t.Fatalf("拒绝后组别数不该变,得到 %d", n)
}
}
// 就地更新也要推大屏、写审计。
func TestUpdateCategoryWritesChangeLogAndAudit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
before := changeLogCount(t, env, "events", "upsert")
status, env2 := env.do(client, http.MethodPut,
fmt.Sprintf("/api/events/%d/categories/%d", ids.event, ids.category),
map[string]any{"name": "男子甲组", "multiplier": 1, "sortOrder": 0}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
}
if got := changeLogCount(t, env, "events", "upsert"); got != before+1 {
t.Fatalf("改名应写 1 条 events change_log,得到 %d(之前 %d)", got, before)
}
after := waitAudit(t, env, "event.category.update", ids.category)
if !strings.Contains(after, `"男子甲组"`) {
t.Fatalf("审计的 after 应含新名字,得到 %s", after)
}
var beforeJSON sql.NullString
if err := env.ev.QueryRow(
`SELECT before_json FROM audit_logs WHERE action = 'event.category.update' AND entity_id = ?`,
ids.category).Scan(&beforeJSON); err != nil {
t.Fatalf("读审计 before: %v", err)
}
if !beforeJSON.Valid || !strings.Contains(beforeJSON.String, `"男子"`) {
t.Fatalf("审计的 before 应含旧名字,得到 %+v", beforeJSON)
}
}
@@ -0,0 +1,466 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"reflect"
"testing"
)
// setLiveOnPublish 直接改赛事库设置,等价于管理端 PUT /api/settings。
// 走库而不是接口是为了让用例只关心结算行为,设置写入由 TestLiveOnPublishSwitchPersists 单独覆盖。
func setLiveOnPublish(t *testing.T, env *testEnv, value bool) {
t.Helper()
env.exec(t, `INSERT INTO settings (key, value, updated_at) VALUES ('standings.live_on_publish', ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, map[bool]string{true: "true", false: "false"}[value])
}
type settlementStatus struct {
LiveOnPublish bool `json:"liveOnPublish"`
Groups []struct {
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
CategoryName string `json:"categoryName"`
Required int `json:"required"`
Published int `json:"published"`
} `json:"groups"`
}
func readSettlement(t *testing.T, env *testEnv, client *http.Client, eventID, categoryID int64) settlementStatus {
t.Helper()
status, resp := env.do(client, http.MethodGet,
fmt.Sprintf("/api/results/settlement?eventId=%d&categoryId=%d", eventID, categoryID), nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读结算状态应 200,得到 %d %+v", status, resp.Error)
}
return decodeData[settlementStatus](t, resp)
}
func activeLedgerRows(t *testing.T, env *testEnv, eventID int64) int {
t.Helper()
var n int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, eventID).Scan(&n); err != nil {
t.Fatalf("统计流水: %v", err)
}
return n
}
func standingsTotal(t *testing.T, env *testEnv, client *http.Client) float64 {
t.Helper()
_, resp := env.do(client, http.MethodGet, "/api/standings", nil, false)
rows := decodeData[[]map[string]any](t, resp)
var total float64
for _, row := range rows {
total += row["totalPoints"].(float64)
}
return total
}
// rankOrZero 读名次:未发布的成绩没有名次(NULL),这里折算成 0,方便分批快照比较。
func rankOrZero(t *testing.T, env *testEnv, entryID int64) int {
t.Helper()
var rank sql.NullInt64
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil {
t.Fatalf("读取名次: %v", err)
}
if !rank.Valid {
return 0
}
return int(rank.Int64)
}
func changeLogCountOf(t *testing.T, env *testEnv, entityType string) int {
t.Helper()
var n int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM change_log WHERE entity_type = ?`, entityType).Scan(&n); err != nil {
t.Fatalf("统计 change_log: %v", err)
}
return n
}
// 默认(设置键不存在)必须是历史行为:分批发一批,流水与榜当场就变。
func TestLiveOnPublishDefaultIsRealTime(t *testing.T) {
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")
times := make([]float64, 8)
for i := range times {
times[i] = 12.0 + float64(i)*0.1
}
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, resp := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error)
}
// 4 条已发布 = 4 条流水;前四名 10+8+6+3 = 27 分上榜。
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 4, ids.event)
if got := standingsTotal(t, env, client); got != 27 {
t.Fatalf("默认设置下发布一批后榜单总分 = %v,期望 27", got)
}
if got := changeLogCountOf(t, env, "standings"); got != 1 {
t.Fatalf("默认设置下发布应推 1 次榜单,得到 %d", got)
}
if got := rankOfEntry(t, env, entries[0]); got != 1 {
t.Fatalf("第一名应第 1 名,得到 %d", got)
}
}
// 开关默认(键不存在)与显式 true 必须逐字节一致:
// 这是「加开关不能悄悄改变现场既有行为」的证明。
func TestLiveOnPublishDefaultMatchesExplicitTrue(t *testing.T) {
// snapshot 是一次发布之后的可比状态:名次、流水、榜单原始字节、榜单推送次数。
type snapshot struct {
ranks []int
ledger []string
standings string
pushes int
}
run := func(t *testing.T, value *bool) (afterFirst, afterSecond snapshot) {
t.Helper()
env := newTestEnv(t)
ids := env.seed(t)
entries := seedBatch(t, env, ids, 8)
if value != nil {
setLiveOnPublish(t, env, *value)
}
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
read := func() snapshot {
t.Helper()
var snap snapshot
for _, id := range entries {
snap.ranks = append(snap.ranks, rankOrZero(t, env, id))
}
rows, err := env.ev.Query(
`SELECT entry_id, team_id, points FROM score_ledger WHERE event_id = ? AND voided = 0 ORDER BY entry_id, team_id`, ids.event)
if err != nil {
t.Fatalf("读流水: %v", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var entryID, teamID int64
var points float64
if err := rows.Scan(&entryID, &teamID, &points); err != nil {
t.Fatalf("扫描流水: %v", err)
}
snap.ledger = append(snap.ledger, fmt.Sprintf("%d/%d/%v", entryID, teamID, points))
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历流水: %v", err)
}
snap.standings = rawData(t, env, client, "/api/standings")
snap.pushes = changeLogCountOf(t, env, "standings")
return snap
}
slow := make([]float64, 8)
fast := make([]float64, 8)
for i := range slow {
slow[i] = 12.0 + float64(i)*0.1
fast[i] = 10.0 + float64(i)*0.1
}
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, slow),
}, true)
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true)
afterFirst = read()
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 8, 4, fast),
}, true)
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true)
afterSecond = read()
return afterFirst, afterSecond
}
assertSame := func(t *testing.T, label string, absent, explicit snapshot) {
t.Helper()
if !reflect.DeepEqual(absent.ranks, explicit.ranks) {
t.Fatalf("%s:名次不一致:默认 %v vs 显式 true %v", label, absent.ranks, explicit.ranks)
}
if !reflect.DeepEqual(absent.ledger, explicit.ledger) {
t.Fatalf("%s:流水不一致:默认 %v vs 显式 true %v", label, absent.ledger, explicit.ledger)
}
if absent.standings != explicit.standings {
t.Fatalf("%s:榜单字节不一致:\n默认 %s\n显式 %s", label, absent.standings, explicit.standings)
}
if absent.pushes != explicit.pushes {
t.Fatalf("%s:榜单推送次数不一致:默认 %d vs 显式 true %d", label, absent.pushes, explicit.pushes)
}
}
absentFirst, absentSecond := run(t, nil)
explicit := true
trueFirst, trueSecond := run(t, &explicit)
assertSame(t, "第一批", absentFirst, trueFirst)
assertSame(t, "第二批", absentSecond, trueSecond)
// 反向证明这份快照确实受开关控制,否则上面的「一致」可能只是两条路径都坏了。
// 关掉开关后第一批只发 4/8:名次照写(延迟结算只省流水与榜单),流水必须为空、榜单必须不动。
off := false
offFirst, _ := run(t, &off)
if len(offFirst.ledger) != 0 {
t.Fatalf("关闭开关后第一批不该写流水,得到 %v", offFirst.ledger)
}
if offFirst.pushes != 0 {
t.Fatalf("关闭开关后第一批不该推榜单,得到 %d 次", offFirst.pushes)
}
if offFirst.standings == absentFirst.standings {
t.Fatalf("关闭开关后榜单应与默认不同,得到同一份:%s", offFirst.standings)
}
}
// 延迟结算:第一批只落成绩,名次 / 流水 / 榜单都不动;
// 第二批录完自动结算,整组重算并把名次与榜单补上。
func TestDeferredSettlementWaitsForWholeGroup(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
entries := seedBatch(t, env, ids, 8)
setLiveOnPublish(t, env, false)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
slow := make([]float64, 8)
fast := make([]float64, 8)
for i := range slow {
slow[i] = 12.0 + float64(i)*0.1
fast[i] = 10.0 + float64(i)*0.1
}
// 发布之前的榜单原始字节:延迟结算下这批发布不允许改动它(含奖牌列)。
beforeStandings := rawData(t, env, client, "/api/standings")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, slow),
}, true)
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true); status != http.StatusOK || !e.OK {
t.Fatalf("第一批发布应 200,得到 %d %+v", status, e.Error)
}
// 成绩要上屏(可以看到时间),但名次 / 流水 / 榜单都要等结算。
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 4, ids.event)
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event)
assertCount(t, env, `SELECT COUNT(*) FROM change_log WHERE entity_type = 'standings'`, 0)
// 名次不写:奖牌榜统计读的就是 results.rank,分批写名次会让奖牌列跟总分对不上。
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, 0, ids.event)
if got := rawData(t, env, client, "/api/standings"); got != beforeStandings {
t.Fatalf("延迟结算下榜单不该动:\n发布前 %s\n发布后 %s", beforeStandings, got)
}
// 未结算清单要能查到这一组。
first := readSettlement(t, env, client, ids.event, ids.category)
if first.LiveOnPublish {
t.Fatalf("设置已关闭,接口应回报 liveOnPublish=false: %+v", first)
}
if len(first.Groups) != 1 || first.Groups[0].Required != 8 || first.Groups[0].Published != 4 {
t.Fatalf("未结算清单应含 4/8 的该组,得到 %+v", first.Groups)
}
// 第二批录完 → 自动结算:整组重算,流水齐全,榜单正确。
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 8, 4, fast),
}, true)
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true); status != http.StatusOK || !e.OK {
t.Fatalf("第二批发布应 200,得到 %d %+v", status, e.Error)
}
assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 8, ids.event)
// 前六名得分 10+8+6+3+2+1 = 30,共 6 条流水。
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
if got := standingsTotal(t, env, client); got != 30 {
t.Fatalf("结算后榜单总分 = %v,期望 30", got)
}
if got := changeLogCountOf(t, env, "standings"); got != 1 {
t.Fatalf("结算应推 1 次榜单,得到 %d", got)
}
// 整组重算的证明:第二批更快,第一批第一名退到第 5 名。
if got := rankOfEntry(t, env, entries[4]); got != 1 {
t.Fatalf("第二批最快者应第 1 名,得到 %d", got)
}
if got := rankOfEntry(t, env, entries[0]); got != 5 {
t.Fatalf("第一批第一名应退到第 5 名,得到 %d", got)
}
if got := readSettlement(t, env, client, ids.event, ids.category); len(got.Groups) != 0 {
t.Fatalf("结算后未结算清单应为空,得到 %+v", got.Groups)
}
}
// 有人弃权(scratched)时不该再等他:7 人录完 + 1 人退赛也算全组录完,自动结算。
func TestDeferredSettlementTreatsScratchedAsFinished(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
entries := seedBatch(t, env, ids, 8)
setLiveOnPublish(t, env, false)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 第 8 人退赛(与前端「批量退赛」同一条接口)。
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "scratch", "ids": []int64{entries[7]},
}, true); status != http.StatusOK || !e.OK {
t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error)
}
times := make([]float64, 8)
for i := range times {
times[i] = 12.0 + float64(i)*0.1
}
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 7, 0, times),
}, true)
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true); status != http.StatusOK || !e.OK {
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
}
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
if got := standingsTotal(t, env, client); got != 30 {
t.Fatalf("退赛者不该阻塞结算,榜单总分 = %v,期望 30", got)
}
if got := readSettlement(t, env, client, ids.event, ids.category); len(got.Groups) != 0 {
t.Fatalf("全组(含退赛)录完后清单应为空,得到 %+v", got.Groups)
}
}
// 发布之后才标退赛:整组因此录完,退赛动作本身也要把这组结算掉,不能停在空流水上。
func TestScratchAfterDeferredPublishSettlesGroup(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
entries := seedBatch(t, env, ids, 8)
setLiveOnPublish(t, env, false)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
times := make([]float64, 8)
for i := range times {
times[i] = 12.0 + float64(i)*0.1
}
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 7, 0, times),
}, true)
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true)
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event)
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "scratch", "ids": []int64{entries[7]},
}, true); status != http.StatusOK || !e.OK {
t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error)
}
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event)
if got := standingsTotal(t, env, client); got != 30 {
t.Fatalf("退赛补全整组后应结算,榜单总分 = %v,期望 30", got)
}
}
// 立即结算是延迟结算的人工兜底:按当前已发布成绩结算,并写审计。
func TestManualSettleUsesPublishedResults(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
entries := seedBatch(t, env, ids, 8)
setLiveOnPublish(t, env, false)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
times := make([]float64, 8)
for i := range times {
times[i] = 12.0 + float64(i)*0.1
}
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times),
}, true)
env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, true)
if status, e := env.do(client, http.MethodPost, "/api/results/settle", map[string]any{
"eventId": ids.event, "categoryId": ids.category, "requestId": "settle-1",
}, true); status != http.StatusOK || !e.OK {
t.Fatalf("立即结算应 200,得到 %d %+v", status, e.Error)
}
// 只有 4 条已发布 → 前四名 10+8+6+3 = 27 分。
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 4, ids.event)
if got := standingsTotal(t, env, client); got != 27 {
t.Fatalf("立即结算后榜单总分 = %v,期望 27", got)
}
waitAudit(t, env, "result.settle", ids.event)
// 未登录不能结算。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodPost, "/api/results/settle", map[string]any{
"eventId": ids.event, "categoryId": ids.category,
}, false); status != http.StatusUnauthorized {
t.Fatalf("未登录结算应 401,得到 %d", status)
}
}
// 开关键写入 / 读出:PUT /api/settings 落库后 GET 要能读回,且缺键默认 true。
func TestLiveOnPublishSwitchPersists(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
read := func() bool {
t.Helper()
_, resp := env.do(client, http.MethodGet, "/api/settings", nil, false)
settings := decodeData[map[string]any](t, resp)
return settings["standings"].(map[string]any)["liveOnPublish"].(bool)
}
if !read() {
t.Fatalf("缺键时 liveOnPublish 应默认 true")
}
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
"standings": map[string]any{"liveOnPublish": false},
}, true); status != http.StatusOK || !e.OK {
t.Fatalf("关闭开关应 200,得到 %d %+v", status, e.Error)
}
if read() {
t.Fatalf("关闭后 liveOnPublish 应读回 false")
}
// 只改一个字段不能把榜单口径的其余取值抹掉。
_, resp := env.do(client, http.MethodGet, "/api/settings", nil, false)
standings := decodeData[map[string]any](t, resp)["standings"].(map[string]any)
if standings["individualToTeamWeight"].(float64) != 1 || standings["rankingMode"] != "points" {
t.Fatalf("其余榜单口径不应被改动: %+v", standings)
}
if status, e := env.do(client, http.MethodPut, "/api/settings", map[string]any{
"standings": map[string]any{"liveOnPublish": true},
}, true); status != http.StatusOK || !e.OK {
t.Fatalf("重新打开应 200,得到 %d %+v", status, e.Error)
}
if !read() {
t.Fatalf("重新打开后 liveOnPublish 应读回 true")
}
}
+3
View File
@@ -248,6 +248,9 @@ type currentUserDTO struct {
Role string `json:"role"`
LastLoginAt *string `json:"lastLoginAt"`
MustChangePassword bool `json:"mustChangePassword"`
// SessionExpiresAt 是当前会话的到期时间(RFC3339 UTC),供界面显示
// 「当前会话将于 … 过期」;未登录时为 null。
SessionExpiresAt *string `json:"sessionExpiresAt"`
}
type backupResultDTO struct {
+370
View File
@@ -0,0 +1,370 @@
package httpapi
import (
"context"
"database/sql"
"fmt"
"net/http"
"slices"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// maxEntryBatch 是单次批量操作的上限:前端一次勾选几十条是常态,
// 500 足够覆盖「一个组别全选」,再大说明是脚本误用,要求分批提交。
const maxEntryBatch = 500
// entryBatchItemDTO 是逐条回报:ids 里每一个 id 都必有一条,
// 不存在 / 被跳过都要写清原因,不能整批失败,也不能静默略过。
type entryBatchItemDTO struct {
ID int64 `json:"id"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
Note string `json:"note,omitempty"`
}
// entryBatchSummaryDTO 是整批汇总:ok 含跳过的行,changed 才是真正改动的行数;
// failed 只统计 id 不存在这类逐条失败(整批 SQL 出错会直接 5xx / 409,不进这里)。
type entryBatchSummaryDTO struct {
OK int `json:"ok"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Changed int `json:"changed"`
RemovedEntries int `json:"removedEntries"`
RemovedResults int `json:"removedResults"`
RemovedLedger int `json:"removedLedger"`
RemovedBreaks int `json:"removedBreaks"`
RecomputedEvent []int64 `json:"recomputedEvents"`
}
type entryBatchResultDTO struct {
Items []entryBatchItemDTO `json:"items"`
Summary entryBatchSummaryDTO `json:"summary"`
}
// entryBatchTarget 是勾选行在库里的落点:换状态要看当前 status,重算要按 (小项, 组别) 分组。
type entryBatchTarget struct {
id int64
eventID int64
categoryID int64
status string
}
// batchEntriesHandler 是 POST /api/entries/batch:
// scratch(批量退赛,可恢复)/ restore(批量恢复)/ remove(批量移出报名,物理删除)。
func (s *server) batchEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in entryBatchPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
action := strings.TrimSpace(in.Action)
switch action {
case "scratch", "restore", "remove":
default:
return nil, badRequest("action 只能是 scratch / restore / remove", in.Action)
}
// 重复 id 去重(前端全选后二次勾选很容易重复),非法 id 直接丢掉。
ids := dedupeIDs(in.IDs)
if len(ids) == 0 {
return nil, badRequest("ids 不能为空", nil)
}
if len(ids) > maxEntryBatch {
return nil, badRequest(fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", maxEntryBatch), nil)
}
result, err := s.batchEntries(r.Context(), action, ids)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entry."+action, "entry", 0, nil, map[string]any{
"ids": ids,
"reason": strings.TrimSpace(in.Reason),
"summary": result.Summary,
})
return result, nil
}
// entrySkipNote 解释某一行为什么没被改动:跳过必须带出现状,
// 否则操作员只看到「没退赛」会以为是系统漏了。
func entrySkipNote(action, status string) string {
switch status {
case "scratched":
return "已是退赛状态,跳过"
case "registered":
return "当前正常参赛,无需恢复"
case "dq":
return "已被判罚取消资格(dq),跳过,不覆盖原判定"
case "dns":
return "已标记未出场(dns),跳过,不覆盖原判定"
}
return "当前状态为「" + status + "」,跳过"
}
// recomputeReason 是流水作废原因,写进 score_ledger.void_reason,事后能对上「榜为什么变了」。
func recomputeReason(action string) string {
switch action {
case "scratch":
return "批量退赛后重算"
case "restore":
return "批量恢复后重算"
}
return "批量移出报名后重算"
}
// loadEntryBatchTargets 读回请求里存在的报名项;不存在的由调用方逐条回报。
func loadEntryBatchTargets(ctx context.Context, tx *sql.Tx, ids []int64) (map[int64]entryBatchTarget, error) {
args := idsToArgs(ids)
rows, err := tx.QueryContext(ctx,
`SELECT id, event_id, category_id, status FROM entries WHERE id IN (`+placeholders(len(ids))+`)`, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64]entryBatchTarget, len(ids))
for rows.Next() {
var t entryBatchTarget
if err := rows.Scan(&t.id, &t.eventID, &t.categoryID, &t.status); err != nil {
return nil, internal(err)
}
out[t.id] = t
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
return out, nil
}
// entryRemoveStats 是移出报名的物理删除规模,随响应汇总返回。
type entryRemoveStats struct {
entries int
results int
ledger int
breaks int
}
// removeEntriesTx 物理删除报名项及其成绩痕迹。
//
// 顺序按外键方向来(与 repo.go 的 purgeAthlete 同一套解法):
// score_ledger 对 entries / results 都是 ON DELETE SET NULL,直接删 entries 只会把
// entry_id 置空——人没了分还留在榜上,所以必须按 entry_id 显式清掉(result_id 一并兜住);
// record_breaks 是 CASCADE、results 对 entries 没有 ON DELETE,都要显式先删。
func (s *server) removeEntriesTx(ctx context.Context, tx *sql.Tx, ids []int64) (entryRemoveStats, error) {
stats := entryRemoveStats{}
if len(ids) == 0 {
return stats, nil
}
ph := placeholders(len(ids))
idArgs := idsToArgs(ids)
steps := []struct {
query string
args []any
count *int
}{
{`DELETE FROM score_ledger
WHERE entry_id IN (` + ph + `)
OR result_id IN (SELECT id FROM results WHERE entry_id IN (` + ph + `))`,
append(append([]any{}, idArgs...), idArgs...), &stats.ledger},
{`DELETE FROM record_breaks WHERE entry_id IN (` + ph + `)`, idArgs, &stats.breaks},
{`DELETE FROM results WHERE entry_id IN (` + ph + `)`, idArgs, &stats.results},
{`DELETE FROM entries WHERE id IN (` + ph + `)`, idArgs, &stats.entries},
}
for _, step := range steps {
res, err := tx.ExecContext(ctx, step.query, step.args...)
if err != nil {
return entryRemoveStats{}, s.entryBatchBlocked(ctx, tx, err)
}
n, err := res.RowsAffected()
if err != nil {
return entryRemoveStats{}, internal(err)
}
*step.count = int(n)
}
return stats, nil
}
// entryBatchBlocked 兜底:撞上外键约束时回滚并给可读的 409,不让它冒成 500。
// 连接池只有一条连接,先把事务交还再返回,避免后续查询自锁。
func (s *server) entryBatchBlocked(ctx context.Context, tx *sql.Tx, cause error) error {
if !isForeignKeyViolation(cause) {
return internal(cause)
}
s.logger.WarnContext(ctx, "批量移出报名撞上外键约束", "err", cause)
_ = tx.Rollback()
return conflict("报名项仍被其它数据引用,本批已回滚,报名与成绩都没有改动。请刷新后重试。")
}
// batchEntries 在一个事务里完成整批状态变更或删除,并按 (小项, 组别) 分组重算积分。
//
// 重算口径照 repo.go 的 purgeAthlete:删完成绩(或改完报名状态)后必须重算流水、
// 把新名次写回 results,再推小项与榜单,否则大屏还挂着旧分数、别人的名次也不会变。
func (s *server) batchEntries(ctx context.Context, action string, ids []int64) (entryBatchResultDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return entryBatchResultDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
targets, err := loadEntryBatchTargets(ctx, tx, ids)
if err != nil {
return entryBatchResultDTO{}, err
}
out := entryBatchResultDTO{Items: make([]entryBatchItemDTO, 0, len(ids))}
affected := make([]entryBatchTarget, 0, len(ids))
for _, id := range ids {
target, ok := targets[id]
if !ok {
out.Items = append(out.Items, entryBatchItemDTO{ID: id, OK: false, Error: "报名项不存在"})
out.Summary.Failed++
continue
}
// 退赛只认 registered、恢复只认 scratched;dq/dns 是别人的判定,一律不覆盖。
want := ""
switch action {
case "scratch":
want = "registered"
case "restore":
want = "scratched"
}
if want != "" && target.status != want {
out.Items = append(out.Items, entryBatchItemDTO{
ID: id, OK: true, Skipped: true, Note: entrySkipNote(action, target.status),
})
out.Summary.OK++
out.Summary.Skipped++
continue
}
affected = append(affected, target)
out.Items = append(out.Items, entryBatchItemDTO{ID: id, OK: true})
out.Summary.OK++
out.Summary.Changed++
}
changedIDs := targetIDs(affected)
switch action {
case "scratch", "restore":
if len(changedIDs) > 0 {
status := "scratched"
if action == "restore" {
status = "registered"
}
if _, err := tx.ExecContext(ctx,
`UPDATE entries SET status = ? WHERE id IN (`+placeholders(len(changedIDs))+`)`,
append([]any{status}, idsToArgs(changedIDs)...)...); err != nil {
return entryBatchResultDTO{}, internal(err)
}
}
case "remove":
stats, err := s.removeEntriesTx(ctx, tx, changedIDs)
if err != nil {
return entryBatchResultDTO{}, err
}
out.Summary.RemovedEntries = stats.entries
out.Summary.RemovedResults = stats.results
out.Summary.RemovedLedger = stats.ledger
out.Summary.RemovedBreaks = stats.breaks
}
// 按 (小项, 组别) 分组重算:跨小项混选是正常的「按人名勾选」场景,但重算必须逐个范围来。
actorID := middleware.User(ctx).ID
live, err := liveOnPublishQ(ctx, tx)
if err != nil {
return entryBatchResultDTO{}, err
}
eventIDs := make([]int64, 0, 2)
settled := false
for _, scope := range distinctScopes(affected) {
// 退赛 / 恢复会改变「这一组该不该等它」:标成退赛后整组可能刚好录完,
// 延迟结算下必须补一次结算,否则榜单会永远停在没有流水的那一版。
settle := live
if !live {
var err error
settle, err = scopeSettled(ctx, tx, scope.eventID, scope.categoryID)
if err != nil {
return entryBatchResultDTO{}, err
}
}
if settle {
// 退赛的成绩不再占名次:results.rank 必须一并清掉。
// buildRanked(奖牌统计 / 运动员榜)只认 results.rank + state='published',
// 不清就会留下「0 分却还挂着金牌」;恢复时由 applyComputedRanks 按新名次写回。
if action == "scratch" {
if _, err := tx.ExecContext(ctx,
`UPDATE results SET rank = NULL
WHERE state = 'published'
AND entry_id IN (SELECT id FROM entries
WHERE category_id = ? AND id IN (`+placeholders(len(changedIDs))+`))`,
append([]any{scope.categoryID}, idsToArgs(changedIDs)...)...); err != nil {
return entryBatchResultDTO{}, internal(err)
}
}
score, err := computeEventScore(ctx, tx, scope.eventID, scope.categoryID)
if err != nil {
return entryBatchResultDTO{}, err
}
// 只重建流水不够:成绩表与奖牌统计看的是 results.rank,必须像发布那样把名次写回去。
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return entryBatchResultDTO{}, err
}
if err := writeLedgerTx(ctx, tx, score, scope.eventID, scope.categoryID, actorID, recomputeReason(action)); err != nil {
return entryBatchResultDTO{}, err
}
settled = true
}
if !slices.Contains(eventIDs, scope.eventID) {
eventIDs = append(eventIDs, scope.eventID)
}
}
// 推大屏:受影响小项与榜单,否则大屏还挂着旧分数。
for _, eventID := range eventIDs {
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return entryBatchResultDTO{}, err
}
}
// 延迟结算且没有一组录完时不推榜单:榜单该保持上一次结算的状态。
if len(eventIDs) > 0 && settled {
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return entryBatchResultDTO{}, err
}
}
out.Summary.RecomputedEvent = eventIDs
if err := tx.Commit(); err != nil {
return entryBatchResultDTO{}, s.entryBatchBlocked(ctx, tx, err)
}
return out, nil
}
func targetIDs(targets []entryBatchTarget) []int64 {
out := make([]int64, 0, len(targets))
for _, t := range targets {
out = append(out, t.id)
}
return out
}
// entryScope 是重算的最小单位:积分与名次都按「小项 + 组别」算。
type entryScope struct{ eventID, categoryID int64 }
// distinctScopes 按出现顺序去重,保证同一批请求的重算次序稳定、可复现。
func distinctScopes(targets []entryBatchTarget) []entryScope {
out := make([]entryScope, 0, len(targets))
for _, t := range targets {
scope := entryScope{t.eventID, t.categoryID}
if !slices.Contains(out, scope) {
out = append(out, scope)
}
}
return out
}
func idsToArgs(ids []int64) []any {
out := make([]any, 0, len(ids))
for _, id := range ids {
out = append(out, id)
}
return out
}
@@ -0,0 +1,411 @@
package httpapi_test
import (
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"time"
)
// batchItem / batchSummary / batchResult 是 POST /api/entries/batch 的响应契约,
// 用例直接按前端会收到的 JSON 字段来断言,避免服务端字段改名后测试还绿着。
type batchItem struct {
ID int64 `json:"id"`
OK bool `json:"ok"`
Error string `json:"error"`
Skipped bool `json:"skipped"`
Note string `json:"note"`
}
type batchSummary struct {
OK int `json:"ok"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Changed int `json:"changed"`
RemovedEntries int `json:"removedEntries"`
RemovedResults int `json:"removedResults"`
RemovedLedger int `json:"removedLedger"`
RemovedBreaks int `json:"removedBreaks"`
RecomputedEvent []int64 `json:"recomputedEvents"`
}
type batchResult struct {
Items []batchItem `json:"items"`
Summary batchSummary `json:"summary"`
Raw map[string]any `json:"-"`
}
// entryStatus 读报名项当前状态。
func entryStatus(t *testing.T, env *testEnv, entryID int64) string {
t.Helper()
var status string
if err := env.ev.QueryRow(`SELECT status FROM entries WHERE id = ?`, entryID).Scan(&status); err != nil {
t.Fatalf("读报名项状态: %v", err)
}
return status
}
// addTeamAthleteEntry 在既有小项 / 组别下补一个单位 + 运动员 + 报名项。
func addTeamAthleteEntry(t *testing.T, env *testEnv, ids seedIDs, code, name string, athleteName string) (int64, int64) {
t.Helper()
teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES (?, ?, ?, 9, 1)`,
code, name, name)
athleteID := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
athleteName, teamID)
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, teamID, athleteID)
return teamID, entryID
}
// 批量退赛后别人被重算、被退赛的人不再占名次;恢复后榜单回到原状。
func TestEntryBatchScratchAndRestore(t *testing.T) {
env, ids, client := recorderWithSeed(t)
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
publishResults(t, env, client, ids.event, "batch",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
)
// 前置:10 / 8 / 6 分。
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
t.Fatalf("前置条件:B 应是 8 分,得到 %v", got)
}
if got := teamPoints(t, env, client, teamC); got != 6 {
t.Fatalf("前置条件:C 单位总分应是 6,得到 %v", got)
}
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "scratch", "ids": []int64{ids.entryB, entryC}, "reason": "临时请假",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[batchResult](t, env2)
if len(result.Items) != 2 || !result.Items[0].OK || !result.Items[1].OK {
t.Fatalf("应有 2 条逐条成功,得到 %+v", result.Items)
}
if result.Summary.OK != 2 || result.Summary.Failed != 0 || result.Summary.Changed != 2 {
t.Fatalf("汇总应是 2 成功 / 0 失败,得到 %+v", result.Summary)
}
if got := entryStatus(t, env, ids.entryB); got != "scratched" {
t.Fatalf("B 应已退赛,得到 %s", got)
}
if got := entryStatus(t, env, entryC); got != "scratched" {
t.Fatalf("C 应已退赛,得到 %s", got)
}
// 退赛的行不再有有效流水,旧流水保留为「已作废」的证据。
if got := activeLedgerPoints(t, env, ids.entryB); len(got) != 0 {
t.Fatalf("B 退赛后不应再有有效流水,得到 %v", got)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryB); n != 1 {
t.Fatalf("B 的旧流水应被作废,得到 %d 条", n)
}
// 别人被重算:A 仍是第一 10 分,C 退赛不占名次。
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
t.Fatalf("A 应保持 10 分,得到 %v", got)
}
if got := teamPoints(t, env, client, teamC); got != 0 {
t.Fatalf("C 单位总分应归零,得到 %v", got)
}
// 退赛不占名次:已发布成绩的 rank 必须清掉,否则奖牌统计还会给他记一块金牌。
for _, id := range []int64{ids.entryB, entryC} {
var rank sql.NullInt64
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank); err != nil {
t.Fatalf("读退赛行的名次: %v", err)
}
if rank.Valid {
t.Fatalf("退赛的报名项不应再占名次,entry %d 的 rank=%d", id, rank.Int64)
}
}
if n := countRows(t, env,
`SELECT COUNT(*) FROM results r JOIN entries e ON e.id = r.entry_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND e.status <> 'registered'`); n != 0 {
t.Fatalf("非正常参赛的已发布成绩不该带名次,得到 %d 条", n)
}
// 恢复:同一端点 restore,只有 scratched 会被改回 registered。
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "restore", "ids": []int64{ids.entryB, entryC},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("批量恢复应 200,得到 %d %+v", status, env2.Error)
}
if got := entryStatus(t, env, ids.entryB); got != "registered" {
t.Fatalf("B 应恢复为 registered,得到 %s", got)
}
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
t.Fatalf("B 恢复后应重算回 8 分,得到 %v", got)
}
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) {
t.Fatalf("C 恢复后应重算回 6 分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 8 {
t.Fatalf("B 单位总分应恢复为 8,得到 %v", got)
}
if got := teamPoints(t, env, client, teamC); got != 6 {
t.Fatalf("C 单位总分应恢复为 6,得到 %v", got)
}
// 恢复后名次写回:B 回到第 2 名(A 第一、C 第三)。
var restoredRank sql.NullInt64
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&restoredRank); err != nil {
t.Fatalf("读恢复后的名次: %v", err)
}
if !restoredRank.Valid || restoredRank.Int64 != 2 {
t.Fatalf("B 恢复后应重算回第 2 名,得到 %+v", restoredRank)
}
assertNoForeignKeyViolation(t, env)
}
// 已是 dq 的行不能被退赛覆盖;不存在的 id 逐条报错;整批不失败。
func TestEntryBatchScratchSkipsDQAndReportsMissing(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE entries SET status = 'dq' WHERE id = ?`, ids.entryB)
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "scratch", "ids": []int64{ids.entryA, ids.entryB, int64(999999)},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("混选批量退赛应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[batchResult](t, env2)
if len(result.Items) != 3 {
t.Fatalf("3 个 id 应有 3 条逐条结果,得到 %+v", result.Items)
}
if !result.Items[0].OK || result.Items[0].Skipped {
t.Fatalf("A 应正常退赛,得到 %+v", result.Items[0])
}
if !result.Items[1].OK || !result.Items[1].Skipped || !strings.Contains(result.Items[1].Note, "dq") {
t.Fatalf("B 应被跳过并说明是 dq,得到 %+v", result.Items[1])
}
if result.Items[2].OK || result.Items[2].Error != "报名项不存在" {
t.Fatalf("不存在的 id 应逐条报错,得到 %+v", result.Items[2])
}
if result.Summary.OK != 2 || result.Summary.Failed != 1 || result.Summary.Skipped != 1 || result.Summary.Changed != 1 {
t.Fatalf("汇总应是 2 成功(含 1 跳过)/ 1 失败,得到 %+v", result.Summary)
}
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
t.Fatalf("A 应已退赛,得到 %s", got)
}
if got := entryStatus(t, env, ids.entryB); got != "dq" {
t.Fatalf("B 的 dq 判定不应被覆盖,得到 %s", got)
}
assertNoForeignKeyViolation(t, env)
}
// 移出报名:报名、成绩、流水全部按 entry_id 清干净(不留 entry_id 置空的空壳流水),
// 留下的人按新名次重算。
func TestEntryBatchRemovePurgesLedgerByEntryID(t *testing.T) {
env, ids, client := recorderWithSeed(t)
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
publishResults(t, env, client, ids.event, "remove",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
)
// 破纪录登记也要跟着消失,否则会留下一条挂不上报名项的记录。
env.exec(t, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, bonus_points, state)
VALUES (?, ?, 'meet', 11.92, 5, 'pending')`, ids.event, ids.entryB)
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "remove", "ids": []int64{ids.entryB},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[batchResult](t, env2)
if result.Summary.RemovedEntries != 1 || result.Summary.RemovedResults != 1 ||
result.Summary.RemovedLedger != 1 || result.Summary.RemovedBreaks != 1 {
t.Fatalf("汇总应带回删除规模,得到 %+v", result.Summary)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 0 {
t.Fatalf("报名项应被物理删除,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryB); n != 0 {
t.Fatalf("成绩应被删除,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`, ids.entryB); n != 0 {
t.Fatalf("流水应按 entry_id 清干净,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM record_breaks WHERE entry_id = ?`, ids.entryB); n != 0 {
t.Fatalf("破纪录登记应被删除,得到 %d 条", n)
}
// 关键证据:删完之后不能留下 entry_id 被 SET NULL 置空的成绩流水。
if n := countRows(t, env,
`SELECT COUNT(*) FROM score_ledger WHERE entry_id IS NULL AND result_id IS NULL AND source_type = 'result'`); n != 0 {
t.Fatalf("不应留下 entry_id 被置空的空壳流水,得到 %d 条", n)
}
// 留下的人被重算:C 从第三 6 分升到第二 8 分。
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{8}) {
t.Fatalf("C 应随新名次重算成 8 分,得到 %v", got)
}
if got := teamPoints(t, env, client, teamC); got != 8 {
t.Fatalf("C 单位总分应重算成 8,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
t.Fatalf("被移出的单位总分应归零,得到 %v", got)
}
assertNoForeignKeyViolation(t, env)
}
// 跨小项混选允许,但要按 (小项, 组别) 分组各算各的。
func TestEntryBatchScratchAcrossEvents(t *testing.T) {
env, ids, client := recorderWithSeed(t)
event2 := 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)
category2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, event2)
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'athlete', 'result', ?, 0)`, event2, profileID)
entry2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, event2, category2, ids.teamA, ids.athleteA)
publishResults(t, env, client, ids.event, "cross1",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
publishResults(t, env, client, event2, "cross2",
map[string]any{"entryId": entry2, "resultValue": 23.10, "resultText": "23.10", "unit": "s", "status": "ok"})
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "scratch", "ids": []int64{ids.entryA, entry2},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("跨小项批量退赛应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[batchResult](t, env2)
if len(result.Summary.RecomputedEvent) != 2 ||
result.Summary.RecomputedEvent[0] != ids.event || result.Summary.RecomputedEvent[1] != event2 {
t.Fatalf("两个小项都应重算,得到 %+v", result.Summary.RecomputedEvent)
}
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
t.Fatalf("小项一的报名应已退赛,得到 %s", got)
}
if got := entryStatus(t, env, entry2); got != "scratched" {
t.Fatalf("小项二的报名应已退赛,得到 %s", got)
}
}
// 边界与权限:空 ids、重复 ids、超上限、非法 action、guest 写。
func TestEntryBatchValidation(t *testing.T) {
env, ids, client := recorderWithSeed(t)
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch",
map[string]any{"action": "scratch", "ids": []int64{}}, true)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("空 ids 应 400,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
map[string]any{"action": "nuke", "ids": []int64{ids.entryA}}, true)
if status != http.StatusBadRequest {
t.Fatalf("非法 action 应 400,得到 %d %+v", status, env2.Error)
}
// 重复 id 去重后只回报一条。
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
map[string]any{"action": "scratch", "ids": []int64{ids.entryA, ids.entryA}}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("带重复 id 的请求应 200,得到 %d %+v", status, env2.Error)
}
dup := decodeData[batchResult](t, env2)
if len(dup.Items) != 1 || dup.Summary.Changed != 1 {
t.Fatalf("重复 id 应去重成一条,得到 %+v", dup)
}
// 超过上限要在动手之前拒绝。
big := make([]int64, 501)
for i := range big {
big[i] = int64(i + 1)
}
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
map[string]any{"action": "remove", "ids": big}, true)
if status != http.StatusBadRequest || env2.Error == nil || !strings.Contains(env2.Error.Message, "分批") {
t.Fatalf("超过上限应 400 并要求分批,得到 %d %+v", status, env2.Error)
}
// guest(未登录)不能写。
guest, _ := env.client()
status, env2 = env.do(guest, http.MethodPost, "/api/entries/batch",
map[string]any{"action": "remove", "ids": []int64{ids.entryA}}, false)
if status != http.StatusUnauthorized {
t.Fatalf("未登录批量操作应 401,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 1 {
t.Fatalf("guest 不应删掉任何报名项,得到 %d 条", n)
}
}
// waitBatchAudit 取批量操作的审计 after_json:批量动作没有单个实体 id
// (entity_id 落库为 NULL),所以按 action 找,和 waitAudit 的单实体口径区分开。
func waitBatchAudit(t *testing.T, env *testEnv, action string) string {
t.Helper()
for i := 0; i < 100; i++ {
var after sql.NullString
err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = ? AND entity_id IS NULL`,
action).Scan(&after)
switch {
case err == nil:
return after.String
case errors.Is(err, sql.ErrNoRows):
time.Sleep(10 * time.Millisecond)
default:
t.Fatalf("查询审计: %v", err)
}
}
t.Fatalf("等不到 %s 的审计记录", action)
return ""
}
// 审计要带 ids 与 summary,三个动作各一条。
func TestEntryBatchAudit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
for _, action := range []string{"scratch", "restore", "remove"} {
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": action, "ids": []int64{ids.entryA}, "reason": "实机验证",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("%s 应 200,得到 %d %+v", action, status, env2.Error)
}
after := waitBatchAudit(t, env, "entry."+action)
for _, want := range []string{fmt.Sprintf(`"ids":[%d]`, ids.entryA), `"summary"`, `"实机验证"`} {
if !strings.Contains(after, want) {
t.Fatalf("%s 审计应含 %s,得到 %s", action, want, after)
}
}
}
}
// 保障:remove 之后 results 表里的 rank 不能留下指向已删报名项的悬挂行。
func TestEntryBatchRemoveKeepsOtherResultsIntact(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "keep",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "remove", "ids": []int64{ids.entryA},
}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
}
var (
rank sql.NullInt64
state string
)
if err := env.ev.QueryRow(`SELECT rank, state FROM results WHERE entry_id = ?`, ids.entryB).
Scan(&rank, &state); err != nil {
t.Fatalf("读 B 的成绩: %v", err)
}
if !rank.Valid || rank.Int64 != 1 || state != "published" {
t.Fatalf("B 的名次应重算为 1 且保持发布态,得到 rank=%+v state=%s", rank, state)
}
}
@@ -0,0 +1,527 @@
package httpapi_test
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
// cellAt 取单元格文本,越界返回空串(表格行可能比表头短)。
func cellAt(rows [][]string, r, c int) string {
if r < 0 || r >= len(rows) || c < 0 || c >= len(rows[r]) {
return ""
}
return rows[r][c]
}
type testSheet struct {
name string
rows [][]string
}
// buildTestXLSX 手工拼一份上传用的工作簿(表头在第 1 行),模拟现场自己整理的表。
func buildTestXLSX(t *testing.T, sheets []testSheet) []byte {
t.Helper()
f := excelize.NewFile()
defer func() { _ = f.Close() }()
for i, sh := range sheets {
if i == 0 {
if err := f.SetSheetName("Sheet1", sh.name); err != nil {
t.Fatalf("重命名工作表: %v", err)
}
} else if _, err := f.NewSheet(sh.name); err != nil {
t.Fatalf("新建工作表: %v", err)
}
for r, row := range sh.rows {
for c, v := range row {
name, err := excelize.CoordinatesToCellName(c+1, r+1)
if err != nil {
t.Fatalf("算单元格坐标: %v", err)
}
if err := f.SetCellValue(sh.name, name, v); err != nil {
t.Fatalf("写单元格: %v", err)
}
}
}
}
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
t.Fatalf("写出测试工作簿: %v", err)
}
return buf.Bytes()
}
// editWorkbookCell 在导出的工作簿里找到「A 列等于 key」的那一行,把另一列改成新值。
// 行号不写死:导出的版式(标题 / 副标题 / 空行 / 表头)以后变了也不会让测试失效。
func editWorkbookCell(t *testing.T, f *excelize.File, sheet, keyCol, key, targetCol, value string) {
t.Helper()
rows, err := f.GetRows(sheet)
if err != nil {
t.Fatalf("读取工作表 %s: %v", sheet, err)
}
for r := range rows {
if cellAt(rows, r, columnIndex(keyCol)) != key {
continue
}
cell, err := excelize.CoordinatesToCellName(columnIndex(targetCol)+1, r+1)
if err != nil {
t.Fatalf("算单元格坐标: %v", err)
}
if err := f.SetCellValue(sheet, cell, value); err != nil {
t.Fatalf("改单元格: %v", err)
}
return
}
t.Fatalf("工作表 %s 里找不到 %s=%s 的行", sheet, keyCol, key)
}
func columnIndex(letter string) int {
n, _ := excelize.ColumnNameToNumber(letter)
return n - 1
}
// uploadBaseData 以 multipart 上传基础数据包,返回状态码与原始响应体。
func (e *testEnv) uploadBaseData(t *testing.T, client *http.Client, filename string, data []byte, fields map[string]string) (int, []byte) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
t.Fatalf("写表单字段: %v", err)
}
}
fw, err := w.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("创建上传字段: %v", err)
}
if _, err := fw.Write(data); err != nil {
t.Fatalf("写工作簿: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("关闭 multipart: %v", err)
}
req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/export/basedata/import", &buf)
if err != nil {
t.Fatalf("构造请求: %v", err)
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
resp, err := client.Do(req)
if err != nil {
t.Fatalf("上传基础数据包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, raw
}
// baseDataResult 是导入响应里我们关心的部分(与后端 baseDataImportResult 同构)。
type baseDataResult struct {
DryRun bool `json:"dryRun"`
Added int `json:"added"`
Updated int `json:"updated"`
Unchanged int `json:"unchanged"`
ErrorCount int `json:"errorCount"`
Errors []string `json:"errors"`
Changes []struct {
Sheet string `json:"sheet"`
Line int `json:"line"`
Action string `json:"action"`
Key string `json:"key"`
Fields []struct {
Field string `json:"field"`
From string `json:"from"`
To string `json:"to"`
} `json:"fields"`
} `json:"changes"`
}
func decodeBaseDataResult(t *testing.T, raw []byte) baseDataResult {
t.Helper()
var env struct {
OK bool `json:"ok"`
Data baseDataResult `json:"data"`
Error *apiErrorBody `json:"error"`
}
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("解析导入响应: %v (%s)", err, raw)
}
if !env.OK {
t.Fatalf("导入失败: %+v", env.Error)
}
return env.Data
}
// 往返硬要求:刚导出的基础数据包原样导入,差异必须是 0/0/0。
func TestBaseDataPackageRoundTripIsNoop(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, header, body := env.getRaw(t, client, "/api/export/basedata.xlsx")
if status != http.StatusOK {
t.Fatalf("导出基础数据包应 200,得到 %d %s", status, body)
}
assertContentDisposition(t, header.Get("Content-Disposition"), "basedata.xlsx", "基础数据包.xlsx")
f := openWorkbook(t, body)
defer func() { _ = f.Close() }()
if got := f.GetSheetList(); !sameStrings(got, []string{"单位", "大项", "小项", "组别"}) {
t.Fatalf("工作表列表 = %v,期望 单位 / 大项 / 小项 / 组别", got)
}
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", body, map[string]string{"dryRun": "1"})
if status != http.StatusOK {
t.Fatalf("预览应 200,得到 %d %s", status, raw)
}
res := decodeBaseDataResult(t, raw)
if !res.DryRun {
t.Errorf("dryRun 应为 true")
}
if res.Added != 0 || res.Updated != 0 || res.ErrorCount != 0 {
t.Fatalf("原样导入应无差异,得到 新增 %d / 更新 %d / 错误 %d:%v",
res.Added, res.Updated, res.ErrorCount, res.Errors)
}
if len(res.Changes) != 0 {
t.Errorf("无差异时不该有逐行差异,得到 %+v", res.Changes)
}
}
// 改掉一个单位名与一个小项名再导入:差异应正好是这 2 条更新,其余 0。
func TestBaseDataImportReportsUpdates(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")
_, _, body := env.getRaw(t, client, "/api/export/basedata.xlsx")
// 在导出的工作簿上改两处:一个单位名、一个小项名。
f := openWorkbook(t, body)
defer func() { _ = f.Close() }()
editWorkbookCell(t, f, "单位", "B", "第一分公司", "B", "第一分公司(改)")
editWorkbookCell(t, f, "小项", "B", "M100", "C", "百米飞人大战")
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
t.Fatalf("写回工作簿: %v", err)
}
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", buf.Bytes(), map[string]string{"dryRun": "1"})
if status != http.StatusOK {
t.Fatalf("预览应 200,得到 %d %s", status, raw)
}
res := decodeBaseDataResult(t, raw)
if res.Added != 0 || res.ErrorCount != 0 {
t.Fatalf("应只有更新,得到 新增 %d / 错误 %d:%v", res.Added, res.ErrorCount, res.Errors)
}
if res.Updated != 2 {
t.Fatalf("更新条数 = %d,期望 2", res.Updated)
}
seen := map[string]string{}
for _, ch := range res.Changes {
for _, f := range ch.Fields {
seen[ch.Sheet+"."+f.Field] = f.From + "→" + f.To
}
}
if got := seen["单位.单位名称"]; got != "第一分公司→第一分公司(改)" {
t.Errorf("单位名差异 = %q,期望 第一分公司→第一分公司(改)", got)
}
if got := seen["小项.小项名称"]; got != "100米跑→百米飞人大战" {
t.Errorf("小项名差异 = %q,期望 100米跑→百米飞人大战", got)
}
// dryRun 绝不落库:库里的名字还是原来的。
var name string
if err := env.ev.QueryRow(`SELECT name FROM teams WHERE id = ?`, ids.teamA).Scan(&name); err != nil {
t.Fatalf("查询单位: %v", err)
}
if name != "第一分公司" {
t.Fatalf("dryRun 不该写库,单位名变成了 %q", name)
}
var eventName string
if err := env.ev.QueryRow(`SELECT name FROM events WHERE id = ?`, ids.event).Scan(&eventName); err != nil {
t.Fatalf("查询小项: %v", err)
}
if eventName != "100米跑" {
t.Fatalf("dryRun 不该写库,小项名变成了 %q", eventName)
}
}
// 绝不删除:只含 1 个新单位的表导进去,其余单位一条不少。
func TestBaseDataImportNeverDeletes(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
before := tableCount(t, env, "teams")
beforeSports := tableCount(t, env, "sports")
beforeEvents := tableCount(t, env, "events")
pack := buildTestXLSX(t, []testSheet{{
name: "单位",
rows: [][]string{
{"单位编码", "单位名称", "启用"},
{"C", "第三分公司", "是"},
},
}})
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil)
if status != http.StatusOK {
t.Fatalf("导入应 200,得到 %d %s", status, raw)
}
res := decodeBaseDataResult(t, raw)
if res.Added != 1 || res.Updated != 0 || res.ErrorCount != 0 {
t.Fatalf("应新增 1 条,得到 新增 %d / 更新 %d / 错误 %d %v", res.Added, res.Updated, res.ErrorCount, res.Errors)
}
if got := tableCount(t, env, "teams"); got != before+1 {
t.Fatalf("单位数 = %d,期望 %d(只多 1 条,其余一条不少)", got, before+1)
}
for _, code := range []string{"A", "B", "C"} {
var n int
if err := env.ev.QueryRow(`SELECT count(*) FROM teams WHERE code = ?`, code).Scan(&n); err != nil {
t.Fatalf("查询单位 %s: %v", code, err)
}
if n != 1 {
t.Errorf("单位 %s 的记录数 = %d,期望 1", code, n)
}
}
if got := tableCount(t, env, "sports"); got != beforeSports {
t.Errorf("大项数 = %d,期望 %d(表里没写的不动)", got, beforeSports)
}
if got := tableCount(t, env, "events"); got != beforeEvents {
t.Errorf("小项数 = %d,期望 %d(表里没写的不动)", got, beforeEvents)
}
}
// 新建的单位 / 大项不能被建成停用:缺省 = 启用。
func TestBaseDataImportCreatesEnabledRows(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
pack := buildTestXLSX(t, []testSheet{
{name: "单位", rows: [][]string{
{"单位编码", "单位名称"},
{"C", "第三分公司"},
}},
{name: "大项", rows: [][]string{
{"大项编码", "大项名称"},
{"SWIM", "游泳"},
}},
{name: "小项", rows: [][]string{
{"大项编码", "小项编码", "小项名称", "类型", "结果类型"},
{"SWIM", "S100", "100米自由泳", "individual", "time"},
}},
{name: "组别", rows: [][]string{
{"小项编码", "组别名", "性别", "年龄下限", "年龄上限"},
{"S100", "男子", "男", "18", "35"},
}},
})
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil)
if status != http.StatusOK {
t.Fatalf("导入应 200,得到 %d %s", status, raw)
}
res := decodeBaseDataResult(t, raw)
if res.Added != 4 || res.ErrorCount != 0 {
t.Fatalf("应新增 4 条,得到 新增 %d / 错误 %d %v", res.Added, res.ErrorCount, res.Errors)
}
var active int
if err := env.ev.QueryRow(`SELECT active FROM teams WHERE code = 'C'`).Scan(&active); err != nil {
t.Fatalf("查询新单位: %v", err)
}
if active != 1 {
t.Fatalf("新单位应启用(active=1),得到 %d", active)
}
if err := env.ev.QueryRow(`SELECT active FROM sports WHERE code = 'SWIM'`).Scan(&active); err != nil {
t.Fatalf("查询新大项: %v", err)
}
if active != 1 {
t.Fatalf("新大项应启用(active=1),得到 %d", active)
}
// 小项挂到了新建的大项下,组别挂到了新建的小项下,枚举值也按原样落库。
var (
sportID int64
evType string
rType string
)
if err := env.ev.QueryRow(`SELECT sport_id, event_type, result_type FROM events WHERE code = 'S100'`).
Scan(&sportID, &evType, &rType); err != nil {
t.Fatalf("查询新小项: %v", err)
}
var wantSport int64
if err := env.ev.QueryRow(`SELECT id FROM sports WHERE code = 'SWIM'`).Scan(&wantSport); err != nil {
t.Fatalf("查询新大项 id: %v", err)
}
if sportID != wantSport {
t.Errorf("新小项的大项 id = %d,期望 %d", sportID, wantSport)
}
if evType != "individual" || rType != "time" {
t.Errorf("枚举值 = %s / %s,期望 individual / time", evType, rType)
}
var gender string
var ageMin, ageMax int
if err := env.ev.QueryRow(`SELECT gender, age_min, age_max FROM event_categories WHERE name = '男子' AND event_id = ?`,
func() int64 {
var id int64
_ = env.ev.QueryRow(`SELECT id FROM events WHERE code = 'S100'`).Scan(&id)
return id
}()).
Scan(&gender, &ageMin, &ageMax); err != nil {
t.Fatalf("查询新组别: %v", err)
}
if gender != "男" || ageMin != 18 || ageMax != 35 {
t.Errorf("组别 = %s %d-%d,期望 男 18-35", gender, ageMin, ageMax)
}
}
// 行级错误:中文、带行号、带工作表名,且 dryRun 下库一点没动。
func TestBaseDataImportRowErrors(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
before := countAllTables(t, env)
pack := buildTestXLSX(t, []testSheet{
{name: "小项", rows: [][]string{
{"大项编码", "小项编码", "小项名称", "类型"},
{"NOPE", "X1", "幽灵小项", "individual"}, // 第 2 行:大项不存在
{"TRACK", "X2", "坏类型", "乱七八糟"}, // 第 3 行:枚举非法
}},
{name: "组别", rows: [][]string{
{"小项编码", "组别名"},
{"NOPE", "甲组"}, // 第 2 行:小项不存在
}},
})
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, map[string]string{"dryRun": "1"})
if status != http.StatusOK {
t.Fatalf("预览应 200,得到 %d %s", status, raw)
}
res := decodeBaseDataResult(t, raw)
if res.ErrorCount != 3 {
t.Fatalf("错误条数 = %d,期望 3:%v", res.ErrorCount, res.Errors)
}
if res.Added != 0 || res.Updated != 0 {
t.Errorf("全错时不该有变更,得到 新增 %d / 更新 %d", res.Added, res.Updated)
}
wantParts := [][]string{
{"小项", "第 2 行", "大项编码 NOPE 不存在"},
{"小项", "第 3 行", "eventType 只能是 individual / team / match / individual_team"},
{"组别", "第 2 行", "小项编码 NOPE 不存在"},
}
for i, parts := range wantParts {
if i >= len(res.Errors) {
t.Fatalf("缺少第 %d 条错误", i+1)
}
for _, p := range parts {
if !strings.Contains(res.Errors[i], p) {
t.Errorf("错误 %d = %q,应包含 %q", i+1, res.Errors[i], p)
}
}
}
// dryRun 不写库:四张表计数一条不变。
if got := countAllTables(t, env); !sameCounts(got, before) {
t.Fatalf("dryRun 不该写库,导入前后计数不同:%v → %v", before, got)
}
}
// 导入是批量写,只有 admin 能做。
func TestBaseDataImportAdminOnly(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
pack := buildTestXLSX(t, []testSheet{{name: "单位", rows: [][]string{
{"单位编码", "单位名称"},
{"C", "第三分公司"},
}}})
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", pack, nil)
if status != http.StatusForbidden {
t.Fatalf("recorder 导入应 403,得到 %d %s", status, raw)
}
if got := tableCount(t, env, "teams"); got != 2 {
t.Fatalf("403 不该写库,单位数 = %d", got)
}
}
func TestBaseDataImportRejectsNonXlsx(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, raw := env.uploadBaseData(t, client, "basedata.csv", []byte("单位编码,单位名称\nC,第三分公司\n"), nil)
if status != http.StatusBadRequest {
t.Fatalf("CSV 应 400,得到 %d %s", status, raw)
}
if !strings.Contains(string(raw), "xlsx") {
t.Errorf("错误信息应说明必须是 xlsx,得到 %s", raw)
}
}
// ---------- 小工具 ----------
func tableCount(t *testing.T, env *testEnv, table string) int {
t.Helper()
var n int
if err := env.ev.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
t.Fatalf("统计 %s: %v", table, err)
}
return n
}
func countAllTables(t *testing.T, env *testEnv) map[string]int {
t.Helper()
out := make(map[string]int, 4)
for _, table := range []string{"teams", "sports", "events", "event_categories"} {
out[table] = tableCount(t, env, table)
}
return out
}
func sameCounts(a, b map[string]int) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
func sameStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
@@ -0,0 +1,106 @@
package httpapi_test
import (
"net/http"
"testing"
)
// 报名名单导出:guest 可用、表头含批次、前导零不丢、筛选参数生效。
func TestExportEntriesXLSX(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
// 号码布带前导零,entryA 分到批次 2,entryB 未分批。
env.exec(t, `UPDATE entries SET bib = '007', heat = 2, remark = '卫冕冠军' WHERE id = ?`, ids.entryA)
env.exec(t, `UPDATE entries SET bib = '0012' WHERE id = ?`, ids.entryB)
client, _ := env.client()
status, header, body := env.getRaw(t, client, "/api/export/entries.xlsx")
if status != http.StatusOK {
t.Fatalf("导出报名名单应 200,得到 %d %s", status, body)
}
assertContentDisposition(t, header.Get("Content-Disposition"), "entries.xlsx", "报名名单.xlsx")
f := openWorkbook(t, body)
defer func() { _ = f.Close() }()
rows, err := f.GetRows("报名名单")
if err != nil {
t.Fatalf("读取报名名单: %v", err)
}
// 4 行版式 + 2 条报名。
if len(rows) != 6 {
t.Fatalf("行数 = %d,期望 6", len(rows))
}
wantHeaders := []string{"小项编码", "小项名", "组别", "单位编码", "单位名", "姓名",
"证件号", "号码布", "批次", "性别", "年龄", "状态", "备注"}
for i, want := range wantHeaders {
if got := cellAt(rows, 3, i); got != want {
t.Errorf("表头第 %d 列 = %q,期望 %q", i+1, got, want)
}
}
// 未分批(heat 为空)的排最前。
if got := cellAt(rows, 4, 5); got != "李强" {
t.Fatalf("第一行姓名 = %q,期望 李强", got)
}
if got := cellAt(rows, 4, 7); got != "0012" {
t.Errorf("号码布 = %q,期望 0012(前导零不能丢)", got)
}
if got := cellAt(rows, 4, 8); got != "" {
t.Errorf("未分批行批次 = %q,期望留空", got)
}
if got := cellAt(rows, 5, 5); got != "张伟" {
t.Fatalf("第二行姓名 = %q,期望 张伟", got)
}
if got := cellAt(rows, 5, 8); got != "2" {
t.Errorf("批次 = %q,期望 2", got)
}
if got := cellAt(rows, 5, 11); got != "正常参赛" {
t.Errorf("状态 = %q,期望 正常参赛", got)
}
// 按批次筛选:只剩批次 2 的那条。
status, _, filtered := env.getRaw(t, client, "/api/export/entries.xlsx?heat=2")
if status != http.StatusOK {
t.Fatalf("按批次筛选应 200,得到 %d", status)
}
f2 := openWorkbook(t, filtered)
defer func() { _ = f2.Close() }()
fRows, err := f2.GetRows("报名名单")
if err != nil {
t.Fatalf("读取筛选结果: %v", err)
}
if len(fRows) != 5 {
t.Fatalf("按批次筛选后行数 = %d,期望 5", len(fRows))
}
if got := cellAt(fRows, 4, 8); got != "2" {
t.Errorf("筛选后批次 = %q,期望 2", got)
}
// 按单位 + 小项 + 组别组合筛选。
path := "/api/export/entries.xlsx?teamId=1&eventId=1&categoryId=1"
status, _, filtered = env.getRaw(t, client, path)
if status != http.StatusOK {
t.Fatalf("组合筛选应 200,得到 %d", status)
}
f3 := openWorkbook(t, filtered)
defer func() { _ = f3.Close() }()
cRows, _ := f3.GetRows("报名名单")
if len(cRows) != 5 {
t.Fatalf("组合筛选后行数 = %d,期望 5", len(cRows))
}
}
// 筛选参数填错必须报错:静默当成「不筛」会让现场拿到全量名单还以为筛过了。
func TestExportEntriesXLSXRejectsBadFilter(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
client, _ := env.client()
status, _, body := env.getRaw(t, client, "/api/export/entries.xlsx?heat=abc")
if status != http.StatusBadRequest {
t.Fatalf("非法批次筛选应 400,得到 %d %s", status, body)
}
status, _, body = env.getRaw(t, client, "/api/export/entries.xlsx?teamId=0")
if status != http.StatusBadRequest {
t.Fatalf("0 不是合法单位 id,应 400,得到 %d %s", status, body)
}
}
File diff suppressed because it is too large. Load diff
+66 -19
View File
@@ -15,6 +15,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/xuri/excelize/v2"
@@ -59,7 +60,12 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
Action: "auth.login", EntityType: "user", EntityID: u.ID, ActorID: u.ID,
After: map[string]any{"username": u.Username},
})
return userDTO(u), nil
out := userDTO(u)
// 刚建好的会话到期时间 = 此刻 + 当前有效期(与 createSession 同一请求内,误差在毫秒级);
// 下发它,界面登录后就能立刻显示「当前会话将于 … 过期」,不用等下一次 /auth/me。
expiresAt := time.Now().UTC().Add(s.auth.SessionTTL()).Format(time.RFC3339)
out.SessionExpiresAt = &expiresAt
return out, nil
}
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
@@ -75,7 +81,31 @@ func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
u := middleware.User(r.Context())
return userDTO(u), nil
out := userDTO(u)
if u.Role == auth.RoleGuest {
return out, nil
}
// 顺带把会话 Cookie 的期限同步到最新的到期时间:
// 滑动续期只改服务端 expires_at,Cookie 的 Max-Age 不跟着走的话,
// 浏览器会在「登录时刻 + 有效期」处丢掉 Cookie,人还是被登出。
// /auth/me 是管理端启动与定时心跳都会走的接口,放在这里就够。
c, err := r.Cookie(auth.CookieSession)
if err != nil || c.Value == "" {
return out, nil
}
_, sess, err := s.auth.Lookup(r.Context(), c.Value)
if err != nil || sess == nil {
return out, nil
}
expiresAt := sess.ExpiresAt.UTC().Format(time.RFC3339)
out.SessionExpiresAt = &expiresAt
csrf := ""
if c, err := r.Cookie(auth.CookieCSRF); err == nil {
csrf = c.Value
}
s.auth.RefreshSessionCookies(w, c.Value, csrf, sess.ExpiresAt)
return out, nil
}
type changePasswordInput struct {
@@ -356,9 +386,19 @@ func (s *server) getEventHandler(w http.ResponseWriter, r *http.Request) (any, e
func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in eventDTO
if err := decodeJSON(r, &in); err != nil {
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
// 与建单位 / 大项同口径:缺省 = 建表默认值(migrations/event/0004_sports_events.sql 里
// scored 与 record_eligible 都 DEFAULT 1)。Go 的 bool 零值是 false,漏传会被静默建成
// 「只排名不计分、不能破纪录」的小项 —— 这与导入侧按建表默认值建档的口径也不一致。
if !keys.has("scored") {
in.Scored = true
}
if !keys.has("recordEligible") {
in.RecordEligible = true
}
created, err := s.createEvent(r.Context(), in)
if err != nil {
return nil, err
@@ -373,13 +413,22 @@ func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any
return nil, err
}
var in eventDTO
if err := decodeJSON(r, &in); err != nil {
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
before, err := s.getEvent(r.Context(), id)
if err != nil {
return nil, err
}
// 更新仍是全量覆盖语义,但漏传这两个开关不等于「关掉它」:沿用库里的现值,
// 与 active 在单位 / 大项上的处理一致。
if !keys.has("scored") {
in.Scored = before.Scored
}
if !keys.has("recordEligible") {
in.RecordEligible = before.RecordEligible
}
updated, err := s.updateEvent(r.Context(), id, in)
if err != nil {
return nil, err
@@ -611,9 +660,12 @@ type entryImportResult struct {
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
Conflicts []importConflict `json:"conflicts"`
// SameNameNewPersons 是「号码布不同 → 按同名的另一个人新建档案」的清单。
// 只作报告:不弹窗、不阻塞导入,但必须让用户看得见。
SameNameNewPersons []sameNameNewPerson `json:"sameNameNewPersons"`
}
// importConflict 是「证件号或号码布已存在、但人员字段与库中不一致」的一行。
// importConflict 是「命中了库里已有人员、但人员字段与库中不一致」的一行。
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
type importConflict struct {
Row int `json:"row"`
@@ -886,9 +938,9 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
}
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
// 非 nil 时按决策处理证件号/号码布命中的冲突,未出现在 decisions 里的冲突继续收集。
// 非 nil 时按决策处理命中人员的冲突,未出现在 decisions 里的冲突继续收集。
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}}
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{}}
if len(rows) < 2 {
return result, badRequest("报名表为空", nil)
}
@@ -904,8 +956,8 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
}
}
// 同一文件里证件号、号码布都只能出现一次,否则无法判断是不是同一个人。
seenIDCards := make(map[string]bool)
// 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错;
// 证件号只是可选档案信息,重复不再拦截(同一份表内、跨行、跨表都不因它拦截)。
seenBibs := make(map[string]bool)
for n, row := range rows[1:] {
line := n + 2
@@ -982,7 +1034,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
if athleteName != "" {
idCard := cell("id_card")
bib := cell("bib")
// 证件号与号码布二选一即可,都没有就无法区分同名的两个人。
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
if idCard == "" && bib == "" {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
@@ -994,14 +1046,6 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
continue
}
if idCard != "" {
if seenIDCards[idCard] {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "证件号 %s 在报名表内重复出现", idCard))
continue
}
seenIDCards[idCard] = true
}
if bib != "" {
if seenBibs[bib] {
result.Skipped++
@@ -1023,13 +1067,16 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
}, decisions)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
result.Errors = append(result.Errors, s.interceptRow(line, "%s", importRowMessage(err)))
continue
}
if matched.Conflict != nil {
result.Conflicts = append(result.Conflicts, *matched.Conflict)
continue
}
if matched.SameNameNewPerson != nil {
result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson)
}
athleteID = &matched.AthleteID
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
teamID = matched.TeamID
+236 -40
View File
@@ -231,12 +231,12 @@ func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
}
}
// 证件号命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
// 号码布命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,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)
@@ -285,8 +285,8 @@ func TestImportCollectsConflictWithoutWriting(t *testing.T) {
}
}
// 同一文件里证件号重复出现 → 该行报错并跳过。
func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
// 同一文件里证件号重复出现不再报错:证件号是可选档案信息,不参与匹配也不拦截。
func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
@@ -297,12 +297,14 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["skipped"] != float64(1) {
t.Fatalf("首行应新建、次行应跳过: %+v", result)
if result["skipped"] != float64(0) {
t.Fatalf("同表重复证件号不应再拦截: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "重复") {
t.Fatalf("应报证件号重复,得到 %+v", errs)
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("不应有行级报错,得到 %+v", errs)
}
if result["created"] != float64(1) || result["updated"] != float64(1) {
t.Fatalf("首行新建、次行按姓名+单位命中同一人并更新报名: %+v", result)
}
var athletes int
@@ -310,7 +312,118 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
t.Fatalf("统计人员: %v", err)
}
if athletes != 1 {
t.Fatalf("重复证件号只应建 1 个人员,得到 %d", athletes)
t.Fatalf("同一个人只应建 1 个人员,得到 %d", athletes)
}
}
// 新人带的证件号已被库里另一个人占用 → 明确的中文行级错误,不静默改库、不静默跳过。
func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
// 另一个人(李四 / 号码布 B200)拿着已被张伟占用的证件号来建档。
body := importHeader + "\nM100,男子,B,李四,110101199001011234,男,,B200,,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(0) || result["skipped"] != float64(1) {
t.Fatalf("撞号的证件号应跳过该行: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 {
t.Fatalf("应报 1 条行级错误,得到 %+v", errs)
}
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
if errs[0] != want {
t.Fatalf("错误文案不对:\n 得到 %q\n 期望 %q", errs[0], want)
}
// 库里的张伟一行没动,也没有多出一个人。
var (
name string
bib string
count int
)
if err := env.ev.QueryRow(`SELECT name, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name, &bib); err != nil {
t.Fatalf("查询人员: %v", err)
}
if name != "张伟" || bib != "A100" {
t.Fatalf("撞号不应改动库里已有人员,得到 name=%s bib=%s", name, bib)
}
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&count); err != nil {
t.Fatalf("统计人员: %v", err)
}
if count != 2 {
t.Fatalf("撞号不应新建人员,得到 %d 个", count)
}
}
// 号码布不同 → 按「同名的另一个人」新建档案,并在 sameNameNewPersons 里单独报告,不进冲突面板。
func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,,男,,2001,,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(1) || result["skipped"] != float64(0) {
t.Fatalf("同名不同号码布应新建报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("同名不同号码布不进冲突面板: %+v", conflicts)
}
reported, _ := result["sameNameNewPersons"].([]any)
if len(reported) != 1 {
t.Fatalf("应报告 1 条同名新建,得到 %+v", reported)
}
item := reported[0].(map[string]any)
if item["name"] != "张伟" || item["teamCode"] != "A" || item["newBib"] != "2001" || item["existingBib"] != "1001" {
t.Fatalf("同名新建报告字段不对: %+v", item)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE name = '张伟' AND team_id = ?`, ids.teamA).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("应新建第二个人,得到 %d 个", athletes)
}
// 原档案的号码布保持不动,新档案拿表里的号码布。
var oldBib string
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&oldBib); err != nil {
t.Fatalf("查询原人员: %v", err)
}
if oldBib != "1001" {
t.Fatalf("原档案号码布不应被改,得到 %s", oldBib)
}
}
// 姓名 + 单位命中多条候选:不能替用户挑人,进冲突面板等决策。
func TestImportAmbiguousNameTeamGoesToConflictPanel(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 同单位两个「张伟」,都没有号码布,表里来第三个候选信息 → 歧义。
env.exec(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA)
body := importHeader + "\nM100,男子,A,张伟,,男,,9001,,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(0) || result["skipped"] != float64(0) {
t.Fatalf("歧义行不落库也不跳过: %+v", result)
}
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
}
if conflicts[0].(map[string]any)["athleteId"] != float64(ids.athleteA) {
t.Fatalf("歧义冲突应以第一条候选为代表: %+v", conflicts[0])
}
}
@@ -360,9 +473,9 @@ func TestImportToleratesMissingOptionalColumns(t *testing.T) {
// keep-existing:保留库中人员字段,报名跟随库里已有的单位。
func TestImportResolveKeepExisting(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
// 未给决策时,冲突原样返回。
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]")
@@ -415,9 +528,9 @@ func TestImportResolveKeepExisting(t *testing.T) {
// keep-new:用表里的字段更新人员,再按表里的单位建报名。
func TestImportResolveKeepNew(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
if status != http.StatusOK || !env2.OK {
@@ -618,44 +731,36 @@ func TestImportRejectsDuplicateBibInFile(t *testing.T) {
}
}
// 证件号命中但号码布与库中不同 → 冲突,快照与差异字段都带 bib,本轮不落库。
func TestImportConflictIncludesBibDiff(t *testing.T) {
// 同名 + 号码布不同本应按「同名的另一个人」新建,但表里的证件号已被库里那个人占用时,
// 唯一索引的底线更高:明确报出占用者,既不静默改库也不静默跳过。
func TestImportSameNameDifferentBibButTakenIDCard(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A200,,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)
t.Fatalf("导入应 200(行级报错),得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
if result["skipped"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("撞号应跳过该行: %+v", result)
}
conflict := conflicts[0].(map[string]any)
if conflict["bib"] != "A200" {
t.Fatalf("冲突定位应带表里的号码布: %+v", conflict)
errs, _ := result["errors"].([]any)
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
if len(errs) != 1 || errs[0] != want {
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
}
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "bib" {
t.Fatalf("应只有 bib 变化,得到 %+v", changed)
}
existing := conflict["existing"].(map[string]any)
if existing["bib"] != "A100" {
t.Fatalf("existing 快照应带库中号码布: %+v", existing)
}
incoming := conflict["incoming"].(map[string]any)
if incoming["bib"] != "A200" {
t.Fatalf("incoming 快照应带表里号码布: %+v", incoming)
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
t.Fatalf("新建被证件号唯一索引挡下时不应报告成同名新建: %+v", reported)
}
var bib string
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
t.Fatalf("查询人员号码布: %v", err)
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if bib != "A100" {
t.Fatalf("冲突不应改库中号码布,得到 %s", bib)
if athletes != 2 {
t.Fatalf("不应新建人员,得到 %d 个", athletes)
}
}
@@ -1266,3 +1371,94 @@ func TestUploadedImagePathRoundTripsThroughSettings(t *testing.T) {
t.Fatalf("配置里的图片路径应可匿名访问,得到 %d", resp.StatusCode)
}
}
// 小项的 scored / recordEligible 漏传 = 建表默认值(migrations/event/0004 里都是 1),
// 显式 false 才是 false;更新时漏传沿用库里的现值,与单位 / 大项的 active 同口径。
// 与基础数据包导入侧(export_handlers.go 里新建小项默认 true/true)也一致。
func TestEventBoolDefaultsFollowSchemaDefaults(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
boolField := func(t *testing.T, body envelope, field string) bool {
t.Helper()
got, ok := decodeData[map[string]any](t, body)[field].(bool)
if !ok {
t.Fatalf("响应里 %s 不是布尔值: %+v", field, body.Data)
}
return got
}
// 库里也真的按接口回报的值落着,不只是响应体好看。
assertStoredFlags := func(t *testing.T, code string, want int) {
t.Helper()
var scored, recordEligible int
if err := env.ev.QueryRow(`SELECT scored, record_eligible FROM events WHERE code = ?`, code).
Scan(&scored, &recordEligible); err != nil {
t.Fatalf("查询小项 %s 的开关: %v", code, err)
}
if scored != want || recordEligible != want {
t.Fatalf("小项 %s 的开关应为 %d/%d,得到 %d/%d", code, want, want, scored, recordEligible)
}
}
base := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省"}
status, body := env.do(client, http.MethodPost, "/api/events", base, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("建小项应 200,得到 %d %+v", status, body.Error)
}
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
t.Fatalf("漏传开关应取建表默认值 true: %+v", body.Data)
}
assertStoredFlags(t, "E-BOOL", 1)
eventID := int64(decodeData[map[string]any](t, body)["id"].(float64))
// 更新漏传沿用现值:库里是 true/true 时,只改名字不能把它们翻成 false。
rename := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名一)"}
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("改名应 200,得到 %d %+v", status, body.Error)
}
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
t.Fatalf("更新漏传应沿用库里的 true: %+v", body.Data)
}
// 显式 false 才关得掉:接口不能无视调用方的明确意图。
turnOff := map[string]any{
"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名二)",
"scored": false, "recordEligible": false,
}
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), turnOff, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("显式关开关应 200,得到 %d %+v", status, body.Error)
}
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
}
// 反过来:库里是 false/false 时,更新漏传也不能把它打开。
rename["name"] = "开关缺省(改名三)"
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("再改名应 200,得到 %d %+v", status, body.Error)
}
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
t.Fatalf("更新漏传应沿用库里的 false: %+v", body.Data)
}
// 新建时显式传 false 同样照办,缺省值不覆盖调用方的明确意图。
explicitOff := map[string]any{
"sportId": ids.sport, "code": "E-BOOL-OFF", "name": "开关显式关闭",
"scored": false, "recordEligible": false,
}
status, body = env.do(client, http.MethodPost, "/api/events", explicitOff, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("显式传 false 应 200,得到 %d %+v", status, body.Error)
}
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
}
assertStoredFlags(t, "E-BOOL-OFF", 0)
assertStoredFlags(t, "E-BOOL", 0)
}
@@ -63,6 +63,27 @@ func (s *server) unpublishHandler(w http.ResponseWriter, r *http.Request) (any,
})
}
// settleResultsHandler 是「立即结算」:延迟结算下的人工兜底,按当前已发布成绩结算一次。
func (s *server) settleResultsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in publishPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
return s.settleResults(r.Context(), in, middlewareUserID(r))
})
}
// settlementStatusHandler 返回未结算的 (小项, 组别) 清单,供录入页提示与「立即结算」入口。
func (s *server) settlementStatusHandler(w http.ResponseWriter, r *http.Request) (any, error) {
eventID, _ := int64Query(r, "eventId")
if eventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
categoryID, _ := int64Query(r, "categoryId")
return s.settlementStatus(r.Context(), eventID, categoryID)
}
func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
+27
View File
@@ -45,6 +45,16 @@ type entryHeatPayload struct {
RequestID string `json:"requestId"`
}
// entryBatchPayload 是报名项批量操作的请求体:
// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名),
// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。
type entryBatchPayload struct {
Action string `json:"action"`
IDs []int64 `json:"ids"`
Reason string `json:"reason"`
RequestID string `json:"requestId"`
}
type adjustmentInput struct {
TeamID int64 `json:"teamId"`
Delta float64 `json:"delta"`
@@ -117,12 +127,29 @@ type settingsStandingsPatch struct {
RankingMode *string `json:"rankingMode"`
ShowIndividualBoard *bool `json:"showIndividualBoard"`
ShowIndividualMedals *bool `json:"showIndividualMedals"`
// LiveOnPublish 是「分批发布时是否实时更新各单位总分与奖牌榜」;nil 表示不改动。
LiveOnPublish *bool `json:"liveOnPublish"`
}
// sessionSettingsPatch 是 PUT /api/system/session 的请求体:
// 只改「登录有效期(小时)」这一个系统级配置。
type sessionSettingsPatch struct {
SessionHours *int `json:"sessionHours"`
RequestID string `json:"requestId"`
}
type categoriesPayload struct {
Categories []categoryDTO `json:"categories"`
}
// athletesBatchPayload 是运动员批量动作的请求体:action 决定停用还是彻底删除。
type athletesBatchPayload struct {
Action string `json:"action"`
IDs []int64 `json:"ids"`
// DryRun 只统计不改库:前端用它先看清「会删掉多少、重算哪些小项」再确认。
DryRun bool `json:"dryRun"`
}
type channelsPayload struct {
Channels []channelDTO `json:"channels"`
}
+318
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"net/http"
"slices"
"strings"
@@ -645,6 +646,323 @@ func (s *server) purgeBlocked(ctx context.Context, tx *sql.Tx, id int64, cause e
return conflict("该运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,删除已回滚,人员和成绩都没有改动。请先删除相关报名与成绩后重试。")
}
// ---------- 运动员批量动作 ----------
const (
athleteBatchDisable = "disable"
athleteBatchPurge = "purge"
)
// athleteBatchItem 是批量动作里逐条的结果。
//
// 批量必然遇到「有人已经被别人删掉了」「有人 id 根本不存在」:整批失败会逼着操作员重试,
// 静默跳过又会让他以为整批都成了,所以一条一条回报。
type athleteBatchItem struct {
ID int64 `json:"id"`
OK bool `json:"ok"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
// athleteBatchResult 是批量动作的响应体:逐条成败 + 合计。
// Purge 只在 action=purge 时出现,带合计删除规模与受影响小项,供前端提示「榜为什么变了」。
type athleteBatchResult struct {
Action string `json:"action"`
DryRun bool `json:"dryRun,omitempty"`
Requested int `json:"requested"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Items []athleteBatchItem `json:"items"`
Purge *athletePurge `json:"purge,omitempty"`
}
// purgeScope 是一条成绩所属的 (小项, 组别):删除后要按它重算积分与名次。
type purgeScope struct{ eventID, categoryID int64 }
func idArgs(ids []int64) []any {
args := make([]any, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
return args
}
// existingAthleteIDs 挑出这批 id 里真实存在的那些,保持传入顺序。
func existingAthleteIDs(ctx context.Context, q queryer, ids []int64) ([]int64, error) {
rows, err := q.QueryContext(ctx,
`SELECT id FROM athletes WHERE id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
found := make(map[int64]bool, len(ids))
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, internal(err)
}
found[id] = true
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
out := make([]int64, 0, len(found))
for _, id := range ids {
if found[id] {
out = append(out, id)
}
}
return out, nil
}
// newBatchResult 按传入顺序拼逐条结果:不在 existing 里的 id 回报「运动员不存在」。
func newBatchResult(action string, ids, existing []int64, dryRun bool, purge *athletePurge) athleteBatchResult {
ok := make(map[int64]bool, len(existing))
for _, id := range existing {
ok[id] = true
}
items := make([]athleteBatchItem, 0, len(ids))
result := athleteBatchResult{Action: action, DryRun: dryRun, Requested: len(ids), Purge: purge}
for _, id := range ids {
if ok[id] {
items = append(items, athleteBatchItem{ID: id, OK: true})
result.Succeeded++
continue
}
items = append(items, athleteBatchItem{ID: id, OK: false, Code: "NOT_FOUND", Message: "运动员不存在"})
result.Failed++
}
result.Items = items
return result
}
// batchAthletes 是 POST /api/athletes/batch 的业务实现:一次请求完成整批停用 / 彻底删除。
//
// 逐个调 DELETE /api/athletes/{id} 是「一次操作一条请求」,现场批量清理时会直接撞上写限流;
// 收成一次写既少一次配额,也让「部分成功」有个统一的地方逐条说清楚。
// dryRun 只统计不改库,供前端在危险确认框里先列出会删掉什么、重算哪些小项。
func (s *server) batchAthletes(ctx context.Context, action string, rawIDs []int64, dryRun bool) (athleteBatchResult, error) {
switch action {
case athleteBatchDisable, athleteBatchPurge:
default:
return athleteBatchResult{}, badRequest("action 只能是 disable / purge", action)
}
ids := dedupeIDs(rawIDs)
if len(ids) == 0 {
return athleteBatchResult{}, badRequest("ids 不能为空", nil)
}
if action == athleteBatchPurge {
return s.batchPurgeAthletes(ctx, ids, dryRun)
}
return s.batchDisableAthletes(ctx, ids, dryRun)
}
// batchDisableAthletes 批量停用:与单条软删除同口径,只置 active = 0,
// 报名、成绩、流水一行不动,也不触发任何重算。
func (s *server) batchDisableAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) {
existing, err := existingAthleteIDs(ctx, s.ev, ids)
if err != nil {
return athleteBatchResult{}, err
}
if !dryRun && len(existing) > 0 {
if _, err := s.ev.ExecContext(ctx,
`UPDATE athletes SET active = 0 WHERE id IN (`+placeholders(len(existing))+`)`,
idArgs(existing)...); err != nil {
return athleteBatchResult{}, internal(err)
}
}
return newBatchResult(athleteBatchDisable, ids, existing, dryRun, nil), nil
}
// batchPurgeAthletes 批量物理删除:删人员及其报名 / 成绩 / 流水 / 纪录,
// 再按受影响的 (小项, 组别) 去重重算一次——重算是批量里最贵的一步,绝不该按人重复做。
//
// 整批一个事务:任一 id 撞上外键就整体回滚并给 409,不会留下删了一半的库。
// 「id 不存在 / 已被别人删掉」属于逐条结果,不影响其余人的删除。
func (s *server) batchPurgeAthletes(ctx context.Context, ids []int64, dryRun bool) (athleteBatchResult, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return athleteBatchResult{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
// 存在性在事务内一次查完:逐条 DELETE 再看 RowsAffected 会白跑半个事务,
// 而且「已被别人删掉」和「id 不存在」本来就该一起回报。
existing, err := existingAthleteIDs(ctx, tx, ids)
if err != nil {
return athleteBatchResult{}, err
}
// 受影响范围必须在删之前收集:成绩没了就再也问不出它属于哪个组别。
scopes, err := purgeScopesTx(ctx, tx, existing)
if err != nil {
return athleteBatchResult{}, err
}
counts, err := deleteAthletesTx(ctx, tx, existing)
if err != nil {
var api *apiError
if errors.As(err, &api) {
return athleteBatchResult{}, err
}
return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err)
}
purge := &athletePurge{
DeletedEntries: counts.DeletedEntries,
DeletedResults: counts.DeletedResults,
DeletedLedger: counts.DeletedLedger,
DeletedBreaks: counts.DeletedBreaks,
}
eventIDs := make([]int64, 0, len(scopes))
for _, sc := range scopes {
if !slices.Contains(eventIDs, sc.eventID) {
eventIDs = append(eventIDs, sc.eventID)
}
}
purge.RecomputedEvent = eventIDs
if dryRun {
// 预览:删除都还在事务里没提交,defer 会回滚;统计与真删时同源,不会两处算法漂移。
return newBatchResult(athleteBatchPurge, ids, existing, true, purge), nil
}
actorID := middleware.User(ctx).ID
for _, sc := range scopes {
score, err := recomputeLedgerTx(ctx, tx, sc.eventID, sc.categoryID, actorID, "人员批量删除后重算")
if err != nil {
return athleteBatchResult{}, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return athleteBatchResult{}, err
}
}
for _, eventID := range eventIDs {
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return athleteBatchResult{}, err
}
}
if len(eventIDs) > 0 {
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return athleteBatchResult{}, err
}
}
if err := tx.Commit(); err != nil {
return athleteBatchResult{}, s.batchPurgeBlocked(ctx, tx, err)
}
return newBatchResult(athleteBatchPurge, ids, existing, false, purge), nil
}
// purgeScopesTx 收集这批人员名下有成绩的 (小项, 组别)。
func purgeScopesTx(ctx context.Context, tx *sql.Tx, ids []int64) ([]purgeScope, error) {
if len(ids) == 0 {
return nil, nil
}
rows, err := tx.QueryContext(ctx,
`SELECT DISTINCT e.event_id, e.category_id
FROM results r JOIN entries e ON e.id = r.entry_id
WHERE e.athlete_id IN (`+placeholders(len(ids))+`)`, idArgs(ids)...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]purgeScope, 0, 4)
for rows.Next() {
var sc purgeScope
if err := rows.Scan(&sc.eventID, &sc.categoryID); err != nil {
return nil, internal(err)
}
out = append(out, sc)
}
return out, rows.Err()
}
// deleteAthletesTx 按外键方向清理这批人员的全部痕迹,返回删除规模。
// 顺序与单条 purgeAthlete 一致:流水 → 纪录 → 成绩 → 报名 → 人员。
func deleteAthletesTx(ctx context.Context, tx *sql.Tx, ids []int64) (athletePurge, error) {
var out athletePurge
if len(ids) == 0 {
return out, nil
}
ph := placeholders(len(ids))
args := idArgs(ids)
// score_ledger 的三个引用各要一份占位符参数,拼三份副本(都是只读,不会互相污染)。
ledgerArgs := append(append(append([]any{}, args...), args...), args...)
for _, step := range []struct {
query string
args []any
count *int
}{
{`DELETE FROM score_ledger
WHERE athlete_id IN (` + ph + `)
OR entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))
OR result_id IN (SELECT r.id FROM results r JOIN entries e ON e.id = r.entry_id
WHERE e.athlete_id IN (` + ph + `))`,
ledgerArgs, &out.DeletedLedger},
{`DELETE FROM record_breaks
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`,
args, &out.DeletedBreaks},
{`DELETE FROM results
WHERE entry_id IN (SELECT id FROM entries WHERE athlete_id IN (` + ph + `))`,
args, &out.DeletedResults},
{`DELETE FROM entries WHERE athlete_id IN (` + ph + `)`, args, &out.DeletedEntries},
} {
res, err := tx.ExecContext(ctx, step.query, step.args...)
if err != nil {
return out, err
}
n, err := res.RowsAffected()
if err != nil {
return out, internal(err)
}
*step.count = int(n)
}
res, err := tx.ExecContext(ctx, `DELETE FROM athletes WHERE id IN (`+ph+`)`, args...)
if err != nil {
return out, err
}
n, err := res.RowsAffected()
if err != nil {
return out, internal(err)
}
// 存在性是同一事务里查过的,行数对不上只可能是并发抢先删了;报 500 会瞒掉真实状态,
// 用 409 明说并让整个事务回滚,别把成绩删了却报成功。
if int(n) != len(ids) {
return out, conflict("有运动员在本次操作期间已被删除,批量删除已回滚,请刷新名单后重试")
}
return out, nil
}
// batchPurgeBlocked 是批量删除的外键兜底:回滚并给出可读的 409,不让它冒成 500。
func (s *server) batchPurgeBlocked(ctx context.Context, tx *sql.Tx, cause error) error {
if !isForeignKeyViolation(cause) {
return internal(cause)
}
s.logger.WarnContext(ctx, "批量彻底删除运动员撞上外键约束", "err", cause)
_ = tx.Rollback()
return conflict("有运动员仍被其它数据(成绩、积分流水或破纪录记录)引用,批量删除已回滚,人员和成绩都没有改动。")
}
// batchAthletesHandler 是 POST /api/athletes/batch:一次请求完成整批停用 / 彻底删除。
func (s *server) batchAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in athletesBatchPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
result, err := s.batchAthletes(r.Context(), in.Action, in.IDs, in.DryRun)
if err != nil {
return nil, err
}
// dryRun 只是预览,什么都没改,不该在审计里留下「做过一次批量删除」的假痕迹。
if result.DryRun {
return result, nil
}
// 整批只登记一条审计:审计与放行日志都是一条一次请求,批量才真把写压力降下来。
// 逐条明细与失败原因都进 after,事后能对着名单查。
s.audit(r.Context(), "athlete.batch."+result.Action, "athlete", 0, nil, result)
return result, nil
}
// ---------- sports ----------
const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active`
+168 -1
View File
@@ -5,6 +5,8 @@ import (
"database/sql"
"errors"
"fmt"
"math"
"net/http"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
@@ -302,6 +304,9 @@ func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryD
// replaceCategories 整体替换某小项的组别(devdocs/05 约定)。已有报名数据时拒绝替换,
// 否则外键会把历史报名项一并带走。
//
// 被拦下的只是「增删组别」:改名 / 改年龄 / 改性别 / 改权重走 updateCategory 就地更新,
// 所以这里的文案要点明范围,别让只改了个名字的人以为改名也不许。
func (s *server) replaceCategories(ctx context.Context, eventID int64, in []categoryDTO) ([]categoryDTO, error) {
has, err := exists(ctx, s.ev,
`SELECT 1 FROM entries e JOIN event_categories c ON c.id = e.category_id
@@ -310,7 +315,7 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
return nil, internal(err)
}
if has {
return nil, conflict("该项目已有报名数据,不能整体替换组别")
return nil, conflict("该项目已有报名数据,不能新增或删除组别;改名、改年龄、改性别、改权重可以直接保存")
}
tx, err := s.ev.BeginTx(ctx, nil)
@@ -345,6 +350,168 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
return s.listCategories(ctx, eventID)
}
// getCategory 读单个组别,并校验它属于该小项。不属于(或用别的小项 id 来探)
// 一律按「组别不存在」处理,不给跨小项改名的机会。
func (s *server) getCategory(ctx context.Context, eventID, categoryID int64) (categoryDTO, error) {
var (
c categoryDTO
gender sql.NullString
ageMin, ageMax sql.NullInt64
)
err := s.ev.QueryRowContext(ctx,
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
FROM event_categories WHERE id = ? AND event_id = ?`, categoryID, eventID).
Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder)
if errors.Is(err, sql.ErrNoRows) {
return categoryDTO{}, notFound("组别不存在")
}
if err != nil {
return categoryDTO{}, internal(err)
}
c.Gender = strPtr(gender)
c.AgeMin = intPtr(ageMin)
c.AgeMax = intPtr(ageMax)
return c, nil
}
// validateCategoryAges 年龄区间可以留空,填了就必须是 0–120 且下限不大于上限。
// 整表替换的老路径不校验这两列,脏值进得去又改不动;就地更新这条路径补上。
func validateCategoryAges(ageMin, ageMax *int) error {
for _, v := range []*int{ageMin, ageMax} {
if v != nil && (*v < 0 || *v > 120) {
return badRequest("组别年龄需要在 0-120 之间", v)
}
}
if ageMin != nil && ageMax != nil && *ageMin > *ageMax {
return badRequest("组别年龄下限不能大于上限", nil)
}
return nil
}
// multiplierRecomputeEpsilon 是「组别权重是否真的变了」的判定容差。
//
// 权重是算分公式的输入,改了就必须重算;但同一个数字经 JSON 往返一圈可能差 1e-16,
// 用 == 会把「原样提交」也当成改动、白重算一次全组积分。1e-9 远小于现场能填出的最小差
// (权重最小刻度 0.1),又远大于浮点噪声。
const multiplierRecomputeEpsilon = 1e-9
func multiplierChanged(before, after float64) bool {
return math.Abs(before-after) > multiplierRecomputeEpsilon
}
// updateCategory 就地更新单个组别,可改 name / multiplier / gender / ageMin / ageMax / sortOrder。
//
// 与 replaceCategories 的「DELETE 全表 + INSERT」不同,这里只发一条 UPDATE:组别 id 不变,
// entries.category_id 以及它名下的成绩、流水、名次、积分全部原地保留。
// 有报名数据的小项正是靠它改名 / 改年龄 / 改性别 / 改权重 / 改排序;增删组别仍然只能走整表替换。
//
// 只有 multiplier 真的变了才在同一个事务里重算该组别的流水与名次:权重进算分公式,
// 不重算就会让已发布成绩的积分与榜单和配置静默不一致。name / gender / ageMin / ageMax /
// sortOrder 都不参与算分,改了不重算(报名、成绩、流水一个字节都不动)。
// 第二个返回值报告本次是否触发了重算,供审计留痕。
func (s *server) updateCategory(ctx context.Context, eventID, categoryID int64, in categoryDTO) (categories []categoryDTO, recomputed bool, err error) {
name := strings.TrimSpace(in.Name)
if name == "" {
return nil, false, badRequest("组别 name 必填", nil)
}
if in.Gender != nil && *in.Gender != "" && *in.Gender != "男" && *in.Gender != "女" {
return nil, false, badRequest("组别 gender 只能是 男 / 女", in.Gender)
}
if err := validateCategoryAges(in.AgeMin, in.AgeMax); err != nil {
return nil, false, err
}
// 先读旧值:要不要重算全靠这次比对,拿不到旧值就无从判断。
before, err := s.getCategory(ctx, eventID, categoryID)
if err != nil {
return nil, false, err
}
newMultiplier := multiplierOrOne(in.Multiplier)
recompute := multiplierChanged(before.Multiplier, newMultiplier)
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, false, internal(err)
}
defer func() { _ = tx.Rollback() }()
// event_id 一起进 WHERE:id 存在但挂在别的小项上时影响 0 行,按「组别不存在」处理。
res, err := tx.ExecContext(ctx,
`UPDATE event_categories SET name = ?, multiplier = ?, gender = ?, age_min = ?, age_max = ?, sort_order = ?
WHERE id = ? AND event_id = ?`,
name, newMultiplier, nullStr(in.Gender), nullInt(in.AgeMin), nullInt(in.AgeMax),
in.SortOrder, categoryID, eventID)
if isUniqueViolation(err) {
return nil, false, conflict("组别名称重复:" + name)
}
if err != nil {
return nil, false, internal(err)
}
if err := requireChanges(res, "组别不存在"); err != nil {
return nil, false, err
}
actorID := middleware.User(ctx).ID
if recompute {
// 与新权重保持一致:作废旧流水、按新分数重建,并把名次写回 results(榜单看的就是它)。
score, err := recomputeLedgerTx(ctx, tx, eventID, categoryID, actorID, "组别权重变更重算")
if err != nil {
return nil, false, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, false, err
}
}
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return nil, false, err
}
if recompute {
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return nil, false, err
}
}
if err := tx.Commit(); err != nil {
return nil, false, internal(err)
}
cats, err := s.listCategories(ctx, eventID)
if err != nil {
return nil, false, err
}
return cats, recompute, nil
}
// updateCategoryHandler 是 PUT /api/events/{id}/categories/{categoryId}:组别的就地更新。
func (s *server) updateCategoryHandler(w http.ResponseWriter, r *http.Request) (any, error) {
eventID, err := pathID(r, "id")
if err != nil {
return nil, err
}
categoryID, err := pathID(r, "categoryId")
if err != nil {
return nil, err
}
var in categoryDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getCategory(r.Context(), eventID, categoryID)
if err != nil {
return nil, err
}
updated, recomputed, err := s.updateCategory(r.Context(), eventID, categoryID, in)
if err != nil {
return nil, err
}
after, err := s.getCategory(r.Context(), eventID, categoryID)
if err != nil {
return nil, err
}
// 审计要能看出这次是不是还动了权重:权重变更会连带重算已发布成绩的积分与榜单,
// 事后查「榜为什么变了」时,光看新旧权重容易漏掉这一层。
s.audit(r.Context(), "event.category.update", "event_category", categoryID, before,
map[string]any{"category": after, "recomputedLedger": recomputed})
return updated, nil
}
// ---------- score channels ----------
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
+179 -61
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
)
func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64, error) {
@@ -35,10 +36,21 @@ func (in athleteImportRow) decisionKey() string {
}
// athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。
// SameNameNewPerson 非空表示本次按「同名的另一个人」新建了档案(只作报告,不拦截)。
type athleteImportMatch struct {
AthleteID int64
TeamID int64
Conflict *importConflict
AthleteID int64
TeamID int64
Conflict *importConflict
SameNameNewPerson *sameNameNewPerson
}
// sameNameNewPerson 是「号码布不同 → 按同名的另一个人新建」的清单项:
// 导入响应里单独列出来给现场看,避免新建了档案却毫无提示。
type sameNameNewPerson struct {
Name string `json:"name"`
TeamCode string `json:"teamCode"`
NewBib string `json:"newBib"`
ExistingBib string `json:"existingBib"`
}
// existingAthlete 是库中已有人员的可比字段。
@@ -53,35 +65,136 @@ type existingAthlete struct {
Age *int
}
// matchImportedAthlete 按契约顺序匹配人员:证件号 → 号码布 → 姓名+单位(库中证件号为空则回填)→ 新建。
// matchImportedAthlete 按新口径匹配人员:号码布 → 姓名 + 单位 → 新建。
// 号码布是单人唯一标识,证件号不参与匹配(只作为可选档案信息写入/更新)。
// 命中人员字段与表里不一致时记为冲突;decisions 里对同一身份有决策时按决策落库。
func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
existing, err := s.athleteByIdentity(ctx, in)
switch {
case err == nil:
return s.matchExistingAthlete(ctx, in, existing, decisions)
case errors.Is(err, sql.ErrNoRows):
return s.matchOrCreateAthleteByName(ctx, in, decisions)
default:
return athleteImportMatch{}, internal(err)
}
}
// athleteByIdentity 先按证件号精确匹配,未命中再按号码布精确匹配。
func (s *server) athleteByIdentity(ctx context.Context, in athleteImportRow) (existingAthlete, error) {
if in.IDCard != "" {
ex, err := s.athleteByIDCard(ctx, in.IDCard)
if !errors.Is(err, sql.ErrNoRows) {
return ex, err
if in.Bib != "" {
ex, err := s.athleteByBib(ctx, in.Bib)
switch {
case err == nil:
return s.matchExistingAthlete(ctx, in, ex, decisions)
case !errors.Is(err, sql.ErrNoRows):
return athleteImportMatch{}, internal(err)
}
}
if in.Bib != "" {
return s.athleteByBib(ctx, in.Bib)
}
return existingAthlete{}, sql.ErrNoRows
return s.matchByNameTeam(ctx, in, decisions)
}
// matchExistingAthlete 处理证件号/号码布命中的人员:无差异时直接复用并补空字段,
// matchByNameTeam 处理「号码布为空、或按号码布没找到」的情况,按姓名 + 单位找候选:
// 0 条 → 新建;1 条且对方没有号码布 → 命中并回填;1 条但对方号码布不同 → 同名的另一个人,新建并报告;
// 多条 → 仍有歧义,进冲突面板等用户决策。
func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
candidates, err := s.athletesByNameTeam(ctx, in.Name, in.TeamID)
if err != nil {
return athleteImportMatch{}, internal(err)
}
switch len(candidates) {
case 0:
return s.createImportedAthlete(ctx, in)
case 1:
ex := candidates[0]
// 只有表里也给了号码布、且与库里不同时才算「同名的另一个人」:
// 表里没给号码布只是「未指定」,不能凭空多建一个没有号码布的同名人。
if ex.Bib != nil && *ex.Bib != "" && in.Bib != "" && *ex.Bib != in.Bib {
created, err := s.createImportedAthlete(ctx, in)
if err != nil {
return athleteImportMatch{}, err
}
created.SameNameNewPerson = &sameNameNewPerson{
Name: in.Name,
TeamCode: in.TeamCode,
NewBib: in.Bib,
ExistingBib: *ex.Bib,
}
return created, nil
}
return s.matchExistingAthlete(ctx, in, ex, decisions)
default:
return s.matchAmbiguousCandidates(ctx, in, candidates, decisions)
}
}
// matchAmbiguousCandidates 处理姓名 + 单位命中多条候选:不能替用户挑人,一律进冲突面板。
// 决策键是整行身份(证件号优先、否则号码布),无法区分同一行的多个候选,
// 所以面板只以第一条候选为代表;有决策时按决策作用于该候选。
func (s *server) matchAmbiguousCandidates(ctx context.Context, in athleteImportRow, candidates []existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
ex := candidates[0]
if _, decided := decisions[in.decisionKey()]; decided {
return s.matchExistingAthlete(ctx, in, ex, decisions)
}
conflict, err := s.importConflictFor(ctx, in, ex, changedAthleteFields(ex, in))
if err != nil {
return athleteImportMatch{}, err
}
return athleteImportMatch{Conflict: &conflict}, nil
}
// createImportedAthlete 新建人员:证件号只作为可选档案信息写入,
// 但它与库里另一个人撞号时仍然明确报错(id_card 的部分唯一索引保留不动,防的是录入错误)。
func (s *server) createImportedAthlete(ctx context.Context, in athleteImportRow) (athleteImportMatch, error) {
if err := s.idCardTakenError(ctx, in.IDCard, 0); err != nil {
return athleteImportMatch{}, err
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age))
switch {
case isIDCardConflict(err):
// 并发下唯一索引兜底:再查一次占用者,把「撞上谁」说清楚。
if taken := s.idCardTakenError(ctx, in.IDCard, 0); taken != nil {
return athleteImportMatch{}, taken
}
return athleteImportMatch{}, internal(err)
case isBibConflict(err):
return athleteImportMatch{}, conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib))
case err != nil:
return athleteImportMatch{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return athleteImportMatch{}, internal(err)
}
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
}
// idCardTakenError 检查证件号是否已被另一个人占用,占用时返回可直接给现场看的中文错误。
// 证件号不参与匹配,但库里一个证件号只能属于一个人(唯一索引保留):撞号时必须报清楚占用者是谁,
// 不允许静默改库或静默跳过。excludeID 是被更新的人(新建传 0),允许他自己继续持有该证件号。
func (s *server) idCardTakenError(ctx context.Context, idCard string, excludeID int64) error {
if idCard == "" {
return nil
}
var (
name string
bib sql.NullString
)
err := s.ev.QueryRowContext(ctx,
`SELECT name, bib FROM athletes WHERE id_card = ? AND id <> ? ORDER BY id LIMIT 1`, idCard, excludeID).
Scan(&name, &bib)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil
case err != nil:
return internal(err)
}
occupied := "未填"
if bib.Valid && bib.String != "" {
occupied = bib.String
}
return conflict(fmt.Sprintf("证件号 %s 已被另一个人占用(姓名 %s,号码布 %s)", idCard, name, occupied))
}
// importRowMessage 把业务错误转成行级提示:去掉「CONFLICT:」这类 code 前缀,只留中文 message。
func importRowMessage(err error) string {
var api *apiError
if errors.As(err, &api) && api.message != "" {
return api.message
}
return err.Error()
}
// matchExistingAthlete 处理号码布或姓名 + 单位命中的人员:无差异时直接复用并补空字段,
// 有差异时走冲突流程,decisions 里对该身份有决策才落库。
func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
changed := changedAthleteFields(ex, in)
@@ -110,30 +223,6 @@ func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow,
}
}
// matchOrCreateAthleteByName 处理证件号/号码布都未命中的情况:
// 姓名+单位命中且库中证件号为空 → 视为同一人(差异按冲突处理);否则新建人员。
func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
existing, err := s.athleteByNameTeam(ctx, in.Name, in.TeamID)
switch {
case err == nil:
return s.matchExistingAthlete(ctx, in, existing, decisions)
case !errors.Is(err, sql.ErrNoRows):
return athleteImportMatch{}, internal(err)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age))
if err != nil {
return athleteImportMatch{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return athleteImportMatch{}, internal(err)
}
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
}
// importAthleteColumns 是人员匹配要读的字段,证件号/号码布/年龄用于回填与冲突判断。
const importAthleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age`
@@ -155,22 +244,30 @@ func scanExistingAthlete(row interface{ Scan(...any) error }) (existingAthlete,
return a, nil
}
func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) {
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes WHERE id_card = ? LIMIT 1`, idCard))
}
func (s *server) athleteByBib(ctx context.Context, bib string) (existingAthlete, error) {
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes WHERE bib = ? LIMIT 1`, bib))
}
// athleteByNameTeam 按「姓名+单位」找库中还没填证件号的人,用于回填身份字段。
func (s *server) athleteByNameTeam(ctx context.Context, name string, teamID int64) (existingAthlete, error) {
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes
WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '')
ORDER BY id LIMIT 1`, name, teamID))
// athletesByNameTeam 按「姓名 + 单位」列出全部候选,供调用方按候选数量决定是回填、新建还是进冲突面板。
// 证件号不参与匹配,所以这里不再过滤证件号为空的人。
func (s *server) athletesByNameTeam(ctx context.Context, name string, teamID int64) ([]existingAthlete, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes WHERE name = ? AND team_id = ? ORDER BY id`, name, teamID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
out := make([]existingAthlete, 0, 1)
for rows.Next() {
ex, err := scanExistingAthlete(rows)
if err != nil {
return nil, err
}
out = append(out, ex)
}
return out, rows.Err()
}
// changedAthleteFields 列出表里与库中不一致的字段名,顺序固定便于前端展示。
@@ -208,7 +305,15 @@ func identityDiffers(dbVal *string, inVal string) bool {
// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄,已有值不动(差异已由 changedAthleteFields 判过)。
func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthlete, in athleteImportRow) error {
if in.IDCard != "" && (ex.IDCard == nil || *ex.IDCard == "") {
if err := s.idCardTakenError(ctx, in.IDCard, ex.ID); err != nil {
return err
}
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, ex.ID); err != nil {
if isIDCardConflict(err) {
if taken := s.idCardTakenError(ctx, in.IDCard, ex.ID); taken != nil {
return taken
}
}
return internal(err)
}
}
@@ -261,10 +366,23 @@ func (s *server) teamCodeByID(ctx context.Context, id int64) (string, error) {
// overwriteAthleteFromImport 用表里的字段覆盖库中人员(keep-new 决策)。
// 表里没填的字段保留库里的值,避免决策后反而丢字段。
func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in athleteImportRow) error {
if in.IDCard != "" {
if err := s.idCardTakenError(ctx, in.IDCard, id); err != nil {
return err
}
}
if _, err := s.ev.ExecContext(ctx,
`UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender),
id_card = COALESCE(?, id_card), bib = COALESCE(?, bib), age = COALESCE(?, age) WHERE id = ?`,
in.Name, in.TeamID, nullStr(&in.Gender), nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age), id); err != nil {
switch {
case isIDCardConflict(err):
if taken := s.idCardTakenError(ctx, in.IDCard, id); taken != nil {
return taken
}
case isBibConflict(err):
return conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib))
}
return internal(err)
}
return nil
+105
View File
@@ -5,10 +5,12 @@ import (
"database/sql"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
@@ -203,6 +205,11 @@ func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.E
return dto.EventSettings{}, err
}
}
if p.LiveOnPublish != nil {
if err := set(dto.SettingKeyLiveOnPublish, strconv.FormatBool(*p.LiveOnPublish)); err != nil {
return dto.EventSettings{}, err
}
}
}
if patch.DefaultProfileID != nil {
code := ""
@@ -263,6 +270,104 @@ func boolDefault(v string, def bool) bool {
}
}
// ---------- 系统级设置:登录有效期 ----------
//
// 存在系统库 system.db 的 settings(键 security.session_hours),与赛事级的
// /api/settings(event.db,随赛事包走)不共用:会话寿命是这台机器的属性,
// 切换到另一个赛事项目时不该跟着变。
func (s *server) getSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.sessionSettings(r)
}
func (s *server) updateSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in sessionSettingsPatch
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if in.SessionHours == nil {
return nil, badRequest("登录有效期 sessionHours 必填", nil)
}
hours := *in.SessionHours
if hours < auth.MinSessionHours || hours > auth.MaxSessionHours {
return nil, badRequest(
fmt.Sprintf("登录有效期(小时)需在 %d~%d 之间", auth.MinSessionHours, auth.MaxSessionHours), hours)
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
if err := s.writeSessionHours(r.Context(), hours); err != nil {
return nil, err
}
s.audit(r.Context(), "settings.session_hours", "system", 0, nil,
map[string]any{"sessionHours": hours})
return s.sessionSettings(r)
})
}
// sessionSettings 组装面板要的登录有效期:生效值 + 取值范围 + 当前会话到期时间。
func (s *server) sessionSettings(r *http.Request) (dto.SessionSettings, error) {
values, err := loadSettingsMap(r.Context(), s.sys)
if err != nil {
return dto.SessionSettings{}, err
}
return dto.SessionSettings{
SessionHours: auth.EffectiveSessionHours(values),
DefaultHours: auth.DefaultSessionHours,
MinHours: auth.MinSessionHours,
MaxHours: auth.MaxSessionHours,
ExpiresAt: s.sessionExpiresAt(r),
}, nil
}
// sessionExpiresAt 取当前请求所用会话的到期时间(RFC3339 UTC);没有有效会话时为 nil。
// 前端要把它按本机时区显示成「当前会话将于 … 过期」,所以必须带时区,不能下发库里的裸时间串。
func (s *server) sessionExpiresAt(r *http.Request) *string {
c, err := r.Cookie(auth.CookieSession)
if err != nil || c.Value == "" {
return nil
}
_, sess, err := s.auth.Lookup(r.Context(), c.Value)
if err != nil || sess == nil {
return nil
}
v := sess.ExpiresAt.UTC().Format(time.RFC3339)
return &v
}
// writeSessionHours 落库登录有效期并立刻在进程内生效。
// 只写配置、不动任何已存在会话的 expires_at:改设置不该把正在录入的人踢下线。
func (s *server) writeSessionHours(ctx context.Context, hours int) error {
tx, err := s.sys.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
upsert := func(key, value string) error {
_, err := tx.ExecContext(ctx,
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
key, value)
if err != nil {
return internal(err)
}
return nil
}
if err := upsert(auth.SessionHoursKey, strconv.Itoa(hours)); err != nil {
return err
}
// 记下这个值是面板写的:读取时就不再把它当成迁移种下的历史占位值,
// 操作员特意选的 12 才不会在重启后被悄悄改成默认的 24。
if err := upsert(auth.SessionHoursSourceKey, auth.SessionHoursSourcePanel); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return internal(err)
}
// 重启才生效会让现场以为「改了面板没反应」,所以同一请求里就换掉进程内的有效期。
s.auth.SetSessionTTL(time.Duration(hours) * time.Hour)
return nil
}
// ---------- 大屏公告 ----------
// 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder,
+293 -21
View File
@@ -7,6 +7,7 @@ import (
"errors"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
@@ -340,6 +341,22 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
// 重算再插一份导致该通道分数翻倍。聚合流水本身不归属组别,且重建通道会换掉通道 id
// (source_id 随之失效),故按 event_id 全量作废;下方补算整项聚合流水时会重新插入全量。
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
score, err := computeEventScore(ctx, tx, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
if err := writeLedgerTx(ctx, tx, score, eventID, categoryID, actorID, reason); err != nil {
return domain.EventScore{}, err
}
return score, nil
}
// writeLedgerTx 把已算好的 score 落成流水:先作废范围内旧流水,再插入新流水。
//
// 与 recomputeLedgerTx 拆开是为了「延迟结算」:那时名次与流水都要等整组录完才落,
// 但分数仍要能算出来(结算时才算,见 publishResults)。计算不读 score_ledger,
// 所以「先算后作废」与老的「先作废后算」结果完全一致。
func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID, actorID int64, reason string) error {
scope := `source_type IN ('result', 'record_bonus') AND event_id = ?`
args := []any{eventID}
if categoryID > 0 {
@@ -351,25 +368,21 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
WHERE voided = 0 AND `+scope,
append([]any{nullID(actorID), reason}, args...)...); err != nil {
return domain.EventScore{}, internal(err)
return internal(err)
}
score, err := computeEventScore(ctx, tx, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
ledger := score.Ledger
if categoryID > 0 {
// 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废;
// 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。
hasAggregate, err := hasAggregateChannel(ctx, tx, eventID)
if err != nil {
return domain.EventScore{}, err
return err
}
if hasAggregate {
all, err := computeEventScore(ctx, tx, eventID, 0)
if err != nil {
return domain.EventScore{}, err
return err
}
ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
for _, l := range score.Ledger {
@@ -390,10 +403,10 @@ func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, act
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID),
l.SourceType, l.SourceID, l.Points, boolToInt(l.CountToTeam), nullID(actorID)); err != nil {
return domain.EventScore{}, internal(err)
return internal(err)
}
}
return score, nil
return nil
}
// isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员,
@@ -445,6 +458,106 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
// 而且编辑页会把整组当成「已发布」锁死,补录无门。
const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')`
// publishableResult 是 publishableResultCond 的 Go 版判据,供分批结算的纯函数使用。
// 两处判据必须同源:SQL 那份决定哪些行能被发布,这份决定「这一条算不算已经出成绩」。
func publishableResult(r domain.Result) bool {
return r.ResultValue != nil || r.Rank > 0 || r.Status != domain.ResultStatusOK
}
// groupStats 统计某组「该出成绩的报名项数」与「已经有可发布成绩的项数」。
//
// 非 registered 的报名项(scratched / dq / dns)本来就不该等它出成绩:
// 计分把非 registered 视为 0 分且不占名次,等它只会让整组永远结算不了。
func groupStats(entries []domain.Entry, results []domain.Result) (total, published int) {
byEntry := make(map[int64]domain.Result, len(results))
for _, r := range results {
byEntry[r.EntryID] = r
}
for _, e := range entries {
if e.Status != domain.EntryStatusRegistered {
continue
}
total++
if r, ok := byEntry[e.ID]; ok && r.State == domain.ResultStatePublished && publishableResult(r) {
published++
}
}
return total, published
}
// groupOf 从整项的行里挑出某个组别的报名与成绩。
// 成绩表本身不带组别,只能靠报名项 id 反查,所以先收组内报名 id 再过滤成绩。
func groupOf(entries []domain.Entry, results []domain.Result, categoryID int64) ([]domain.Entry, []domain.Result) {
inGroup := make(map[int64]bool)
groupEntries := make([]domain.Entry, 0, len(entries))
for _, e := range entries {
if e.CategoryID != categoryID {
continue
}
inGroup[e.ID] = true
groupEntries = append(groupEntries, e)
}
groupResults := make([]domain.Result, 0, len(groupEntries))
for _, r := range results {
if inGroup[r.EntryID] {
groupResults = append(groupResults, r)
}
}
return groupEntries, groupResults
}
// scopeStats 按发布 / 结算的作用域累加 groupStats:categoryID > 0 只看该组别,
// categoryID = 0 看整项(逐组别累加,任何一组没录完都算没录完)。
func scopeStats(ctx context.Context, q queryer, eventID, categoryID int64) (total, published int, err error) {
entries, err := loadEntriesQ(ctx, q, eventID, 0)
if err != nil {
return 0, 0, err
}
results, err := loadResultsQ(ctx, q, eventID, 0)
if err != nil {
return 0, 0, err
}
categories := []int64{categoryID}
if categoryID == 0 {
cats, err := loadCategoriesQ(ctx, q, eventID)
if err != nil {
return 0, 0, err
}
categories = make([]int64, 0, len(cats))
for _, cat := range cats {
categories = append(categories, cat.ID)
}
}
for _, id := range categories {
groupEntries, groupResults := groupOf(entries, results, id)
t, p := groupStats(groupEntries, groupResults)
total += t
published += p
}
return total, published, nil
}
// scopeSettled 判断作用域内是否「所有批次都录完」,即可以结算。
// 没有报名项(total = 0)视为录完:没有要等的东西,结算一次把范围清干净。
func scopeSettled(ctx context.Context, q queryer, eventID, categoryID int64) (bool, error) {
total, published, err := scopeStats(ctx, q, eventID, categoryID)
if err != nil {
return false, err
}
return published == total, nil
}
// liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。
func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) {
var raw string
err := q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, dto.SettingKeyLiveOnPublish).Scan(&raw)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, internal(err)
}
return boolDefault(raw, true), nil
}
func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
@@ -470,7 +583,8 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
// 只发布「已有内容」的成绩行:现场分批比赛时(如 100 米预赛分 4 组、每组 8 条跑道),
// 只跑完一组就要先把这一组上屏,没跑/没录的行必须留在 draft,不能被一起发布。
// 判据见 publishableResultCond。名次与积分由下方重算按已发布部分计算,后续再发一批会自动纳入。
// 判据见 publishableResultCond。名次与积分是否当场落库,取决于「发布后是否立即结算」
// (见下方 liveOnPublishQ):实时模式按已发布部分重算,后续再发一批会自动纳入。
setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'),
updated_at = datetime('now')
WHERE event_id = ? AND ` + publishableResultCond
@@ -483,12 +597,33 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
return nil, internal(err)
}
score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "发布重算")
live, err := liveOnPublishQ(ctx, tx)
if err != nil {
return nil, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, err
settle := live
if !live {
// 延迟结算:等该 (小项, 组别) 所有批次都录完才结算一次。
// 结算时按整组重算,之前各批的成绩一起纳入,避免名次停留在分批算的口径。
settle, err = scopeSettled(ctx, tx, in.EventID, catID)
if err != nil {
return nil, err
}
}
if settle {
// 名次也要等结算才写:奖牌榜统计读的就是 results.rank(见 buildRanked),
// 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动,
// 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。
score, err := computeEventScore(ctx, tx, in.EventID, catID)
if err != nil {
return nil, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, err
}
if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil {
return nil, err
}
}
if catID == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil {
@@ -506,8 +641,11 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return nil, err
// 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。
if settle {
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
@@ -563,6 +701,9 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, internal(err)
}
// 撤销发布是回滚动作,延迟结算也要立刻重算:这一组已经没有已发布成绩,
// 留着上一次结算写下的流水就是「榜上有分、成绩却撤了」。重算本身会把旧流水作废、
// 按当前已发布内容重写,之后再次发布时判据回到「未结算」,行为自然衔接。
if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil {
return nil, err
}
@@ -643,12 +784,28 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
return resultDTO{}, internal(err)
}
// 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason)
if err != nil {
// withdrawn 仍是「可发布成绩」,作废不会让该组变成没录完,所以录完过的组照样能结算;
// 没录完的组按延迟结算处理:名次与流水都等结算,只把作废这件事落库。
settle := true
if live, err := liveOnPublishQ(ctx, tx); err != nil {
return resultDTO{}, err
} else if !live {
settle, err = scopeSettled(ctx, tx, eventID, catID)
if err != nil {
return resultDTO{}, err
}
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return resultDTO{}, err
if settle {
score, err := computeEventScore(ctx, tx, eventID, catID)
if err != nil {
return resultDTO{}, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return resultDTO{}, err
}
if err := writeLedgerTx(ctx, tx, score, eventID, catID, userID, "作废成绩:"+reason); err != nil {
return resultDTO{}, err
}
}
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
@@ -658,8 +815,10 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil {
return resultDTO{}, err
}
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return resultDTO{}, err
if settle {
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return resultDTO{}, err
}
}
if err := tx.Commit(); err != nil {
return resultDTO{}, internal(err)
@@ -669,6 +828,119 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
return s.getResult(ctx, resultID)
}
// ---------- 分批结算 ----------
// settlementGroupDTO 是「未结算的 (小项, 组别)」:延迟结算下已发布成绩还没进榜单的那一组。
// Required / Published 只数「该出成绩的报名项」,弃权 / 失格不计入,与结算判据同源。
type settlementGroupDTO struct {
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
CategoryName string `json:"categoryName"`
Required int `json:"required"`
Published int `json:"published"`
}
// settlementStatusDTO 是 GET /api/results/settlement 的线上结构。
type settlementStatusDTO struct {
LiveOnPublish bool `json:"liveOnPublish"`
Groups []settlementGroupDTO `json:"groups"`
}
// settlementStatus 列出范围内尚未结算的组别,供录入页提示「榜单还没反映这批」。
// 实时结算(默认)时清单恒为空:那种模式下每次发布都当场结算了。
func (s *server) settlementStatus(ctx context.Context, eventID, catID int64) (settlementStatusDTO, error) {
out := settlementStatusDTO{Groups: []settlementGroupDTO{}}
live, err := liveOnPublishQ(ctx, s.ev)
if err != nil {
return out, err
}
out.LiveOnPublish = live
if live {
return out, nil
}
categories, err := loadCategoriesQ(ctx, s.ev, eventID)
if err != nil {
return out, err
}
entries, err := loadEntriesQ(ctx, s.ev, eventID, 0)
if err != nil {
return out, err
}
results, err := loadResultsQ(ctx, s.ev, eventID, 0)
if err != nil {
return out, err
}
for _, cat := range categories {
if catID > 0 && cat.ID != catID {
continue
}
groupEntries, groupResults := groupOf(entries, results, cat.ID)
required, published := groupStats(groupEntries, groupResults)
// 一条都没发布 = 还没开始结算;已录完 = 下次发布就会结算(或被立即结算过)。
if required == 0 || published == 0 || published == required {
continue
}
out.Groups = append(out.Groups, settlementGroupDTO{
EventID: eventID,
CategoryID: cat.ID,
CategoryName: cat.Name,
Required: required,
Published: published,
})
}
return out, nil
}
// settleResults 立即结算:无视延迟结算设置,按当前已发布成绩重算积分流水并刷新榜单。
//
// 这是现场安全绳:只要有一条成绩永远录不出来(退赛却没标 scratched),
// 延迟结算就永远不会自动触发,必须有一个显式入口把已发布部分结算掉。
func (s *server) settleResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
catID := int64(0)
if in.CategoryID != nil {
catID = *in.CategoryID
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "立即结算")
if err != nil {
return nil, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, err
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
s.audit(ctx, "result.settle", "event", in.EventID, before, after)
return s.resultsByEvent(ctx, in.EventID)
}
// ---------- 变更日志与版本 ----------
func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action string, results []resultDTO, actorID int64, message string) error {
+15
View File
@@ -197,6 +197,11 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/settings", s.read(auth.RoleAdmin, s.getSettingsHandler))
mux.HandleFunc("PUT /api/settings", s.write(auth.RoleAdmin, s.updateSettingsHandler))
// 登录有效期是机器级配置(系统库 settings),与上面的赛事级 /api/settings 不共用;
// 它决定所有人的会话寿命,所以读写都限 admin。
mux.HandleFunc("GET /api/system/session", s.read(auth.RoleAdmin, s.getSessionSettingsHandler))
mux.HandleFunc("PUT /api/system/session", s.write(auth.RoleAdmin, s.updateSessionSettingsHandler))
// 基础配置
mux.HandleFunc("GET /api/teams", s.public(s.listTeamsHandler))
mux.HandleFunc("POST /api/teams", s.write(auth.RoleRecorder, s.createTeamHandler))
@@ -218,6 +223,8 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/events/{id}/entries", s.public(s.listEntriesHandler))
mux.HandleFunc("GET /api/events/{id}/categories", s.public(s.listCategoriesHandler))
mux.HandleFunc("PUT /api/events/{id}/categories", s.write(auth.RoleRecorder, s.replaceCategoriesHandler))
// 单个组别的就地更新:有报名数据时改名 / 改年龄 / 改性别 / 改权重只走这条,不重建组别。
mux.HandleFunc("PUT /api/events/{id}/categories/{categoryId}", s.write(auth.RoleRecorder, s.updateCategoryHandler))
mux.HandleFunc("GET /api/events/{id}/channels", s.public(s.listChannelsHandler))
mux.HandleFunc("PUT /api/events/{id}/channels", s.write(auth.RoleRecorder, s.replaceChannelsHandler))
@@ -231,17 +238,25 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/athletes/{id}", s.public(s.getAthleteHandler))
mux.HandleFunc("PUT /api/athletes/{id}", s.write(auth.RoleRecorder, s.updateAthleteHandler))
mux.HandleFunc("DELETE /api/athletes/{id}", s.write(auth.RoleRecorder, s.deleteAthleteHandler))
// 批量动作端点:一次请求完成整批停用 / 彻底删除,逐条回报成败;逐个删会逐个消耗写配额。
mux.HandleFunc("POST /api/athletes/batch", s.write(auth.RoleRecorder, s.batchAthletesHandler))
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))
// 报名项批量操作:退赛(scratched,可恢复)/ 恢复 / 移出报名(物理删除,不可恢复)。
// 权限与发布同档(recorder),不新增角色;跨小项混选允许,服务端按 (小项, 组别) 分组重算。
mux.HandleFunc("POST /api/entries/batch", s.write(auth.RoleRecorder, s.batchEntriesHandler))
// 成绩
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
mux.HandleFunc("PUT /api/results/draft", s.write(auth.RoleRecorder, s.saveDraftHandler))
mux.HandleFunc("POST /api/results/publish", s.write(auth.RoleRecorder, s.publishHandler))
mux.HandleFunc("POST /api/results/unpublish", s.write(auth.RoleRecorder, s.unpublishHandler))
// 分批延迟结算:settlement 给录入页查「哪些组还没结算」,settle 是人工兜底(写审计)。
mux.HandleFunc("GET /api/results/settlement", s.read(auth.RoleRecorder, s.settlementStatusHandler))
mux.HandleFunc("POST /api/results/settle", s.write(auth.RoleRecorder, s.settleResultsHandler))
mux.HandleFunc("POST /api/results/{id}/void", s.write(auth.RoleRecorder, s.voidResultHandler))
// 积分与排名
+330
View File
@@ -0,0 +1,330 @@
package httpapi_test
import (
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
// ---------- 登录有效期(系统级设置)的测试脚手架 ----------
type sessionSettingsDTO struct {
SessionHours int `json:"sessionHours"`
DefaultHours int `json:"defaultSessionHours"`
MinHours int `json:"minHours"`
MaxHours int `json:"maxHours"`
ExpiresAt *string `json:"expiresAt"`
}
func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
// sysSetting 读系统库 settings 里的键。
func sysSetting(t *testing.T, env *testEnv, key string) (string, bool) {
t.Helper()
var v string
err := env.sys.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", false
}
if err != nil {
t.Fatalf("读系统设置 %s: %v", key, err)
}
return v, true
}
// sessionExpiry 取某用户当前会话的到期时间(测试里每个用户只保留一个会话)。
func sessionExpiry(t *testing.T, env *testEnv, username string) time.Time {
t.Helper()
var raw string
if err := env.sys.QueryRow(
`SELECT s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE u.username = ?`,
username).Scan(&raw); err != nil {
t.Fatalf("读 %s 的会话到期时间: %v", username, err)
}
at, err := time.Parse(time.DateTime, raw)
if err != nil {
t.Fatalf("解析到期时间 %q: %v", raw, err)
}
return at
}
// sessionLastSeen 取某用户当前会话的活动时间。
func sessionLastSeen(t *testing.T, env *testEnv, username string) time.Time {
t.Helper()
var raw string
if err := env.sys.QueryRow(
`SELECT COALESCE(s.last_seen_at, '') FROM sessions s JOIN users u ON u.id = s.user_id WHERE u.username = ?`,
username).Scan(&raw); err != nil {
t.Fatalf("读 %s 的会话活动时间: %v", username, err)
}
at, err := time.Parse(time.DateTime, raw)
if err != nil {
t.Fatalf("解析活动时间 %q: %v", raw, err)
}
return at
}
func setSessionExpiry(t *testing.T, env *testEnv, username string, at time.Time) {
t.Helper()
if _, err := env.sys.Exec(
`UPDATE sessions SET expires_at = ?
WHERE user_id = (SELECT id FROM users WHERE username = ?)`,
at.UTC().Format(time.DateTime), username); err != nil {
t.Fatalf("改 %s 的会话到期时间: %v", username, err)
}
}
func adminClient(t *testing.T, env *testEnv) *http.Client {
t.Helper()
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
return client
}
// ---------- 默认值与落库 ----------
// 面板默认 24 小时:迁移种下的历史占位值 12 从没被任何代码读过,按「未配置」处理;
// 写完之后落到系统库,重启(新建 Service)也读得到。
func TestSessionSettingsDefaultsAndWrite(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
status, body := env.do(client, http.MethodGet, "/api/system/session", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("读取登录有效期应 200,得到 %d %+v", status, body.Error)
}
got := decodeData[sessionSettingsDTO](t, body)
if got.SessionHours != 24 || got.DefaultHours != 24 || got.MinHours != 1 || got.MaxHours != 720 {
t.Fatalf("默认值/范围不符:%+v", got)
}
if got.ExpiresAt == nil || !strings.HasSuffix(*got.ExpiresAt, "Z") {
t.Fatalf("应回带当前会话的到期时间(RFC3339 UTC),得到 %v", got.ExpiresAt)
}
if v, ok := sysSetting(t, env, auth.SessionHoursKey); !ok || v != "12" {
t.Fatalf("初始库里应是迁移种下的 12,得到 %q(存在=%v)", v, ok)
}
status, body = env.do(client, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 72}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("写入应 200,得到 %d %+v", status, body.Error)
}
if got := decodeData[sessionSettingsDTO](t, body); got.SessionHours != 72 {
t.Fatalf("应回显 72,得到 %+v", got)
}
if v, _ := sysSetting(t, env, auth.SessionHoursKey); v != "72" {
t.Fatalf("落库应为 72,得到 %q", v)
}
// 来源标记:有这个键,读取时就不会把值当成迁移占位值。
if v, _ := sysSetting(t, env, auth.SessionHoursSourceKey); v != auth.SessionHoursSourcePanel {
t.Fatalf("应登记来源 %q,得到 %q", auth.SessionHoursSourcePanel, v)
}
// 重启等价物:新起一个 Service 读同一个库,仍是 72h。
restarted := auth.New(env.sys, auth.Options{Logger: testLogger(), BcryptCost: bcrypt.MinCost})
if restarted.SessionTTL() != 72*time.Hour {
t.Fatalf("重启后应仍读 72h,得到 %s", restarted.SessionTTL())
}
}
// 非法值一律 400 + 中文提示,且不落库;读写都是 admin-only。
func TestSessionSettingsValidationAndPermission(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
for _, tc := range []struct {
name string
body map[string]any
}{
{"缺字段", map[string]any{}},
{"过小", map[string]any{"sessionHours": 0}},
{"负数", map[string]any{"sessionHours": -3}},
{"过大", map[string]any{"sessionHours": 721}},
} {
status, body := env.do(client, http.MethodPut, "/api/system/session", tc.body, true)
if status != http.StatusBadRequest || body.Error == nil || body.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("%s应 400 VALIDATION_FAILED,得到 %d %+v", tc.name, status, body.Error)
}
if !strings.Contains(body.Error.Message, "登录有效期") {
t.Fatalf("%s 的中文提示应说明是登录有效期,得到 %q", tc.name, body.Error.Message)
}
}
if v, _ := sysSetting(t, env, auth.SessionHoursKey); v != "12" {
t.Fatalf("非法值不该落库,库里是 %q", v)
}
env.createUser("recorder", "recorder-pass", "recorder")
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
if status, _ := env.do(recorder, http.MethodGet, "/api/system/session", nil, false); status != http.StatusForbidden {
t.Fatalf("recorder 读登录有效期应 403,得到 %d", status)
}
if status, _ := env.do(recorder, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 1}, true); status != http.StatusForbidden {
t.Fatalf("recorder 写登录有效期应 403,得到 %d", status)
}
}
// ---------- 滑动续期 ----------
// 会话只要还在被使用就顺延;节流生效时不会每次请求都写 expires_at。
func TestSlidingRenewalThrottledOverHTTP(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
// 模拟「已经消耗掉大半寿命」:剩 23h58m(不足 24h-1m 的节流窗口)。
setSessionExpiry(t, env, "admin", time.Now().UTC().Add(23*time.Hour+58*time.Minute))
before := sessionExpiry(t, env, "admin")
status, body := env.do(client, http.MethodGet, "/api/auth/me", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("请求应成功,得到 %d %+v", status, body.Error)
}
renewed := sessionExpiry(t, env, "admin")
if diff := renewed.Sub(before); diff < time.Minute || diff > 3*time.Minute {
t.Fatalf("应把到期时间顺延约 2 分钟(到 now+24h),实际 %s → %s(差 %s)",
before.Format(time.DateTime), renewed.Format(time.DateTime), diff)
}
// 紧接着的多次请求都落在节流窗口内:expires_at 与 last_seen_at 都一动不动
// (改动前是每个请求写一次 last_seen_at,现在最多每分钟一次)。
seen := sessionLastSeen(t, env, "admin")
for i := 0; i < 5; i++ {
if status, _ := env.do(client, http.MethodGet, "/api/auth/me", nil, false); status != http.StatusOK {
t.Fatalf("第 %d 次请求应成功,得到 %d", i, status)
}
if got := sessionExpiry(t, env, "admin"); !got.Equal(renewed) {
t.Fatalf("节流窗口内不该再写 expires_at:%s → %s",
renewed.Format(time.DateTime), got.Format(time.DateTime))
}
if got := sessionLastSeen(t, env, "admin"); !got.Equal(seen) {
t.Fatalf("节流窗口内不该再写 last_seen_at:%s → %s",
seen.Format(time.DateTime), got.Format(time.DateTime))
}
}
}
// 改设置不动在线的人:旧会话的 expires_at 不变、请求照常成功,下一次使用时按新有效期延长。
func TestSessionHoursChangeKeepsOnlineSessions(t *testing.T) {
env := newTestEnv(t)
boss := adminClient(t, env)
if status, body := env.do(boss, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 1}, true); status != http.StatusOK {
t.Fatalf("先设成 1 小时应 200,得到 %d %+v", status, body.Error)
}
env.createUser("recorder", "recorder-pass", "recorder")
worker, _ := env.client()
env.login(worker, "recorder", "recorder-pass")
before := sessionExpiry(t, env, "recorder")
// 管理员把有效期改成 72 小时。
if status, body := env.do(boss, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 72}, true); status != http.StatusOK {
t.Fatalf("改成 72 小时应 200,得到 %d %+v", status, body.Error)
}
if after := sessionExpiry(t, env, "recorder"); !after.Equal(before) {
t.Fatalf("改设置不该动已登录会话的 expires_at:%s → %s",
before.Format(time.DateTime), after.Format(time.DateTime))
}
// 旧会话仍被认,而且拿到的是新的(更长的)到期时间。
status, body := env.do(worker, http.MethodGet, "/api/auth/me", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("旧会话不该被踢下线,得到 %d %+v", status, body.Error)
}
me := decodeData[map[string]any](t, body)
if me["role"] != "recorder" {
t.Fatalf("旧会话应仍是 recorder 身份,得到 %v", me["role"])
}
if me["sessionExpiresAt"] == nil {
t.Fatal("me 应回带当前会话的到期时间")
}
after := sessionExpiry(t, env, "recorder")
if diff := after.Sub(before); diff < 70*time.Hour {
t.Fatalf("下一次使用应按新有效期顺延到 ~72h,实际 %s → %s",
before.Format(time.DateTime), after.Format(time.DateTime))
}
}
// 到期校验不能被改坏:手工把 expires_at 改到 1 分钟前,写请求必须 401 AUTH_REQUIRED。
func TestExpiredSessionReturnsAuthRequired(t *testing.T) {
env := newTestEnv(t)
client := adminClient(t, env)
setSessionExpiry(t, env, "admin", time.Now().UTC().Add(-time.Minute))
status, body := env.do(client, http.MethodPut, "/api/settings",
map[string]any{"event": map[string]any{"name": "秋季运动会"}}, true)
if status != http.StatusUnauthorized || body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("过期会话写请求应 401 AUTH_REQUIRED,得到 %d %+v", status, body.Error)
}
}
// ---------- 安全口径没有被放宽 ----------
func TestSessionSettingsKeepsSecurityPosture(t *testing.T) {
env := newTestEnv(t)
env.createUser("admin", "admin-pass", "admin")
// 登录下发的 Cookie 仍带 HttpOnly + SameSite=Lax;CSRF Cookie 仍可被前端读取。
resp, err := http.Post(env.server.URL+"/api/auth/login", "application/json",
strings.NewReader(`{"username":"admin","password":"admin-pass"}`))
if err != nil {
t.Fatalf("登录: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var session, csrf *http.Cookie
for _, c := range resp.Cookies() {
switch c.Name {
case auth.CookieSession:
session = c
case auth.CookieCSRF:
csrf = c
}
}
if session == nil || !session.HttpOnly || session.SameSite != http.SameSiteLaxMode || session.Secure {
t.Fatalf("会话 Cookie 应 HttpOnly + SameSite=Lax:%+v", session)
}
if csrf == nil || csrf.HttpOnly {
t.Fatalf("CSRF Cookie 应可被前端读取(非 HttpOnly):%+v", csrf)
}
client, _ := env.client()
env.login(client, "admin", "admin-pass")
// CSRF 双提交仍然拦写:不带 X-CSRF-Token 的写请求一律 403。
if status, body := env.do(client, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 2}, false); status != http.StatusForbidden ||
body.Error == nil || body.Error.Code != "FORBIDDEN" {
t.Fatalf("缺 CSRF 头的写请求应 403 FORBIDDEN,得到 %d %+v", status, body.Error)
}
// must_change_password 仍然拦写操作。
env.createUser("fresh", "fresh-pass", "admin")
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE username = 'fresh'`); err != nil {
t.Fatalf("置 must_change_password: %v", err)
}
fresh, _ := env.client()
env.login(fresh, "fresh", "fresh-pass")
if status, body := env.do(fresh, http.MethodPut, "/api/system/session",
map[string]any{"sessionHours": 2}, true); status != http.StatusForbidden ||
body.Error == nil || body.Error.Message != "请先修改初始密码" {
t.Fatalf("待改密账号的写请求应被拦下,得到 %d %+v", status, body.Error)
}
// 未登录访客读不到登录有效期。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/system/session", nil, false); status != http.StatusForbidden {
t.Fatalf("访客读登录有效期应 403,得到 %d", status)
}
}
@@ -0,0 +1,149 @@
package httpapi
import (
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 分批结算的判据是纯函数,这里逐类钉住它的口径:
// 「所有批次都录完」= 该组所有 registered 报名项都有可发布的已发布成绩。
func TestGroupStatsSettlementJudgement(t *testing.T) {
value := 11.58
cases := []struct {
name string
entries []domain.Entry
results []domain.Result
wantTotal int
wantPublished int
}{
{
name: "全部发完",
entries: []domain.Entry{
{ID: 1, Status: domain.EntryStatusRegistered},
{ID: 2, Status: domain.EntryStatusRegistered},
},
results: []domain.Result{
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
{EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
},
wantTotal: 2,
wantPublished: 2,
},
{
name: "有人弃权(scratched 不该再等它)",
entries: []domain.Entry{
{ID: 1, Status: domain.EntryStatusRegistered},
{ID: 2, Status: domain.EntryStatusRegistered},
{ID: 3, Status: domain.EntryStatusScratched},
},
results: []domain.Result{
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
{EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
},
wantTotal: 2,
wantPublished: 2,
},
{
name: "有人失格(dq/dns 同样已有现场结论)",
entries: []domain.Entry{
{ID: 1, Status: domain.EntryStatusRegistered},
{ID: 2, Status: domain.EntryStatusRegistered},
{ID: 3, Status: domain.EntryStatusDNS},
},
results: []domain.Result{
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
{EntryID: 2, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
},
wantTotal: 2,
wantPublished: 2,
},
{
name: "有草稿但不该发布(还没跑 / 还没录)",
entries: []domain.Entry{
{ID: 1, Status: domain.EntryStatusRegistered},
{ID: 2, Status: domain.EntryStatusRegistered},
},
results: []domain.Result{
{EntryID: 1, Status: domain.ResultStatusOK, ResultValue: &value, State: domain.ResultStatePublished},
{EntryID: 2, Status: domain.ResultStatusOK, State: domain.ResultStateDraft},
},
wantTotal: 2,
wantPublished: 1,
},
{
name: "已发布但空且 ok 的行不算成绩",
entries: []domain.Entry{
{ID: 1, Status: domain.EntryStatusRegistered},
},
results: []domain.Result{
{EntryID: 1, Status: domain.ResultStatusOK, State: domain.ResultStatePublished},
},
wantTotal: 1,
wantPublished: 0,
},
{
name: "DNS 这类没有成绩值的现场结论可以发布",
entries: []domain.Entry{
{ID: 1, Status: domain.EntryStatusRegistered},
},
results: []domain.Result{
{EntryID: 1, Status: domain.ResultStatusDNS, State: domain.ResultStatePublished},
},
wantTotal: 1,
wantPublished: 1,
},
{
name: "整组弃权:没有要等的人",
entries: []domain.Entry{{ID: 1, Status: domain.EntryStatusScratched}},
results: nil,
wantTotal: 0,
wantPublished: 0,
},
{
name: "没有报名项",
entries: nil,
results: nil,
wantTotal: 0,
wantPublished: 0,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
total, published := groupStats(tc.entries, tc.results)
if total != tc.wantTotal || published != tc.wantPublished {
t.Fatalf("groupStats = (%d, %d),期望 (%d, %d)", total, published, tc.wantTotal, tc.wantPublished)
}
got := published == total
want := tc.wantPublished == tc.wantTotal
if got != want {
t.Fatalf("结算判据 = %v,期望 %v", got, want)
}
})
}
}
// groupOf 必须只留下该组别的行:成绩表不带组别,靠报名 id 反查时最容易把别组的成绩算进来。
func TestGroupOfFiltersByCategory(t *testing.T) {
entries := []domain.Entry{
{ID: 1, CategoryID: 7},
{ID: 2, CategoryID: 7},
{ID: 3, CategoryID: 8},
}
results := []domain.Result{
{ID: 11, EntryID: 1},
{ID: 12, EntryID: 2},
{ID: 13, EntryID: 3},
}
groupEntries, groupResults := groupOf(entries, results, 7)
if len(groupEntries) != 2 || len(groupResults) != 2 {
t.Fatalf("第 7 组应只剩 2 条报名 / 2 条成绩,得到 %d / %d", len(groupEntries), len(groupResults))
}
for _, r := range groupResults {
if r.EntryID == 3 {
t.Fatalf("别组的成绩不该被算进来: %+v", r)
}
}
}
+1 -1
View File
@@ -254,7 +254,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) {
assertKeys(t, "settings.display", asMap(t, settings["display"]),
[]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"})
[]string{"individualToTeamWeight", "rankingMode", "showIndividualBoard", "showIndividualMedals", "liveOnPublish"})
_, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false)
var rows []map[string]any