fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛) results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」, 团体手工通道把个人名次当团体名次又按团体表发一遍分。 - 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建) 与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键, 避免规则包重导时级联删掉录入数据) - results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回; 团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底, 个人报名项的名次绝不进团体通道 - writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked / realtime.loadRanked 三份同源 SQL 一起改读新表 - 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证) ## 奖牌结算开关与奖牌通道(P1(5),新功能) - events.award_medals + 通道 count_to_medals / count_to_team_medals (迁移默认 1,升级不改变既有榜单) - 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位) - 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算 - 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、 录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks) ## 身份口径改回证件号(P1(15),推翻第三十六轮) - 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard - 同表重复证件号 = 同一人报多个组别(notices 提示,不报错); 只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate) - 补齐后端缺失但前端/文档已在依赖的三处契约 ## 其余 P1 - 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径, 被限流的请求挂起并由心跳兜底补发,不丢刷新) - 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 / 积分模板 awardRanks → 400 或 404,不再 500 - 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、 报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价) - 配置改动触发重算:通道 / 小项参数 / 积分模板 - 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算 (服务对外监听之前完成,避免升级后奖牌榜全 0) - 示例数据模式下破坏性操作被阻断,不再假成功 - Console:修复「只剩一块屏」后屏幕监听器被永久摘掉 - 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常 ## 文档 - docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、 常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、 部署指南、上线检查清单 - 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步) 自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build; Console 35 例 + 语法检查。
This commit is contained in:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
@@ -3,6 +3,7 @@ package httpapi_test
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
@@ -50,6 +51,22 @@ func (e *testEnv) exec(t *testing.T, query string, args ...any) {
|
||||
}
|
||||
}
|
||||
|
||||
// entryBoard 是 GET /api/events/{id}/entries 的响应(设计 17 §七:成绩行 + 随行下发的团体名次)。
|
||||
type entryBoard struct {
|
||||
Entries []map[string]any `json:"entries"`
|
||||
TeamRanks []map[string]any `json:"teamRanks"`
|
||||
}
|
||||
|
||||
// getEntryBoard 取成绩录入接口的完整读数:报名项 + 成绩 + 该小项的团体名次视图。
|
||||
func getEntryBoard(t *testing.T, env *testEnv, client *http.Client, eventID int64) entryBoard {
|
||||
t.Helper()
|
||||
status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", eventID), nil, false)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("读成绩录入接口应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
return decodeData[entryBoard](t, resp)
|
||||
}
|
||||
|
||||
func (e *testEnv) insert(t *testing.T, query string, args ...any) int64 {
|
||||
t.Helper()
|
||||
res, err := e.ev.Exec(query, args...)
|
||||
|
||||
@@ -319,6 +319,51 @@ func TestUpdateCategoryMissingReturns404(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 整表替换是「新建 / 重排组别」的唯一入口,字段校验必须与就地更新同口径:
|
||||
// 非法 gender 不能再撞 CHECK 报 500,越界 / 颠倒的年龄也不能静默入库。
|
||||
func TestReplaceCategoriesValidatesFields(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
// 换一个还没有报名数据的小项:有报名时整表替换本来就按 409 拒绝。
|
||||
event := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport)
|
||||
path := fmt.Sprintf("/api/events/%d/categories", event)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
want string
|
||||
}{
|
||||
{"性别枚举", map[string]any{"name": "男子", "multiplier": 1, "gender": "M"}, "组别 gender 只能是 男 / 女"},
|
||||
{"年龄范围", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 500, "ageMax": -3}, "组别年龄需要在 0-120 之间"},
|
||||
{"年龄区间", map[string]any{"name": "男子", "multiplier": 1, "ageMin": 30, "ageMax": 10}, "组别年龄下限不能大于上限"},
|
||||
} {
|
||||
status, env2 := env.do(client, http.MethodPut, path,
|
||||
map[string]any{"categories": []map[string]any{tc.body}}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
|
||||
}
|
||||
if env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" || env2.Error.Message != tc.want {
|
||||
t.Fatalf("%s 文案应为 %q(VALIDATION_FAILED),得到 %+v", tc.name, tc.want, env2.Error)
|
||||
}
|
||||
// 拒绝时整表替换不能留下半截组别(DELETE 在同一事务里,必须一起回滚)。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM event_categories WHERE event_id = ?`, event); n != 0 {
|
||||
t.Fatalf("%s 被拒后不该留下组别,得到 %d 条", tc.name, n)
|
||||
}
|
||||
}
|
||||
|
||||
// 合法值(含留空 = 不限的性别)照常写入。
|
||||
status, env2 := env.do(client, http.MethodPut, path, map[string]any{"categories": []map[string]any{
|
||||
{"name": "男子", "multiplier": 1, "gender": "男", "ageMin": 18, "ageMax": 35},
|
||||
{"name": "女子", "multiplier": 1},
|
||||
}}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("合法组别应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM event_categories WHERE event_id = ?`, event); n != 2 {
|
||||
t.Fatalf("合法组别应写入 2 条,得到 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 老路径(整表替换)不能被改坏:有报名数据时仍然拒绝,但文案要说清是「不能增删」。
|
||||
func TestReplaceCategoriesStillRejectsWithEntries(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
@@ -59,28 +59,36 @@ type channelDTO struct {
|
||||
// CountToTeam 决定该通道产出的流水是否计入单位总分;请求体里缺省(null)按 true 处理,
|
||||
// 「个人+团体」项目的个人通道传 false。响应里始终是具体值。
|
||||
CountToTeam *bool `json:"countToTeam"`
|
||||
// CountToMedals 决定该通道产出的名次是否发奖牌;缺省(null)按 true 处理(与库中默认值一致)。
|
||||
CountToMedals *bool `json:"countToMedals"`
|
||||
// CountToTeamMedals 决定该通道的个人奖牌是否并入单位奖牌榜;缺省按 true 处理。
|
||||
// 只对 scope=athlete 的通道有意义:scope=team 的名次本来就记在单位头上。
|
||||
CountToTeamMedals *bool `json:"countToTeamMedals"`
|
||||
}
|
||||
|
||||
type eventDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
SportID int64 `json:"sportId"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
EventType string `json:"eventType"`
|
||||
ResultMode string `json:"resultMode"`
|
||||
ResultType string `json:"resultType"`
|
||||
Compare string `json:"compare"`
|
||||
RecordEligible bool `json:"recordEligible"`
|
||||
Scored bool `json:"scored"`
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
Status string `json:"status"`
|
||||
ScheduledAt *string `json:"scheduledAt"`
|
||||
Venue *string `json:"venue"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Remark *string `json:"remark"`
|
||||
AvgMode string `json:"avgMode"`
|
||||
Categories []categoryDTO `json:"categories,omitempty"`
|
||||
Channels []channelDTO `json:"channels,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
SportID int64 `json:"sportId"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
EventType string `json:"eventType"`
|
||||
ResultMode string `json:"resultMode"`
|
||||
ResultType string `json:"resultType"`
|
||||
Compare string `json:"compare"`
|
||||
RecordEligible bool `json:"recordEligible"`
|
||||
Scored bool `json:"scored"`
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
Status string `json:"status"`
|
||||
ScheduledAt *string `json:"scheduledAt"`
|
||||
Venue *string `json:"venue"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Remark *string `json:"remark"`
|
||||
AvgMode string `json:"avgMode"`
|
||||
// AwardMedals 决定该小项是否结算奖牌;请求体缺省按 true 处理(与库中默认值一致)。
|
||||
// 关闭后小项的名次不产生任何奖牌,积分不受影响。
|
||||
AwardMedals bool `json:"awardMedals"`
|
||||
Categories []categoryDTO `json:"categories,omitempty"`
|
||||
Channels []channelDTO `json:"channels,omitempty"`
|
||||
}
|
||||
|
||||
type profileRuleDTO struct {
|
||||
|
||||
@@ -269,50 +269,40 @@ func (s *server) batchEntries(ctx context.Context, action string, ids []int64) (
|
||||
}
|
||||
|
||||
// 按 (小项, 组别) 分组重算:跨小项混选是正常的「按人名勾选」场景,但重算必须逐个范围来。
|
||||
//
|
||||
// 退赛 / 恢复 / 移出都属回滚类动作,延迟结算(live_on_publish=false)下也必须当场重算:
|
||||
// - 它们改的不是「录了多少」,而是「谁还算数」,与撤销发布、作废成绩同一个性质,
|
||||
// 文档 02 §5.1 也承诺退赛会把 results.rank 显式清掉再按新名次重算,没有「延迟结算下除外」;
|
||||
// - 不重算就会留下「退赛者仍挂着名次与分数、同组别人的名次停在旧口径」,移出报名更糟:
|
||||
// 流水已经物理删掉、榜单数据被改,大屏却推不动,永远停在上一次的旧画面;
|
||||
// - 现场可能已经结算过一次(人工立即结算、改组别权重、规则导入都会在延迟模式下写流水)
|
||||
// 而整组还没录完,此时 scopeSettled 返回 false,只按「录完了没」判断就补不上这一刀。
|
||||
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
|
||||
// 退赛的成绩不再占名次:results.rank(个人名次)必须一并清掉,channel_ranks 由
|
||||
// writeLedgerTx 按当前已发布成绩整组重建,不清就会留下「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)
|
||||
}
|
||||
}
|
||||
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
|
||||
// 与发布 / 立即结算同一套:recomputeLedgerTx(算 + 作废旧流水 + 写新流水 + 重建 channel_ranks)
|
||||
// 之后再把个人名次写回 results.rank,别处改了重算口径这里要跟着改。
|
||||
score, err := recomputeLedgerTx(ctx, tx, scope.eventID, scope.categoryID, actorID, recomputeReason(action))
|
||||
if err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
// 只重建流水不够:成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去;
|
||||
// 奖牌榜与运动员榜看的是 channel_ranks,由 writeLedgerTx 一并重建。
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
if !slices.Contains(eventIDs, scope.eventID) {
|
||||
eventIDs = append(eventIDs, scope.eventID)
|
||||
@@ -324,8 +314,8 @@ func (s *server) batchEntries(ctx context.Context, action string, ids []int64) (
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
}
|
||||
// 延迟结算且没有一组录完时不推榜单:榜单该保持上一次结算的状态。
|
||||
if len(eventIDs) > 0 && settled {
|
||||
// 重算过就必须推榜单:上面刚把名次与流水改成当前口径,不推大屏就还是旧画面。
|
||||
if len(eventIDs) > 0 {
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return entryBatchResultDTO{}, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -409,3 +410,595 @@ func TestEntryBatchRemoveKeepsOtherResultsIntact(t *testing.T) {
|
||||
t.Fatalf("B 的名次应重算为 1 且保持发布态,得到 rank=%+v state=%s", rank, state)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 延迟结算 vs 实时结算:口径一致 ----------
|
||||
|
||||
// ranksOf 读每个报名项当前写回 results 的名次;成绩行不存在(被移出)按「无名次」处理。
|
||||
func ranksOf(t *testing.T, env *testEnv, entryIDs []int64) map[int64]int {
|
||||
t.Helper()
|
||||
out := make(map[int64]int, len(entryIDs))
|
||||
for _, id := range entryIDs {
|
||||
var rank sql.NullInt64
|
||||
err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("读名次: %v", err)
|
||||
}
|
||||
if rank.Valid {
|
||||
out[id] = int(rank.Int64)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ledgerRowsOf 把有效流水 dump 成可比文本;only 非空时只保留这些报名项的流水
|
||||
// (用于确认别的组别没有被牵连)。
|
||||
func ledgerRowsOf(t *testing.T, env *testEnv, eventID int64, only []int64) []string {
|
||||
t.Helper()
|
||||
var allowed map[int64]bool
|
||||
if only != nil {
|
||||
allowed = make(map[int64]bool, len(only))
|
||||
for _, id := range only {
|
||||
allowed[id] = true
|
||||
}
|
||||
}
|
||||
rows, err := env.ev.Query(
|
||||
`SELECT entry_id, team_id, source_type, points FROM score_ledger
|
||||
WHERE event_id = ? AND voided = 0 ORDER BY entry_id, source_type, points`, eventID)
|
||||
if err != nil {
|
||||
t.Fatalf("读流水: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entryID, teamID sql.NullInt64
|
||||
source string
|
||||
points float64
|
||||
)
|
||||
if err := rows.Scan(&entryID, &teamID, &source, &points); err != nil {
|
||||
t.Fatalf("扫描流水: %v", err)
|
||||
}
|
||||
if allowed != nil && !allowed[entryID.Int64] {
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%v/%v/%s/%v", entryID.Int64, teamID.Int64, source, points))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("遍历流水: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// batchSnapshot 是一次批量操作前后的可比状态:名次、有效流水、榜单原始字节。
|
||||
type batchSnapshot struct {
|
||||
ranks map[int64]int
|
||||
ledger []string
|
||||
standings string
|
||||
}
|
||||
|
||||
func readBatchSnapshot(t *testing.T, env *testEnv, client *http.Client, eventID int64, entryIDs []int64) batchSnapshot {
|
||||
t.Helper()
|
||||
return batchSnapshot{
|
||||
ranks: ranksOf(t, env, entryIDs),
|
||||
ledger: ledgerRowsOf(t, env, eventID, nil),
|
||||
standings: rawData(t, env, client, "/api/standings"),
|
||||
}
|
||||
}
|
||||
|
||||
// standingsPushes 数榜单推送次数:每条都是 action=replace 的整张榜单快照。
|
||||
func standingsPushes(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
return countRows(t, env,
|
||||
`SELECT COUNT(*) FROM change_log WHERE entity_type = 'standings' AND action = 'replace'`)
|
||||
}
|
||||
|
||||
// batchRun 是「同一串操作在某种结算模式下」的结果,两次跑之间只差 live_on_publish 一个开关。
|
||||
type batchRun struct {
|
||||
entries []int64 // 8 条报名项,顺序与 seedBatch 一致
|
||||
teamA, teamB int64
|
||||
before batchSnapshot
|
||||
after batchSnapshot
|
||||
pushesBefore int
|
||||
pushesAfter int
|
||||
}
|
||||
|
||||
// runScenario 用同一串操作跑一遍场景,act 是本次被考察的那个「回滚类」动作:
|
||||
// 8 人报名 → 只录完前 4 条并发布 →(withSettle 时)立即结算 → act。
|
||||
//
|
||||
// live 只控制 standings.live_on_publish 一个开关,其它输入完全一样,
|
||||
// 所以「实时 vs 延迟」两次跑出来的快照差异只可能来自结算模式。
|
||||
// withSettle 区分两种现场:已经人工结算过一次(延迟模式下的复现前置)与延迟窗口内一次都没结算过。
|
||||
func runScenario(t *testing.T, live, withSettle bool,
|
||||
act func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64)) batchRun {
|
||||
t.Helper()
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
entries := seedBatch(t, env, ids, 8)
|
||||
setLiveOnPublish(t, env, live)
|
||||
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
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 4, 0, times),
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
if withSettle {
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/settle", map[string]any{
|
||||
"eventId": ids.event, "categoryId": ids.category, "requestId": "settle-before-act",
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("立即结算应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
}
|
||||
|
||||
run := batchRun{
|
||||
entries: entries,
|
||||
teamA: ids.teamA,
|
||||
teamB: ids.teamB,
|
||||
before: readBatchSnapshot(t, env, client, ids.event, entries),
|
||||
pushesBefore: standingsPushes(t, env),
|
||||
}
|
||||
act(t, env, client, ids, entries)
|
||||
run.after = readBatchSnapshot(t, env, client, ids.event, entries)
|
||||
run.pushesAfter = standingsPushes(t, env)
|
||||
assertNoForeignKeyViolation(t, env)
|
||||
return run
|
||||
}
|
||||
|
||||
// runBatchScenario 跑一遍批量动作(scratch / restore / remove)。
|
||||
func runBatchScenario(t *testing.T, live bool, action string, target int, withSettle bool) batchRun {
|
||||
t.Helper()
|
||||
return runScenario(t, live, withSettle,
|
||||
func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64) {
|
||||
t.Helper()
|
||||
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": action, "ids": []int64{entries[target]}, "reason": "结算模式一致用例",
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("批量 %s 应 200,得到 %d %+v", action, status, e.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// voidResultOf 按报名项找到它的成绩行并调作废接口(前端同一条 POST /api/results/{id}/void)。
|
||||
func voidResultOf(t *testing.T, env *testEnv, client *http.Client, entryID int64) {
|
||||
t.Helper()
|
||||
var resultID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, entryID).Scan(&resultID); err != nil {
|
||||
t.Fatalf("读成绩 id: %v", err)
|
||||
}
|
||||
if status, e := env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/results/%d/void", resultID), map[string]any{"reason": "结算模式一致用例"}, true); status != http.StatusOK {
|
||||
t.Fatalf("作废成绩应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// runVoidScenario 跑一遍「作废单条成绩」,targets 是要作废的报名项(可为多条,用于「整组作废」边界)。
|
||||
func runVoidScenario(t *testing.T, live bool, withSettle bool, targets ...int) batchRun {
|
||||
t.Helper()
|
||||
return runScenario(t, live, withSettle,
|
||||
func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64) {
|
||||
t.Helper()
|
||||
for _, target := range targets {
|
||||
voidResultOf(t, env, client, entries[target])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// entryLedgerRow 拼一条流水的可比文本,期望值按报名项与单位 id 现算,不写死数字。
|
||||
func entryLedgerRow(entryID, teamID int64, points float64) string {
|
||||
return fmt.Sprintf("%d/%d/result/%v", entryID, teamID, points)
|
||||
}
|
||||
|
||||
// assertSameBefore 断言批量动作发生之前,两种结算模式手里的数据已经一致。
|
||||
func assertSameBefore(t *testing.T, label string, live, deferred batchRun) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(live.before.ranks, deferred.before.ranks) {
|
||||
t.Fatalf("%s:操作前的名次就不一致:实时 %v vs 延迟 %v", label, live.before.ranks, deferred.before.ranks)
|
||||
}
|
||||
if !reflect.DeepEqual(live.before.ledger, deferred.before.ledger) {
|
||||
t.Fatalf("%s:操作前的流水就不一致:实时 %v vs 延迟 %v", label, live.before.ledger, deferred.before.ledger)
|
||||
}
|
||||
if live.before.standings != deferred.before.standings {
|
||||
t.Fatalf("%s:操作前的榜单不一致:\n实时 %s\n延迟 %s", label, live.before.standings, deferred.before.standings)
|
||||
}
|
||||
}
|
||||
|
||||
// assertSameAfter 断言两种结算模式得到的是同一份数据:名次、有效流水、榜单原始字节。
|
||||
// 这是「同一份数据不管实时结算还是延迟结算,最终榜单必须一模一样」的落点。
|
||||
func assertSameAfter(t *testing.T, label string, live, deferred batchRun) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(live.after.ranks, deferred.after.ranks) {
|
||||
t.Fatalf("%s:名次不一致:实时 %v vs 延迟 %v", label, live.after.ranks, deferred.after.ranks)
|
||||
}
|
||||
if !reflect.DeepEqual(live.after.ledger, deferred.after.ledger) {
|
||||
t.Fatalf("%s:流水不一致:实时 %v vs 延迟 %v", label, live.after.ledger, deferred.after.ledger)
|
||||
}
|
||||
if live.after.standings != deferred.after.standings {
|
||||
t.Fatalf("%s:榜单不一致:\n实时 %s\n延迟 %s", label, live.after.standings, deferred.after.standings)
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟结算 + 批量退赛:退赛的人必须当场丢掉名次与分数、同组别人按新名次重算,
|
||||
// 结果与实时结算逐字节一致(报告模块7 [1])。
|
||||
// 两种现场都跑:已经人工结算过一次(报告里的复现前置),以及延迟窗口内一次都没结算过。
|
||||
func TestDeferredBatchScratchMatchesRealTime(t *testing.T) {
|
||||
check := func(t *testing.T, live, deferred batchRun, compareBefore bool) {
|
||||
t.Helper()
|
||||
if compareBefore {
|
||||
assertSameBefore(t, "批量退赛", live, deferred)
|
||||
}
|
||||
assertSameAfter(t, "批量退赛", live, deferred)
|
||||
|
||||
// 前置:前四名 10/8/6/3 已经在实时结算那侧落了账。
|
||||
if len(live.before.ledger) != 4 {
|
||||
t.Fatalf("前置:应已结算 4 条流水,得到 %v", live.before.ledger)
|
||||
}
|
||||
// 退赛的第 1 名:名次被清、流水被作废;剩下 3 人顺推成第 1/2/3 名 10/8/6 分。
|
||||
after := deferred.after
|
||||
if _, ok := after.ranks[live.entries[0]]; ok {
|
||||
t.Fatalf("退赛者不应再占名次,得到 %v", after.ranks)
|
||||
}
|
||||
wantRanks := map[int64]int{
|
||||
live.entries[1]: 1, live.entries[2]: 2, live.entries[3]: 3,
|
||||
}
|
||||
for id, rank := range wantRanks {
|
||||
if after.ranks[id] != rank {
|
||||
t.Fatalf("报名项 %d 应第 %d 名,得到 %v", id, rank, after.ranks)
|
||||
}
|
||||
}
|
||||
wantLedger := []string{
|
||||
entryLedgerRow(live.entries[1], live.teamB, 10),
|
||||
entryLedgerRow(live.entries[2], live.teamA, 8),
|
||||
entryLedgerRow(live.entries[3], live.teamB, 6),
|
||||
}
|
||||
if !reflect.DeepEqual(after.ledger, wantLedger) {
|
||||
t.Fatalf("退赛后流水应重算成 %v,得到 %v", wantLedger, after.ledger)
|
||||
}
|
||||
// 延迟结算下这个动作也必须推榜单:否则大屏还挂着「退赛者仍拿金牌」的旧画面。
|
||||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||||
t.Fatalf("退赛应推 1 次榜单(standings replace),得到 %d", got)
|
||||
}
|
||||
if got := live.pushesAfter - live.pushesBefore; got != 1 {
|
||||
t.Fatalf("实时结算下退赛也应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("现场已人工结算过一次", func(t *testing.T) {
|
||||
check(t,
|
||||
runBatchScenario(t, true, "scratch", 0, true),
|
||||
runBatchScenario(t, false, "scratch", 0, true), true)
|
||||
})
|
||||
t.Run("延迟窗口内一次都没结算过", func(t *testing.T) {
|
||||
// 这一档下延迟侧操作前本就还没有流水/名次,只比操作之后的最终数据。
|
||||
check(t,
|
||||
runBatchScenario(t, true, "scratch", 0, false),
|
||||
runBatchScenario(t, false, "scratch", 0, false), false)
|
||||
})
|
||||
}
|
||||
|
||||
// 延迟结算 + 批量移出报名:流水被正确作废/重建、名次更新、榜单被推送,
|
||||
// 且与实时结算逐字节一致(报告模块7 [10])。
|
||||
func TestDeferredBatchRemoveMatchesRealTime(t *testing.T) {
|
||||
check := func(t *testing.T, live, deferred batchRun, compareBefore bool) {
|
||||
t.Helper()
|
||||
if compareBefore {
|
||||
assertSameBefore(t, "批量移出报名", live, deferred)
|
||||
}
|
||||
assertSameAfter(t, "批量移出报名", live, deferred)
|
||||
|
||||
after := deferred.after
|
||||
// 被移出的人:报名项与成绩都没了(ranks 里没有它),流水一条不剩。
|
||||
if _, ok := after.ranks[live.entries[1]]; ok {
|
||||
t.Fatalf("被移出的报名项不该还有名次,得到 %v", after.ranks)
|
||||
}
|
||||
wantRanks := map[int64]int{
|
||||
live.entries[0]: 1, live.entries[2]: 2, live.entries[3]: 3,
|
||||
}
|
||||
for id, rank := range wantRanks {
|
||||
if after.ranks[id] != rank {
|
||||
t.Fatalf("报名项 %d 应第 %d 名,得到 %v", id, rank, after.ranks)
|
||||
}
|
||||
}
|
||||
// 原来 10/8/6/3:移出第 2 名后剩下 10/8/6(第 3、4 名各升一位)。
|
||||
wantLedger := []string{
|
||||
entryLedgerRow(live.entries[0], live.teamA, 10),
|
||||
entryLedgerRow(live.entries[2], live.teamA, 8),
|
||||
entryLedgerRow(live.entries[3], live.teamB, 6),
|
||||
}
|
||||
if !reflect.DeepEqual(after.ledger, wantLedger) {
|
||||
t.Fatalf("移出后流水应重建为 %v,得到 %v", wantLedger, after.ledger)
|
||||
}
|
||||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||||
t.Fatalf("移出报名应推 1 次榜单(standings replace),得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("现场已人工结算过一次", func(t *testing.T) {
|
||||
check(t,
|
||||
runBatchScenario(t, true, "remove", 1, true),
|
||||
runBatchScenario(t, false, "remove", 1, true), true)
|
||||
})
|
||||
t.Run("延迟窗口内一次都没结算过", func(t *testing.T) {
|
||||
check(t,
|
||||
runBatchScenario(t, true, "remove", 1, false),
|
||||
runBatchScenario(t, false, "remove", 1, false), false)
|
||||
})
|
||||
}
|
||||
|
||||
// 延迟结算 + 作废单条成绩:作废的人当场退出名次与流水、同组别人按新名次重算、榜单被推送,
|
||||
// 结果与实时结算逐字节一致(报告模块7 [2])。
|
||||
// 作废与撤销发布、批量退赛/移出同属回滚动作,改的是「谁还算数」,不受延迟结算约束。
|
||||
func TestDeferredVoidMatchesRealTime(t *testing.T) {
|
||||
check := func(t *testing.T, live, deferred batchRun, compareBefore bool, wantLedger []string, wantRanks map[int64]int) {
|
||||
t.Helper()
|
||||
if compareBefore {
|
||||
assertSameBefore(t, "作废成绩", live, deferred)
|
||||
}
|
||||
assertSameAfter(t, "作废成绩", live, deferred)
|
||||
|
||||
after := deferred.after
|
||||
if !reflect.DeepEqual(after.ledger, wantLedger) {
|
||||
t.Fatalf("作废后流水应为 %v,得到 %v", wantLedger, after.ledger)
|
||||
}
|
||||
if !reflect.DeepEqual(after.ranks, wantRanks) {
|
||||
t.Fatalf("作废后名次应为 %v,得到 %v", wantRanks, after.ranks)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("作废的是最后一名", func(t *testing.T) {
|
||||
// 前四名 10/8/6/3,作废第 4 名:别人名次不动,只剩前三名的 10/8/6,总分 27 → 24。
|
||||
for _, withSettle := range []bool{true, false} {
|
||||
live := runVoidScenario(t, true, withSettle, 3)
|
||||
deferred := runVoidScenario(t, false, withSettle, 3)
|
||||
// 期望值里的 entry/team id 按现跑的 env 现算,不写死数字。
|
||||
check(t, live, deferred, withSettle,
|
||||
[]string{
|
||||
entryLedgerRow(live.entries[0], live.teamA, 10),
|
||||
entryLedgerRow(live.entries[1], live.teamB, 8),
|
||||
entryLedgerRow(live.entries[2], live.teamA, 6),
|
||||
},
|
||||
map[int64]int{live.entries[0]: 1, live.entries[1]: 2, live.entries[2]: 3})
|
||||
// 被作废的人不再占名次(与批量退赛同口径:作废行也让出 results.rank)。
|
||||
if _, ok := deferred.after.ranks[live.entries[3]]; ok {
|
||||
t.Fatalf("被作废的成绩不该再占名次,得到 %v", deferred.after.ranks)
|
||||
}
|
||||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||||
t.Fatalf("作废成绩应推 1 次榜单(standings replace),得到 %d", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("作废后整组只剩无效成绩", func(t *testing.T) {
|
||||
// 四条已发布成绩全部作废:整组没有可排名的成绩,流水清空、名次全空、榜单归零。
|
||||
for _, withSettle := range []bool{true, false} {
|
||||
live := runVoidScenario(t, true, withSettle, 0, 1, 2, 3)
|
||||
deferred := runVoidScenario(t, false, withSettle, 0, 1, 2, 3)
|
||||
check(t, live, deferred, withSettle, []string{}, map[int64]int{})
|
||||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 4 {
|
||||
t.Fatalf("每次作废都应推榜单,4 次应推 4 次,得到 %d", got)
|
||||
}
|
||||
if deferred.pushesAfter-deferred.pushesBefore != live.pushesAfter-live.pushesBefore {
|
||||
t.Fatalf("实时与延迟的推榜次数应一致:%d vs %d",
|
||||
live.pushesAfter-live.pushesBefore, deferred.pushesAfter-deferred.pushesBefore)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 作废立刻重算 ≠ 提前结算:延迟窗口内作废一条成绩后,这一组仍是「未结算」,
|
||||
// 录入页照旧提示「榜单还没反映这批」,其它批次照样等录完才结算。
|
||||
// 这条钉住 scoring.go 里那句「作废不写 state,不会把未录完的组提前变成已结算」。
|
||||
func TestDeferredVoidKeepsGroupUnsettled(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)
|
||||
// 发布 4/8 且没结算:这一组在延迟窗口里,流水为空。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 0, ids.event)
|
||||
|
||||
voidResultOf(t, env, client, entries[0])
|
||||
|
||||
// 作废当场生效:剩下 3 条已发布成绩算出 10/8/6,名次写回,榜单被推。
|
||||
assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 3, ids.event)
|
||||
if got := rankOfEntry(t, env, entries[1]); got != 1 {
|
||||
t.Fatalf("作废冠军后第 2 名应升到第 1 名,得到 %d", got)
|
||||
}
|
||||
if got := standingsTotal(t, env, client); got != 24 {
|
||||
t.Fatalf("作废后榜单总分应为 10+8+6=24,得到 %v", got)
|
||||
}
|
||||
// 但这一组还没录完(8 人里只录出 4 条,其中 1 条作废),仍应留在未结算清单里。
|
||||
pending := readSettlement(t, env, client, ids.event, ids.category)
|
||||
if len(pending.Groups) != 1 || pending.Groups[0].Required != 8 || pending.Groups[0].Published != 4 {
|
||||
t.Fatalf("作废不该把未录完的组变成已结算,得到 %+v", pending.Groups)
|
||||
}
|
||||
}
|
||||
|
||||
// 反例保护:批量动作只重算被勾选报名项所在的 (小项, 组别),
|
||||
// 同小项另一个组别的名次与流水必须原样不动。
|
||||
func TestDeferredBatchScratchKeepsOtherCategoriesIntact(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
setLiveOnPublish(t, env, false)
|
||||
|
||||
// 同小项的第二个组别:自己一条报名 + 一条已发布成绩。
|
||||
category2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
entryC := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, ?, 'registered')`, ids.event, category2, ids.teamA, ids.athleteA)
|
||||
publishResults(t, env, client, ids.event, "cat2",
|
||||
map[string]any{"entryId": entryC, "resultValue": 13.20, "resultText": "13.20", "unit": "s", "status": "ok"},
|
||||
)
|
||||
if status, e := env.do(client, http.MethodPost, "/api/results/settle", map[string]any{
|
||||
"eventId": ids.event, "categoryId": category2, "requestId": "settle-cat2",
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("结算组别二应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
// 组别一:录一条已发布成绩,但不结算(延迟窗口内)。
|
||||
publishResults(t, env, client, ids.event, "cat1",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 12.00, "resultText": "12.00", "unit": "s", "status": "ok"},
|
||||
)
|
||||
|
||||
ranksBefore := ranksOf(t, env, []int64{entryC})
|
||||
ledgerBefore := ledgerRowsOf(t, env, ids.event, []int64{entryC})
|
||||
if ranksBefore[entryC] != 1 || !reflect.DeepEqual(ledgerBefore, []string{entryLedgerRow(entryC, ids.teamA, 10)}) {
|
||||
t.Fatalf("前置:组别二应是第 1 名 10 分,得到 ranks=%v ledger=%v", ranksBefore, ledgerBefore)
|
||||
}
|
||||
standingsBefore := rawData(t, env, client, "/api/standings")
|
||||
|
||||
if status, e := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "scratch", "ids": []int64{ids.entryA},
|
||||
}, true); status != http.StatusOK || !e.OK {
|
||||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, e.Error)
|
||||
}
|
||||
|
||||
if got := ranksOf(t, env, []int64{entryC}); !reflect.DeepEqual(got, ranksBefore) {
|
||||
t.Fatalf("别的组别的名次不该被改动:%v → %v", ranksBefore, got)
|
||||
}
|
||||
if got := ledgerRowsOf(t, env, ids.event, []int64{entryC}); !reflect.DeepEqual(got, ledgerBefore) {
|
||||
t.Fatalf("别的组别的流水不该被改动:%v → %v", ledgerBefore, got)
|
||||
}
|
||||
if got := entryStatus(t, env, entryC); got != "registered" {
|
||||
t.Fatalf("别的组别的报名状态不该被改动,得到 %s", got)
|
||||
}
|
||||
// 组别一的成绩还没结算、退赛后更不该有分,所以整张榜单的内容一格都不该动。
|
||||
if got := rawData(t, env, client, "/api/standings"); got != standingsBefore {
|
||||
t.Fatalf("别的组别没被牵连时榜单内容不该变:\n之前 %s\n之后 %s", standingsBefore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 破纪录加分跟随状态 ----------
|
||||
|
||||
// 打开破纪录加分并配一档「赛会纪录 +2 分」,用于端到端验证加分随状态出现 / 消失(P1-6)。
|
||||
func enableRecordBonus(t *testing.T, env *testEnv) {
|
||||
t.Helper()
|
||||
env.exec(t, `INSERT INTO settings (key, value) VALUES ('record_bonus.enabled', 'true')
|
||||
ON CONFLICT(key) DO UPDATE SET value = 'true'`)
|
||||
env.exec(t, `INSERT INTO settings (key, value) VALUES ('record_bonus.levels',
|
||||
'[{"code":"meet","name":"赛会纪录","points":2}]')
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
|
||||
}
|
||||
|
||||
// activeRecordBonusPoints 取某报名项名下的有效破纪录加分(无则 0)。
|
||||
func activeRecordBonusPoints(t *testing.T, env *testEnv, entryID int64) float64 {
|
||||
t.Helper()
|
||||
var points sql.NullFloat64
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT SUM(points) FROM score_ledger
|
||||
WHERE entry_id = ? AND source_type = 'record_bonus' AND voided = 0`, entryID).Scan(&points); err != nil {
|
||||
t.Fatalf("统计破纪录加分: %v", err)
|
||||
}
|
||||
return points.Float64
|
||||
}
|
||||
|
||||
// 退赛 / 恢复 / 作废成绩都不能让加分留在榜上:加分与名次分同生同灭(P1-6)。
|
||||
func TestRecordBonusFollowsStatusEndToEnd(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
enableRecordBonus(t, env)
|
||||
publishResults(t, env, client, ids.event, "bonus",
|
||||
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"},
|
||||
)
|
||||
|
||||
// 登记并确认「赛会纪录」:确认会当场重算,加分流水随之出现。
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
|
||||
"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
|
||||
"resultValue": 11.58, "resultText": "11.58",
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("登记破纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
breakID := int64(decodeData[map[string]any](t, env2)["id"].(float64))
|
||||
if status, env2 = env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/record-breaks/%d/approve", breakID), nil, true); status != http.StatusOK {
|
||||
t.Fatalf("确认破纪录应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||||
t.Fatalf("确认后应记 2 分加分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
|
||||
t.Fatalf("确认后单位总分应是 10+2,得到 %v", got)
|
||||
}
|
||||
|
||||
// 退赛 → 加分与名次一起消失。
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "scratch", "ids": []int64{ids.entryA}, "reason": "破纪录加分口径用例",
|
||||
}, true); status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
|
||||
t.Fatalf("退赛后不该再有破纪录加分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||||
t.Fatalf("退赛后该单位总分应归零,得到 %v", got)
|
||||
}
|
||||
|
||||
// 恢复 → 同一个登记把加分还回来(登记是现场事实,不能被一次性作废)。
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||||
"action": "restore", "ids": []int64{ids.entryA},
|
||||
}, true); status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("批量恢复应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||||
t.Fatalf("恢复后应把加分还回来,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
|
||||
t.Fatalf("恢复后单位总分应回到 12,得到 %v", got)
|
||||
}
|
||||
|
||||
// 作废成绩(results.status = withdrawn)→ 加分同样消失。
|
||||
var resultID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&resultID); err != nil {
|
||||
t.Fatalf("读成绩 id: %v", err)
|
||||
}
|
||||
if status, env2 := env.do(client, http.MethodPost,
|
||||
fmt.Sprintf("/api/results/%d/void", resultID), map[string]any{"reason": "破纪录加分口径用例"}, true); status != http.StatusOK {
|
||||
t.Fatalf("作废成绩应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 0 {
|
||||
t.Fatalf("成绩作废后不该再有破纪录加分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||||
t.Fatalf("成绩作废后该单位总分应归零,得到 %v", got)
|
||||
}
|
||||
|
||||
// 把成绩改回有效再发布 → 加分回来(与名次同一判据)。
|
||||
env.exec(t, `UPDATE results SET status = 'ok' WHERE id = ?`, resultID)
|
||||
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event, "requestId": "bonus-republish",
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("重新发布应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if got := activeRecordBonusPoints(t, env, ids.entryA); got != 2 {
|
||||
t.Fatalf("成绩改回有效后应把加分还回来,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 12 {
|
||||
t.Fatalf("成绩改回有效后单位总分应回到 12,得到 %v", got)
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func TestSetEntriesHeatSetsAndClears(t *testing.T) {
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读报名列表应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
items := decodeData[[]map[string]any](t, env2)
|
||||
items := getEntryBoard(t, env, client, ids.event).Entries
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("报名列表应有 2 条,得到 %d", len(items))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
@@ -1133,3 +1134,239 @@ func TestBaseDataImportBlankTextAndWeightKeepTheirRules(t *testing.T) {
|
||||
t.Errorf("权重留空应取 1,得到 %v", multiplier)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 枚举列的三态口径:整格留空 = 不改动,显式填值 = 覆盖 ----------
|
||||
//
|
||||
// 这 7 列以前把「整格留空」当成「取系统默认」:类型 team→individual 会改掉计分语义、
|
||||
// 平均口径 all→valid 会改掉团体均分、状态 published→pending 会把已发布小项打回「待录入」,
|
||||
// 而三处用户文档列举「留空怎么处理」时只写了文本 / 年龄 / 布尔 / 权重 / 排序五类,
|
||||
// 读起来像穷举。现在枚举列与「列缺席」同款:留空一律不动,要改就显式填值。
|
||||
|
||||
// baseDataEnumColumn 是一个枚举列:表格里的表头 + 读库里现值的取数函数。
|
||||
type baseDataEnumColumn struct {
|
||||
sheet string
|
||||
header string
|
||||
read func(t *testing.T, env *testEnv, ids seedIDs) string
|
||||
}
|
||||
|
||||
func baseDataEnumColumns() []baseDataEnumColumn {
|
||||
eventCol := func(col string) func(t *testing.T, env *testEnv, ids seedIDs) string {
|
||||
return func(t *testing.T, env *testEnv, ids seedIDs) string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := env.ev.QueryRow(`SELECT `+col+` FROM events WHERE id = ?`, ids.event).Scan(&v); err != nil {
|
||||
t.Fatalf("读 events.%s: %v", col, err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
return []baseDataEnumColumn{
|
||||
{"小项", "类型", eventCol("event_type")},
|
||||
{"小项", "结果模式", eventCol("result_mode")},
|
||||
{"小项", "结果类型", eventCol("result_type")},
|
||||
{"小项", "比较", eventCol("compare")},
|
||||
{"小项", "状态", eventCol("status")},
|
||||
{"小项", "平均口径", eventCol("avg_mode")},
|
||||
{"组别", "性别", func(t *testing.T, env *testEnv, ids seedIDs) string {
|
||||
t.Helper()
|
||||
var v string
|
||||
if err := env.ev.QueryRow(`SELECT COALESCE(gender, '') FROM event_categories WHERE id = ?`,
|
||||
ids.category).Scan(&v); err != nil {
|
||||
t.Fatalf("读 event_categories.gender: %v", err)
|
||||
}
|
||||
return v
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// seedNonDefaultEnumValues 把 7 个枚举列都改成非默认值。
|
||||
// 原值等于默认值时,「留空 = 不改」与「留空 = 取默认」在库上看不出区别,用例会假绿。
|
||||
func seedNonDefaultEnumValues(t *testing.T, env *testEnv, ids seedIDs) {
|
||||
t.Helper()
|
||||
env.exec(t, `UPDATE events SET event_type = 'team', result_mode = 'manual', result_type = 'time',
|
||||
compare = 'desc', status = 'published', avg_mode = 'all' WHERE id = ?`, ids.event)
|
||||
env.exec(t, `UPDATE event_categories SET gender = '男' WHERE id = ?`, ids.category)
|
||||
}
|
||||
|
||||
// baseDataNonDefaultEnums 是 seedNonDefaultEnumValues 之后的原值,按表头索引。
|
||||
func baseDataNonDefaultEnums() map[string]string {
|
||||
return map[string]string{
|
||||
"类型": "team", "结果模式": "manual", "结果类型": "time", "比较": "desc",
|
||||
"状态": "published", "平均口径": "all", "性别": "男",
|
||||
}
|
||||
}
|
||||
|
||||
// 留空 = 不改动:整格清空既不取系统默认、也不清空原值。
|
||||
func TestBaseDataImportBlankEnumColumnsKeepExisting(t *testing.T) {
|
||||
origin := baseDataNonDefaultEnums()
|
||||
for _, col := range baseDataEnumColumns() {
|
||||
t.Run(col.sheet+"/"+col.header, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedNonDefaultEnumValues(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
rewriteColumn(t, f, col.sheet, col.header, "")
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.ErrorCount != 0 {
|
||||
t.Fatalf("留空不该报错,得到 %v", res.Errors)
|
||||
}
|
||||
if res.Added != 0 || res.Updated != 0 {
|
||||
t.Fatalf("「%s」留空 = 不改动,不该产生差异:新增 %d / 更新 %d %+v",
|
||||
col.header, res.Added, res.Updated, res.Changes)
|
||||
}
|
||||
assertNoFieldDiff(t, res, col.header)
|
||||
|
||||
// 落地一次也一样:预览与写入的口径必须同源。
|
||||
res = importPackage(t, env, client, pack, nil)
|
||||
if res.Added != 0 || res.Updated != 0 {
|
||||
t.Fatalf("「%s」留空落地后仍不该有改动:新增 %d / 更新 %d", col.header, res.Added, res.Updated)
|
||||
}
|
||||
if got := col.read(t, env, ids); got != origin[col.header] {
|
||||
t.Fatalf("「%s」留空后库里应保持原值 %q,得到 %q", col.header, origin[col.header], got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 显式合法值 = 覆盖:预览报出「原值→新值」,落地后库里就是新值。
|
||||
func TestBaseDataImportExplicitEnumValuesAreWritten(t *testing.T) {
|
||||
origin := baseDataNonDefaultEnums()
|
||||
explicit := map[string]string{
|
||||
"类型": "individual", "结果模式": "auto", "结果类型": "distance", "比较": "asc",
|
||||
"状态": "pending", "平均口径": "valid", "性别": "女",
|
||||
}
|
||||
for _, col := range baseDataEnumColumns() {
|
||||
t.Run(col.sheet+"/"+col.header, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedNonDefaultEnumValues(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
want := explicit[col.header]
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
rewriteColumn(t, f, col.sheet, col.header, want)
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.ErrorCount != 0 || res.Updated != 1 {
|
||||
t.Fatalf("显式填 %q 应只更新 1 条,得到 更新 %d / 错误 %d %v",
|
||||
want, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
found := false
|
||||
for _, ch := range res.Changes {
|
||||
for _, fld := range ch.Fields {
|
||||
if fld.Field != col.header {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if fld.From != origin[col.header] || fld.To != want {
|
||||
t.Fatalf("「%s」差异应为 %s→%s,得到 %s→%s",
|
||||
col.header, origin[col.header], want, fld.From, fld.To)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("显式填 %q 应产生「%s」差异,得到 %+v", want, col.header, res.Changes)
|
||||
}
|
||||
|
||||
if res = importPackage(t, env, client, pack, nil); res.Updated != 1 {
|
||||
t.Fatalf("落地后应更新 1 条,得到 %d", res.Updated)
|
||||
}
|
||||
if got := col.read(t, env, ids); got != want {
|
||||
t.Fatalf("「%s」显式填 %q 后库里应为 %q,得到 %q", col.header, want, want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 显式非法值 = 行级错误:预览与落地都不写库(这一行被跳过),错误里带表名与行号。
|
||||
func TestBaseDataImportInvalidEnumValuesAreRowErrors(t *testing.T) {
|
||||
origin := baseDataNonDefaultEnums()
|
||||
illegal := map[string]string{
|
||||
"类型": "individualx", "结果模式": "autox", "结果类型": "timex", "比较": "ascx",
|
||||
"状态": "publishedx", "平均口径": "validx", "性别": "其他",
|
||||
}
|
||||
for _, col := range baseDataEnumColumns() {
|
||||
t.Run(col.sheet+"/"+col.header, func(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
seedNonDefaultEnumValues(t, env, ids)
|
||||
client := baseDataAdminClient(t, env)
|
||||
bad := illegal[col.header]
|
||||
|
||||
f := exportBaseDataWorkbook(t, env, client)
|
||||
rewriteColumn(t, f, col.sheet, col.header, bad)
|
||||
pack := writeWorkbook(t, f)
|
||||
|
||||
res := importPackage(t, env, client, pack, map[string]string{"dryRun": "1"})
|
||||
if res.ErrorCount != 1 || res.Added != 0 || res.Updated != 0 {
|
||||
t.Fatalf("非法枚举值应只报 1 条行级错误、其余不改,得到 新增 %d / 更新 %d / 错误 %d %v",
|
||||
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if !strings.Contains(res.Errors[0], col.sheet) || !strings.Contains(res.Errors[0], "行") {
|
||||
t.Fatalf("行级错误应带表名与行号,得到 %q", res.Errors[0])
|
||||
}
|
||||
|
||||
res = importPackage(t, env, client, pack, nil)
|
||||
if res.ErrorCount != 1 || res.Updated != 0 {
|
||||
t.Fatalf("落地时这一行同样要被跳过,得到 更新 %d / 错误 %d %v",
|
||||
res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
if got := col.read(t, env, ids); got != origin[col.header] {
|
||||
t.Fatalf("非法值不该落库,「%s」应保持 %q,得到 %q", col.header, origin[col.header], got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 反向:留空 = 不改动只对「库里已有的行」成立。新建行没有现值可沿用,
|
||||
// 留空仍然落建表默认值(类型 individual、结果模式 auto、结果类型 none、比较 none、
|
||||
// 状态 pending、平均口径 valid、性别 NULL),绝不能写成空串去撞 CHECK。
|
||||
func TestBaseDataImportNewRowBlankEnumsUseSchemaDefaults(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
client := baseDataAdminClient(t, env)
|
||||
|
||||
pack := buildTestXLSX(t, []testSheet{
|
||||
{name: "小项", rows: [][]string{
|
||||
{"大项编码", "小项编码", "小项名称", "类型", "结果模式", "结果类型", "比较", "状态", "平均口径"},
|
||||
{"TRACK", "M300", "300米跑"},
|
||||
}},
|
||||
{name: "组别", rows: [][]string{
|
||||
{"小项编码", "组别名", "性别", "年龄下限", "年龄上限"},
|
||||
{"M300", "男子", "", "", ""},
|
||||
}},
|
||||
})
|
||||
res := importPackage(t, env, client, pack, nil)
|
||||
if res.Added != 2 || res.Updated != 0 || res.ErrorCount != 0 {
|
||||
t.Fatalf("应新增 2 条,得到 新增 %d / 更新 %d / 错误 %d %v",
|
||||
res.Added, res.Updated, res.ErrorCount, res.Errors)
|
||||
}
|
||||
|
||||
var (
|
||||
eventType, resultMode, resultType, cmpare, status, avgMode string
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT event_type, result_mode, result_type, compare, status, avg_mode
|
||||
FROM events WHERE code = 'M300'`).
|
||||
Scan(&eventType, &resultMode, &resultType, &cmpare, &status, &avgMode); err != nil {
|
||||
t.Fatalf("查询新建小项: %v", err)
|
||||
}
|
||||
if eventType != "individual" || resultMode != "auto" || resultType != "none" ||
|
||||
cmpare != "none" || status != "pending" || avgMode != "valid" {
|
||||
t.Fatalf("新建小项留空的枚举列应落建表默认值,得到 %s/%s/%s/%s/%s/%s",
|
||||
eventType, resultMode, resultType, cmpare, status, avgMode)
|
||||
}
|
||||
|
||||
var gender sql.NullString
|
||||
if err := env.ev.QueryRow(`SELECT c.gender FROM event_categories c JOIN events e ON e.id = c.event_id
|
||||
WHERE e.code = 'M300' AND c.name = '男子'`).Scan(&gender); err != nil {
|
||||
t.Fatalf("查询新建组别: %v", err)
|
||||
}
|
||||
if gender.Valid {
|
||||
t.Fatalf("新建组别留空的性别应是 NULL(不限),得到 %q", gender.String)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,12 @@ func (s *server) exportEventXLSX(w http.ResponseWriter, r *http.Request) (any, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 先确认小项存在:LoadEventWorkbook 对小项不存在只回一句裸错误串(没有哨兵错误),
|
||||
// 落到统一错误处理里就是 500 + 原始文本,而导出接口承诺「资源不存在 → 404」。
|
||||
// 这里复用读接口的同一句话,现场看到的与 GET /api/events/{id} 一致。
|
||||
if _, err := eventByIDQ(r.Context(), s.ev, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wb, err := export.LoadEventWorkbook(s.ev, id, export.Options{})
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -169,6 +175,11 @@ func (s *server) exportTeamXLSX(w http.ResponseWriter, r *http.Request) (any, er
|
||||
}
|
||||
wb, err := export.LoadTeamDetailWorkbook(s.ev, id, export.Options{})
|
||||
if err != nil {
|
||||
// 单位不存在是「资源不存在」,按 404 报(与 GET /api/teams/{id} 同一句话);
|
||||
// 其它错误(读库失败等)才是 500。全部走 internal 会让每次点空单位导出都记一条伪 5xx。
|
||||
if errors.Is(err, export.ErrTeamNotFound) {
|
||||
return nil, notFound("单位不存在")
|
||||
}
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "team.xlsx", "单位积分明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
@@ -794,53 +805,57 @@ func (p *baseDataPlanner) planEvents(rows []export.BaseDataRow) {
|
||||
avgMode: existing.avgMode, sortOrder: existing.sortOrder,
|
||||
venue: existing.venue, remark: existing.remark,
|
||||
}
|
||||
if row.Has["eventType"] {
|
||||
v, err := normalizeEventType(row.Cells["eventType"])
|
||||
// 枚举列的三态口径:列不在表里 / 整格留空都 = 不改动,只有显式填值才覆盖。
|
||||
// 以前格子留空会落到「取系统默认」(类型→individual、状态→pending、平均口径→valid …),
|
||||
// 一次误清格就能把已发布小项打回「待录入」、把团体项目的类型与均分口径改掉,
|
||||
// 而预览里只是一条不显眼的差异。
|
||||
if v := row.Cells["eventType"]; v != "" {
|
||||
parsed, err := normalizeEventType(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.eventType = v
|
||||
plan.eventType = parsed
|
||||
}
|
||||
if row.Has["resultMode"] {
|
||||
v, err := normalizeResultMode(row.Cells["resultMode"])
|
||||
if v := row.Cells["resultMode"]; v != "" {
|
||||
parsed, err := normalizeResultMode(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.resultMode = v
|
||||
plan.resultMode = parsed
|
||||
}
|
||||
if row.Has["resultType"] {
|
||||
v, err := normalizeResultType(row.Cells["resultType"])
|
||||
if v := row.Cells["resultType"]; v != "" {
|
||||
parsed, err := normalizeResultType(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.resultType = v
|
||||
plan.resultType = parsed
|
||||
}
|
||||
if row.Has["compare"] {
|
||||
v, err := normalizeCompare(row.Cells["compare"])
|
||||
if v := row.Cells["compare"]; v != "" {
|
||||
parsed, err := normalizeCompare(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.compare = v
|
||||
plan.compare = parsed
|
||||
}
|
||||
if row.Has["status"] {
|
||||
v, err := normalizeEventStatus(row.Cells["status"])
|
||||
if v := row.Cells["status"]; v != "" {
|
||||
parsed, err := normalizeEventStatus(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.status = v
|
||||
plan.status = parsed
|
||||
}
|
||||
if row.Has["avgMode"] {
|
||||
v, err := normalizeAvgMode(row.Cells["avgMode"])
|
||||
if v := row.Cells["avgMode"]; v != "" {
|
||||
parsed, err := normalizeAvgMode(v)
|
||||
if err != nil {
|
||||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||||
continue
|
||||
}
|
||||
plan.avgMode = v
|
||||
plan.avgMode = parsed
|
||||
}
|
||||
if row.Has["scored"] {
|
||||
v, ok := export.ParseBaseDataBool(row.Cells["scored"], true)
|
||||
@@ -963,13 +978,13 @@ func (p *baseDataPlanner) planCategories(rows []export.BaseDataRow) {
|
||||
}
|
||||
plan.multiplier = v
|
||||
}
|
||||
if row.Has["gender"] {
|
||||
gender := strings.TrimSpace(row.Cells["gender"])
|
||||
if gender != "" && gender != "男" && gender != "女" {
|
||||
p.rowError(sheet, line, "性别只能填 男 / 女(留空表示不限)")
|
||||
// 组别的枚举列同口径:留空 = 不改动(新建行留空仍是「不限」,因为起手值就是空)。
|
||||
if v := strings.TrimSpace(row.Cells["gender"]); v != "" {
|
||||
if v != "男" && v != "女" {
|
||||
p.rowError(sheet, line, "性别只能填 男 / 女(留空表示不改动,新建时留空即不限)")
|
||||
continue
|
||||
}
|
||||
plan.gender = baseDataStrPtr(gender)
|
||||
plan.gender = baseDataStrPtr(v)
|
||||
}
|
||||
if row.Has["ageMin"] {
|
||||
n, ok := export.ParseBaseDataInt(row.Cells["ageMin"])
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi_test
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -176,3 +177,47 @@ func TestExportProjectZipConcurrentUnique(t *testing.T) {
|
||||
t.Errorf("并发导出: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 导出不存在的小项 / 单位是「资源不存在」,按 404 报(与 GET /api/events/{id}、
|
||||
// GET /api/teams/{id} 同一句话),不能落成 500 + 原始错误串:
|
||||
// 现场点到一个已失效的 id 时,「服务端异常」会把排查方向带偏,还平白记一条伪 5xx。
|
||||
func TestExportPerIDNotFoundIs404(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{"/api/export/events/999999", "小项不存在"},
|
||||
{"/api/export/teams/999999", "单位不存在"},
|
||||
} {
|
||||
status, _, body := env.getRaw(t, client, tc.path)
|
||||
if status != http.StatusNotFound {
|
||||
t.Fatalf("%s 应 404,得到 %d %s", tc.path, status, body)
|
||||
}
|
||||
var payload struct {
|
||||
OK bool `json:"ok"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("解析 %s 响应: %v (%s)", tc.path, err, body)
|
||||
}
|
||||
if payload.OK || payload.Error.Code != "VALIDATION_FAILED" || payload.Error.Message != tc.want {
|
||||
t.Fatalf("%s 应 404 VALIDATION_FAILED %q,得到 %+v", tc.path, tc.want, payload.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 存在的小项 / 单位照旧 200:404 只针对真的不存在的 id。
|
||||
for _, path := range []string{"/api/export/events/1", "/api/export/teams/1"} {
|
||||
if status, _, body := env.getRaw(t, client, path); status != http.StatusOK {
|
||||
t.Fatalf("%s 应 200,得到 %d %s", path, status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ type athleteStandingRow struct {
|
||||
|
||||
// seedScoredResults 给 seed 的报名项补成绩与积分流水:
|
||||
// 单位 A 的运动员拿第 1 名 10 分,单位 B 的运动员拿第 2 名 8 分。
|
||||
//
|
||||
// 名次同时写进 channel_ranks:奖牌榜与导出都读这张表(见 buildRanked / loadRanked),
|
||||
// results.rank 只是个人名次,不再作为名次来源。
|
||||
func (e *testEnv) seedScoredResults(t *testing.T, ids seedIDs) {
|
||||
t.Helper()
|
||||
for _, in := range []struct {
|
||||
@@ -38,6 +41,15 @@ func (e *testEnv) seedScoredResults(t *testing.T, ids seedIDs) {
|
||||
source_type, source_id, points)
|
||||
SELECT ?, ?, ?, ?, id, 'result', 1, ? FROM results WHERE entry_id = ?`,
|
||||
in.team, in.athlete, ids.event, in.entry, in.points, in.entry)
|
||||
e.exec(t, `INSERT INTO channel_ranks (event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points, status)
|
||||
SELECT r.event_id, e.category_id, c.id, 'result', 'athlete', 'e:' || e.id,
|
||||
e.id, r.id, e.athlete_id, e.team_id, r.rank, ?, r.status
|
||||
FROM results r
|
||||
JOIN entries e ON e.id = r.entry_id
|
||||
JOIN event_score_channels c ON c.event_id = e.event_id
|
||||
WHERE e.id = ?`,
|
||||
in.points, in.entry)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -400,6 +400,10 @@ func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any
|
||||
if !keys.has("recordEligible") {
|
||||
in.RecordEligible = true
|
||||
}
|
||||
// award_medals 同样是 DEFAULT 1(migration 0020):漏传 ≠ 关掉奖牌结算。
|
||||
if !keys.has("awardMedals") {
|
||||
in.AwardMedals = true
|
||||
}
|
||||
created, err := s.createEvent(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -430,6 +434,10 @@ func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any
|
||||
if !keys.has("recordEligible") {
|
||||
in.RecordEligible = before.RecordEligible
|
||||
}
|
||||
// 奖牌结算是 DEFAULT 1 的新开关:漏传等于「保持原值」,不能当成「关掉」。
|
||||
if !keys.has("awardMedals") {
|
||||
in.AwardMedals = before.AwardMedals
|
||||
}
|
||||
updated, err := s.updateEvent(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -454,12 +462,14 @@ func (s *server) deleteEventHandler(w http.ResponseWriter, r *http.Request) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// listEntriesHandler 返回成绩录入页的完整读数(成绩行 + 团体名次,见 entryBoardDTO)。
|
||||
// 旧客户端把它当成绩行数组用;设计 17 §七之后同一响应多带一个 teamRanks,前端两种形状都能读。
|
||||
func (s *server) listEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.listEntriesWithResults(r.Context(), id)
|
||||
return s.listEntryBoard(r.Context(), id)
|
||||
}
|
||||
|
||||
func (s *server) listCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
@@ -585,9 +595,15 @@ func (s *server) getAthleteHandler(w http.ResponseWriter, r *http.Request) (any,
|
||||
|
||||
func (s *server) createAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in athleteDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 缺省 = 启用(与单位 / 大项 / 小项同口径):漏传 active 不该把新人静默建成「停用」,
|
||||
// 停用的人在报名选择器里等于不存在——少写一个字段就变成另一种东西,只能靠键是否存在判断。
|
||||
if !keys.has("active") {
|
||||
in.Active = true
|
||||
}
|
||||
created, err := s.createAthlete(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -602,13 +618,19 @@ func (s *server) updateAthleteHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
return nil, err
|
||||
}
|
||||
var in athleteDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getAthlete(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 更新仍是「全量覆盖」语义,但漏传 active 不等于「把他停用」:沿用库里的现值。
|
||||
// 想停用请显式传 false(或走 DELETE / 批量停用)。
|
||||
if !keys.has("active") {
|
||||
in.Active = before.Active
|
||||
}
|
||||
updated, err := s.updateAthlete(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -660,8 +682,9 @@ type entryImportResult struct {
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
Notices []string `json:"notices"`
|
||||
Conflicts []importConflict `json:"conflicts"`
|
||||
// SameNameNewPersons 是「号码布不同 → 按同名的另一个人新建档案」的清单。
|
||||
// SameNameNewPersons 是「身份字段对不上 → 按同名的另一个人新建档案」的清单。
|
||||
// 只作报告:不弹窗、不阻塞导入,但必须让用户看得见。
|
||||
SameNameNewPersons []sameNameNewPerson `json:"sameNameNewPersons"`
|
||||
}
|
||||
@@ -669,10 +692,13 @@ type entryImportResult struct {
|
||||
// importConflict 是「命中了库里已有人员、但人员字段与库中不一致」的一行。
|
||||
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
|
||||
type importConflict struct {
|
||||
Row int `json:"row"`
|
||||
IDCard string `json:"idCard"`
|
||||
Bib string `json:"bib"`
|
||||
AthleteID int64 `json:"athleteId"`
|
||||
Row int `json:"row"`
|
||||
IDCard string `json:"idCard"`
|
||||
Bib string `json:"bib"`
|
||||
AthleteID int64 `json:"athleteId"`
|
||||
// Duplicate 表示这不是人员字段不一致,而是「同一份表里同一证件号 + 同一个小项同一个组别」的真重复行:
|
||||
// 两行字段可能完全一样,但要不要覆盖已经落库的那条报名必须由用户决定。
|
||||
Duplicate bool `json:"duplicate"`
|
||||
Existing importAthleteView `json:"existing"`
|
||||
Incoming importAthleteView `json:"incoming"`
|
||||
ChangedFields []string `json:"changedFields"`
|
||||
@@ -941,7 +967,9 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
||||
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
|
||||
// 非 nil 时按决策处理命中人员的冲突,未出现在 decisions 里的冲突继续收集。
|
||||
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
|
||||
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{}}
|
||||
result := entryImportResult{
|
||||
Errors: []string{}, Notices: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{},
|
||||
}
|
||||
if len(rows) < 2 {
|
||||
return result, badRequest("报名表为空", nil)
|
||||
}
|
||||
@@ -957,9 +985,17 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错;
|
||||
// 证件号只是可选档案信息,重复不再拦截(同一份表内、跨行、跨表都不因它拦截)。
|
||||
seenBibs := make(map[string]bool)
|
||||
// 号码布不再是人员的唯一标识:同一个人报多个组别时可以重复使用,只有「两个不同的人共用一个号码布」
|
||||
// 才按行报错,所以这里记的是号码布 -> 第一次出现的那个人的证件号。
|
||||
bibOwners := make(map[string]string)
|
||||
// 证件号才是唯一标识:同一份表里重复出现 = 同一个人报多个组别(不报错,收尾统一给 notices 提示);
|
||||
// 但「同一证件号 + 同一个小项同一个组别」是真重复,只有它进冲突面板等决策。
|
||||
idCardCounts := make(map[string]int)
|
||||
seenIdentities := make(map[string]bool)
|
||||
// 受影响的 (小项, 组别):只有「新建了报名项」或「可排名状态跨了界」的行才加进来。
|
||||
// 名单里的姓名 / 号码布 / 批次这类不影响算分的字段改动不该触发重算,
|
||||
// 否则几百行的常规导入会把每个组白算一遍(见 recomputeImportedScopes)。
|
||||
affectedScopes := make([]entryScope, 0, 2)
|
||||
for n, row := range rows[1:] {
|
||||
line := n + 2
|
||||
cell := func(name string) string {
|
||||
@@ -1048,23 +1084,34 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
continue
|
||||
}
|
||||
if bib != "" {
|
||||
if seenBibs[bib] {
|
||||
// 同一个人(证件号相同且都填了)可以共用号码布;其余重复一律按行报错。
|
||||
if owner, seen := bibOwners[bib]; seen && (idCard == "" || owner != idCard) {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
|
||||
continue
|
||||
}
|
||||
seenBibs[bib] = true
|
||||
bibOwners[bib] = idCard
|
||||
}
|
||||
// 真重复判据与服务端认人顺序一致:同一证件号 + 同一个小项同一个组别。
|
||||
// 组别留空时上面已取该小项的第一个组别,这里用解析出的 categoryID 比。
|
||||
duplicate := false
|
||||
if idCard != "" {
|
||||
identity := fmt.Sprintf("%s\x00%d\x00%d", idCard, eventID, categoryID)
|
||||
duplicate = seenIdentities[identity]
|
||||
seenIdentities[identity] = true
|
||||
idCardCounts[idCard]++
|
||||
}
|
||||
|
||||
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
|
||||
Line: line,
|
||||
Name: athleteName,
|
||||
Gender: normalizeGender(cell("gender")),
|
||||
TeamID: teamID,
|
||||
TeamCode: teamCode,
|
||||
IDCard: idCard,
|
||||
Bib: bib,
|
||||
Age: age,
|
||||
Line: line,
|
||||
Name: athleteName,
|
||||
Gender: normalizeGender(cell("gender")),
|
||||
TeamID: teamID,
|
||||
TeamCode: teamCode,
|
||||
IDCard: idCard,
|
||||
Bib: bib,
|
||||
Age: age,
|
||||
Duplicate: duplicate,
|
||||
}, decisions)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
@@ -1075,6 +1122,11 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
||||
continue
|
||||
}
|
||||
if matched.Skip {
|
||||
// 真重复行 + 用户选了「保留已有」:库里那条报名原样不动,本行不落库。
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if matched.SameNameNewPerson != nil {
|
||||
result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson)
|
||||
}
|
||||
@@ -1083,7 +1135,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
teamID = matched.TeamID
|
||||
}
|
||||
|
||||
created, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
EventID: eventID,
|
||||
CategoryID: categoryID,
|
||||
TeamID: teamID,
|
||||
@@ -1098,12 +1150,34 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
case err != nil:
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
case created:
|
||||
case outcome.Created:
|
||||
result.Created++
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
default:
|
||||
result.Updated++
|
||||
if outcome.StatusChanged {
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 同一证件号在本表里出现多次 = 同一个人报多个组别:不报错,但要让现场看见。
|
||||
// 按证件号排序,保证同一份表的提示顺序稳定(前端展示与用例断言都依赖它)。
|
||||
idCards := make([]string, 0, len(idCardCounts))
|
||||
for idCard := range idCardCounts {
|
||||
idCards = append(idCards, idCard)
|
||||
}
|
||||
sort.Strings(idCards)
|
||||
for _, idCard := range idCards {
|
||||
if count := idCardCounts[idCard]; count > 1 {
|
||||
result.Notices = append(result.Notices,
|
||||
fmt.Sprintf("证件号 %s 在本表中出现 %d 次(同一人报名多个组别)", idCard, count))
|
||||
}
|
||||
}
|
||||
// 导入动过「谁还算数」的组,按当前报名与成绩重算名次与流水并推榜。
|
||||
// 与批量退赛/移出、作废成绩同一口径:延迟结算下也要当场算,不然名单改了榜不动。
|
||||
if err := s.recomputeImportedScopes(ctx, affectedScopes, middleware.User(ctx).ID); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -232,7 +233,7 @@ func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
||||
// 证件号命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
||||
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
@@ -286,7 +287,10 @@ func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里证件号重复出现不再报错:证件号是可选档案信息,不参与匹配也不拦截。
|
||||
// 同一份表里同一证件号、同一小项同一个组别出现两次 = 真重复:
|
||||
// 两行都解析到同一个人(不新建第二份档案、不报「证件号已被占用」),
|
||||
// 但要不要覆盖已落库的那条报名由用户决定,所以次行进冲突面板并带 duplicate 标记;
|
||||
// 同时给一条 notices 提示,说明这个证件号在本表里出现了两次。
|
||||
func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
@@ -298,16 +302,35 @@ func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["skipped"] != float64(0) {
|
||||
t.Fatalf("同表重复证件号不应再拦截: %+v", result)
|
||||
if result["created"] != float64(1) || result["updated"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("首行新建、次行等决策(既不算更新也不算跳过): %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("不应有行级报错,得到 %+v", errs)
|
||||
t.Fatalf("同证件号重复不应报行级错误,得到 %+v", errs)
|
||||
}
|
||||
if result["created"] != float64(1) || result["updated"] != float64(1) {
|
||||
t.Fatalf("首行新建、次行按姓名+单位命中同一人并更新报名: %+v", result)
|
||||
notices, _ := result["notices"].([]any)
|
||||
wantNotice := "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)"
|
||||
if len(notices) != 1 || notices[0] != wantNotice {
|
||||
t.Fatalf("应给一条重复提示 %q,得到 %+v", wantNotice, notices)
|
||||
}
|
||||
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("同一个小项同一个组别的重复行应进冲突面板,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["duplicate"] != true || conflict["row"] != float64(3) {
|
||||
t.Fatalf("重复行应带 duplicate 标记并指向次行: %+v", conflict)
|
||||
}
|
||||
var athleteID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athleteID); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if conflict["athleteId"] != float64(athleteID) {
|
||||
t.Fatalf("重复行应指向同一个人 %d: %+v", athleteID, conflict)
|
||||
}
|
||||
|
||||
// 同一个人只应建 1 份档案(次行绝不能再新建或撞 id_card 唯一索引)。
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
@@ -317,28 +340,36 @@ func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 新人带的证件号已被库里另一个人占用 → 明确的中文行级错误,不静默改库、不静默跳过。
|
||||
func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) {
|
||||
// 证件号优先:表里的证件号在库里有主时,命中那个人走冲突面板,而不是报「证件号已被占用」。
|
||||
// 姓名 / 单位 / 号码布都对不上也只当差异交给用户决策——认人是按证件号,不是按号码布或姓名。
|
||||
func TestImportIDCardHitGoesToConflictNotTakenError(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
// 另一个人(李四 / 号码布 B200)拿着已被张伟占用的证件号来建档。
|
||||
// 另一个人(李四 / 号码布 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)
|
||||
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)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("证件号命中应进冲突面板,既不算新建也不算跳过: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("应报 1 条行级错误,得到 %+v", errs)
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("证件号能认到人就不该报「已被占用」,得到 %+v", errs)
|
||||
}
|
||||
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
|
||||
if errs[0] != want {
|
||||
t.Fatalf("错误文案不对:\n 得到 %q\n 期望 %q", errs[0], want)
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["athleteId"] != float64(ids.athleteA) || conflict["duplicate"] != false {
|
||||
t.Fatalf("冲突应指向证件号命中的人: %+v", conflict)
|
||||
}
|
||||
changed, _ := conflict["changedFields"].([]any)
|
||||
if len(changed) != 3 || changed[0] != "name" || changed[1] != "teamCode" || changed[2] != "bib" {
|
||||
t.Fatalf("姓名 / 单位 / 号码布三处差异都该列出,得到 %+v", changed)
|
||||
}
|
||||
|
||||
// 库里的张伟一行没动,也没有多出一个人。
|
||||
@@ -351,17 +382,18 @@ func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if name != "张伟" || bib != "A100" {
|
||||
t.Fatalf("撞号不应改动库里已有人员,得到 name=%s bib=%s", name, bib)
|
||||
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)
|
||||
t.Fatalf("证件号命中不应新建人员,得到 %d 个", count)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布不同 → 按「同名的另一个人」新建档案,并在 sameNameNewPersons 里单独报告,不进冲突面板。
|
||||
// 号码布不同(表里证件号也对不上)→ 按「同名的另一个人」新建档案,
|
||||
// 并在 sameNameNewPersons 里带上证件号 / 号码布两侧取值单独报告,不进冲突面板。
|
||||
func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
|
||||
@@ -386,6 +418,10 @@ func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
|
||||
if item["name"] != "张伟" || item["teamCode"] != "A" || item["newBib"] != "2001" || item["existingBib"] != "1001" {
|
||||
t.Fatalf("同名新建报告字段不对: %+v", item)
|
||||
}
|
||||
// 证件号两侧都没有值也要如实带上,现场看到的「原 / 新」两栏才完整。
|
||||
if item["newIdCard"] != "" || item["existingIdCard"] != "" {
|
||||
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 {
|
||||
@@ -671,7 +707,7 @@ func TestImportBackfillsBibForExistingAthlete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布命中时,表里多给的证件号也回填进库中为空的人员。
|
||||
// 证件号未命中 → 退回号码布命中:对方没登记证件号 → 认定同一个人并回填证件号,不新建。
|
||||
func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
@@ -685,6 +721,12 @@ func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||||
t.Fatalf("号码布命中应更新已有报名: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("号码布命中且对方没有证件号,不该报错: %+v", errs)
|
||||
}
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("号码布命中且对方没有证件号,回填不算冲突: %+v", conflicts)
|
||||
}
|
||||
|
||||
var idCard sql.NullString
|
||||
if err := env.ev.QueryRow(`SELECT id_card FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard); err != nil {
|
||||
@@ -703,7 +745,7 @@ func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里号码布重复出现 → 该行报错并跳过。
|
||||
// 同一文件里两个不同的人共用一个号码布 → 该行报错并跳过;同一个人报多个组别时号码布可以重复。
|
||||
func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
@@ -732,28 +774,114 @@ func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同名 + 号码布不同本应按「同名的另一个人」新建,但表里的证件号已被库里那个人占用时,
|
||||
// 唯一索引的底线更高:明确报出占用者,既不静默改库也不静默跳过。
|
||||
func TestImportSameNameDifferentBibButTakenIDCard(t *testing.T) {
|
||||
// 证件号优先:库里某人 id_card=X / bib=A100,表里同证件号但号码布写错成 A200 时,
|
||||
// 命中同一个人(不新建、不报「证件号已被占用」),号码布差异按既有口径进冲突面板;
|
||||
// 决策 keep-new 后号码布按表里的值更新。
|
||||
func TestImportIDCardPriorityIgnoresWrongBib(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)
|
||||
if result["skipped"] != float64(1) || result["created"] != float64(0) {
|
||||
t.Fatalf("撞号应跳过该行: %+v", result)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("冲突行不落库也不跳过: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
|
||||
if len(errs) != 1 || errs[0] != want {
|
||||
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("证件号命中同一个人,不该报「已被占用」: %+v", errs)
|
||||
}
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("号码布差异应进冲突面板,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["athleteId"] != float64(ids.athleteA) || conflict["duplicate"] != false {
|
||||
t.Fatalf("冲突应指向证件号命中的那个人: %+v", conflict)
|
||||
}
|
||||
changed, _ := conflict["changedFields"].([]any)
|
||||
if len(changed) != 1 || changed[0] != "bib" {
|
||||
t.Fatalf("应只有号码布差异,得到 %+v", changed)
|
||||
}
|
||||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||||
t.Fatalf("新建被证件号唯一索引挡下时不应报告成同名新建: %+v", reported)
|
||||
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)
|
||||
}
|
||||
if bib != "A100" {
|
||||
t.Fatalf("未决策不应改号码布,得到 %s", bib)
|
||||
}
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 2 {
|
||||
t.Fatalf("证件号命中不应新建人员,得到 %d 个", athletes)
|
||||
}
|
||||
|
||||
// 决策键是证件号(证件号优先),keep-new 用表里的号码布覆盖。
|
||||
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
|
||||
status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result = decodeData[map[string]any](t, env2)
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
|
||||
}
|
||||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||||
t.Fatalf("keep-new 应更新已有报名: %+v", result)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if bib != "A200" {
|
||||
t.Fatalf("keep-new 应用表里的号码布覆盖,得到 %s", bib)
|
||||
}
|
||||
}
|
||||
|
||||
// 证件号与号码布都没命中 → 按「姓名 + 单位」命中且对方既没证件号也没号码布:
|
||||
// 认定同一个人,把表里的证件号与号码布一并回填,不新建档案。
|
||||
func TestImportBackfillsIDCardAndBibBySameNameTeam(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,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)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["updated"] != float64(1) || result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("姓名 + 单位命中应更新已有报名: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("回填不该报行级错误: %+v", errs)
|
||||
}
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("两边都没登记身份字段时回填不算冲突: %+v", conflicts)
|
||||
}
|
||||
|
||||
var (
|
||||
idCard sql.NullString
|
||||
bib sql.NullString
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT id_card, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard, &bib); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if !idCard.Valid || idCard.String != "110101199001011234" {
|
||||
t.Fatalf("应回填证件号,得到 %+v", idCard)
|
||||
}
|
||||
if !bib.Valid || bib.String != "A100" {
|
||||
t.Fatalf("应回填号码布,得到 %+v", bib)
|
||||
}
|
||||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||||
t.Fatalf("一边没给身份字段只是「未指定」,不该按同名的另一个人新建: %+v", reported)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
@@ -761,7 +889,223 @@ func TestImportSameNameDifferentBibButTakenIDCard(t *testing.T) {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 2 {
|
||||
t.Fatalf("不应新建人员,得到 %d 个", athletes)
|
||||
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 姓名 + 单位命中、对方有不同号码布、且表里的证件号也对不上任何人 → 同名的另一个人:
|
||||
// 新建第二份档案并报告(新档案拿表里的证件号与号码布)。
|
||||
func TestImportSameNameDifferentBibWithUnmatchedIDCardCreatesSecondPerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,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)
|
||||
want := map[string]any{
|
||||
"name": "张伟", "teamCode": "A",
|
||||
"newIdCard": "110101199001011234", "existingIdCard": "",
|
||||
"newBib": "2001", "existingBib": "1001",
|
||||
}
|
||||
for key, value := range want {
|
||||
if item[key] != value {
|
||||
t.Fatalf("同名新建报告 %s 应为 %q,得到 %+v", key, value, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 姓名 + 单位命中、对方有另一个证件号、表里也没给号码布 → 同名的另一个人(与号码布不同同一条路径)。
|
||||
func TestImportSameNameDifferentIDCardCreatesSecondPerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001019999', bib = '1001' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
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["newIdCard"] != "110101199001011234" || item["existingIdCard"] != "110101199001019999" {
|
||||
t.Fatalf("同名新建报告应带两侧证件号: %+v", item)
|
||||
}
|
||||
if item["newBib"] != "" || item["existingBib"] != "1001" {
|
||||
t.Fatalf("表里没给号码布时应是空串、库里那条如实带上: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一份表里同一证件号出现多次(同一个人报多个组别):两行都解析到同一个档案、各自落一条报名,
|
||||
// 只给 notices 提示,绝不新建第二个人、也绝不撞 id_card 唯一索引。
|
||||
func TestImportDuplicateIDCardAcrossScopesResolvesToOnePerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
// 加一个组别,让同一人的两行落在不同 (小项, 组别) 上——那才是真正的「报多个组别」。
|
||||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,A100,,registered,\n" +
|
||||
"M100,女子,A,张三,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)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(2) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("同一人的两条报名都应落库: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("同证件号重复不该报行级错误,得到 %+v", errs)
|
||||
}
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("不同组别不是真重复,不该进冲突面板: %+v", conflicts)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
if len(notices) != 1 || notices[0] != "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)" {
|
||||
t.Fatalf("应给一条重复提示,得到 %+v", notices)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 1 {
|
||||
t.Fatalf("同一个人只应建 1 个档案,得到 %d", athletes)
|
||||
}
|
||||
var entries int
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT COUNT(*) FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
|
||||
"110101199001011234").Scan(&entries); err != nil {
|
||||
t.Fatalf("统计报名: %v", err)
|
||||
}
|
||||
if entries != 2 {
|
||||
t.Fatalf("同一个人两个组别应各有一条报名,得到 %d", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一份表里同一证件号两行的号码布不同(号码布写错):两行仍是同一个人(冲突指向同一个 athleteId),
|
||||
// 号码布差异按既有口径进冲突面板,不新建第二个人、不报「证件号已被占用」。
|
||||
func TestImportDuplicateIDCardDifferentBibStaysOnePerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,B1,,registered,\n" +
|
||||
"M100,女子,A,张三,110101199001011234,男,,B2,,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 errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("不该报「证件号已被占用」这类行级错误,得到 %+v", errs)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
if len(notices) != 1 || notices[0] != "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)" {
|
||||
t.Fatalf("应给一条重复提示,得到 %+v", notices)
|
||||
}
|
||||
|
||||
var athleteID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athleteID); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("号码布写错应进冲突面板,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["athleteId"] != float64(athleteID) {
|
||||
t.Fatalf("次行必须解析到同一个人 %d,得到 %+v", athleteID, conflict)
|
||||
}
|
||||
changed, _ := conflict["changedFields"].([]any)
|
||||
if len(changed) != 1 || changed[0] != "bib" {
|
||||
t.Fatalf("应只有号码布差异,得到 %+v", changed)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 1 {
|
||||
t.Fatalf("同一个人只应建 1 个档案,得到 %d", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布已经在库里被 A 占用,表里这一行的证件号又对不上 A → 两个人共用一个号码布:
|
||||
// 按既有号码布防呆口径报行级错误并跳过,绝不静默改库、也不新建第二个占号的人。
|
||||
func TestImportRejectsBibTakenByDifferentIDCard(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001019999', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,李四,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)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(1) {
|
||||
t.Fatalf("两人共用一个号码布应跳过该行: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
want := "第 2 行:号码布 A100 已被其它人员使用"
|
||||
if len(errs) != 1 || errs[0] != want {
|
||||
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
|
||||
}
|
||||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||||
t.Fatalf("被号码布防呆挡下时不该报告成同名新建: %+v", reported)
|
||||
}
|
||||
|
||||
// 库里那条一个字段都没动,也没多出一个人。
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,6 +1399,253 @@ func TestImportRejectsUnknownStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 导入改「谁还算数」必须重算 ----------
|
||||
|
||||
// importZhangWeiIDCard 是种子人员张伟在导入用例里用的证件号(先写库,导入才能按证件号命中他)。
|
||||
const importZhangWeiIDCard = "110101199001011234"
|
||||
|
||||
// importZhangWeiRow 拼一行「M100 / 男子 / A / 张伟」的报名表内容;表头见 importHeader。
|
||||
func importZhangWeiRow(status string) string {
|
||||
return "M100,男子,A,张伟," + importZhangWeiIDCard + ",男,,,," + status + ",\n"
|
||||
}
|
||||
|
||||
// seedZhangWeiIDCard 给张伟补一个证件号:证件号优先的匹配口径下,导入才认得出是同一个人。
|
||||
func seedZhangWeiIDCard(t *testing.T, env *testEnv, ids seedIDs) {
|
||||
t.Helper()
|
||||
env.exec(t, `UPDATE athletes SET id_card = ? WHERE id = ?`, importZhangWeiIDCard, ids.athleteA)
|
||||
}
|
||||
|
||||
// runImportScenario 用与批量动作同一串操作跑一遍「名单导入改状态」:
|
||||
// 8 人报名 → 录完前 4 条并发布 →(withSettle 时)立即结算 → 导入把第 1 名改成 status。
|
||||
//
|
||||
// 直接复用 entries_batch_test.go 的 runScenario,是为了让两个入口(批量退赛 / 名单导入)
|
||||
// 的最终数据能逐字节对照:它们改的是同一件事(谁还算数)。
|
||||
func runImportScenario(t *testing.T, live, withSettle bool, status string) batchRun {
|
||||
t.Helper()
|
||||
return runScenario(t, live, withSettle,
|
||||
func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64) {
|
||||
t.Helper()
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
body := importHeader + "\n" + importZhangWeiRow(status)
|
||||
if st, e := env.postImport(t, client, "/api/entries/import", body, ""); st != http.StatusOK || !e.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", st, e.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 同一批导入里多行改状态、且都落在同一组:按 (小项, 组别) 去重,只重算一次、只推一次榜。
|
||||
// 导入动辄几百行,逐行推榜会让大屏收一串中间态、也会把现场拖慢。
|
||||
func TestImportManyStatusChangesPushOnce(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
env.exec(t, `UPDATE athletes SET id_card = ? WHERE id = ?`, "110101199001015678", ids.athB)
|
||||
publishResults(t, env, client, ids.event, "import-many",
|
||||
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"},
|
||||
)
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
body := importHeader + "\n" + importZhangWeiRow("scratched") +
|
||||
"M100,男子,B,李强,110101199001015678,男,,,,scratched,\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["updated"] != float64(2) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("两行都应就地更新,得到 %+v", result)
|
||||
}
|
||||
// 一个组只推一次榜(两行都改了状态,但 scope 去重成一次重算)。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("同一组的多行改动只应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
// 两人都退出名次与流水:整组没有可排名成绩,榜单归零。
|
||||
if got := ledgerRowsOf(t, env, ids.event, nil); len(got) != 0 {
|
||||
t.Fatalf("两人都退赛后不该有有效流水,得到 %v", got)
|
||||
}
|
||||
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); len(got) != 0 {
|
||||
t.Fatalf("两人都退赛后不该占名次,得到 %v", got)
|
||||
}
|
||||
if got := standingsTotal(t, env, client); got != 0 {
|
||||
t.Fatalf("榜单总分应归零,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入把已发布成绩的人改成退赛:流水作废、名次让出来给别人、榜单被推。
|
||||
// 现场「改完名单重新导入一遍」很自然,不重算就会留下「名单说退赛、榜上还挂着 10 分与金牌」。
|
||||
func TestImportStatusChangeRecomputes(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
publishResults(t, env, client, ids.event, "import-status",
|
||||
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 第 1 名 10 分、B 第 2 名 8 分。
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||||
t.Fatalf("前置条件:A 应是 10 分,得到 %v", got)
|
||||
}
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+importZhangWeiRow("scratched"), "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["updated"] != float64(1) || result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("应就地更新 1 条报名(不是新建、不跳过),得到 %+v", result)
|
||||
}
|
||||
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||||
t.Fatalf("A 应已改成退赛,得到 %s", got)
|
||||
}
|
||||
|
||||
// 流水作废:旧流水留着当证据,有效流水一条不剩。
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); len(got) != 0 {
|
||||
t.Fatalf("退赛后不应再有有效流水,得到 %v", got)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryA); n == 0 {
|
||||
t.Fatalf("旧流水应被作废留痕,得到 %d 条", n)
|
||||
}
|
||||
// 名次让出来:退赛的人不再占名次,B 升到第 1 名。
|
||||
if got := ranksOf(t, env, []int64{ids.entryA}); len(got) != 0 {
|
||||
t.Fatalf("退赛的报名项不该再占名次,得到 %v", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
|
||||
t.Fatalf("B 应升到第 1 名,得到 %d", got)
|
||||
}
|
||||
// 榜单跟着变:A 归零,B 从第 2 名的 8 分变成第 1 名的 10 分。
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||||
t.Fatalf("A 单位总分应归零,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
|
||||
t.Fatalf("B 单位总分应变成 10,得到 %v", got)
|
||||
}
|
||||
// 大屏要收到新榜单,否则现场看到的还是「退赛者拿金牌」的旧画面。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("导入改状态应推 1 次榜单(standings replace),得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 名单导入与批量退赛是同一口径:实时 / 延迟两种结算模式下最终数据逐字节一致。
|
||||
func TestImportScratchMatchesRealTime(t *testing.T) {
|
||||
for _, withSettle := range []bool{true, false} {
|
||||
live := runImportScenario(t, true, withSettle, "scratched")
|
||||
deferred := runImportScenario(t, false, withSettle, "scratched")
|
||||
if withSettle {
|
||||
assertSameBefore(t, "名单导入改状态", live, deferred)
|
||||
}
|
||||
assertSameAfter(t, "名单导入改状态", live, deferred)
|
||||
|
||||
// 期望值与「批量退赛第 1 名」同一份:退赛者退出名次与流水,剩下 3 人 10/8/6。
|
||||
wantLedger := []string{
|
||||
entryLedgerRow(live.entries[1], live.teamB, 10),
|
||||
entryLedgerRow(live.entries[2], live.teamA, 8),
|
||||
entryLedgerRow(live.entries[3], live.teamB, 6),
|
||||
}
|
||||
if !reflect.DeepEqual(deferred.after.ledger, wantLedger) {
|
||||
t.Fatalf("导入退赛后流水应重算成 %v,得到 %v", wantLedger, deferred.after.ledger)
|
||||
}
|
||||
wantRanks := map[int64]int{live.entries[1]: 1, live.entries[2]: 2, live.entries[3]: 3}
|
||||
if !reflect.DeepEqual(deferred.after.ranks, wantRanks) {
|
||||
t.Fatalf("导入退赛后名次应为 %v,得到 %v", wantRanks, deferred.after.ranks)
|
||||
}
|
||||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||||
t.Fatalf("延迟结算下导入改状态也应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 反例保护:只改不影响算分的字段(号码布 / 名单 / 批次)不触发重算,也不推榜单。
|
||||
// 导入动辄几百行,逐行无条件重算会把现场拖慢,也会让大屏收到一串无意义刷新。
|
||||
func TestImportNonScoringChangeDoesNotRecompute(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
publishResults(t, env, client, ids.event, "import-non-scoring",
|
||||
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"},
|
||||
)
|
||||
|
||||
ledgerBefore := ledgerRowsOf(t, env, ids.event, nil)
|
||||
ranksBefore := ranksOf(t, env, []int64{ids.entryA, ids.entryB})
|
||||
standingsBefore := rawData(t, env, client, "/api/standings")
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
// 号码布 / 种子 / 团体名单都是展示字段;status 写 registered,与库中现值相同。
|
||||
body := importHeader + "\nM100,男子,A,张伟," + importZhangWeiIDCard + ",男,,A-777,S-3,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["updated"] != float64(1) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("应就地更新 1 条报名,得到 %+v", result)
|
||||
}
|
||||
// 先证明这一行确实写了(否则「没重算」可能只是行被跳过了)。
|
||||
var bib, seed string
|
||||
if err := env.ev.QueryRow(`SELECT COALESCE(bib, ''), COALESCE(seed, '') FROM entries WHERE id = ?`, ids.entryA).
|
||||
Scan(&bib, &seed); err != nil {
|
||||
t.Fatalf("读报名项: %v", err)
|
||||
}
|
||||
if bib != "A-777" || seed != "S-3" {
|
||||
t.Fatalf("号码布 / 种子应已落库,得到 bib=%q seed=%q", bib, seed)
|
||||
}
|
||||
|
||||
if got := ledgerRowsOf(t, env, ids.event, nil); !reflect.DeepEqual(got, ledgerBefore) {
|
||||
t.Fatalf("不影响算分的改动不该动流水:%v → %v", ledgerBefore, got)
|
||||
}
|
||||
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); !reflect.DeepEqual(got, ranksBefore) {
|
||||
t.Fatalf("不影响算分的改动不该动名次:%v → %v", ranksBefore, got)
|
||||
}
|
||||
if got := rawData(t, env, client, "/api/standings"); got != standingsBefore {
|
||||
t.Fatalf("不影响算分的改动不该动榜单:\n之前 %s\n之后 %s", standingsBefore, got)
|
||||
}
|
||||
if got := standingsPushes(t, env); got != pushesBefore {
|
||||
t.Fatalf("不影响算分的改动不该推榜单:%d → %d", pushesBefore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:同一份名单连导两次,第二次不该再改动流水与榜单(状态已经是 scratched,口径没变)。
|
||||
func TestImportStatusChangeIsIdempotent(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
publishResults(t, env, client, ids.event, "import-idempotent",
|
||||
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"},
|
||||
)
|
||||
body := importHeader + "\n" + importZhangWeiRow("scratched")
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("第 %d 次导入应 200,得到 %d %+v", i+1, status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["updated"] != float64(1) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("第 %d 次导入应就地更新 1 条,得到 %+v", i+1, result)
|
||||
}
|
||||
if i == 0 {
|
||||
// 第一次确实改了口径:推一次榜。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("第一次导入应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 第二次:状态没变,不该再重算、也不该再推榜。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("重复导入不该再推榜单,得到 %d 次(期望仍是 1)", got)
|
||||
}
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); len(got) != 0 {
|
||||
t.Fatalf("重复导入不该让退赛者重新有流水,得到 %v", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
|
||||
t.Fatalf("重复导入后 B 仍应是第 1 名,得到 %d", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
|
||||
t.Fatalf("重复导入后 B 单位总分应仍是 10,得到 %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 图片上传与静态访问 ----------
|
||||
|
||||
// newUploadEnv 构造带赛事项目目录的测试环境,返回 uploads 目录的真实路径。
|
||||
@@ -1812,3 +2403,71 @@ func TestDeleteUploadRejectsTraversal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 建运动员漏传 active 必须默认「启用」(与单位 / 大项 / 小项同口径):
|
||||
// Go 的 bool 零值是 false,静默建成「停用」的人在报名选择器里等于不存在——
|
||||
// 少写一个字段就变成另一种东西,只能靠 decodeJSONPresence 的「键是否存在」判断。
|
||||
func TestAthleteActivePresenceDefaults(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
// 漏传 active:建出来是启用的,库里的 active 也真的是 1。
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
||||
"name": "无active", "gender": "男", "teamId": ids.teamA,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("建人员应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
created := decodeData[map[string]any](t, env2)
|
||||
if created["active"] != true {
|
||||
t.Fatalf("漏传 active 应建成启用,得到 %+v", created)
|
||||
}
|
||||
newID := int64(created["id"].(float64))
|
||||
var storedActive int
|
||||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||||
t.Fatalf("读人员启用状态: %v", err)
|
||||
}
|
||||
if storedActive != 1 {
|
||||
t.Fatalf("漏传 active 应在库里落 1,得到 %d", storedActive)
|
||||
}
|
||||
|
||||
// 显式 false 仍然是「停用」(不能把默认值做成「永远启用」)。
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
||||
"name": "显式停用", "gender": "女", "teamId": ids.teamA, "active": false,
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("显式 active=false 应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if decodeData[map[string]any](t, env2)["active"] != false {
|
||||
t.Fatalf("显式 active=false 应建成停用")
|
||||
}
|
||||
|
||||
// 更新时漏传 active 沿用库里的现值:不能把在用的人静默停用。
|
||||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", newID), map[string]any{
|
||||
"name": "无active改名", "gender": "男", "teamId": ids.teamA,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if decodeData[map[string]any](t, env2)["active"] != true {
|
||||
t.Fatalf("更新漏传 active 应沿用启用状态,得到 %+v", decodeData[map[string]any](t, env2))
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||||
t.Fatalf("读人员启用状态: %v", err)
|
||||
}
|
||||
if storedActive != 1 {
|
||||
t.Fatalf("更新漏传 active 不该把人停用,库里得到 %d", storedActive)
|
||||
}
|
||||
|
||||
// 显式 false 的更新仍能把人停用(「漏传 = 沿用」不是「永远不能停用」)。
|
||||
if status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", newID), map[string]any{
|
||||
"name": "无active改名", "gender": "男", "teamId": ids.teamA, "active": false,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("显式停用应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||||
t.Fatalf("读人员启用状态: %v", err)
|
||||
}
|
||||
if storedActive != 0 {
|
||||
t.Fatalf("显式 active=false 应把人停用,库里得到 %d", storedActive)
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,16 @@ type channelsPayload struct {
|
||||
Channels []channelDTO `json:"channels"`
|
||||
}
|
||||
|
||||
// teamRanksPayload 是 PUT /api/events/{id}/categories/{cid}/team-ranks 的请求体:
|
||||
// 一次提交某组别某类通道(当前只认 scope=team + basis=manual)的整组团体名次。
|
||||
// ranks 里的 rank 为 0 表示清空该单位的记录;未出现在 ranks 里的单位保持原值,
|
||||
// 录入页只需提交改动过的行,误传部分数据不会把没提到的单位一并清掉。
|
||||
type teamRanksPayload struct {
|
||||
Scope string `json:"scope"`
|
||||
Basis string `json:"basis"`
|
||||
Ranks []teamManualRankDTO `json:"ranks"`
|
||||
}
|
||||
|
||||
// ---------- 用户管理 ----------
|
||||
|
||||
type createUserInput struct {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
)
|
||||
|
||||
// 会话撤销后 /ws 长连接必须断开,且新的握手不能再按原身份进来。
|
||||
//
|
||||
// 这条链路有四个接头:Cookie → Authenticate 把会话写进 context → 握手时固化角色与会话 id →
|
||||
// 心跳里按会话 id 复查。少任何一环,被停用的人只要不关页面就还能一直收推送,
|
||||
// 所以这里用真实的 auth.Service 接真实 Hub,撤销动作也走真实接口(管理员停用用户)。
|
||||
func TestWebSocketClosesWhenSessionRevoked(t *testing.T) {
|
||||
var hub *realtime.Hub
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) {
|
||||
h := realtime.NewHub(o.Event, o.Logger)
|
||||
h.SetHeartbeatInterval(50 * time.Millisecond)
|
||||
h.SetSessionChecker(o.Auth)
|
||||
t.Cleanup(func() { _ = h.Close() })
|
||||
hub = h
|
||||
o.WS = h.Handler()
|
||||
})
|
||||
env.createUser("boss", "boss-pass", "admin")
|
||||
victimID := env.createUser("victim", "victim-pass", "admin")
|
||||
|
||||
// 被停用的人:登录后打开管理端长连接。
|
||||
victim, _ := env.client()
|
||||
env.login(victim, "victim", "victim-pass")
|
||||
victimCookie := sessionCookie(t, victim, env.server.URL)
|
||||
|
||||
c := dialWS(t, env.server.URL, victimCookie)
|
||||
c.send("hello", map[string]any{"role": "admin"})
|
||||
c.recv("snapshot")
|
||||
|
||||
// 管理员在后台停用该用户:HTTP 侧会连带作废他的全部会话。
|
||||
boss, _ := env.client()
|
||||
env.login(boss, "boss", "boss-pass")
|
||||
status, body := env.do(boss, http.MethodPut, "/api/users/"+strconv.FormatInt(victimID, 10),
|
||||
map[string]any{"active": false}, true)
|
||||
if status != http.StatusOK || !body.OK {
|
||||
t.Fatalf("停用用户应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
|
||||
// 最迟一个心跳周期内断开,关闭码 1008「登录失效」:前端据此停止重连并引导重新登录。
|
||||
if code := c.waitClose(3 * time.Second); code != websocket.StatusPolicyViolation {
|
||||
t.Fatalf("会话撤销后 /ws 应以 1008 关闭,得到 %d", code)
|
||||
}
|
||||
|
||||
// 握手侧同样要校验会话:仍带着这条失效 Cookie 再连一次,只能降级成 guest——
|
||||
// 连得上(大屏是公开的),但申请管理端房间会被按策略拒绝。
|
||||
re := dialWS(t, env.server.URL, victimCookie)
|
||||
re.send("hello", map[string]any{"role": "admin"})
|
||||
if code := re.waitClose(3 * time.Second); code != websocket.StatusPolicyViolation {
|
||||
t.Fatalf("失效会话的握手不该拿到管理端身份,关闭码得到 %d", code)
|
||||
}
|
||||
if n := len(hub.Instances()); n != 0 {
|
||||
t.Fatalf("被拒的连接不该登记成大屏实例,得到 %d 条", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 会话仍有效时连接不受影响:复查不能把在线用户误判成失效。
|
||||
func TestWebSocketStaysOpenWhileSessionActive(t *testing.T) {
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) {
|
||||
h := realtime.NewHub(o.Event, o.Logger)
|
||||
h.SetHeartbeatInterval(50 * time.Millisecond)
|
||||
h.SetSessionChecker(o.Auth)
|
||||
t.Cleanup(func() { _ = h.Close() })
|
||||
o.WS = h.Handler()
|
||||
})
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
c := dialWS(t, env.server.URL, sessionCookie(t, client, env.server.URL))
|
||||
c.send("hello", map[string]any{"role": "admin"})
|
||||
c.recv("snapshot")
|
||||
|
||||
// 连收三个心跳仍活着,说明会话复查没有误伤。
|
||||
for i := 0; i < 3; i++ {
|
||||
c.recv("heartbeat")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 测试用 WebSocket 客户端 ----------
|
||||
|
||||
type wsFrame struct {
|
||||
event string
|
||||
err error
|
||||
code websocket.StatusCode
|
||||
}
|
||||
|
||||
type wsTestConn struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
conn *websocket.Conn
|
||||
frames chan wsFrame
|
||||
}
|
||||
|
||||
// dialWS 用浏览器会话 Cookie 打开 /ws,等价于管理端页面里的连接。
|
||||
func dialWS(t *testing.T, serverURL, cookie string) *wsTestConn {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
header := http.Header{}
|
||||
if cookie != "" {
|
||||
header.Set("Cookie", cookie)
|
||||
}
|
||||
conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(serverURL, "http")+"/ws",
|
||||
&websocket.DialOptions{HTTPHeader: header})
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatalf("连接 /ws: %v", err)
|
||||
}
|
||||
|
||||
c := &wsTestConn{t: t, ctx: ctx, cancel: cancel, conn: conn, frames: make(chan wsFrame, 64)}
|
||||
go c.readLoop()
|
||||
t.Cleanup(c.close)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *wsTestConn) readLoop() {
|
||||
defer close(c.frames)
|
||||
for {
|
||||
_, data, err := c.conn.Read(c.ctx)
|
||||
if err != nil {
|
||||
c.frames <- wsFrame{err: err, code: websocket.CloseStatus(err)}
|
||||
return
|
||||
}
|
||||
var env struct {
|
||||
Event string `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &env); err != nil {
|
||||
c.frames <- wsFrame{err: err}
|
||||
return
|
||||
}
|
||||
c.frames <- wsFrame{event: env.Event}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *wsTestConn) close() {
|
||||
_ = c.conn.CloseNow()
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
func (c *wsTestConn) send(event string, data any) {
|
||||
c.t.Helper()
|
||||
msg, err := json.Marshal(struct {
|
||||
Event string `json:"event"`
|
||||
Data any `json:"data"`
|
||||
}{Event: event, Data: data})
|
||||
if err != nil {
|
||||
c.t.Fatalf("序列化 %s 帧: %v", event, err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := c.conn.Write(ctx, websocket.MessageText, msg); err != nil {
|
||||
c.t.Fatalf("发送 %s: %v", event, err)
|
||||
}
|
||||
}
|
||||
|
||||
// recv 等待指定事件,跳过心跳等无关消息。
|
||||
func (c *wsTestConn) recv(want string) {
|
||||
c.t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case f, ok := <-c.frames:
|
||||
if !ok || f.err != nil {
|
||||
c.t.Fatalf("等待 %s 时连接已断开: %v", want, f.err)
|
||||
}
|
||||
if f.event == want {
|
||||
return
|
||||
}
|
||||
case <-time.After(time.Until(deadline)):
|
||||
}
|
||||
}
|
||||
c.t.Fatalf("等待事件 %s 超时", want)
|
||||
}
|
||||
|
||||
// waitClose 等待服务端关闭连接并返回关闭码;超时返回 0。
|
||||
func (c *wsTestConn) waitClose(d time.Duration) websocket.StatusCode {
|
||||
c.t.Helper()
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case f, ok := <-c.frames:
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if f.err != nil {
|
||||
return f.code
|
||||
}
|
||||
case <-timer.C:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sessionCookie 从登录后的 Cookie jar 里取会话 Cookie,拼成请求头。
|
||||
func sessionCookie(t *testing.T, client *http.Client, serverURL string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
t.Fatalf("解析测试服务地址: %v", err)
|
||||
}
|
||||
for _, c := range client.Jar.Cookies(u) {
|
||||
if c.Name == auth.CookieSession {
|
||||
return c.Name + "=" + c.Value
|
||||
}
|
||||
}
|
||||
t.Fatal("登录后应拿到会话 Cookie")
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealResult 是一次启动自愈的结果,供调用方记日志、也供测试断言「到底触发没有」。
|
||||
type HealResult struct {
|
||||
// Triggered 表示两个触发条件同时成立、本次确实执行了全量重算。
|
||||
Triggered bool
|
||||
// EventCount 是「有已发布名次的小项数」,即这次自愈要修的那些小项的规模。
|
||||
EventCount int
|
||||
// LedgerRows 是重算写回的积分流水条数(recomputeAllEvents 的返回值)。
|
||||
LedgerRows int
|
||||
}
|
||||
|
||||
// HealChannelRanksOnce 修「老库升级后奖牌榜整块归零」。
|
||||
//
|
||||
// 背景:迁移 0020 只建 channel_ranks 表、不回填历史名次;而奖牌统计与导出侧现在
|
||||
// 都只读这张表(httpapi.buildRanked / realtime.snapshot.loadRanked / export.loadRanked)。
|
||||
// 于是老库升级完成到「下一次发布 / 重算」之间表是空的,大屏与管理面板的金银铜全是 0,
|
||||
// 现场正开着运动会时这是不可接受的。启动时补一次全量重算即可把名次按通道重建出来。
|
||||
//
|
||||
// 触发条件(必须同时成立):
|
||||
// 1. channel_ranks 一行都没有。表非空说明发布 / 重算已经建过名次,不需要也不该再动它;
|
||||
// 2. results 里存在「已发布且名次为正」的行。说明库里确实有该显示在奖牌榜上的历史成绩。
|
||||
//
|
||||
// 两者同时成立才是「老库升级」这一种形态:全新库的表是空的但没有已发布成绩,
|
||||
// 无成绩的库同样是空的但没有名次可补,都不触发。
|
||||
// 规则包导入、发布、模板改动等既有路径本来就会重算(写成非空),
|
||||
// 所以自愈成功后下次启动自然不再触发。
|
||||
//
|
||||
// 调用方必须在**开始监听端口之前**同步调用:一旦对外服务,大屏会先拿到一帧空白奖牌榜。
|
||||
// 参数沿用 httpapi.Options,只用到 Logger 与 Event,方便 main 用同一份配置调用。
|
||||
func HealChannelRanksOnce(ctx context.Context, opts Options) (HealResult, error) {
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
if opts.Event == nil {
|
||||
return HealResult{}, errors.New("赛事库未接入,无法自愈通道名次")
|
||||
}
|
||||
|
||||
eventCount, needed, err := channelRankHealNeeded(ctx, opts.Event)
|
||||
if err != nil {
|
||||
return HealResult{}, err
|
||||
}
|
||||
if !needed {
|
||||
return HealResult{EventCount: eventCount}, nil
|
||||
}
|
||||
|
||||
logger.Info("检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算",
|
||||
"events", eventCount)
|
||||
|
||||
// 重新组装一个最小 server:recomputeAllEvents 只依赖 s.ev 与 s.logger,
|
||||
// 复用同一份实现(含事务语义)而不是另起一套重算,避免两条路径的结果漂移。
|
||||
srv := &server{logger: logger, ev: opts.Event}
|
||||
start := time.Now()
|
||||
rows, err := srv.recomputeAllEvents(ctx, 0)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
return HealResult{Triggered: true, EventCount: eventCount}, err
|
||||
}
|
||||
|
||||
logger.Info("一次性全量重算完成,通道名次与奖牌榜已补齐",
|
||||
"events", eventCount, "ledgerRows", rows, "elapsed", elapsed)
|
||||
return HealResult{Triggered: true, EventCount: eventCount, LedgerRows: rows}, nil
|
||||
}
|
||||
|
||||
// channelRankHealNeeded 读出自愈的两个判据,返回「有已发布名次的小项数」与是否需要触发。
|
||||
// 两条读数走同一次查询,避免两次查询之间库被改动导致判据互不自洽。
|
||||
func channelRankHealNeeded(ctx context.Context, event *sql.DB) (eventCount int, needed bool, err error) {
|
||||
var rankRows, publishedRankedEvents int64
|
||||
if err := event.QueryRowContext(ctx,
|
||||
`SELECT (SELECT COUNT(*) FROM channel_ranks),
|
||||
(SELECT COUNT(DISTINCT event_id) FROM results
|
||||
WHERE state = 'published' AND rank IS NOT NULL AND rank > 0)`,
|
||||
).Scan(&rankRows, &publishedRankedEvents); err != nil {
|
||||
return 0, false, fmt.Errorf("检查通道名次自愈条件: %w", err)
|
||||
}
|
||||
return int(publishedRankedEvents), rankRows == 0 && publishedRankedEvents > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||||
)
|
||||
|
||||
// 本文件只覆盖「老库升级自愈」:迁移 0020 只建 channel_ranks、不回填,
|
||||
// 升级后到下一次重算之间奖牌榜会全 0,启动自愈必须在监听端口前把它补回来。
|
||||
|
||||
// healResult 与 httpapi.HealResult 对应;测试里用别名只是为了让断言读起来短一点。
|
||||
type healResult = httpapi.HealResult
|
||||
|
||||
// healLogger 造一个把日志写进 buffer 的 logger:自愈触发与否既有返回值、也有日志,
|
||||
// 两个都断言,避免「返回值对了但根本没打日志」或反之。
|
||||
func healLogger(buf *bytes.Buffer) *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
}
|
||||
|
||||
// runHeal 以 httpapi.Options 最小形态调用自愈(只有 Event 是必需的)。
|
||||
func runHeal(t *testing.T, env *testEnv, logs *bytes.Buffer) healResult {
|
||||
t.Helper()
|
||||
res, err := httpapi.HealChannelRanksOnce(context.Background(),
|
||||
httpapi.Options{Logger: healLogger(logs), Event: env.ev})
|
||||
if err != nil {
|
||||
t.Fatalf("自愈失败: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// healStanding 是 GET /api/standings 里本文件关心的字段。
|
||||
type healStanding struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Gold int `json:"gold"`
|
||||
Silver int `json:"silver"`
|
||||
Bronze int `json:"bronze"`
|
||||
}
|
||||
|
||||
// medalsByTeam 读当前奖牌榜(只读 channel_ranks 的那张),返回「单位 → 金银铜」。
|
||||
func medalsByTeam(t *testing.T, env *testEnv) map[int64]healStanding {
|
||||
t.Helper()
|
||||
client, _ := env.client()
|
||||
status, resp := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("读奖牌榜应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
out := map[int64]healStanding{}
|
||||
for _, row := range decodeData[[]healStanding](t, resp) {
|
||||
out[row.TeamID] = row
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func channelRankCount(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM channel_ranks`).Scan(&n); err != nil {
|
||||
t.Fatalf("统计 channel_ranks: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func activeLedgerCount(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0`).Scan(&n); err != nil {
|
||||
t.Fatalf("统计流水: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// healSeedAndPublish 造出「老库」的原料:两个单位各一名运动员跑 100 米,发布后 A 金 B 银。
|
||||
// 不复用 rules_handlers_test.go 的 publishTwoResults,避免测试之间互相牵制。
|
||||
func healSeedAndPublish(t *testing.T, env *testEnv, ids seedIDs) {
|
||||
t.Helper()
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"requestId": "heal-draft-1",
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"requestId": "heal-publish-1",
|
||||
}, true)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// simulateOldDatabase 把库打回迁移 0020 刚跑完的样子:已发布成绩与 results.rank 都在,
|
||||
// 但 channel_ranks 是空的。此时奖牌榜必然全 0,正是升级当天会出的那个故障。
|
||||
func simulateOldDatabase(t *testing.T, env *testEnv) {
|
||||
t.Helper()
|
||||
if n := channelRankCount(t, env); n == 0 {
|
||||
t.Fatalf("发布后 channel_ranks 应非空,否则这条用例没在模拟老库")
|
||||
}
|
||||
env.exec(t, `DELETE FROM channel_ranks`)
|
||||
}
|
||||
|
||||
func TestHealChannelRanksRebuildsMedals(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
healSeedAndPublish(t, env, ids)
|
||||
|
||||
// 发布后的正常形态:A 金、B 银。
|
||||
before := medalsByTeam(t, env)
|
||||
if before[ids.teamA].Gold != 1 || before[ids.teamB].Silver != 1 {
|
||||
t.Fatalf("发布后应 A 金 B 银,得到 %+v", before)
|
||||
}
|
||||
|
||||
simulateOldDatabase(t, env)
|
||||
// 先确认故障确实复现:只读 channel_ranks 的奖牌榜整块归零。
|
||||
if broken := medalsByTeam(t, env); broken[ids.teamA].Gold != 0 || broken[ids.teamB].Silver != 0 {
|
||||
t.Fatalf("channel_ranks 清空后奖牌榜应为 0(故障复现),得到 %+v", broken)
|
||||
}
|
||||
|
||||
var logs bytes.Buffer
|
||||
res := runHeal(t, env, &logs)
|
||||
|
||||
if !res.Triggered {
|
||||
t.Fatalf("老库形态应触发自愈,得到 %+v", res)
|
||||
}
|
||||
if res.EventCount != 1 {
|
||||
t.Fatalf("影响的小项数应为 1,得到 %d", res.EventCount)
|
||||
}
|
||||
if res.LedgerRows <= 0 {
|
||||
t.Fatalf("重算应写回流水,得到 %d", res.LedgerRows)
|
||||
}
|
||||
if n := channelRankCount(t, env); n == 0 {
|
||||
t.Fatalf("自愈后 channel_ranks 应非空")
|
||||
}
|
||||
after := medalsByTeam(t, env)
|
||||
if after[ids.teamA].Gold != 1 || after[ids.teamB].Silver != 1 {
|
||||
t.Fatalf("自愈后奖牌榜应恢复 A 金 B 银,得到 %+v", after)
|
||||
}
|
||||
|
||||
logText := logs.String()
|
||||
if !strings.Contains(logText, "检测到通道名次为空但已有已发布成绩,正在执行一次性全量重算") {
|
||||
t.Fatalf("触发时应有一条中文 INFO,实际日志:\n%s", logText)
|
||||
}
|
||||
if !strings.Contains(logText, "一次性全量重算完成") {
|
||||
t.Fatalf("完成时应有一条中文 INFO,实际日志:\n%s", logText)
|
||||
}
|
||||
}
|
||||
|
||||
// channel_ranks 非空 = 名次已经建过,哪怕是老库也不该再动:不能触发重算。
|
||||
func TestHealChannelRanksSkipsWhenRanksPresent(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
healSeedAndPublish(t, env, ids)
|
||||
|
||||
before := channelRankCount(t, env)
|
||||
beforeLedger := activeLedgerCount(t, env)
|
||||
|
||||
var logs bytes.Buffer
|
||||
res := runHeal(t, env, &logs)
|
||||
|
||||
if res.Triggered {
|
||||
t.Fatalf("channel_ranks 非空不应触发,得到 %+v", res)
|
||||
}
|
||||
if n := channelRankCount(t, env); n != before {
|
||||
t.Fatalf("不应改动 channel_ranks:before=%d after=%d", before, n)
|
||||
}
|
||||
if n := activeLedgerCount(t, env); n != beforeLedger {
|
||||
t.Fatalf("不应改动流水:before=%d after=%d", beforeLedger, n)
|
||||
}
|
||||
if strings.Contains(logs.String(), "一次性全量重算") {
|
||||
t.Fatalf("未触发时不应有重算日志,实际日志:\n%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 没有「已发布且名次为正」的成绩时(全新库、只有草稿的库)都不该触发。
|
||||
func TestHealChannelRanksSkipsWithoutPublishedRanks(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
|
||||
// 全新库:channel_ranks 空,但没有已发布成绩。
|
||||
var logs bytes.Buffer
|
||||
if res := runHeal(t, env, &logs); res.Triggered {
|
||||
t.Fatalf("全新库不应触发,得到 %+v", res)
|
||||
}
|
||||
|
||||
// 只有草稿、且草稿连名次都填了:state=draft 仍不算「已发布」。
|
||||
env.exec(t,
|
||||
`INSERT INTO results (event_id, entry_id, result_value, result_text, unit, status, state, rank)
|
||||
VALUES (?, ?, 11.58, '11.58', 's', 'ok', 'draft', 1)`, ids.event, ids.entryA)
|
||||
logs.Reset()
|
||||
if res := runHeal(t, env, &logs); res.Triggered {
|
||||
t.Fatalf("只有草稿不应触发,得到 %+v", res)
|
||||
}
|
||||
if logs.Len() != 0 {
|
||||
t.Fatalf("未触发时不应有日志,实际:\n%s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 两次启动(两次调用)结果一致:第一次补上名次,第二次因表非空而跳过,数据不翻倍。
|
||||
func TestHealChannelRanksIsIdempotent(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
healSeedAndPublish(t, env, ids)
|
||||
simulateOldDatabase(t, env)
|
||||
|
||||
var firstLogs bytes.Buffer
|
||||
first := runHeal(t, env, &firstLogs)
|
||||
if !first.Triggered {
|
||||
t.Fatalf("第一次应触发,得到 %+v", first)
|
||||
}
|
||||
ranksAfterFirst := channelRankCount(t, env)
|
||||
ledgerAfterFirst := activeLedgerCount(t, env)
|
||||
medalsAfterFirst := medalsByTeam(t, env)
|
||||
|
||||
var secondLogs bytes.Buffer
|
||||
second := runHeal(t, env, &secondLogs)
|
||||
if second.Triggered {
|
||||
t.Fatalf("第二次不应再触发,得到 %+v", second)
|
||||
}
|
||||
if n := channelRankCount(t, env); n != ranksAfterFirst {
|
||||
t.Fatalf("两次调用后 channel_ranks 应一致:%d → %d", ranksAfterFirst, n)
|
||||
}
|
||||
if n := activeLedgerCount(t, env); n != ledgerAfterFirst {
|
||||
t.Fatalf("两次调用后流水应一致(不翻倍):%d → %d", ledgerAfterFirst, n)
|
||||
}
|
||||
if after := medalsByTeam(t, env); after[ids.teamA] != medalsAfterFirst[ids.teamA] ||
|
||||
after[ids.teamB] != medalsAfterFirst[ids.teamB] {
|
||||
t.Fatalf("两次调用后奖牌榜应一致:%+v → %+v", medalsAfterFirst, after)
|
||||
}
|
||||
|
||||
// 再换一个 logger 调一次(模拟重启后新建 logger 的进程),结论必须一样。
|
||||
discard := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if res, err := httpapi.HealChannelRanksOnce(context.Background(),
|
||||
httpapi.Options{Logger: discard, Event: env.ev}); err != nil || res.Triggered {
|
||||
t.Fatalf("第三次调用应跳过:res=%+v err=%v", res, err)
|
||||
}
|
||||
}
|
||||
@@ -502,6 +502,22 @@ func validateAthleteAge(age *int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAthleteGender 性别可以留空(历史档案里就有空的),填了就必须是「男」或「女」。
|
||||
//
|
||||
// athletes.gender 有 CHECK (gender IN ('男', '女')),不在写入侧拦下来,现场把性别填成
|
||||
// M / male 就只能看到 500 + 英文约束原文 —— 同一类错在组别就地更新路径上是 400。
|
||||
// 与组别那条(validateCategoryGender)同口径:不做 TrimSpace,带空格的值按非法拒绝,
|
||||
// 免得「校验通过、落库撞 CHECK」这种更难看的结果。
|
||||
func validateAthleteGender(gender *string) error {
|
||||
if gender == nil {
|
||||
return nil
|
||||
}
|
||||
if v := *gender; v != "" && v != "男" && v != "女" {
|
||||
return badRequest("gender 只能是 男 / 女", gender)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) {
|
||||
if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 {
|
||||
return athleteDTO{}, badRequest("name 与 teamId 必填", nil)
|
||||
@@ -509,6 +525,9 @@ func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO,
|
||||
if err := validateAthleteAge(in.Age); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
if err := validateAthleteGender(in.Gender); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
// 证件号与号码布都是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。
|
||||
if err := s.checkAthleteIdentity(ctx, in, 0); err != nil {
|
||||
return athleteDTO{}, err
|
||||
@@ -560,6 +579,9 @@ func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (at
|
||||
if err := validateAthleteAge(in.Age); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
if err := validateAthleteGender(in.Gender); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
if err := s.checkAthleteIdentity(ctx, in, id); err != nil {
|
||||
return athleteDTO{}, err
|
||||
}
|
||||
@@ -676,8 +698,9 @@ func (s *server) purgeAthlete(ctx context.Context, id int64) (athletePurge, erro
|
||||
return athletePurge{}, err
|
||||
}
|
||||
|
||||
// 重算:删完成绩后按组别重建流水,别人的名次与得分随新排序更新。
|
||||
// 只重建流水还不够——成绩表与奖牌统计看的是 results.rank,必须像发布那样把名次写回去。
|
||||
// 重算:删完成绩后按组别重建流水与通道名次,别人的名次与得分随新排序更新。
|
||||
// 只重建流水不够——成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去;
|
||||
// 奖牌榜与运动员榜看的是 channel_ranks,由 recomputeLedgerTx 一并重建。
|
||||
actorID := middleware.User(ctx).ID
|
||||
eventIDs := make([]int64, 0, len(scopes))
|
||||
for _, sc := range scopes {
|
||||
@@ -1210,6 +1233,19 @@ func (s *server) getProfile(ctx context.Context, id int64) (profileDTO, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// validateAwardRanks 校验积分模板的「录取名次」(scoring_profiles.award_ranks)。
|
||||
//
|
||||
// 口径照建表约束来(migrations/event/0003:CHECK (award_ranks >= 0)),不自己发明上限:
|
||||
// 0 表示不按名次封顶——算分引擎只在 award_ranks > 0 时才按名次截断(domain.basePoints),
|
||||
// 规则包侧(rules/validate.go)也只拒负数。不在这里拦,-1 会一路撞 CHECK 报 500 +
|
||||
// 原始约束文本,而同一张表的 tieMode / recordBonusMode 早就是 400。
|
||||
func validateAwardRanks(awardRanks int) error {
|
||||
if awardRanks < 0 {
|
||||
return badRequest("awardRanks 需要是不小于 0 的整数(0 表示不按名次封顶)", awardRanks)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) {
|
||||
if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" {
|
||||
return profileDTO{}, badRequest("code 与 name 必填", nil)
|
||||
@@ -1222,6 +1258,9 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
|
||||
if err != nil {
|
||||
return profileDTO{}, err
|
||||
}
|
||||
if err := validateAwardRanks(in.AwardRanks); err != nil {
|
||||
return profileDTO{}, err
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return profileDTO{}, internal(err)
|
||||
@@ -1248,6 +1287,15 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO,
|
||||
if err := tx.Commit(); err != nil {
|
||||
return profileDTO{}, internal(err)
|
||||
}
|
||||
// 新建的模板此刻还没有任何引用(sports.default_profile_id 与
|
||||
// event_score_channels.profile_id 都是指向 scoring_profiles(id) 的外键,
|
||||
// 模板必须先存在才挂得上),所以这一次重算实际上是空转;留在这里是为了与
|
||||
// 「改模板」走同一条落地路径,免得以后给新建流程加上「顺手挂到某处」的逻辑时漏掉重算。
|
||||
// recomputeAllEvents 自带事务与推榜,别再包一层。
|
||||
if _, err := s.recomputeAllEvents(ctx, middleware.User(ctx).ID); err != nil {
|
||||
s.logger.Error("积分模板新建后重算失败", "err", err)
|
||||
return profileDTO{}, internalMsg("积分模板已保存,但积分重算失败,成绩与积分尚未同步;请重试保存", err)
|
||||
}
|
||||
return s.getProfile(ctx, id)
|
||||
}
|
||||
|
||||
@@ -1260,6 +1308,9 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr
|
||||
if err != nil {
|
||||
return profileDTO{}, err
|
||||
}
|
||||
if err := validateAwardRanks(in.AwardRanks); err != nil {
|
||||
return profileDTO{}, err
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return profileDTO{}, internal(err)
|
||||
@@ -1288,6 +1339,12 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr
|
||||
if err := tx.Commit(); err != nil {
|
||||
return profileDTO{}, internal(err)
|
||||
}
|
||||
// 模板分值是算分公式的输入:改了就必须全量重算已发布成绩的积分与名次,并把新榜单推给大屏。
|
||||
// recomputeAllEvents 内部自建事务并写 standings 版本,这里不要再套事务(嵌套会死锁在单连接上)。
|
||||
if _, err := s.recomputeAllEvents(ctx, middleware.User(ctx).ID); err != nil {
|
||||
s.logger.Error("积分模板更新后重算失败", "err", err)
|
||||
return profileDTO{}, internalMsg("积分模板已保存,但积分重算失败,成绩与积分尚未同步;请重试保存", err)
|
||||
}
|
||||
return s.getProfile(ctx, id)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,16 +16,19 @@ import (
|
||||
// ---------- events ----------
|
||||
|
||||
const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode`
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
|
||||
award_medals`
|
||||
|
||||
func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) {
|
||||
var (
|
||||
e eventDTO
|
||||
scheduledAt, venue, note sql.NullString
|
||||
awardMedals int
|
||||
)
|
||||
err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType,
|
||||
&e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status,
|
||||
&scheduledAt, &venue, &e.SortOrder, ¬e, &e.AvgMode)
|
||||
&scheduledAt, &venue, &e.SortOrder, ¬e, &e.AvgMode, &awardMedals)
|
||||
e.AwardMedals = awardMedals != 0
|
||||
e.ScheduledAt = strPtr(scheduledAt)
|
||||
e.Venue = strPtr(venue)
|
||||
e.Remark = strPtr(note)
|
||||
@@ -124,12 +127,14 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
|
||||
award_medals)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.SportID, in.Code, in.Name,
|
||||
eventType, resultMode, resultType, compare,
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode)
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode,
|
||||
boolToInt(in.AwardMedals))
|
||||
if isUniqueViolation(err) {
|
||||
return eventDTO{}, conflict("小项编码已存在")
|
||||
}
|
||||
@@ -184,15 +189,25 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// 先读旧值:multiplier / avg_mode / scored / award_medals 这四个字段进算分与奖牌口径,
|
||||
// 「是否真变」决定要不要重算,拿不到旧值就无从判断。
|
||||
before, err := eventByIDQ(ctx, tx, id)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
newMultiplier := multiplierOrOne(in.Multiplier)
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?,
|
||||
result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?,
|
||||
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?
|
||||
status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ?, avg_mode = ?,
|
||||
award_medals = ?
|
||||
WHERE id = ?`,
|
||||
in.SportID, in.Code, in.Name,
|
||||
eventType, resultMode, resultType, compare,
|
||||
in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), status,
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode, id)
|
||||
in.RecordEligible, in.Scored, newMultiplier, status,
|
||||
nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), avgMode,
|
||||
boolToInt(in.AwardMedals), id)
|
||||
if isUniqueViolation(err) {
|
||||
return eventDTO{}, conflict("小项编码已存在")
|
||||
}
|
||||
@@ -205,9 +220,30 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
|
||||
if err := requireChanges(res, "小项不存在"); err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
// 只有这四个进公式的字段真的变了才重算:改名 / 换场地这类改动重算全项既慢又会
|
||||
// 无谓地作废、重写一遍流水。avgMode 之类的空值口径已在 normalize 阶段落成具体值,可直接比。
|
||||
recompute := multiplierChanged(before.Multiplier, newMultiplier) ||
|
||||
before.AvgMode != avgMode ||
|
||||
before.Scored != in.Scored ||
|
||||
before.AwardMedals != in.AwardMedals
|
||||
actorID := middleware.User(ctx).ID
|
||||
if recompute {
|
||||
score, err := recomputeLedgerTx(ctx, tx, id, 0, actorID, "小项计分配置变更重算")
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
}
|
||||
if err := s.bumpEvent(ctx, tx, id); err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
if recompute {
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return eventDTO{}, internal(err)
|
||||
}
|
||||
@@ -307,6 +343,10 @@ func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryD
|
||||
//
|
||||
// 被拦下的只是「增删组别」:改名 / 改年龄 / 改性别 / 改权重走 updateCategory 就地更新,
|
||||
// 所以这里的文案要点明范围,别让只改了个名字的人以为改名也不许。
|
||||
//
|
||||
// 字段校验与 updateCategory 共用同一套(validateCategoryGender / validateCategoryAges):
|
||||
// 新建或重排组别时这条路是唯一入口,漏校验就会让 ageMin=500、gender='M' 这样的值直接进库
|
||||
// (gender 撞 CHECK 报 500,年龄则静默入库),而就地更新路径又拒绝原样保存。
|
||||
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
|
||||
@@ -331,6 +371,12 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
|
||||
if strings.TrimSpace(c.Name) == "" {
|
||||
return nil, badRequest("组别 name 必填", i)
|
||||
}
|
||||
if err := validateCategoryGender(c.Gender); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateCategoryAges(c.AgeMin, c.AgeMax); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO event_categories (event_id, name, multiplier, gender, age_min, age_max, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
@@ -375,7 +421,8 @@ func (s *server) getCategory(ctx context.Context, eventID, categoryID int64) (ca
|
||||
}
|
||||
|
||||
// validateCategoryAges 年龄区间可以留空,填了就必须是 0–120 且下限不大于上限。
|
||||
// 整表替换的老路径不校验这两列,脏值进得去又改不动;就地更新这条路径补上。
|
||||
// 两条写入路径(整表替换与就地更新)共用:以前只有就地更新校验,整表替换能建出
|
||||
// ageMin=500 / ageMax=-3 的组别,脏值进得去、之后就地更新又拒绝原样保存。
|
||||
func validateCategoryAges(ageMin, ageMax *int) error {
|
||||
for _, v := range []*int{ageMin, ageMax} {
|
||||
if v != nil && (*v < 0 || *v > 120) {
|
||||
@@ -388,6 +435,21 @@ func validateCategoryAges(ageMin, ageMax *int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateCategoryGender 组别性别可以留空(= 不限),填了就必须是「男」或「女」。
|
||||
//
|
||||
// event_categories.gender 有 CHECK (gender IN ('男','女')),不拦就落成 500 + 原始约束文本;
|
||||
// 整表替换与就地更新共用这一条,两条路径的 400 语义才一致。
|
||||
// 刻意不做 TrimSpace:带空格的值按非法拒绝,「校验放行、落库撞 CHECK」比直接报错更难查。
|
||||
func validateCategoryGender(gender *string) error {
|
||||
if gender == nil {
|
||||
return nil
|
||||
}
|
||||
if v := *gender; v != "" && v != "男" && v != "女" {
|
||||
return badRequest("组别 gender 只能是 男 / 女", gender)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// multiplierRecomputeEpsilon 是「组别权重是否真的变了」的判定容差。
|
||||
//
|
||||
// 权重是算分公式的输入,改了就必须重算;但同一个数字经 JSON 往返一圈可能差 1e-16,
|
||||
@@ -414,8 +476,8 @@ func (s *server) updateCategory(ctx context.Context, eventID, categoryID int64,
|
||||
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 := validateCategoryGender(in.Gender); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := validateCategoryAges(in.AgeMin, in.AgeMax); err != nil {
|
||||
return nil, false, err
|
||||
@@ -516,7 +578,8 @@ func (s *server) updateCategoryHandler(w http.ResponseWriter, r *http.Request) (
|
||||
|
||||
func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
|
||||
count_to_medals, count_to_team_medals
|
||||
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -526,13 +589,18 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO,
|
||||
out := make([]channelDTO, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
c channelDTO
|
||||
countToTeam int
|
||||
c channelDTO
|
||||
countToTeam int
|
||||
countToMedals int
|
||||
countToTeamMedals int
|
||||
)
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
|
||||
&countToMedals, &countToTeamMedals); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
c.CountToTeam = boolPtr(countToTeam != 0)
|
||||
c.CountToMedals = boolPtr(countToMedals != 0)
|
||||
c.CountToTeamMedals = boolPtr(countToTeamMedals != 0)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -544,6 +612,12 @@ func channelCountsToTeam(c channelDTO) bool {
|
||||
return c.CountToTeam == nil || *c.CountToTeam
|
||||
}
|
||||
|
||||
// channelFlag 是通道两个奖牌开关的缺省口径:缺省(null)按 true 处理,
|
||||
// 与「升级前所有通道都发牌、个人奖牌计入单位榜」的既有行为一致。
|
||||
func channelFlag(v *bool) bool {
|
||||
return v == nil || *v
|
||||
}
|
||||
|
||||
func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -567,9 +641,11 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
|
||||
return nil, badRequest("通道 profileId 必填", i)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c))); err != nil {
|
||||
`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team,
|
||||
count_to_medals, count_to_team_medals)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c)),
|
||||
boolToInt(channelFlag(c.CountToMedals)), boolToInt(channelFlag(c.CountToTeamMedals))); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, conflict("同一 scope+basis 只能配置一个通道")
|
||||
}
|
||||
@@ -579,9 +655,23 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
|
||||
return nil, internal(err)
|
||||
}
|
||||
}
|
||||
// 这是「DELETE 全表 + 重新 INSERT」:通道 id 每次都变,挂在旧 id 上的 channel_ranks
|
||||
// 会被外键级联清空。不重算就会留下「通道换了、名次是空的」——大屏奖牌榜随之清零。
|
||||
// 名次与流水一起按新通道重建,并推榜单。团体手工名次不挂通道外键,录入数据不受影响。
|
||||
actorID := middleware.User(ctx).ID
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "通道配置变更重算")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
)
|
||||
|
||||
func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64, error) {
|
||||
@@ -25,6 +28,10 @@ type athleteImportRow struct {
|
||||
IDCard string
|
||||
Bib string
|
||||
Age *int
|
||||
// Duplicate 表示这一行是「同一份表里同一个证件号 + 同一个小项同一个组别」的重复行。
|
||||
// 同一个人报多个组别是合法的,只有落进同一个组别才是真重复;真重复两行的字段可能完全一样,
|
||||
// 但要不要用表里这行覆盖已经落库的报名必须由用户决定,所以它一律进冲突面板。
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// decisionKey 是冲突决策的键:优先证件号,没有证件号时用号码布。
|
||||
@@ -37,20 +44,25 @@ func (in athleteImportRow) decisionKey() string {
|
||||
|
||||
// athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。
|
||||
// SameNameNewPerson 非空表示本次按「同名的另一个人」新建了档案(只作报告,不拦截)。
|
||||
// Skip 表示本行是已决策为「保留已有」的真重复行:连报名也不写,库里那条原样不动。
|
||||
type athleteImportMatch struct {
|
||||
AthleteID int64
|
||||
TeamID int64
|
||||
Conflict *importConflict
|
||||
SameNameNewPerson *sameNameNewPerson
|
||||
Skip bool
|
||||
}
|
||||
|
||||
// sameNameNewPerson 是「号码布不同 → 按同名的另一个人新建」的清单项:
|
||||
// sameNameNewPerson 是「身份字段不同 → 按同名的另一个人新建」的清单项:
|
||||
// 导入响应里单独列出来给现场看,避免新建了档案却毫无提示。
|
||||
// 证件号与号码布都要带上:现场要核对的是「表里这两个号有没有写错」。
|
||||
type sameNameNewPerson struct {
|
||||
Name string `json:"name"`
|
||||
TeamCode string `json:"teamCode"`
|
||||
NewBib string `json:"newBib"`
|
||||
ExistingBib string `json:"existingBib"`
|
||||
Name string `json:"name"`
|
||||
TeamCode string `json:"teamCode"`
|
||||
NewIDCard string `json:"newIdCard"`
|
||||
ExistingIDCard string `json:"existingIdCard"`
|
||||
NewBib string `json:"newBib"`
|
||||
ExistingBib string `json:"existingBib"`
|
||||
}
|
||||
|
||||
// existingAthlete 是库中已有人员的可比字段。
|
||||
@@ -65,14 +77,30 @@ type existingAthlete struct {
|
||||
Age *int
|
||||
}
|
||||
|
||||
// matchImportedAthlete 按新口径匹配人员:号码布 → 姓名 + 单位 → 新建。
|
||||
// 号码布是单人唯一标识,证件号不参与匹配(只作为可选档案信息写入/更新)。
|
||||
// 命中人员字段与表里不一致时记为冲突;decisions 里对同一身份有决策时按决策落库。
|
||||
// matchImportedAthlete 按新口径匹配人员:证件号 → 号码布 → 姓名 + 单位 → 新建。
|
||||
//
|
||||
// 证件号才是人员的唯一标识,命中即认定同一个人(号码布差异按既有冲突口径处理,该进面板就进);
|
||||
// 证件号没命中(或表里没给)才退到号码布。号码布命中的那个人已经拿着另一个证件号时,
|
||||
// 说明「两个不同的人共用一个号码布」,这不是认人分歧而是数据错误,按既有号码布防呆口径报行级错误。
|
||||
func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
||||
if in.IDCard != "" {
|
||||
ex, err := s.athleteByIDCard(ctx, in.IDCard)
|
||||
switch {
|
||||
case err == nil:
|
||||
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
}
|
||||
if in.Bib != "" {
|
||||
ex, err := s.athleteByBib(ctx, in.Bib)
|
||||
switch {
|
||||
case err == nil:
|
||||
if in.IDCard != "" && ex.IDCard != nil && *ex.IDCard != "" && *ex.IDCard != in.IDCard {
|
||||
// 号码布在库里有主,且那个人的证件号与表里对不上:两个人共用一个号码布。
|
||||
// 沿用 createImportedAthlete 撞号码布唯一索引时的同一句话,现场看到的口径一致。
|
||||
return athleteImportMatch{}, conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib))
|
||||
}
|
||||
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return athleteImportMatch{}, internal(err)
|
||||
@@ -81,9 +109,9 @@ func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow,
|
||||
return s.matchByNameTeam(ctx, in, decisions)
|
||||
}
|
||||
|
||||
// matchByNameTeam 处理「号码布为空、或按号码布没找到」的情况,按姓名 + 单位找候选:
|
||||
// 0 条 → 新建;1 条且对方没有号码布 → 命中并回填;1 条但对方号码布不同 → 同名的另一个人,新建并报告;
|
||||
// 多条 → 仍有歧义,进冲突面板等用户决策。
|
||||
// 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 {
|
||||
@@ -94,18 +122,21 @@ func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decis
|
||||
return s.createImportedAthlete(ctx, in)
|
||||
case 1:
|
||||
ex := candidates[0]
|
||||
// 只有表里也给了号码布、且与库里不同时才算「同名的另一个人」:
|
||||
// 表里没给号码布只是「未指定」,不能凭空多建一个没有号码布的同名人。
|
||||
if ex.Bib != nil && *ex.Bib != "" && in.Bib != "" && *ex.Bib != in.Bib {
|
||||
// 两边都给了证件号 / 号码布、且对不上时才算「同名的另一个人」:
|
||||
// 一边没给只是「未指定」,不能凭空多建一个缺身份的同名人。
|
||||
// 证件号能对上的人前面就命中了,走到这里说明表里的证件号确实不属于他。
|
||||
if identityDiffers(ex.IDCard, in.IDCard) || identityDiffers(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,
|
||||
Name: in.Name,
|
||||
TeamCode: in.TeamCode,
|
||||
NewIDCard: in.IDCard,
|
||||
ExistingIDCard: strValue(ex.IDCard),
|
||||
NewBib: in.Bib,
|
||||
ExistingBib: strValue(ex.Bib),
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
@@ -123,15 +154,15 @@ func (s *server) matchAmbiguousCandidates(ctx context.Context, in athleteImportR
|
||||
if _, decided := decisions[in.decisionKey()]; decided {
|
||||
return s.matchExistingAthlete(ctx, in, ex, decisions)
|
||||
}
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changedAthleteFields(ex, in))
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changedAthleteFields(ex, in), in.Duplicate)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
return athleteImportMatch{Conflict: &conflict}, nil
|
||||
}
|
||||
|
||||
// createImportedAthlete 新建人员:证件号只作为可选档案信息写入,
|
||||
// 但它与库里另一个人撞号时仍然明确报错(id_card 的部分唯一索引保留不动,防的是录入错误)。
|
||||
// createImportedAthlete 新建人员:证件号是唯一标识,能走到新建说明它在库里还没有主,
|
||||
// 但录入错误 / 并发仍可能让唯一索引撞上,撞上时明确报出占用者是谁。
|
||||
func (s *server) createImportedAthlete(ctx context.Context, in athleteImportRow) (athleteImportMatch, error) {
|
||||
if err := s.idCardTakenError(ctx, in.IDCard, 0); err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
@@ -159,7 +190,8 @@ func (s *server) createImportedAthlete(ctx context.Context, in athleteImportRow)
|
||||
}
|
||||
|
||||
// idCardTakenError 检查证件号是否已被另一个人占用,占用时返回可直接给现场看的中文错误。
|
||||
// 证件号不参与匹配,但库里一个证件号只能属于一个人(唯一索引保留):撞号时必须报清楚占用者是谁,
|
||||
// 证件号优先匹配的人本来就会命中占用者、走冲突面板;这里兜的是「新建 / 回填一个库里已有主的证件号」
|
||||
// 这条异常路径(正常导入走不到,录入错误与并发下才会):撞号时必须报清楚占用者是谁,
|
||||
// 不允许静默改库或静默跳过。excludeID 是被更新的人(新建传 0),允许他自己继续持有该证件号。
|
||||
func (s *server) idCardTakenError(ctx context.Context, idCard string, excludeID int64) error {
|
||||
if idCard == "" {
|
||||
@@ -194,21 +226,35 @@ func importRowMessage(err error) string {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// matchExistingAthlete 处理号码布或姓名 + 单位命中的人员:无差异时直接复用并补空字段,
|
||||
// matchExistingAthlete 处理证件号 / 号码布 / 姓名 + 单位命中的人员:无差异时直接复用并补空字段,
|
||||
// 有差异时走冲突流程,decisions 里对该身份有决策才落库。
|
||||
// 真重复行(同证件号 + 同小项同组别)同样要用户拍板:未决策时进面板;已决策为保留已有时连报名都不写。
|
||||
func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
|
||||
changed := changedAthleteFields(ex, in)
|
||||
action, decided := decisions[in.decisionKey()]
|
||||
if in.Duplicate {
|
||||
if !decided {
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changed, true)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
return athleteImportMatch{Conflict: &conflict}, nil
|
||||
}
|
||||
if action == decisionKeepExisting {
|
||||
// 保留已有:人员字段不动,库里那条报名也不动——本行整个跳过。
|
||||
return athleteImportMatch{AthleteID: ex.ID, TeamID: ex.TeamID, Skip: true}, nil
|
||||
}
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
if err := s.backfillEmptyAthleteFields(ctx, ex, in); err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
return athleteImportMatch{AthleteID: ex.ID, TeamID: in.TeamID}, nil
|
||||
}
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changed)
|
||||
conflict, err := s.importConflictFor(ctx, in, ex, changed, false)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, err
|
||||
}
|
||||
action, decided := decisions[in.decisionKey()]
|
||||
switch {
|
||||
case !decided:
|
||||
return athleteImportMatch{Conflict: &conflict}, nil
|
||||
@@ -249,8 +295,14 @@ func (s *server) athleteByBib(ctx context.Context, bib string) (existingAthlete,
|
||||
`SELECT `+importAthleteColumns+` FROM athletes WHERE bib = ? LIMIT 1`, bib))
|
||||
}
|
||||
|
||||
// athleteByIDCard 按证件号找人员。证件号上有部分唯一索引,最多一行。
|
||||
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))
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -330,7 +382,7 @@ func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthl
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex existingAthlete, changed []string) (importConflict, error) {
|
||||
func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex existingAthlete, changed []string, duplicate bool) (importConflict, error) {
|
||||
existingCode, err := s.teamCodeByID(ctx, ex.TeamID)
|
||||
if err != nil {
|
||||
return importConflict{}, err
|
||||
@@ -340,6 +392,7 @@ func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex
|
||||
IDCard: in.IDCard,
|
||||
Bib: in.Bib,
|
||||
AthleteID: ex.ID,
|
||||
Duplicate: duplicate,
|
||||
Existing: importAthleteView{
|
||||
Name: ex.Name, Gender: ex.Gender, TeamCode: existingCode, EmployeeNo: ex.EmployeeNo,
|
||||
IDCard: ex.IDCard, Bib: ex.Bib, Age: ex.Age,
|
||||
@@ -388,39 +441,140 @@ func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in at
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回 true 表示新建。
|
||||
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, error) {
|
||||
var existing int64
|
||||
var err error
|
||||
// entryImportOutcome 是一次导入写入的落点,供调用方判断「要不要重算这一组」。
|
||||
//
|
||||
// 报名导入会改 entries.status(现场「改完名单重新导入一遍」很自然),
|
||||
// 而 status 正是「谁还算数」的开关:把正常参赛的人改成退赛/失格,
|
||||
// 他在名次与流水里就必须当场消失,否则会出现「名单说退赛、榜上还挂着分数与金牌」。
|
||||
type entryImportOutcome struct {
|
||||
// Created 表示这次是新建报名项:名单变了,团体平均口径(basis=aggregate + avg_mode=all)
|
||||
// 的分母跟着变,所以新建同样要重算这一组。
|
||||
Created bool
|
||||
// EventID / CategoryID 是这条报名项所在的 (小项, 组别);重算与推榜的最小单位。
|
||||
EventID int64
|
||||
CategoryID int64
|
||||
// StatusChanged 表示「可排名 / 不可排名」跨了界(registered ↔ scratched / dq / dns)。
|
||||
// 同一侧的状态改动(scratched → dq)不影响谁算数,不触发重算。
|
||||
StatusChanged bool
|
||||
}
|
||||
|
||||
// entryStatusRankable 与 domain.EntryStatus.rankable() 同一口径:
|
||||
// 空值按 registered 处理,只有 registered 可参与排名(见 domain/types.go)。
|
||||
// 那个方法是 domain 包内未导出的,httpapi 侧只能在此复述口径;domain 那边改了这里要一起改。
|
||||
func entryStatusRankable(status string) bool {
|
||||
return status == "" || status == string(domain.EntryStatusRegistered)
|
||||
}
|
||||
|
||||
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。
|
||||
//
|
||||
// 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把
|
||||
// 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。
|
||||
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImportOutcome, error) {
|
||||
var (
|
||||
existing int64
|
||||
oldStatus string
|
||||
err error
|
||||
)
|
||||
if in.AthleteID != nil {
|
||||
err = s.ev.QueryRowContext(ctx,
|
||||
`SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id = ?`,
|
||||
in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing)
|
||||
`SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id = ?`,
|
||||
in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing, &oldStatus)
|
||||
} else {
|
||||
err = s.ev.QueryRowContext(ctx,
|
||||
`SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`,
|
||||
in.CategoryID, in.TeamID).Scan(&existing)
|
||||
`SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`,
|
||||
in.CategoryID, in.TeamID).Scan(&existing, &oldStatus)
|
||||
}
|
||||
|
||||
status := orDefault(in.Status, "registered")
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), orDefault(in.Status, "registered")); err != nil {
|
||||
return false, err
|
||||
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status); err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
return true, nil
|
||||
return entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}, nil
|
||||
case err != nil:
|
||||
return false, err
|
||||
return entryImportOutcome{}, err
|
||||
default:
|
||||
if _, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE entries SET member_names = ?, bib = ?, seed = ?, heat = ?, status = ? WHERE id = ?`,
|
||||
marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat),
|
||||
orDefault(in.Status, "registered"), existing); err != nil {
|
||||
return false, err
|
||||
status, existing); err != nil {
|
||||
return entryImportOutcome{}, err
|
||||
}
|
||||
return false, nil
|
||||
return entryImportOutcome{
|
||||
EventID: in.EventID,
|
||||
CategoryID: in.CategoryID,
|
||||
StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// recomputeImportedScopes 重算报名导入动过的 (小项, 组别),并推小项与榜单。
|
||||
//
|
||||
// 改的是「谁还算数」,与批量退赛/恢复/移出、作废成绩、撤销发布同一口径:
|
||||
// 延迟结算(standings.live_on_publish=false)下也必须当场重算并推榜,
|
||||
// 否则名单里明明已经退赛的人会一直挂着名次与分数,直到下一次结算。
|
||||
//
|
||||
// 性能:调用方只把「新建过报名项」或「可排名状态跨了界」的组传进来,且已按组去重,
|
||||
// 所以几百行的导入通常只落到个位数组别;每组一次 recomputeLedgerTx。
|
||||
// 全部组共用一个事务:任一失败就整体回滚,不会留下「只重算了一半」的榜;
|
||||
// 榜单只在最后推一次,避免大屏收一串中间态(推榜次数也是用例的观察点)。
|
||||
func (s *server) recomputeImportedScopes(ctx context.Context, scopes []entryScope, actorID int64) error {
|
||||
if len(scopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
eventIDs := make([]int64, 0, 2)
|
||||
for _, scope := range scopes {
|
||||
// 改成退赛 / 失格的人不能再占名次:results.rank 这一列必须让出来。
|
||||
// applyComputedRanks 只写不清,不清就会留下「名单说退赛、名次还挂在他名下」——
|
||||
// 导出的分项成绩表读的正是这一列,会把他继续列成第 N 名。
|
||||
// 与批量退赛同口径(entries_batch 对退赛行显式 UPDATE ... rank = NULL)。
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE results SET rank = NULL
|
||||
WHERE state = 'published' AND rank IS NOT NULL
|
||||
AND entry_id IN (SELECT id FROM entries
|
||||
WHERE event_id = ? AND category_id = ? AND status <> 'registered')`,
|
||||
scope.eventID, scope.categoryID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
score, err := recomputeLedgerTx(ctx, tx, scope.eventID, scope.categoryID, actorID, "报名导入后重算")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 只重建流水不够:成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去。
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return err
|
||||
}
|
||||
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 err
|
||||
}
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// addEntryScope 把受影响范围按出现顺序去重:同一批导入里每个 (小项, 组别) 只重算一次。
|
||||
func addEntryScope(scopes []entryScope, eventID, categoryID int64) []entryScope {
|
||||
scope := entryScope{eventID: eventID, categoryID: categoryID}
|
||||
if slices.Contains(scopes, scope) {
|
||||
return scopes
|
||||
}
|
||||
return append(scopes, scope)
|
||||
}
|
||||
@@ -570,20 +570,24 @@ func (s *server) domainAthletes(ctx context.Context, q queryer) ([]domain.Athlet
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// buildRanked 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。
|
||||
// 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致;
|
||||
// 只排名不计分(scored=0)的项目不产出奖牌,必须与导出侧同口径。
|
||||
// buildRanked 从 channel_ranks 读名次明细(用于奖牌统计与个人榜)。
|
||||
//
|
||||
// 数据源是「发布/重算时按通道全量重建」的 channel_ranks,而不是 results.rank:
|
||||
// 后者只是个人名次(见 applyComputedRanks),团体名次与聚合名次根本没有对应的成绩行。
|
||||
// 三个奖牌开关随行携带(events.award_medals + 通道两个开关),domain 的榜单函数不必回查库。
|
||||
// 只排名不计分(scored=0)的项目不产出奖牌,必须与导出侧同口径(export/load.loadRanked)。
|
||||
func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedResult, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status,
|
||||
e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id
|
||||
FROM results r
|
||||
JOIN entries e ON e.id = r.entry_id
|
||||
JOIN events ev ON ev.id = r.event_id AND ev.scored = 1
|
||||
JOIN event_score_channels c ON c.event_id = r.event_id
|
||||
LEFT JOIN athletes a ON a.id = e.athlete_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
|
||||
AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)`)
|
||||
`SELECT COALESCE(cr.result_id, 0), cr.event_id, cr.category_id, cr.rank, cr.result_value, cr.status,
|
||||
COALESCE(cr.entry_id, 0), COALESCE(cr.team_id, 0), COALESCE(cr.athlete_id, 0),
|
||||
COALESCE(a.name, ''), cr.scope, cr.channel_id, cr.basis, cr.subject_key,
|
||||
ev.award_medals, c.count_to_medals, c.count_to_team_medals
|
||||
FROM channel_ranks cr
|
||||
JOIN events ev ON ev.id = cr.event_id AND ev.scored = 1
|
||||
JOIN event_score_channels c ON c.id = cr.channel_id
|
||||
LEFT JOIN athletes a ON a.id = cr.athlete_id
|
||||
WHERE cr.rank > 0
|
||||
ORDER BY cr.event_id, cr.category_id, c.sort_order, c.id, cr.rank, cr.id`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
@@ -592,27 +596,61 @@ func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedRes
|
||||
out := make([]domain.RankedResult, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
rank int
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
status sql.NullString
|
||||
awardMedals int
|
||||
countToMedals int
|
||||
countToTeamMedals int
|
||||
)
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
|
||||
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rr.Rank, &value, &status,
|
||||
&rr.EntryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID,
|
||||
&rr.Basis, &rr.SubjectKey, &awardMedals, &countToMedals, &countToTeamMedals); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
rr.Rank = rank
|
||||
rr.Status = domain.ResultStatus(status.String)
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
rr.ResultValue = &v
|
||||
}
|
||||
rr.AthleteID = athleteID.Int64
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
// 归属份额沿用「主单位独占 100%」:team_id 为空(理论上不会)时不造份额,
|
||||
// 免得给 0 号单位凭空记牌,与 domain 的 wholeShare 一致。
|
||||
if rr.TeamID != 0 {
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
}
|
||||
rr.AwardMedals = awardMedals != 0
|
||||
rr.CountToMedals = countToMedals != 0
|
||||
rr.CountToTeamMedals = countToTeamMedals != 0
|
||||
out = append(out, rr)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// breakdownRankExpr 取一条单位流水的名次(devdocs 17 §11.3):优先按流水对应的通道与主体
|
||||
// 关联 channel_ranks,关联不上时回落到 results.rank。
|
||||
//
|
||||
// score_ledger 上没有通道列(LedgerEntry.ChannelID 没有落库),只能按流水自身能确定的主体找:
|
||||
// - 有 entry_id 的成绩流水:按报名项关联,多通道命中时取「主通道」(scope=athlete 优先,其次 sort_order);
|
||||
// - entry_id / result_id 都为空的聚合 / 团体手工名次流水:写入侧把 source_id 退化为通道 id,就用它 + 主体关联。
|
||||
//
|
||||
// 组别用该行能唯一确定的那一个:报名项的组别(cat)优先;聚合流水没有报名项,
|
||||
// 靠 ca 回推(该单位在本小项只参加了一个组别时那个组别才是答案,见下方子查询)。
|
||||
// 回推不出来也不按「碰巧撞上的另一组」报数:放宽到不限组别取最优名次,宁可留空也不张冠李戴。
|
||||
const breakdownRankExpr = `COALESCE((
|
||||
SELECT cr.rank
|
||||
FROM channel_ranks cr
|
||||
JOIN event_score_channels ch ON ch.id = cr.channel_id
|
||||
WHERE cr.event_id = l.event_id
|
||||
AND ((cat.id IS NOT NULL AND cr.category_id = cat.id)
|
||||
OR (cat.id IS NULL AND (ca.id IS NULL OR cr.category_id = ca.id)))
|
||||
AND ((l.entry_id IS NOT NULL AND cr.entry_id = l.entry_id)
|
||||
OR (l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result'
|
||||
AND cr.channel_id = l.source_id
|
||||
AND ((cr.scope = 'team' AND cr.team_id = l.team_id)
|
||||
OR (cr.scope = 'athlete' AND cr.athlete_id = l.athlete_id))))
|
||||
ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id
|
||||
LIMIT 1), r.rank)`
|
||||
|
||||
func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdownDTO, error) {
|
||||
team, err := s.getTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
@@ -628,17 +666,28 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
|
||||
if weight <= 0 {
|
||||
weight = 1
|
||||
}
|
||||
// 名次不再只读 results.rank:那是个人名次(见 applyComputedRanks),
|
||||
// 团体名次与聚合名次只落在 channel_ranks,读旧列会让下钻的名次列整列变空。
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name,
|
||||
l.athlete_id, a.name, r.rank, r.result_text, l.points, COALESCE(l.created_at, '')
|
||||
l.athlete_id, a.name, `+breakdownRankExpr+`, r.result_text, l.points, COALESCE(l.created_at, '')
|
||||
FROM score_ledger l
|
||||
LEFT JOIN events ev ON ev.id = l.event_id
|
||||
LEFT JOIN entries e ON e.id = l.entry_id
|
||||
LEFT JOIN event_categories cat ON cat.id = e.category_id
|
||||
LEFT JOIN (
|
||||
SELECT en2.event_id, MIN(en2.category_id) AS category_id
|
||||
FROM entries en2
|
||||
WHERE en2.team_id = ?
|
||||
GROUP BY en2.event_id
|
||||
HAVING COUNT(DISTINCT en2.category_id) = 1
|
||||
) ac ON ac.event_id = l.event_id AND l.entry_id IS NULL AND l.result_id IS NULL
|
||||
AND l.source_type = 'result'
|
||||
LEFT JOIN event_categories ca ON ca.id = ac.category_id
|
||||
LEFT JOIN athletes a ON a.id = l.athlete_id
|
||||
LEFT JOIN results r ON r.id = l.result_id
|
||||
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
|
||||
ORDER BY l.id DESC`, teamID)
|
||||
ORDER BY l.id DESC`, teamID, teamID)
|
||||
if err != nil {
|
||||
return teamBreakdownDTO{}, internal(err)
|
||||
}
|
||||
|
||||
@@ -191,6 +191,12 @@ func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, i
|
||||
if it.EntryID == 0 {
|
||||
return nil, badRequest("results[].entryId 必填", i)
|
||||
}
|
||||
// 名次是手填名次项目的输入,results.rank 的约束是 rank IS NULL OR rank >= 1:
|
||||
// 留空 = 不计名次(合法),填了就必须不小于 1。不在这里拦就会一路落到 SQLite,
|
||||
// 报成 500 + 原始约束文本,录入员看不出是哪一格填错了。
|
||||
if it.Rank != nil && *it.Rank < 1 {
|
||||
return nil, badRequest("名次需要是不小于 1 的整数(留空表示不计名次)", it.Rank)
|
||||
}
|
||||
var entryEvent int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, it.EntryID).Scan(&entryEvent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi_test
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -111,3 +112,53 @@ func TestSaveDraftKeepsUnitWhenOmitted(t *testing.T) {
|
||||
t.Fatalf("显式传 unit 应覆盖为 min,得到 %+v", unit)
|
||||
}
|
||||
}
|
||||
|
||||
// 手填名次只接受「不传」或「>= 1」:0 / 负数撞 results.rank 的 CHECK
|
||||
// (rank IS NULL OR rank >= 1),落库才炸就是 500 + 原始约束文本,
|
||||
// 录入员看到「服务端异常」根本不知道是哪一格错了。
|
||||
func TestSaveDraftRejectsNonPositiveRank(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
for _, rank := range []int{0, -1} {
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "rank": rank, "resultValue": 11.58, "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("rank=%d 应 400 VALIDATION_FAILED,得到 %d %+v", rank, status, env2.Error)
|
||||
}
|
||||
if !strings.Contains(env2.Error.Message, "名次") {
|
||||
t.Fatalf("400 文案应点明「名次」与合法范围,得到 %q", env2.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// 整批拒绝:非法名次不该把同一次请求里的其它行顺手写进去。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM results`); n != 0 {
|
||||
t.Fatalf("被拒的存草稿不该写库,得到 %d 条成绩", n)
|
||||
}
|
||||
|
||||
// 留空(不传 rank)与 rank=1 都是合法输入,照常保存。
|
||||
status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
|
||||
"eventId": ids.event,
|
||||
"results": []map[string]any{
|
||||
{"entryId": ids.entryA, "resultValue": 11.58, "status": "ok"},
|
||||
{"entryId": ids.entryB, "rank": 1, "resultValue": 11.92, "status": "ok"},
|
||||
},
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("留空名次与 rank=1 应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
var rank sql.NullInt64
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&rank); err != nil {
|
||||
t.Fatalf("读名次: %v", err)
|
||||
}
|
||||
if !rank.Valid || rank.Int64 != 1 {
|
||||
t.Fatalf("rank=1 应落库,得到 %+v", rank)
|
||||
}
|
||||
}
|
||||
@@ -321,3 +321,172 @@ func TestAthleteAgeReadWriteAndRange(t *testing.T) {
|
||||
t.Fatalf("越界的 400 不该改库中年龄,得到 %+v", age)
|
||||
}
|
||||
}
|
||||
|
||||
// 运动员性别与组别同口径:非法值在接口层就 400(带字段名与非法值),
|
||||
// 不再原样落库撞 athletes.gender 的 CHECK 报 500 + 英文约束原文。
|
||||
func TestAthleteGenderInvalidIsBadRequest(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
ids := env.seed(t)
|
||||
env.createUser("recorder", "recorder-pass", "recorder")
|
||||
client, _ := env.client()
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
for _, gender := range []string{"M", "male", "未知"} {
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
||||
"name": "张三", "gender": gender, "teamId": ids.teamA, "active": true,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("新建 gender=%q 应 400 VALIDATION_FAILED,得到 %d %+v", gender, status, env2.Error)
|
||||
}
|
||||
if !strings.Contains(env2.Error.Message, "gender") || !strings.Contains(env2.Error.Message, "男") {
|
||||
t.Fatalf("400 文案应点明字段与合法取值,得到 %q", env2.Error.Message)
|
||||
}
|
||||
if !strings.Contains(string(env2.Error.Detail), gender) {
|
||||
t.Fatalf("400 的 detail 应带上非法值 %q,得到 %s", gender, env2.Error.Detail)
|
||||
}
|
||||
|
||||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athleteA), map[string]any{
|
||||
"name": "张伟", "gender": gender, "teamId": ids.teamA, "active": true,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("改 gender=%q 应 400 VALIDATION_FAILED,得到 %d %+v", gender, status, env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// 被拒的两次都没改库:性别仍是「男」。
|
||||
var gender string
|
||||
if err := env.ev.QueryRow(`SELECT gender FROM athletes WHERE id = ?`, ids.athleteA).Scan(&gender); err != nil {
|
||||
t.Fatalf("读性别: %v", err)
|
||||
}
|
||||
if gender != "男" {
|
||||
t.Fatalf("400 不该改库中性别,得到 %q", gender)
|
||||
}
|
||||
|
||||
// 留空(历史档案里就有空性别)与「男 / 女」照常可写。
|
||||
for _, g := range []any{nil, "女"} {
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athleteA), map[string]any{
|
||||
"name": "张伟", "gender": g, "teamId": ids.teamA, "active": true,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("gender=%v 应可保存,得到 %d %+v", g, status, env2.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 积分模板的「录取名次」(scoring_profiles.award_ranks)在写入侧没有校验:
|
||||
// -1 一路落库撞建表 CHECK(migrations/event/0003:CHECK (award_ranks >= 0)),
|
||||
// 报出来是 500 + 原始约束文本,而同表的 tieMode / recordBonusMode 早就是 400
|
||||
// (见 enums_test.go 的 TestProfileEnumValidation)。这里钉住新建与更新两条路径。
|
||||
func TestProfileAwardRanksValidation(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
client := recorderClient(t, env)
|
||||
|
||||
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
||||
"code": "P-BAD", "name": "非法录取名次", "awardRanks": -1,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("awardRanks=-1 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if !strings.Contains(resp.Error.Message, "awardRanks") {
|
||||
t.Fatalf("400 文案应点明字段名,得到 %q", resp.Error.Message)
|
||||
}
|
||||
if !strings.Contains(string(resp.Error.Detail), "-1") {
|
||||
t.Fatalf("400 的 detail 应带上非法值 -1,得到 %s", resp.Error.Detail)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'P-BAD'`); n != 0 {
|
||||
t.Fatalf("被拒的模板不该落库,得到 %d 行", n)
|
||||
}
|
||||
|
||||
// 合法值:0 表示不按名次封顶(domain.basePoints 只在 award_ranks > 0 时才截断),正整数照常。
|
||||
for _, want := range []int{0, 8} {
|
||||
status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
||||
"code": fmt.Sprintf("P-OK-%d", want), "name": "合法录取名次", "awardRanks": want,
|
||||
}, true)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("awardRanks=%d 应 200,得到 %d %+v", want, status, resp.Error)
|
||||
}
|
||||
if got := decodeData[map[string]any](t, resp)["awardRanks"]; got != float64(want) {
|
||||
t.Fatalf("awardRanks=%d 应原样落库,得到 %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新路径同口径。
|
||||
status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
||||
"code": "P-UPD", "name": "待改模板", "awardRanks": 3,
|
||||
}, true)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("建待改模板应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
profileID := int64(decodeData[map[string]any](t, resp)["id"].(float64))
|
||||
path := fmt.Sprintf("/api/scoring-profiles/%d", profileID)
|
||||
|
||||
status, resp = env.do(client, http.MethodPut, path, map[string]any{
|
||||
"code": "P-UPD", "name": "待改模板", "awardRanks": -5,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("更新 awardRanks=-5 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if !strings.Contains(resp.Error.Message, "awardRanks") {
|
||||
t.Fatalf("更新的 400 文案也应点明字段名,得到 %q", resp.Error.Message)
|
||||
}
|
||||
var stored int
|
||||
if err := env.ev.QueryRow(`SELECT award_ranks FROM scoring_profiles WHERE id = ?`, profileID).Scan(&stored); err != nil {
|
||||
t.Fatalf("读录取名次: %v", err)
|
||||
}
|
||||
if stored != 3 {
|
||||
t.Fatalf("被拒的更新不该改库,得到 %d", stored)
|
||||
}
|
||||
|
||||
if status, resp = env.do(client, http.MethodPut, path, map[string]any{
|
||||
"code": "P-UPD", "name": "待改模板", "awardRanks": 4,
|
||||
}, true); status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("更新 awardRanks=4 应 200,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT award_ranks FROM scoring_profiles WHERE id = ?`, profileID).Scan(&stored); err != nil {
|
||||
t.Fatalf("读录取名次: %v", err)
|
||||
}
|
||||
if stored != 4 {
|
||||
t.Fatalf("合法更新应落库 4,得到 %d", stored)
|
||||
}
|
||||
}
|
||||
|
||||
// last_place_zero 没有补写入侧校验,因为它取不到 CHECK (last_place_zero IN (0, 1)) 之外的值:
|
||||
// profileDTO.LastPlaceZero 是 Go 的 bool,落库的 0/1 由类型直接决定,传数字会被 JSON 解码
|
||||
// 拦成 400(而不是 500)。这条用例把「本来就有的保证」钉住,以后谁把字段改成 int 就会变红。
|
||||
func TestProfileLastPlaceZeroIsBooleanTyped(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
client := recorderClient(t, env)
|
||||
|
||||
status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
||||
"code": "P-LPZ-BAD", "name": "非布尔", "lastPlaceZero": 2,
|
||||
}, true)
|
||||
if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("lastPlaceZero=2 应 400 VALIDATION_FAILED(类型不合法),得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'P-LPZ-BAD'`); n != 0 {
|
||||
t.Fatalf("类型不合法的模板不该落库,得到 %d 行", n)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
code string
|
||||
set bool
|
||||
want int
|
||||
}{
|
||||
{"P-LPZ-TRUE", true, 1},
|
||||
{"P-LPZ-FALSE", false, 0},
|
||||
} {
|
||||
status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{
|
||||
"code": tc.code, "name": "布尔模板", "lastPlaceZero": tc.set,
|
||||
}, true)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("%s lastPlaceZero=%v 应 200,得到 %d %+v", tc.code, tc.set, status, resp.Error)
|
||||
}
|
||||
var stored int
|
||||
if err := env.ev.QueryRow(`SELECT last_place_zero FROM scoring_profiles WHERE code = ?`, tc.code).Scan(&stored); err != nil {
|
||||
t.Fatalf("读最后名次计分: %v", err)
|
||||
}
|
||||
if stored != tc.want {
|
||||
t.Fatalf("%s last_place_zero 应为 %d,得到 %d", tc.code, tc.want, stored)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,17 @@ func loadEventQ(ctx context.Context, q queryer, eventID int64) (domain.Event, er
|
||||
e domain.Event
|
||||
scheduledAt sql.NullString
|
||||
venue, remark sql.NullString
|
||||
awardMedals int
|
||||
)
|
||||
err := q.QueryRowContext(ctx,
|
||||
`SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode
|
||||
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
|
||||
award_medals
|
||||
FROM events WHERE id = ?`, eventID).
|
||||
Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType, &e.Compare,
|
||||
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark, &e.AvgMode)
|
||||
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark, &e.AvgMode,
|
||||
&awardMedals)
|
||||
e.AwardMedals = awardMedals != 0
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Event{}, notFound("小项不存在")
|
||||
}
|
||||
@@ -81,7 +85,8 @@ func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.Ev
|
||||
|
||||
func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team
|
||||
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
|
||||
count_to_medals, count_to_team_medals
|
||||
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -91,21 +96,66 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor
|
||||
out := make([]domain.ScoreChannel, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
c domain.ScoreChannel
|
||||
countToTeam int
|
||||
c domain.ScoreChannel
|
||||
countToTeam int
|
||||
countToMedals int
|
||||
countToTeamMedals int
|
||||
)
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
|
||||
&countToMedals, &countToTeamMedals); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分)。
|
||||
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分 / 发奖牌)。
|
||||
if countToTeam == 0 {
|
||||
c.CountToTeam = boolPtr(false)
|
||||
}
|
||||
if countToMedals == 0 {
|
||||
c.CountToMedals = boolPtr(false)
|
||||
}
|
||||
if countToTeamMedals == 0 {
|
||||
c.CountToTeamMedals = boolPtr(false)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// loadTeamRanksQ 读「按单位录入的团体名次」(channel_manual_ranks),按组别分组返回。
|
||||
//
|
||||
// 按组别分组是必须的:computeEventScore 会逐组别调用引擎,组别名次只在组别内有效,
|
||||
// 一次查询整小项再分发,避免每个组别各发一条 SQL。
|
||||
// 只取 scope=team + basis=manual 的行:这两列是这张表的定位键,
|
||||
// 其它 scope/basis 的行属于别的通道形态,引擎的团体手工通道也不该看到它们。
|
||||
func loadTeamRanksQ(ctx context.Context, q queryer, eventID, categoryID int64) (map[int64][]domain.TeamManualRank, error) {
|
||||
query := `SELECT category_id, team_id, rank FROM channel_manual_ranks
|
||||
WHERE event_id = ? AND scope = 'team' AND basis = 'manual'`
|
||||
args := []any{eventID}
|
||||
if categoryID > 0 {
|
||||
query += ` AND category_id = ?`
|
||||
args = append(args, categoryID)
|
||||
}
|
||||
query += ` ORDER BY category_id, rank, team_id`
|
||||
|
||||
rows, err := q.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64][]domain.TeamManualRank)
|
||||
for rows.Next() {
|
||||
var (
|
||||
catID int64
|
||||
r domain.TeamManualRank
|
||||
)
|
||||
if err := rows.Scan(&catID, &r.TeamID, &r.Rank); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out[catID] = append(out[catID], r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadProfilesQ(ctx context.Context, q queryer, ids map[int64]bool) (map[int64]domain.ScoringProfile, error) {
|
||||
out := make(map[int64]domain.ScoringProfile, len(ids))
|
||||
for id := range ids {
|
||||
@@ -309,6 +359,10 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
teamRanks, err := loadTeamRanksQ(ctx, q, eventID, categoryID)
|
||||
if err != nil {
|
||||
return domain.EventScore{}, err
|
||||
}
|
||||
|
||||
var total domain.EventScore
|
||||
for _, cat := range cats {
|
||||
@@ -324,6 +378,8 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
|
||||
Results: results,
|
||||
RecordBonus: &bonusCfg,
|
||||
RecordBreaks: breaks,
|
||||
// 每个组别只拿自己组别的团体名次:名次在组别内有效,串组会算出别人的分。
|
||||
TeamRanks: teamRanks[cat.ID],
|
||||
})
|
||||
if err != nil {
|
||||
return domain.EventScore{}, mapDomainError(err)
|
||||
@@ -373,25 +429,25 @@ func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eve
|
||||
|
||||
ledger := score.Ledger
|
||||
if categoryID > 0 {
|
||||
// 聚合流水无法按组别区分,上面已把本小项的全部聚合流水一并作废;
|
||||
// 这里补算整项的聚合流水,避免按组别重算丢掉其它组别的聚合得分。
|
||||
hasAggregate, err := hasAggregateChannel(ctx, tx, eventID)
|
||||
// 「无报名项归属」的流水无法按组别区分,上面已把本小项的全部这类流水一并作废;
|
||||
// 这里补算整项的同类流水,避免按组别重算丢掉其它组别的得分。
|
||||
hasSubject, err := hasSubjectChannel(ctx, tx, eventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasAggregate {
|
||||
if hasSubject {
|
||||
all, err := computeEventScore(ctx, tx, eventID, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
|
||||
for _, l := range score.Ledger {
|
||||
if !isAggregateLedger(l) {
|
||||
if !isSubjectLedger(l) {
|
||||
ledger = append(ledger, l)
|
||||
}
|
||||
}
|
||||
for _, l := range all.Ledger {
|
||||
if isAggregateLedger(l) {
|
||||
if isSubjectLedger(l) {
|
||||
ledger = append(ledger, l)
|
||||
}
|
||||
}
|
||||
@@ -406,35 +462,98 @@ func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eve
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
// 名次与流水同源同事务:流水改了名次必然跟着变,分开落库就会出现「榜上有分、名次还是旧的」。
|
||||
return writeChannelRanksTx(ctx, tx, score, eventID, categoryID)
|
||||
}
|
||||
|
||||
// isAggregateLedger 判断流水是否来自聚合通道:聚合名次的主体是单位/运动员,
|
||||
// 落库时 entry_id 与 result_id 都为空。
|
||||
func isAggregateLedger(l domain.LedgerEntry) bool {
|
||||
// isSubjectLedger 判断流水是否来自「按主体排名」的通道,这类流水的 EntryID 与 ResultID 都为空:
|
||||
//
|
||||
// - 聚合通道(basis=aggregate):名次主体是单位(team)或运动员(athlete),
|
||||
// 不对应任何一条报名项;
|
||||
// - 团体手工通道(scope=team + basis=manual,主体来自 channel_manual_ranks):
|
||||
// 录入的是单位名次,同样没有报名项与成绩行(走团体报名项兜底时才有 entry_id,
|
||||
// 那种流水仍归属组别,不算在这里)。
|
||||
//
|
||||
// 判据必须涵盖这两类:按组别重算时它们都无法靠 entry_id 定位(见 writeLedgerTx 的作废范围),
|
||||
// 少认一类就会让另一类旧流水作废不掉或补不回来,该通道的分数随之翻倍或丢失。
|
||||
func isSubjectLedger(l domain.LedgerEntry) bool {
|
||||
return l.SourceType == domain.SourceResult && l.EntryID == 0 && l.ResultID == 0
|
||||
}
|
||||
|
||||
// hasAggregateChannel 判断小项是否配置了聚合通道;没有时按组别重算无需补算整项。
|
||||
func hasAggregateChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, error) {
|
||||
// hasSubjectChannel 判断小项是否配置了「按主体排名」的通道(聚合 / 团体手工);
|
||||
// 没有时按组别重算无需补算整项流水。
|
||||
func hasSubjectChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, error) {
|
||||
var n int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM event_score_channels WHERE event_id = ? AND basis = 'aggregate'`, eventID).Scan(&n); err != nil {
|
||||
`SELECT COUNT(*) FROM event_score_channels
|
||||
WHERE event_id = ? AND (basis = 'aggregate' OR (scope = 'team' AND basis = 'manual'))`, eventID).Scan(&n); err != nil {
|
||||
return false, internal(err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。
|
||||
// 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。
|
||||
// writeChannelRanksTx 按引擎产出的名次明细全量重建 channel_ranks:先删范围内旧行,再逐条插入。
|
||||
//
|
||||
// 结算范围与流水一致:categoryID > 0 只重建该组别,否则重建整个小项。
|
||||
// SubjectKey 是 (通道, 组别, 主体) 的唯一键,聚合与团体手工名次没有报名项,靠它落库。
|
||||
// AwardMedals / CountToMedals / CountToTeamMedals 是读取侧 join events / event_score_channels
|
||||
// 得到的,不落这张表;shares 不进表(当前只有「主单位独占 100%」一种口径,读取侧按 team_id 重建)。
|
||||
func writeChannelRanksTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID int64) error {
|
||||
if categoryID > 0 {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM channel_ranks
|
||||
WHERE category_id = ? AND channel_id IN (SELECT id FROM event_score_channels WHERE event_id = ?)`,
|
||||
categoryID, eventID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
} else if _, err := tx.ExecContext(ctx, `DELETE FROM channel_ranks WHERE event_id = ?`, eventID); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
|
||||
for _, rr := range score.Ranked {
|
||||
// 没有通道(只排名不计分的小项走退化路径,通道 id 为 0)与非法名次都落不了这张表:
|
||||
// 表上 channel_id 有外键、rank 有 CHECK(rank >= 1),硬插只会整笔事务失败。
|
||||
if rr.ChannelID == 0 || rr.Rank <= 0 || rr.SubjectKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO channel_ranks (event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
entry_id, result_id, athlete_id, team_id, rank, points, result_value, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
eventID, rr.CategoryID, rr.ChannelID, rr.Basis, rr.Scope, rr.SubjectKey,
|
||||
nullID(rr.EntryID), nullID(rr.ResultID), nullID(rr.AthleteID), nullID(rr.TeamID),
|
||||
rr.Rank, rr.Points, nullF64(rr.ResultValue), nullStr(resultStatusPtr(rr.Status))); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resultStatusPtr 把成绩状态转成可空串:空状态落 NULL,与 results.status 的口径一致。
|
||||
func resultStatusPtr(s domain.ResultStatus) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
v := string(s)
|
||||
return &v
|
||||
}
|
||||
|
||||
// applyComputedRanks 把引擎算出的「个人名次」写回 results.rank。
|
||||
//
|
||||
// results.rank 的语义收窄为个人名次:只处理 scope=athlete 且带真实成绩行的名次。
|
||||
// 团体通道(scope=team)与聚合 / 团体手工名次(ResultID=0)一律不碰这一列——
|
||||
// 它们过去把个人名次覆盖成团体名次,双通道项目第二次发布会把同一份名次再发一次分(P0)。
|
||||
//
|
||||
// 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次),
|
||||
// 其次通道 id 小者,保证同一份输入每次写回同一个值。
|
||||
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
|
||||
best := make(map[int64]domain.RankedResult)
|
||||
for _, rr := range ranked {
|
||||
if rr.ResultID == 0 || rr.Rank <= 0 {
|
||||
if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
|
||||
continue
|
||||
}
|
||||
cur, ok := best[rr.ResultID]
|
||||
if !ok || (cur.Scope == domain.ScopeTeam && rr.Scope == domain.ScopeAthlete) {
|
||||
if !ok || preferComputedRank(rr, cur) {
|
||||
best[rr.ResultID] = rr
|
||||
}
|
||||
}
|
||||
@@ -446,6 +565,14 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR
|
||||
return nil
|
||||
}
|
||||
|
||||
// preferComputedRank 判断候选 a 是否比已选中的 b 更该写回 results.rank。
|
||||
func preferComputedRank(a, b domain.RankedResult) bool {
|
||||
if (a.Basis == domain.BasisResult) != (b.Basis == domain.BasisResult) {
|
||||
return a.Basis == domain.BasisResult
|
||||
}
|
||||
return a.ChannelID < b.ChannelID
|
||||
}
|
||||
|
||||
// ---------- 发布 / 撤销 / 作废 ----------
|
||||
|
||||
// publishableResultCond 判断一条 results 行是否承载了「可以上屏的成绩」:
|
||||
@@ -611,7 +738,7 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
|
||||
}
|
||||
}
|
||||
if settle {
|
||||
// 名次也要等结算才写:奖牌榜统计读的就是 results.rank(见 buildRanked),
|
||||
// 名次也要等结算才写:名次(results.rank 与 channel_ranks)只在这里落,
|
||||
// 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动,
|
||||
// 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。
|
||||
score, err := computeEventScore(ctx, tx, in.EventID, catID)
|
||||
@@ -783,29 +910,26 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
|
||||
reason, resultID); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
}
|
||||
// 作废会让同组别名次前移,必须把重算出的名次回写,否则奖牌榜与积分榜对不上。
|
||||
// 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
|
||||
}
|
||||
// 作废的成绩不再占名次:results.rank 这一列必须让出来。作废行仍是 state='published',
|
||||
// 而导出(分项成绩表)读的正是 results.rank,不清就会把作废行列成第 1 名、与真正的冠军并列;
|
||||
// 整组全部作废时更是几条成绩都挂着第 1 名。与批量退赛同口径(entries_batch 显式清 rank)。
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE results SET rank = NULL WHERE id = ?`, resultID); err != nil {
|
||||
return resultDTO{}, internal(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
|
||||
}
|
||||
// 作废是回滚动作(改的是「谁还算数」,不是「录了多少」),与撤销发布、批量退赛/恢复/移出
|
||||
// 同一口径:无条件重算并推榜,不看 standings.live_on_publish、也不看 scopeSettled。
|
||||
// 否则没录完的组里作废一条成绩会留下一整套旧名次与旧流水(榜上有分、成绩却撤了),
|
||||
// 被作废的人会一直挂到下一次结算。
|
||||
// 这一刀不会把「未录完的组」提前变成「已结算」:结算判据只数已发布成绩,作废不写 state
|
||||
// (仍是 published、仍算「已录出」),延迟窗口里其它批次照旧等录完才结算。
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason)
|
||||
if err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
// 只重建流水不够:成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去;
|
||||
// 奖牌榜与运动员榜看的是 channel_ranks,由 recomputeLedgerTx 一并重建。
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
|
||||
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
|
||||
@@ -815,10 +939,9 @@ 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 settle {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
// 名次与流水刚改成当前口径,必须推榜单,否则大屏还挂着作废前的旧画面。
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return resultDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return resultDTO{}, internal(err)
|
||||
|
||||
@@ -229,6 +229,10 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
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))
|
||||
// 团体手工名次(devdocs/17 §七):成绩页要读(guest 也能看成绩),录入员可写。
|
||||
// 与成绩读取同权限:组别下没有团体手工通道时回空视图,不是 404。
|
||||
mux.HandleFunc("GET /api/events/{id}/categories/{categoryId}/team-ranks", s.public(s.teamRanksHandler))
|
||||
mux.HandleFunc("PUT /api/events/{id}/categories/{categoryId}/team-ranks", s.write(auth.RoleRecorder, s.updateTeamRanksHandler))
|
||||
|
||||
mux.HandleFunc("GET /api/scoring-profiles", s.public(s.listProfilesHandler))
|
||||
mux.HandleFunc("POST /api/scoring-profiles", s.write(auth.RoleRecorder, s.createProfileHandler))
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
)
|
||||
|
||||
// 本文件实现「团体手工名次」的读写(devdocs/17 §七)。
|
||||
//
|
||||
// 团体名次不再进 results.rank:它属于单位而不是某一条报名项,落在 channel_manual_ranks,
|
||||
// 由 (小项, 组别, 单位, scope, basis) 定位。通道 id 会随规则包导入 / 通道替换而变,
|
||||
// 所以这张表故意不挂通道外键,读写也都按 (scope, basis) 重新找通道。
|
||||
|
||||
// teamManualRankDTO 是一条「单位 → 名次」记录;rank 为 0 只出现在请求体里,表示清空。
|
||||
type teamManualRankDTO struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Rank int `json:"rank"`
|
||||
}
|
||||
|
||||
// teamRankChannelDTO 是团体名次读数里点名用的通道引用:只带渲染与写回所需的字段,
|
||||
// 不把整条通道配置(权重、开关等)复制一份出来,避免两处契约各自漂移。
|
||||
type teamRankChannelDTO struct {
|
||||
ChannelID int64 `json:"channelId"`
|
||||
Scope string `json:"scope"`
|
||||
Basis string `json:"basis"`
|
||||
ProfileID int64 `json:"profileId"`
|
||||
}
|
||||
|
||||
// teamRankTeamDTO 是启用单位清单的一行:录入页据此渲染「每个单位一个名次输入框」。
|
||||
type teamRankTeamDTO struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
ShortName *string `json:"shortName"`
|
||||
}
|
||||
|
||||
// teamRanksViewDTO 是团体名次读数(GET 的响应体,也是成绩录入接口随行下发的元素)。
|
||||
// categoryId 只在随行下发时出现:按组别路径取时路径已经表明了组别,不再重复。
|
||||
type teamRanksViewDTO struct {
|
||||
CategoryID int64 `json:"categoryId,omitempty"`
|
||||
Channels []teamRankChannelDTO `json:"channels"`
|
||||
Teams []teamRankTeamDTO `json:"teams"`
|
||||
Ranks []teamManualRankDTO `json:"ranks"`
|
||||
}
|
||||
|
||||
// entryBoardDTO 是成绩录入接口(GET /api/events/{id}/entries)的响应体:成绩行 + 团体名次。
|
||||
// 设计 17 §七要求录入页一次请求拿到两者,避免为了团体名次多开一轮。
|
||||
// teamRanks 只在存在团体手工通道时非空(按组别各一个视图),没有时是空数组而不是 null。
|
||||
type entryBoardDTO struct {
|
||||
Entries []entryWithResultDTO `json:"entries"`
|
||||
TeamRanks []teamRanksViewDTO `json:"teamRanks"`
|
||||
}
|
||||
|
||||
// listEntryBoard 组装成绩录入页的完整读数。
|
||||
func (s *server) listEntryBoard(ctx context.Context, eventID int64) (entryBoardDTO, error) {
|
||||
entries, err := s.listEntriesWithResults(ctx, eventID)
|
||||
if err != nil {
|
||||
return entryBoardDTO{}, err
|
||||
}
|
||||
views, err := s.teamRanksViews(ctx, s.ev, eventID)
|
||||
if err != nil {
|
||||
return entryBoardDTO{}, err
|
||||
}
|
||||
return entryBoardDTO{Entries: entries, TeamRanks: views}, nil
|
||||
}
|
||||
|
||||
// teamManualChannels 从整小项的通道里挑出团体手工通道(scope=team + basis=manual)。
|
||||
// 只有这类通道会读 channel_manual_ranks,录入入口也只认它。
|
||||
func teamManualChannels(channels []domain.ScoreChannel) []domain.ScoreChannel {
|
||||
out := make([]domain.ScoreChannel, 0, 1)
|
||||
for _, ch := range channels {
|
||||
if ch.Scope == domain.ScopeTeam && ch.Basis == domain.BasisManual {
|
||||
out = append(out, ch)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// activeTeams 读启用单位清单(active=1 且未假删除);假删除的单位不出现在录入页。
|
||||
func (s *server) activeTeams(ctx context.Context, q queryer) ([]teamRankTeamDTO, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT id, code, name, short_name FROM teams
|
||||
WHERE active = 1 AND deleted_at IS NULL
|
||||
ORDER BY sort_order, id`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]teamRankTeamDTO, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
t teamRankTeamDTO
|
||||
shortName sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&t.TeamID, &t.Code, &t.Name, &shortName); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
t.ShortName = strPtr(shortName)
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// teamRanksView 组装某组别的团体名次读数:已填名次 + 团体手工通道 + 启用单位清单。
|
||||
// 没有团体手工通道时 channels 为空、ranks 为空(不是 404):录入页据此隐藏整块录入区。
|
||||
func (s *server) teamRanksView(ctx context.Context, q queryer, eventID, categoryID int64, withCategoryID bool) (teamRanksViewDTO, error) {
|
||||
channels, err := loadChannelsQ(ctx, q, eventID)
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
teams, err := s.activeTeams(ctx, q)
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
ranks, err := loadTeamRanksQ(ctx, q, eventID, categoryID)
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
view := buildTeamRanksView(channels, teams, ranks[categoryID])
|
||||
if withCategoryID {
|
||||
view.CategoryID = categoryID
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// teamRanksViews 组装整小项的团体名次读数(每个有团体手工通道的组别一个视图),
|
||||
// 供成绩录入接口随行下发:录入页一次请求就拿到「成绩行 + 团体名次」,少发一轮。
|
||||
//
|
||||
// 三个基础读数各查一次再分发:逐组别调用 teamRanksView 会把通道、单位清单重复查 N 遍。
|
||||
// 没有团体手工通道的小项直接回空列表——不返回「看起来像有配置」的空视图。
|
||||
func (s *server) teamRanksViews(ctx context.Context, q queryer, eventID int64) ([]teamRanksViewDTO, error) {
|
||||
channels, err := loadChannelsQ(ctx, q, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(teamManualChannels(channels)) == 0 {
|
||||
return []teamRanksViewDTO{}, nil
|
||||
}
|
||||
categories, err := loadCategoriesQ(ctx, q, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
teams, err := s.activeTeams(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ranks, err := loadTeamRanksQ(ctx, q, eventID, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]teamRanksViewDTO, 0, len(categories))
|
||||
for _, cat := range categories {
|
||||
view := buildTeamRanksView(channels, teams, ranks[cat.ID])
|
||||
view.CategoryID = cat.ID
|
||||
out = append(out, view)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildTeamRanksView 把已读到的通道 / 单位 / 名次拼成一个视图,不做任何查询。
|
||||
func buildTeamRanksView(channels []domain.ScoreChannel, teams []teamRankTeamDTO, ranks []domain.TeamManualRank) teamRanksViewDTO {
|
||||
view := teamRanksViewDTO{
|
||||
Channels: make([]teamRankChannelDTO, 0, 1),
|
||||
Teams: teams,
|
||||
Ranks: make([]teamManualRankDTO, 0, len(ranks)),
|
||||
}
|
||||
for _, ch := range teamManualChannels(channels) {
|
||||
view.Channels = append(view.Channels, teamRankChannelDTO{
|
||||
ChannelID: ch.ID,
|
||||
Scope: string(ch.Scope),
|
||||
Basis: string(ch.Basis),
|
||||
ProfileID: ch.ProfileID,
|
||||
})
|
||||
}
|
||||
for _, r := range ranks {
|
||||
view.Ranks = append(view.Ranks, teamManualRankDTO{TeamID: r.TeamID, Rank: r.Rank})
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// updateTeamRanks 写入某组别的团体手工名次并同步重算。
|
||||
//
|
||||
// 校验口径(devdocs/17 §4.6):rank 为负 → 400(0 是合法的「清空」);teamId 不存在或已假删除 → 400;
|
||||
// scope/basis 与该小项的通道对不上 → 400。同名次允许多个单位(并列),引擎按 tieMode 处理。
|
||||
//
|
||||
// 写成功后必须重算:名次进通道算分公式,不重算就是「界面显示已保存、榜单还是旧分」。
|
||||
// 重算会一并重建该组别的 channel_ranks 并推榜单(recomputeLedgerTx + bumpStandings),
|
||||
// 大屏不需要等下一次发布。
|
||||
func (s *server) updateTeamRanks(ctx context.Context, eventID, categoryID int64, in teamRanksPayload) (teamRanksViewDTO, error) {
|
||||
scope := domain.Scope(strings.TrimSpace(in.Scope))
|
||||
basis := domain.Basis(strings.TrimSpace(in.Basis))
|
||||
if scope != domain.ScopeTeam || basis != domain.BasisManual {
|
||||
return teamRanksViewDTO{}, badRequest("团体名次只支持 scope=team + basis=manual", in)
|
||||
}
|
||||
|
||||
channels, err := loadChannelsQ(ctx, s.ev, eventID)
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
if len(teamManualChannels(channels)) == 0 {
|
||||
return teamRanksViewDTO{}, badRequest("该小项没有 scope=team + basis=manual 的计分通道", in)
|
||||
}
|
||||
|
||||
for i, r := range in.Ranks {
|
||||
if r.TeamID == 0 {
|
||||
return teamRanksViewDTO{}, badRequest("ranks[].teamId 必填", i)
|
||||
}
|
||||
if r.Rank < 0 {
|
||||
return teamRanksViewDTO{}, badRequest("ranks[].rank 不能为负数(0 表示清空)", i)
|
||||
}
|
||||
}
|
||||
if err := s.checkTeamRankTargets(ctx, in.Ranks); err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
actorID := middleware.User(ctx).ID
|
||||
for _, r := range in.Ranks {
|
||||
if r.Rank <= 0 {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM channel_manual_ranks
|
||||
WHERE event_id = ? AND category_id = ? AND team_id = ? AND scope = ? AND basis = ?`,
|
||||
eventID, categoryID, r.TeamID, scope, basis); err != nil {
|
||||
return teamRanksViewDTO{}, internal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO channel_manual_ranks (event_id, category_id, team_id, scope, basis, rank, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(event_id, category_id, team_id, scope, basis) DO UPDATE SET
|
||||
rank = excluded.rank, updated_by = excluded.updated_by, updated_at = datetime('now')`,
|
||||
eventID, categoryID, r.TeamID, scope, basis, r.Rank, nullID(actorID)); err != nil {
|
||||
return teamRanksViewDTO{}, internal(err)
|
||||
}
|
||||
}
|
||||
|
||||
score, err := recomputeLedgerTx(ctx, tx, eventID, categoryID, actorID, "团体名次录入")
|
||||
if err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
|
||||
return teamRanksViewDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return teamRanksViewDTO{}, internal(err)
|
||||
}
|
||||
return s.teamRanksView(ctx, s.ev, eventID, categoryID, false)
|
||||
}
|
||||
|
||||
// checkTeamRankTargets 校验写入目标单位存在且未被假删除。
|
||||
// 只校验要落名次的行(rank > 0):清空(rank = 0)是删除动作,单位后来被删了也照样该能清掉。
|
||||
// 批量查一次再比对,缺哪个报哪个,现场能直接照着改。
|
||||
func (s *server) checkTeamRankTargets(ctx context.Context, ranks []teamManualRankDTO) error {
|
||||
ids := make([]int64, 0, len(ranks))
|
||||
seen := make(map[int64]bool, len(ranks))
|
||||
for _, r := range ranks {
|
||||
if r.TeamID == 0 || r.Rank <= 0 || seen[r.TeamID] {
|
||||
continue
|
||||
}
|
||||
seen[r.TeamID] = true
|
||||
ids = append(ids, r.TeamID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT id FROM teams WHERE deleted_at IS NULL AND id IN (`+placeholders(len(ids))+`)`, args...)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
exists := make(map[int64]bool, len(ids))
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
exists[id] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
missing := make([]int64, 0)
|
||||
for _, id := range ids {
|
||||
if !exists[id] {
|
||||
missing = append(missing, id)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return badRequest("teamId 不存在或已删除", missing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// teamRanksHandler 是 GET /api/events/{id}/categories/{cid}/team-ranks。
|
||||
// 录入页(guest 也能看成绩)据此渲染团体名次区块,故与成绩读取同权限。
|
||||
func (s *server) teamRanksHandler(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
|
||||
}
|
||||
// 组别必须属于该小项:路径拼错时要明确报「组别不存在」,不能拿别的小项的组别名次回话。
|
||||
if _, err := s.getCategory(r.Context(), eventID, categoryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.teamRanksView(r.Context(), s.ev, eventID, categoryID, false)
|
||||
}
|
||||
|
||||
// updateTeamRanksHandler 是 PUT 同路径:写团体手工名次并重算,返回保存后的读数。
|
||||
func (s *server) updateTeamRanksHandler(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 teamRanksPayload
|
||||
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, err := s.updateTeamRanks(r.Context(), eventID, categoryID, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "event.team_ranks.update", "event_category", categoryID,
|
||||
map[string]any{"category": before, "ranks": in.Ranks}, updated)
|
||||
return updated, nil
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件钉住设计 17 的服务端行为:团体手工名次入口、channel_ranks 派生名次、
|
||||
// 改配置即重算,以及 P0(同一份数据发布两次结果必须一致)。
|
||||
|
||||
// ---------- 造数与读数助手 ----------
|
||||
|
||||
// profileIDByCode 按编码取积分模板 id。
|
||||
func profileIDByCode(t *testing.T, env *testEnv, code string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id); err != nil {
|
||||
t.Fatalf("读积分模板 %s: %v", code, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// addTeamManualChannel 给某小项加一条「团体手工名次」通道,返回通道 id。
|
||||
func addTeamManualChannel(t *testing.T, env *testEnv, eventID int64) int64 {
|
||||
t.Helper()
|
||||
return env.insert(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, 'team', 'manual', ?, 9)`, eventID, profileIDByCode(t, env, "team_top6"))
|
||||
}
|
||||
|
||||
// ledgerActiveDump 把未作废流水导出成文本:比条数与分值更严格,重复 / 丢失 / 顺序变化都能看出来。
|
||||
func ledgerActiveDump(t *testing.T, env *testEnv) string {
|
||||
t.Helper()
|
||||
return tableDump(t, env, `SELECT team_id, COALESCE(entry_id, 0) AS entry_id, COALESCE(result_id, 0) AS result_id,
|
||||
source_type, source_id, points, count_to_team
|
||||
FROM score_ledger WHERE voided = 0
|
||||
ORDER BY team_id, entry_id, result_id, source_type, source_id, points`)
|
||||
}
|
||||
|
||||
// channelRanksDump 导出通道名次明细(不含自增 id 与时间戳,便于发布前后逐字节比较)。
|
||||
func channelRanksDump(t *testing.T, env *testEnv) string {
|
||||
t.Helper()
|
||||
return tableDump(t, env, `SELECT event_id, category_id, channel_id, basis, scope, subject_key,
|
||||
COALESCE(entry_id, 0) AS entry_id, COALESCE(result_id, 0) AS result_id,
|
||||
COALESCE(athlete_id, 0) AS athlete_id, COALESCE(team_id, 0) AS team_id, rank, points
|
||||
FROM channel_ranks ORDER BY event_id, category_id, channel_id, subject_key`)
|
||||
}
|
||||
|
||||
// standingsChangeRows 统计 change_log 里推给大屏的榜单版本条数。
|
||||
func standingsChangeRows(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
return countRows(t, env, `SELECT COUNT(*) FROM change_log WHERE entity_type = 'standings'`)
|
||||
}
|
||||
|
||||
// activeLedgerTotal 统计未作废流水的分值合计。
|
||||
func activeLedgerTotal(t *testing.T, env *testEnv) float64 {
|
||||
t.Helper()
|
||||
var total float64
|
||||
if err := env.ev.QueryRow(`SELECT COALESCE(SUM(points), 0) FROM score_ledger WHERE voided = 0`).Scan(&total); err != nil {
|
||||
t.Fatalf("统计流水合计: %v", err)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// putTeamRanks 调团体名次写接口。
|
||||
func putTeamRanks(t *testing.T, env *testEnv, client *http.Client, eventID, categoryID int64, ranks []map[string]any) (int, envelope) {
|
||||
t.Helper()
|
||||
return env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", eventID, categoryID),
|
||||
map[string]any{"scope": "team", "basis": "manual", "ranks": ranks}, true)
|
||||
}
|
||||
|
||||
// getTeamRanksRaw 调团体名次读接口,返回原始响应体(用于断言空数组等结构)。
|
||||
func getTeamRanksRaw(t *testing.T, env *testEnv, client *http.Client, eventID, categoryID int64) (int, envelope) {
|
||||
t.Helper()
|
||||
return env.do(client, http.MethodGet,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", eventID, categoryID), nil, false)
|
||||
}
|
||||
|
||||
// publishEvent 只调发布接口(不重新存草稿):成绩已发布后再存草稿会被 409 拦下,
|
||||
// 而「同一份数据发布两次」要验证的正是重复发布本身。
|
||||
func publishEvent(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string) {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/results/publish",
|
||||
map[string]any{"eventId": eventID, "requestId": tag + "-publish"}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// seedTeamEvent 建一个纯团体小项:一条团体报名项(athlete_id 为空)+ 一条按成绩排名的团体通道。
|
||||
func seedTeamEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID, entryID int64) {
|
||||
t.Helper()
|
||||
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
|
||||
compare, scored, multiplier, status)
|
||||
VALUES (?, 'T4X100', '4x100 米接力', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
||||
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '男子', 1, 0)`, eventID)
|
||||
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
|
||||
VALUES (?, 'team', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "team_top6"))
|
||||
entryID = env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
|
||||
VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, ids.teamA)
|
||||
return eventID, categoryID, entryID
|
||||
}
|
||||
|
||||
// ---------- 团体名次读写 ----------
|
||||
|
||||
// 团体手工名次走接口写入后:名次落 channel_manual_ranks、按单位算分、大屏榜单被推送。
|
||||
func TestTeamManualRanksWriteRecomputesAndPushesStandings(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
|
||||
// 录入前:有通道、有单位清单,但没有任何名次。
|
||||
status, env2 := getTeamRanksRaw(t, env, client, ids.event, ids.category)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读团体名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
view := decodeData[map[string]any](t, env2)
|
||||
if len(view["channels"].([]any)) != 1 || len(view["teams"].([]any)) != 2 || len(view["ranks"].([]any)) != 0 {
|
||||
t.Fatalf("录入前的视图应为「1 通道 + 2 单位 + 0 名次」,得到 %+v", view)
|
||||
}
|
||||
|
||||
before := standingsChangeRows(t, env)
|
||||
status, env2 = putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 1},
|
||||
{"teamId": ids.teamB, "rank": 2},
|
||||
})
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
filled := decodeData[map[string]any](t, env2)
|
||||
if len(filled["ranks"].([]any)) != 2 {
|
||||
t.Fatalf("写后应回填两条名次,得到 %+v", filled["ranks"])
|
||||
}
|
||||
if filled["categoryId"] != nil {
|
||||
t.Fatalf("按组别路径取时响应不该带 categoryId,得到 %+v", filled["categoryId"])
|
||||
}
|
||||
|
||||
// 团体名次只记单位:流水的 entry_id / result_id 都为空(「无报名项归属」那一类)。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 0`); n != 2 {
|
||||
t.Fatalf("团体名次应产出 2 条流水,得到 %d 条", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger
|
||||
WHERE voided = 0 AND (entry_id IS NOT NULL OR result_id IS NOT NULL)`); n != 0 {
|
||||
t.Fatalf("团体名次流水不该挂报名项/成绩,得到 %d 条", n)
|
||||
}
|
||||
|
||||
// 名次与流水同源:A 第 1 名 15 分、B 第 2 名 10 分。
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
|
||||
t.Fatalf("A 单位总分应为 15,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
|
||||
t.Fatalf("B 单位总分应为 10,得到 %v", got)
|
||||
}
|
||||
|
||||
// channel_ranks 是榜单读取来源:主体是单位,名次与分值同引擎口径。
|
||||
for _, want := range []struct {
|
||||
teamID int64
|
||||
rank int
|
||||
points float64
|
||||
}{{ids.teamA, 1, 15}, {ids.teamB, 2, 10}} {
|
||||
n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks
|
||||
WHERE subject_key = ? AND scope = 'team' AND basis = 'manual'
|
||||
AND team_id = ? AND rank = ? AND points = ?`,
|
||||
fmt.Sprintf("t:%d", want.teamID), want.teamID, want.rank, want.points)
|
||||
if n != 1 {
|
||||
t.Fatalf("channel_ranks 应有一行 team_id=%d rank=%d points=%v,得到 %d 行:%s",
|
||||
want.teamID, want.rank, want.points, n, channelRanksDump(t, env))
|
||||
}
|
||||
}
|
||||
|
||||
// 大屏:写名次必须推榜单,否则界面说保存了、榜单还是旧分。
|
||||
if after := standingsChangeRows(t, env); after <= before {
|
||||
t.Fatalf("写团体名次后应推榜单:之前 %d 条,之后 %d 条", before, after)
|
||||
}
|
||||
|
||||
// rank=0 表示清空该单位的记录:B 回到无分。
|
||||
status, env2 = putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamB, "rank": 0},
|
||||
})
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("清空 B 的名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_manual_ranks WHERE team_id = ?`, ids.teamB); n != 0 {
|
||||
t.Fatalf("rank=0 应删掉该单位的记录,得到 %d 条", n)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
|
||||
t.Fatalf("清空后 B 应无分,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 15 {
|
||||
t.Fatalf("清空 B 不该动 A,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 按组别重算只动该组别:团体名次的流水是「无报名项归属」那一类,
|
||||
// 按组别重算时若只作废不补算,另一组的名次分就会凭空消失(设计 17 §十一.2)。
|
||||
func TestTeamManualRanksCategoryRecomputeKeepsOtherGroups(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
// 两个组别各录一轮团体名次(名次只在组别内有效,A/B 的先后可以不同)。
|
||||
if status, env2 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 1},
|
||||
{"teamId": ids.teamB, "rank": 2},
|
||||
}); status != http.StatusOK {
|
||||
t.Fatalf("写男子组名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if status, env2 := putTeamRanks(t, env, client, ids.event, otherCat, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 2},
|
||||
{"teamId": ids.teamB, "rank": 1},
|
||||
}); status != http.StatusOK {
|
||||
t.Fatalf("写女子组名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
// A = 15 + 10、B = 10 + 15;四类「无报名项归属」流水都在。
|
||||
if total := activeLedgerTotal(t, env); total != 50 {
|
||||
t.Fatalf("两组名次分合计应为 50,得到 %v", total)
|
||||
}
|
||||
ledgerBefore := ledgerActiveDump(t, env)
|
||||
ranksBefore := channelRanksDump(t, env)
|
||||
|
||||
// 只重写男子组:女子组的团体名次分必须原样保留(既不丢也不翻倍)。
|
||||
status, env2 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 1},
|
||||
{"teamId": ids.teamB, "rank": 2},
|
||||
})
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("重写男子组名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if after := ledgerActiveDump(t, env); after != ledgerBefore {
|
||||
t.Fatalf("按组别重算不该动其它组别的名次分:\n之前 %s\n之后 %s", ledgerBefore, after)
|
||||
}
|
||||
if after := channelRanksDump(t, env); after != ranksBefore {
|
||||
t.Fatalf("按组别重算不该动其它组别的名次:\n之前 %s\n之后 %s", ranksBefore, after)
|
||||
}
|
||||
if total := activeLedgerTotal(t, env); total != 50 {
|
||||
t.Fatalf("重算后合计应仍为 50,得到 %v", total)
|
||||
}
|
||||
}
|
||||
|
||||
// P0 回归:同一份数据连发两次,流水与名次必须逐字节一致。
|
||||
func TestPublishTwiceKeepsLedgerAndRanksIdentical(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
|
||||
// 团体名次先录好:双通道项目(个人按成绩 + 团体按录入名次)。
|
||||
if status, env2 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 1},
|
||||
{"teamId": ids.teamB, "rank": 2},
|
||||
}); status != http.StatusOK {
|
||||
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
publishResults(t, env, client, ids.event, "p1",
|
||||
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"},
|
||||
)
|
||||
// 个人通道 10 / 8 分;团体手工通道读 channel_manual_ranks,不受 results.rank 影响。
|
||||
if total := activeLedgerTotal(t, env); total != 10+8+15+10 {
|
||||
t.Fatalf("首次发布后合计应为 43(10+8+15+10),得到 %v", total)
|
||||
}
|
||||
ledgerFirst := ledgerActiveDump(t, env)
|
||||
ranksFirst := channelRanksDump(t, env)
|
||||
// channel_ranks 每个通道每个主体一行:个人 2 行 + 团体 2 行。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks`); n != 4 {
|
||||
t.Fatalf("channel_ranks 应有 4 行(个人 2 + 团体 2),得到 %d", n)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE category_id = ?`, ids.category); n != 4 {
|
||||
t.Fatalf("该组别应有 4 行名次,得到 %d", n)
|
||||
}
|
||||
|
||||
// 换一个 requestId,确保第二次是真执行而不是幂等回放(成绩已发布,不再重存草稿)。
|
||||
publishEvent(t, env, client, ids.event, "p2")
|
||||
if total := activeLedgerTotal(t, env); total != 43 {
|
||||
t.Fatalf("第二次发布后合计应仍为 43(不得翻倍),得到 %v", total)
|
||||
}
|
||||
if after := ledgerActiveDump(t, env); after != ledgerFirst {
|
||||
t.Fatalf("同一份数据发布两次流水应完全一致:\n第一次 %s\n第二次 %s", ledgerFirst, after)
|
||||
}
|
||||
if after := channelRanksDump(t, env); after != ranksFirst {
|
||||
t.Fatalf("同一份数据发布两次名次应完全一致:\n第一次 %s\n第二次 %s", ranksFirst, after)
|
||||
}
|
||||
}
|
||||
|
||||
// 团体通道的名次不进 results.rank:个人名次照写,团体报名项的名次留在 channel_ranks。
|
||||
func TestApplyComputedRanksLeavesTeamRanksAlone(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
eventID, categoryID, teamEntry := seedTeamEvent(t, env, ids)
|
||||
|
||||
// 个人小项:两条个人成绩,rank 应写回 results。
|
||||
publishResults(t, env, client, ids.event, "ind",
|
||||
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"},
|
||||
)
|
||||
// 纯团体小项:团体报名项(athlete_id 为空)按成绩排名。
|
||||
publishResults(t, env, client, eventID, "team",
|
||||
map[string]any{"entryId": teamEntry, "resultValue": 45.20, "resultText": "45.20", "unit": "s", "status": "ok"},
|
||||
)
|
||||
|
||||
var rankA, rankB, teamRank any
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryA).Scan(&rankA); err != nil {
|
||||
t.Fatalf("读个人名次: %v", err)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&rankB); err != nil {
|
||||
t.Fatalf("读个人名次: %v", err)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, teamEntry).Scan(&teamRank); err != nil {
|
||||
t.Fatalf("读团体名次: %v", err)
|
||||
}
|
||||
if rankA != int64(1) || rankB != int64(2) {
|
||||
t.Fatalf("个人名次应回写 results.rank(1 / 2),得到 %v / %v", rankA, rankB)
|
||||
}
|
||||
if teamRank != nil {
|
||||
t.Fatalf("团体名次不该写进 results.rank,得到 %v", teamRank)
|
||||
}
|
||||
|
||||
// 但团体名次必须落在 channel_ranks,奖牌榜才看得到。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks
|
||||
WHERE event_id = ? AND category_id = ? AND scope = 'team' AND rank = 1`, eventID, categoryID); n != 1 {
|
||||
t.Fatalf("团体名次应落 channel_ranks 一行 rank=1,得到 %d 行", n)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 15+10 {
|
||||
t.Fatalf("A 应拿团体 15 分与个人 10 分,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 改配置即重算 ----------
|
||||
|
||||
// 整体替换通道会重建通道 id(channel_ranks 随之级联清空):必须重算重建 + 推榜单。
|
||||
func TestReplaceChannelsRecomputesAndPushesStandings(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "cfg",
|
||||
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"},
|
||||
)
|
||||
ledgerBefore := ledgerActiveDump(t, env)
|
||||
before := standingsChangeRows(t, env)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event),
|
||||
map[string]any{"channels": []map[string]any{
|
||||
{"scope": "athlete", "basis": "result", "profileId": profileIDByCode(t, env, "individual_top6"), "sortOrder": 0},
|
||||
}}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("替换通道应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if after := ledgerActiveDump(t, env); after != ledgerBefore {
|
||||
t.Fatalf("同配置替换通道后流水应一致:\n之前 %s\n之后 %s", ledgerBefore, after)
|
||||
}
|
||||
// 通道 id 变了、旧名次被级联删掉;不重算就会是空榜。
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE event_id = ?`, ids.event); n != 2 {
|
||||
t.Fatalf("替换通道后应重建 2 行名次,得到 %d", n)
|
||||
}
|
||||
if after := standingsChangeRows(t, env); after <= before {
|
||||
t.Fatalf("替换通道应推榜单:之前 %d,之后 %d", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// updateEvent 只在 multiplier / avg_mode / scored / award_medals 真的变了时重算并推榜。
|
||||
func TestUpdateEventRecomputesOnlyOnScoringFieldChange(t *testing.T) {
|
||||
type tc struct {
|
||||
name string
|
||||
mutate func(body map[string]any)
|
||||
check func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client)
|
||||
}
|
||||
|
||||
baseBody := func(ids seedIDs) map[string]any {
|
||||
return map[string]any{
|
||||
"sportId": ids.sport, "code": "M100", "name": "100米跑",
|
||||
"eventType": "individual", "resultMode": "auto", "resultType": "time",
|
||||
"compare": "asc", "multiplier": 1, "status": "ongoing", "avgMode": "valid",
|
||||
}
|
||||
}
|
||||
|
||||
cases := []tc{
|
||||
{
|
||||
name: "multiplier 真变",
|
||||
mutate: func(b map[string]any) { b["multiplier"] = 2 },
|
||||
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
|
||||
if total := activeLedgerTotal(t, env); total != 20+16 {
|
||||
t.Fatalf("权重翻倍后合计应为 36,得到 %v", total)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "avg_mode 真变",
|
||||
mutate: func(b map[string]any) { b["avgMode"] = "all" },
|
||||
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
|
||||
// 个人按成绩的通道不受平均值口径影响:分值不变,但流水被重写了一遍。
|
||||
if total := activeLedgerTotal(t, env); total != 18 {
|
||||
t.Fatalf("改平均值口径不该改个人通道分值,得到 %v", total)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 1`); n == 0 {
|
||||
t.Fatalf("改平均值口径应触发重算(应有作废流水)")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scored 真变",
|
||||
mutate: func(b map[string]any) { b["scored"] = false },
|
||||
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
|
||||
if total := activeLedgerTotal(t, env); total != 0 {
|
||||
t.Fatalf("关掉计分后应无流水,得到 %v", total)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||||
t.Fatalf("关掉计分后榜单应清零,得到 %v", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "award_medals 真变",
|
||||
mutate: func(b map[string]any) { b["awardMedals"] = false },
|
||||
check: func(t *testing.T, env *testEnv, ids seedIDs, client *http.Client) {
|
||||
if total := activeLedgerTotal(t, env); total != 18 {
|
||||
t.Fatalf("关掉奖牌不该动积分,得到 %v", total)
|
||||
}
|
||||
if gold := teamGold(t, env, client, ids.teamA); gold != 0 {
|
||||
t.Fatalf("关掉奖牌结算后 A 不该有金牌,得到 %d", gold)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "evt",
|
||||
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 gold := teamGold(t, env, client, ids.teamA); gold != 1 {
|
||||
t.Fatalf("前置条件:A 应有 1 金,得到 %d", gold)
|
||||
}
|
||||
before := standingsChangeRows(t, env)
|
||||
|
||||
body := baseBody(ids)
|
||||
c.mutate(body)
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), body, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("更新小项应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if after := standingsChangeRows(t, env); after <= before {
|
||||
t.Fatalf("改算分字段应推榜单:之前 %d,之后 %d", before, after)
|
||||
}
|
||||
c.check(t, env, ids, client)
|
||||
})
|
||||
}
|
||||
|
||||
// 只改名不该触发重算:既不重写流水,也不推榜单(重算会白作废一遍、还多一次推送)。
|
||||
t.Run("只改名不重算", func(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "noop",
|
||||
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 := standingsChangeRows(t, env)
|
||||
ledgerBefore := ledgerActiveDump(t, env)
|
||||
|
||||
body := baseBody(ids)
|
||||
body["name"] = "100米跑(改名)"
|
||||
status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", ids.event), body, 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 voided = 1`); n != 0 {
|
||||
t.Fatalf("只改名不该重算,得到 %d 条作废流水", n)
|
||||
}
|
||||
if after := ledgerActiveDump(t, env); after != ledgerBefore {
|
||||
t.Fatalf("只改名不该动流水:\n之前 %s\n之后 %s", ledgerBefore, after)
|
||||
}
|
||||
if after := standingsChangeRows(t, env); after != before {
|
||||
t.Fatalf("只改名不该推榜单:之前 %d,之后 %d", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 积分模板分值变更要全量重算(模板是算分公式的输入)。
|
||||
func TestUpdateProfileRecomputesAllEvents(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
publishResults(t, env, client, ids.event, "prof",
|
||||
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 total := activeLedgerTotal(t, env); total != 18 {
|
||||
t.Fatalf("前置条件:合计应为 18,得到 %v", total)
|
||||
}
|
||||
before := standingsChangeRows(t, env)
|
||||
|
||||
profileID := profileIDByCode(t, env, "individual_top6")
|
||||
status, env2 := env.do(client, http.MethodGet, fmt.Sprintf("/api/scoring-profiles/%d", profileID), nil, false)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("读积分模板应 200,得到 %d", status)
|
||||
}
|
||||
body := decodeData[map[string]any](t, env2)
|
||||
body["rules"] = []map[string]any{
|
||||
{"rank": 1, "points": 20},
|
||||
{"rank": 2, "points": 16},
|
||||
{"rank": 3, "points": 6},
|
||||
{"rank": 4, "points": 3},
|
||||
{"rank": 5, "points": 2},
|
||||
{"rank": 6, "points": 1},
|
||||
}
|
||||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/scoring-profiles/%d", profileID), body, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("更新积分模板应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if total := activeLedgerTotal(t, env); total != 20+16 {
|
||||
t.Fatalf("模板改分后应全量重算为 36,得到 %v", total)
|
||||
}
|
||||
if after := standingsChangeRows(t, env); after <= before {
|
||||
t.Fatalf("模板改分应推榜单:之前 %d,之后 %d", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 鉴权与校验 ----------
|
||||
|
||||
func TestTeamRanksAuthAndValidation(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
|
||||
// guest 只读:读 200。
|
||||
guest, _ := env.client()
|
||||
if status, _ := getTeamRanksRaw(t, env, guest, ids.event, ids.category); status != http.StatusOK {
|
||||
t.Fatalf("guest 读团体名次应 200,得到 %d", status)
|
||||
}
|
||||
// guest 写被拦(与其它写路由同口径:未登录 → 401 AUTH_REQUIRED,不是 200)。
|
||||
// 未登录请求由 RequireWrite 处理,不带 CSRF 也无需 CSRF。
|
||||
status, env2 := env.do(guest, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", ids.event, ids.category),
|
||||
map[string]any{"scope": "team", "basis": "manual", "ranks": []map[string]any{{"teamId": ids.teamA, "rank": 1}}},
|
||||
false)
|
||||
if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
|
||||
t.Fatalf("guest 写团体名次应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM channel_manual_ranks`); n != 0 {
|
||||
t.Fatalf("被拦下的写请求不该落库,得到 %d 条", n)
|
||||
}
|
||||
|
||||
// recorder 可写。
|
||||
if status, env2 := putTeamRanks(t, env, client, ids.event, ids.category,
|
||||
[]map[string]any{{"teamId": ids.teamA, "rank": 1}}); status != http.StatusOK {
|
||||
t.Fatalf("recorder 写团体名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
// 校验失败一律 400 + 标准错误体(不是 500,也不能静默成功)。
|
||||
checkBadRequest := func(name string, wantCode any, ranks []map[string]any, scope string) {
|
||||
t.Helper()
|
||||
status, resp := env.do(client, http.MethodPut,
|
||||
fmt.Sprintf("/api/events/%d/categories/%d/team-ranks", ids.event, ids.category),
|
||||
map[string]any{"scope": scope, "basis": "manual", "ranks": ranks}, true)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("%s 应 400,得到 %d %+v", name, status, resp.Error)
|
||||
}
|
||||
if resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" || resp.Error.Message == "" {
|
||||
t.Fatalf("%s 应返回 VALIDATION_FAILED 标准错误体,得到 %+v", name, resp.Error)
|
||||
}
|
||||
if wantCode != nil && !containsDetail(resp.Error.Detail, wantCode) {
|
||||
t.Fatalf("%s 的 detail 应指向 %v,得到 %s", name, wantCode, resp.Error.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
checkBadRequest("rank 为负", nil, []map[string]any{{"teamId": ids.teamA, "rank": -1}}, "team")
|
||||
checkBadRequest("teamId 不存在", float64(999999), []map[string]any{{"teamId": 999999, "rank": 1}}, "team")
|
||||
|
||||
// 已假删除的单位也不能写。
|
||||
env.exec(t, `UPDATE teams SET deleted_at = datetime('now'), active = 0 WHERE id = ?`, ids.teamB)
|
||||
checkBadRequest("teamId 已假删除", float64(ids.teamB), []map[string]any{{"teamId": ids.teamB, "rank": 1}}, "team")
|
||||
|
||||
// scope/basis 与该小项的通道对不上。
|
||||
checkBadRequest("scope 不匹配", "athlete", []map[string]any{{"teamId": ids.teamA, "rank": 1}}, "athlete")
|
||||
}
|
||||
|
||||
// 单位积分下钻的名次列也要读 channel_ranks:团体名次没有 results.rank 可读(设计 17 §11.3)。
|
||||
func TestTeamBreakdownShowsChannelRanks(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
publishResults(t, env, client, ids.event, "bd",
|
||||
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 := putTeamRanks(t, env, client, ids.event, ids.category, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 1},
|
||||
{"teamId": ids.teamB, "rank": 2},
|
||||
}); status != http.StatusOK {
|
||||
t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
status, env2 := env.do(client, http.MethodGet,
|
||||
fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读单位积分明细应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
breakdown := decodeData[map[string]any](t, env2)
|
||||
ranks := make(map[float64]any)
|
||||
for _, raw := range breakdown["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
ranks[item["points"].(float64)] = item["rank"]
|
||||
}
|
||||
// 个人流水(10 分)与团体名次流水(15 分)都要能显示名次:A 两项都是第 1 名。
|
||||
if ranks[10] != float64(1) {
|
||||
t.Fatalf("个人流水应显示名次 1,得到 %+v", ranks)
|
||||
}
|
||||
if ranks[15] != float64(1) {
|
||||
t.Fatalf("团体名次流水应显示名次 1(读 channel_ranks),得到 %+v", ranks)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 录入页载荷 ----------
|
||||
|
||||
// 成绩录入接口随行下发团体名次:有团体手工通道的组别才出现在 teamRanks 里。
|
||||
func TestEntryBoardCarriesTeamRanks(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
// 没有团体手工通道时:teamRanks 是空数组,不是 null / 不是缺字段。
|
||||
board := getEntryBoard(t, env, client, ids.event)
|
||||
if board.TeamRanks == nil || len(board.TeamRanks) != 0 {
|
||||
t.Fatalf("无团体手工通道时 teamRanks 应为空数组,得到 %+v", board.TeamRanks)
|
||||
}
|
||||
if len(board.Entries) != 2 {
|
||||
t.Fatalf("报名行应照旧下发 2 条,得到 %d", len(board.Entries))
|
||||
}
|
||||
|
||||
addTeamManualChannel(t, env, ids.event)
|
||||
if status, env2 := putTeamRanks(t, env, client, ids.event, otherCat, []map[string]any{
|
||||
{"teamId": ids.teamA, "rank": 3},
|
||||
}); status != http.StatusOK {
|
||||
t.Fatalf("写女子组名次应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
board = getEntryBoard(t, env, client, ids.event)
|
||||
if len(board.TeamRanks) != 2 {
|
||||
t.Fatalf("两个组别都应随行下发团体名次视图,得到 %+v", board.TeamRanks)
|
||||
}
|
||||
byCategory := make(map[float64]map[string]any, len(board.TeamRanks))
|
||||
for _, view := range board.TeamRanks {
|
||||
id, _ := view["categoryId"].(float64)
|
||||
byCategory[id] = view
|
||||
}
|
||||
other := byCategory[float64(otherCat)]
|
||||
if other == nil {
|
||||
t.Fatalf("随行视图应带 categoryId 供录入页按组别取用,得到 %+v", board.TeamRanks)
|
||||
}
|
||||
if len(other["channels"].([]any)) != 1 || len(other["teams"].([]any)) != 2 || len(other["ranks"].([]any)) != 1 {
|
||||
t.Fatalf("女子组视图应为「1 通道 + 2 单位 + 1 名次」,得到 %+v", other)
|
||||
}
|
||||
if v := byCategory[float64(ids.category)]; v == nil || len(v["ranks"].([]any)) != 0 {
|
||||
t.Fatalf("男子组视图应存在但没有名次,得到 %+v", byCategory)
|
||||
}
|
||||
}
|
||||
|
||||
// containsDetail 判断错误 detail 里是否出现某个值(detail 可能是数字或字符串数组)。
|
||||
func containsDetail(detail []byte, want any) bool {
|
||||
s := string(detail)
|
||||
switch v := want.(type) {
|
||||
case string:
|
||||
return strings.Contains(s, v)
|
||||
case float64:
|
||||
return strings.Contains(s, fmt.Sprintf("%d", int64(v)))
|
||||
default:
|
||||
return strings.Contains(s, fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
|
||||
// teamGold 从榜单读某单位的金牌数。
|
||||
func teamGold(t *testing.T, env *testEnv, client *http.Client, teamID int64) int {
|
||||
t.Helper()
|
||||
status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("读积分榜应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
for _, row := range decodeData[[]map[string]any](t, env2) {
|
||||
if row["teamId"] == float64(teamID) {
|
||||
gold, _ := row["gold"].(float64)
|
||||
return int(gold)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func TestRestoreTeamBringsHiddenTeamBack(t *testing.T) {
|
||||
if status != http.StatusOK || !env3.OK {
|
||||
t.Fatalf("读报名应 200,得到 %d %+v", status, env3.Error)
|
||||
}
|
||||
entry := findEntry(decodeData[[]map[string]any](t, env3), ids.entryA)
|
||||
entry := findEntry(getEntryBoard(t, env, client, ids.event).Entries, ids.entryA)
|
||||
if entry == nil {
|
||||
t.Fatalf("恢复后报名不该消失")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user