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:
AskaEth committed 2026-09-11 21:47:07 +08:00
1 parent 53a0cb225c
commit 45edfbeb71
115 files changed
+10104 -755

No files matched your search

+26
View File
@@ -477,6 +477,32 @@ func (s *Service) RevokeUserSessions(ctx context.Context, userID int64) error {
return err
}
// SessionActive 判定一条会话现在是否仍然可用:会话行还在、未过期、用户未停用。
//
// 判定口径与 Lookup 完全一致(同样是 JOIN users 并比对 expires_at),但不做滑动续期、
// 也不回写 last_seen_at:它只回答「这条会话还在不在」,供 /ws 长连接周期性复查。
// 撤销类操作(登出、改密、管理员重置密码、停用用户)都会删掉会话行或停用用户,
// 因此这里查不到即为失效。查询失败按错误返回,由调用方决定断开还是保留连接。
func (s *Service) SessionActive(ctx context.Context, sessionID string) (bool, error) {
if strings.TrimSpace(sessionID) == "" {
return false, nil
}
var one int
err := s.db.QueryRowContext(ctx,
`SELECT 1 FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > ? AND u.active = 1`,
sessionID, s.now().UTC().Format(time.DateTime)).Scan(&one)
switch {
case errors.Is(err, sql.ErrNoRows):
return false, nil
case err != nil:
return false, fmt.Errorf("复查会话: %w", err)
default:
return true, nil
}
}
func (s *Service) createSession(ctx context.Context, userID int64, ip, userAgent string) (string, *Session, error) {
token, err := randomToken(32)
if err != nil {
+55
View File
@@ -158,6 +158,61 @@ func TestLookupTouchThrottleKeepsLongLivedSession(t *testing.T) {
}
}
// 会话复查:/ws 长连接靠它判断背后的会话是否还有效。撤销(登出 / 改密 / 管理员重置密码 /
// 停用用户)与到期都必须判为失效,否则被停用的人只要不关页面就能一直收推送。
func TestSessionActiveTracksRevocation(t *testing.T) {
now := time.Date(2026, 9, 11, 20, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
sessionID string
prepare func(t *testing.T, db *sql.DB)
want bool
}{
{"有效会话", "sid", nil, true},
{"会话被撤销(登出 / 改密 / 重置密码都删行)", "sid", func(t *testing.T, db *sql.DB) {
execTest(t, db, `DELETE FROM sessions`)
}, false},
{"会话已过期", "sid", func(t *testing.T, db *sql.DB) {
setExpiry(t, db, now.Add(-time.Second))
}, false},
{"用户已停用", "sid", func(t *testing.T, db *sql.DB) {
execTest(t, db, `UPDATE users SET active = 0`)
}, false},
{"会话 id 未知", "other", nil, false},
{"会话 id 为空", "", nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
db := newSettingsDB(t, nil)
svc := New(db, Options{Logger: quietLogger(), SessionTTL: time.Hour,
Now: func() time.Time { return now }})
userID := insertUser(t, db, "admin")
insertSession(t, db, userID, now.Add(time.Hour))
if tc.prepare != nil {
tc.prepare(t, db)
}
got, err := svc.SessionActive(context.Background(), tc.sessionID)
if err != nil {
t.Fatalf("复查会话: %v", err)
}
if got != tc.want {
t.Fatalf("SessionActive = %v,期望 %v", got, tc.want)
}
// 复查是只读判定:不能顺延到期时间,也不能回写活动时间。
if tc.want && dbExpiry(t, db) != now.Add(time.Hour).Format(time.DateTime) {
t.Fatalf("复查不该写库,库里到期时间是 %s", dbExpiry(t, db))
}
})
}
}
func execTest(t *testing.T, db *sql.DB, query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatalf("执行 %s: %v", query, err)
}
}
// 越过节流窗口后再次顺延:单独跑一遍,避免上面的用例把它和活动时间混在一起。
func TestLookupRenewsAfterThrottleWindow(t *testing.T) {
db := newSettingsDB(t, nil)
@@ -0,0 +1,363 @@
package domain
import (
"reflect"
"strconv"
"testing"
)
// 本文件守住 devdocs/17 的两条底线:
// 1. P0:同一份数据连续发布两次,流水与名次必须完全一致——团体通道绝不读
// 「athlete_id != 0 的报名项」的 results.rank(那是个人名次,读了会让同一份成绩发两次团体分);
// 2. 团体名次的来源只有 in.TeamRanks 与团体报名项(athlete_id == 0)的 results.rank 两处,
// 同一单位两边都有时以 TeamRanks 为准。
// teamPullupInput 构造 TEAM_PULLUP 场景:1 个小项、1 个组别、6 名运动员同属 A 队(1001),
// 成绩 30/29/28/27/26/25(引体向上,次数多者名次靠前),两条通道 =
// 个人通道(athlete + result + 个人表)+ 团体通道(team + manual + 团体表)。
// teamRanks 是录入员填的团体名次(可为空)。
func teamPullupInput(teamRanks []TeamManualRank) EventScoreInput {
values := []float64{30, 29, 28, 27, 26, 25}
ps := make([]participant, 0, len(values))
for i, v := range values {
ps = append(ps, participant{
entryID: int64(i + 1), athleteID: int64(101 + i), teamID: 1001,
name: "选手" + string(rune('A'+i)), value: v,
})
}
entries, results := fixture(ps)
ev := individualEvent(1)
ev.EventType = EventTypeIndividualTeam
ev.Compare = CompareDesc
return EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisManual, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries,
Results: results,
TeamRanks: teamRanks,
}
}
// writeBackComputedRanks 模拟 httpapi 的 applyComputedRanks:
// 只把个人通道(scope=athlete)算出的名次写回对应成绩的 results.rank。
func writeBackComputedRanks(in EventScoreInput, ranked []RankedResult) {
for _, rr := range ranked {
if rr.Scope != ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
for i := range in.Results {
if in.Results[i].ID == rr.ResultID {
in.Results[i].Rank = rr.Rank
}
}
}
}
// TestTeamPullupPublishTwiceIsIdempotent 是 P0 回归:
// 「发布 → 把个人名次写回 results.rank → 再发布一次」两次的 Ledger 与 Ranked 必须逐字段一致。
func TestTeamPullupPublishTwiceIsIdempotent(t *testing.T) {
t.Run("团体名次已录:两次发布一致,A 队总分不跳变", func(t *testing.T) {
in := teamPullupInput([]TeamManualRank{{TeamID: 1001, Rank: 1}})
first := mustScore(t, in)
writeBackComputedRanks(in, first.Ranked)
second := mustScore(t, in)
if !reflect.DeepEqual(first.Ledger, second.Ledger) {
t.Fatalf("两次发布的流水不一致:\n第一次 %+v\n第二次 %+v", first.Ledger, second.Ledger)
}
if !reflect.DeepEqual(first.Ranked, second.Ranked) {
t.Fatalf("两次发布的名次不一致:\n第一次 %+v\n第二次 %+v", first.Ranked, second.Ranked)
}
// 团体通道的分数只来自团体名次:唯一一条单位流水,没有报名项 / 成绩(EntryID=0、ResultID=0)。
var teamLedger []LedgerEntry
for _, l := range second.Ledger {
if l.AthleteID == 0 {
teamLedger = append(teamLedger, l)
}
}
if len(teamLedger) != 1 {
t.Fatalf("团体流水 = %d 条,期望 1 条:%+v", len(teamLedger), teamLedger)
}
l := teamLedger[0]
if l.TeamID != 1001 || l.EntryID != 0 || l.ResultID != 0 {
t.Fatalf("团体流水 = %+v,期望只记单位 1001 且无报名项 / 成绩", l)
}
if !nearlyEqual(l.Points, 15) {
t.Fatalf("团体名次第 1 名的分 = %v,期望 15(团体表)", l.Points)
}
if l.SourceID != 2 {
t.Fatalf("sourceID 兜底应取通道 id=2,得到 %d", l.SourceID)
}
if l.SourceType != SourceResult {
t.Fatalf("SourceType = %q,期望 %q", l.SourceType, SourceResult)
}
// 团体通道只有一条名次明细,主体是单位,绝不消费那 6 条个人名次。
var teamRanked []RankedResult
for _, rr := range second.Ranked {
if rr.ChannelID == 2 {
teamRanked = append(teamRanked, rr)
}
}
if len(teamRanked) != 1 {
t.Fatalf("团体通道名次 = %d 条,期望 1 条(只认团体名次):%+v", len(teamRanked), teamRanked)
}
if teamRanked[0].EntryID != 0 || teamRanked[0].AthleteID != 0 || teamRanked[0].SubjectKey != "t:1001" {
t.Fatalf("团体名次 = %+v,期望合成主体 t:1001", teamRanked[0])
}
if teamRanked[0].Rank != 1 {
t.Fatalf("团体名次 Rank = %d,期望 1", teamRanked[0].Rank)
}
// 个人通道照旧按成绩排名 1..6。
ranks := rankByEntry(second.Ranked)
for i := 1; i <= 6; i++ {
if ranks[int64(i)] != i {
t.Fatalf("个人名次 = %v,期望 entry%d → %d", ranks, i, i)
}
}
// A 队总分 = 个人表 10+8+6+3+2+1 = 30,加团体名次第 1 名的 15,共 45;
// 第二次发布如果团体通道又读了一遍个人名次,这里会变成 85(P0 的真机表现)。
for _, es := range []EventScore{first, second} {
if got := SumByTeam(es.Ledger, 1)[1001].Total; !nearlyEqual(got, 45) {
t.Fatalf("A 队总分 = %v,期望 45(30 个人 + 15 团体)", got)
}
}
})
t.Run("团体名次未录:个人名次绝不进团体通道", func(t *testing.T) {
in := teamPullupInput(nil)
first := mustScore(t, in)
writeBackComputedRanks(in, first.Ranked)
second := mustScore(t, in)
if !reflect.DeepEqual(first, second) {
t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second)
}
for _, rr := range second.Ranked {
if rr.ChannelID == 2 {
t.Fatalf("没录团体名次时团体通道不应产出名次:%+v", rr)
}
}
if got := SumByTeam(second.Ledger, 1)[1001].Total; !nearlyEqual(got, 30) {
t.Fatalf("A 队总分 = %v,期望只算个人通道的 30(不跳变)", got)
}
})
}
// TestTeamManualChannelCandidateSources 守住团体手工通道的候选来源。
func TestTeamManualChannelCandidateSources(t *testing.T) {
score := func(t *testing.T, in EventScoreInput) EventScore {
t.Helper()
return mustScore(t, in)
}
t.Run("只认团体报名项兜底,个人报名项的名次不进团体通道", func(t *testing.T) {
entries, results := fixture([]participant{
// 个人报名项:录入名次第 1,是个人名次,团体通道不许读。
{entryID: 1, athleteID: 101, teamID: 1001, name: "甲", rank: 1, noValue: true},
// 团体报名项(athlete_id = 0):团体名次第 2。
{entryID: 2, teamID: 1002, rank: 2, noValue: true},
})
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1002], 10) {
t.Fatalf("单位 1002 = %v,期望团体名次第 2 名的 10 分:%v", got[1002], got)
}
if _, ok := got[1001]; ok {
t.Fatalf("个人报名项的名次进了团体通道:%v", got)
}
if len(es.Ranked) != 1 || es.Ranked[0].TeamID != 1002 || es.Ranked[0].EntryID != 2 {
t.Fatalf("团体名次明细 = %+v,期望只有单位 1002 的团体报名项", es.Ranked)
}
})
t.Run("TeamRanks 覆盖团体报名项的兜底名次", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, teamID: 1001, rank: 3, noValue: true},
})
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}},
})
if got := pointsByTeam(es.Ledger); !nearlyEqual(got[1001], 15) {
t.Fatalf("单位 1001 = %v,期望以 TeamRanks 的第 1 名计 15 分:%v", got[1001], got)
}
if len(es.Ranked) != 1 {
t.Fatalf("同一单位应只产出一条名次,得到 %d 条:%+v", len(es.Ranked), es.Ranked)
}
if es.Ranked[0].Rank != 1 || es.Ranked[0].EntryID != 1 || es.Ranked[0].SubjectKey != "e:1" {
t.Fatalf("覆盖后的名次 = %+v,期望报名项 1 记为第 1 名", es.Ranked[0])
}
})
t.Run("rank<=0 的团体名次视为未填,退回团体报名项", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, teamID: 1001, rank: 2, noValue: true},
})
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 0}, {TeamID: 1002, Rank: -1}},
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1001], 10) {
t.Fatalf("单位 1001 = %v,期望退回团体报名项的第 2 名(10 分):%v", got[1001], got)
}
if _, ok := got[1002]; ok {
t.Fatalf("rank<=0 的团体名次不应产出名次:%v", got)
}
if len(es.Ranked) != 1 {
t.Fatalf("名次明细 = %d 条,期望 1 条:%+v", len(es.Ranked), es.Ranked)
}
})
t.Run("并列团体名次走模板的 tieMode", func(t *testing.T) {
ranks := []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 1}}
cases := []struct {
name string
tie TieMode
points float64
}{
{"顺延(默认)", TieSkip, 15},
{"平均", TieAverage, 12.5},
{"就高", TieHigher, 15},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
profile := teamTop6()
profile.TieMode = tc.tie
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: profile},
TeamRanks: ranks,
})
if len(es.Ranked) != 2 {
t.Fatalf("并列名次 = %d 条,期望 2 条:%+v", len(es.Ranked), es.Ranked)
}
got := pointsByTeam(es.Ledger)
for _, rr := range es.Ranked {
if rr.Rank != 1 {
t.Fatalf("并列名次 = %+v,期望同为第 1 名", rr)
}
}
if !nearlyEqual(got[1001], tc.points) || !nearlyEqual(got[1002], tc.points) {
t.Fatalf("并列得分 = %v,期望各 %v", got, tc.points)
}
})
}
})
}
// TestRankedSubjectKeys 覆盖计分引擎各条产出路径的 SubjectKey 口径。
func TestRankedSubjectKeys(t *testing.T) {
t.Run("result 通道按报名项", func(t *testing.T) {
entries, results := fixture(lineupValues(11, 12))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
for _, rr := range es.Ranked {
if want := "e:" + strconv.FormatInt(rr.EntryID, 10); rr.SubjectKey != want {
t.Fatalf("SubjectKey = %q,期望 %q(%+v)", rr.SubjectKey, want, rr)
}
}
})
t.Run("团体手工名次按单位", func(t *testing.T) {
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 2}},
})
want := []string{"t:1001", "t:1002"}
if len(es.Ranked) != len(want) {
t.Fatalf("名次条数 = %d,期望 %d", len(es.Ranked), len(want))
}
for i, key := range want {
if es.Ranked[i].SubjectKey != key {
t.Fatalf("第 %d 条 SubjectKey = %q,期望 %q", i, es.Ranked[i].SubjectKey, key)
}
}
})
t.Run("聚合通道按主体", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 11},
{entryID: 2, athleteID: 2, teamID: 1001, value: 12},
{entryID: 3, athleteID: 3, teamID: 1002, value: 13},
})
ev := teamEvent(1)
ev.Compare = CompareAsc
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisAggregate, ProfileID: 1},
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
})
for _, rr := range es.Ranked {
var want string
if rr.Scope == ScopeAthlete {
want = "a:" + strconv.FormatInt(rr.AthleteID, 10)
} else {
want = "t:" + strconv.FormatInt(rr.TeamID, 10)
}
if rr.SubjectKey != want || rr.EntryID != 0 {
t.Fatalf("聚合名次 = %+v,期望 SubjectKey %q 且 EntryID=0", rr, want)
}
}
})
}
// TestSubjectKeyOfFallback 直接覆盖 subjectKeyOf 的四层口径(含退化兜底)。
func TestSubjectKeyOfFallback(t *testing.T) {
cases := []struct {
name string
rr RankedResult
want string
}{
{"有报名项按报名项", RankedResult{EntryID: 7, AthleteID: 101, TeamID: 1001, ChannelID: 3, Scope: ScopeAthlete}, "e:7"},
{"个人主体", RankedResult{AthleteID: 101, TeamID: 1001, ChannelID: 3, Scope: ScopeAthlete}, "a:101"},
{"单位主体", RankedResult{TeamID: 1001, ChannelID: 3, Scope: ScopeTeam}, "t:1001"},
{"团体名次但没有单位", RankedResult{AthleteID: 101, ChannelID: 3, Scope: ScopeTeam}, "ch:3"},
{"全空退化通道", RankedResult{ChannelID: 5}, "ch:5"},
{"连通道都没有", RankedResult{}, "ch:0"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := subjectKeyOf(tc.rr); got != tc.want {
t.Fatalf("subjectKeyOf = %q,期望 %q", got, tc.want)
}
})
}
}
+4 -1
View File
@@ -6,6 +6,8 @@ import "testing"
func ptr(f float64) *float64 { return &f }
func boolPtr(b bool) *bool { return &b }
// individualTop6 是规则包里的个人项目前六名:10/8/6/3/2/1。
func individualTop6() ScoringProfile {
return ScoringProfile{
@@ -40,7 +42,8 @@ func testCategory() EventCategory {
func individualEvent(multiplier float64) Event {
return Event{
ID: 1, Code: "E1", Name: "100米跑", EventType: EventTypeIndividual,
Compare: CompareAsc, RecordEligible: true, Scored: true, Multiplier: multiplier, Status: EventStatusPublished,
Compare: CompareAsc, RecordEligible: true, Scored: true, AwardMedals: true,
Multiplier: multiplier, Status: EventStatusPublished,
}
}
+9 -7
View File
@@ -61,6 +61,8 @@ func TestMatrix04_ThreeWayTieAtThird(t *testing.T) {
func TestMatrix05_TeamEventDoubled(t *testing.T) {
ps := lineup(8)
for i := range ps {
// 纯团体项目的报名项是团体报名项(无运动员):团体名次由 results.rank 兜底。
ps[i].athleteID = 0
ps[i].rank = i + 1
}
entries, results := fixture(ps)
@@ -193,24 +195,24 @@ func TestMatrix13_TieBreakOrder(t *testing.T) {
}{
{
name: "金牌优先",
ranked: []RankedResult{{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 1, Scope: ScopeAthlete}},
ranked: []RankedResult{mkRanked(2, ScopeAthlete, 1)},
want: 2,
},
{
name: "银牌优先",
ranked: []RankedResult{
{TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 1, Scope: ScopeAthlete},
{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 1, Scope: ScopeAthlete},
{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 2, Scope: ScopeAthlete},
mkRanked(1, ScopeAthlete, 1),
mkRanked(2, ScopeAthlete, 1),
mkRanked(2, ScopeAthlete, 2),
},
want: 2,
},
{
name: "铜牌优先",
ranked: []RankedResult{
{TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 3, Scope: ScopeAthlete},
{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 3, Scope: ScopeAthlete},
{TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 3, Scope: ScopeAthlete},
mkRanked(1, ScopeAthlete, 3),
mkRanked(2, ScopeAthlete, 3),
mkRanked(1, ScopeAthlete, 3),
},
want: 1,
},
@@ -0,0 +1,223 @@
package domain
import (
"strconv"
"testing"
)
// 本文件守住 devdocs/17 §4.5 的奖牌口径:
// 入选 = 小项结算奖牌 && 通道是奖牌通道 && 名次前三;
// 单位榜再加 scope == team || count_to_team_medals;
// 个人榜只取 scope == athlete && AthleteID != 0。
// 奖牌开关只影响奖牌,不影响积分流水。
// medalInput 是奖牌开关的测试底座:3 名运动员各代表一个单位,个人通道按成绩排出 1/2/3 名。
func medalInput(ch ScoreChannel) EventScoreInput {
entries, results := fixture(lineupValues(11, 12, 13))
return EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{ch},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}
}
// medalTotals 汇总单位榜 / 个人榜的金银铜。
func medalTotals(t *testing.T, in StandingsInput) (team, athlete [3]int) {
t.Helper()
for _, r := range BuildMedalStandings(in) {
team[0] += r.Gold
team[1] += r.Silver
team[2] += r.Bronze
}
for _, r := range BuildAthleteStandings(in) {
athlete[0] += r.Gold
athlete[1] += r.Silver
athlete[2] += r.Bronze
}
return team, athlete
}
func TestAwardMedalsSwitch(t *testing.T) {
cases := []struct {
name string
award bool
wantTeam [3]int
wantAthlet [3]int
}{
{"开启奖牌结算", true, [3]int{1, 1, 1}, [3]int{1, 1, 1}},
{"关闭奖牌结算", false, [3]int{0, 0, 0}, [3]int{0, 0, 0}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in := medalInput(athleteResultChannel(1))
in.Event.AwardMedals = tc.award
es := mustScore(t, in)
if len(es.Ledger) != 3 {
t.Fatalf("流水条数 = %d,期望 3(奖牌开关不影响积分)", len(es.Ledger))
}
if got := pointsByEntry(es.Ledger); !nearlyEqual(got[1], 10) || !nearlyEqual(got[3], 6) {
t.Fatalf("积分 = %v,期望 10/8/6(奖牌开关不影响积分)", got)
}
team, athlete := medalTotals(t, StandingsInput{
Teams: testTeams(1001, 1002, 1003), Ranked: es.Ranked,
})
if team != tc.wantTeam || athlete != tc.wantAthlet {
t.Fatalf("奖牌 = 单位 %v / 个人 %v,期望 %v / %v", team, athlete, tc.wantTeam, tc.wantAthlet)
}
})
}
}
func TestChannelCountToMedalsSwitch(t *testing.T) {
cases := []struct {
name string
countTo *bool
want [3]int
}{
{"未配置按发牌", nil, [3]int{1, 1, 1}},
{"显式开启", boolPtr(true), [3]int{1, 1, 1}},
{"显式关闭", boolPtr(false), [3]int{0, 0, 0}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ch := athleteResultChannel(1)
ch.CountToMedals = tc.countTo
in := medalInput(ch)
es := mustScore(t, in)
if len(es.Ledger) != 3 {
t.Fatalf("流水条数 = %d,期望 3(奖牌通道开关不影响积分)", len(es.Ledger))
}
team, athlete := medalTotals(t, StandingsInput{
Teams: testTeams(1001, 1002, 1003), Ranked: es.Ranked,
})
if team != tc.want || athlete != tc.want {
t.Fatalf("奖牌 = 单位 %v / 个人 %v,期望 %v", team, athlete, tc.want)
}
})
}
}
// 个人奖牌不并入单位榜:个人榜照常记牌,单位榜一枚都没有。
func TestCountToTeamMedalsSwitch(t *testing.T) {
ch := athleteResultChannel(1)
ch.CountToTeamMedals = boolPtr(false)
in := medalInput(ch)
es := mustScore(t, in)
team, athlete := medalTotals(t, StandingsInput{
Teams: testTeams(1001, 1002, 1003), Ranked: es.Ranked,
})
if team != [3]int{0, 0, 0} {
t.Fatalf("单位奖牌榜 = %v,期望 0(个人奖牌不并入单位榜)", team)
}
if athlete != [3]int{1, 1, 1} {
t.Fatalf("个人奖牌榜 = %v,期望 1/1/1", athlete)
}
// 单位总分不受奖牌通道开关影响。
if got := SumByTeam(es.Ledger, 1); !nearlyEqual(got[1001].Total, 10) || !nearlyEqual(got[1003].Total, 6) {
t.Fatalf("单位总分 = %v,期望 10/8/6(奖牌通道开关不影响积分)", got)
}
}
// scope=team 的奖牌不受 count_to_team_medals 影响:它本来就记在单位头上。
func TestTeamScopeMedalIgnoresCountToTeamMedals(t *testing.T) {
ch := teamManualChannel(2)
ch.CountToTeamMedals = boolPtr(false)
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{ch},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 2}},
})
team, athlete := medalTotals(t, StandingsInput{Teams: testTeams(1001, 1002), Ranked: es.Ranked})
if team != [3]int{1, 1, 0} {
t.Fatalf("单位奖牌榜 = %v,期望 1 金 1 银", team)
}
if athlete != [3]int{0, 0, 0} {
t.Fatalf("个人奖牌榜 = %v,期望 0(团体名次不发个人牌)", athlete)
}
}
// 旧全局开关与 per-通道开关是「与」的关系:总闸关掉时团体项目的奖牌一律不进单位榜。
func TestExcludeTeamEventMedalsStillApplies(t *testing.T) {
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}},
})
teams := testTeams(1001)
team, _ := medalTotals(t, StandingsInput{Teams: teams, Ranked: es.Ranked})
if team != [3]int{1, 0, 0} {
t.Fatalf("默认口径单位榜 = %v,期望 1 金", team)
}
team, _ = medalTotals(t, StandingsInput{Teams: teams, Ranked: es.Ranked, ExcludeTeamEventMedals: true})
if team != [3]int{0, 0, 0} {
t.Fatalf("全局排除团体奖牌后单位榜 = %v,期望 0", team)
}
}
// P1(5) 回归:individual_team 项目的团体聚合名次现在能进 Ranked 并产出单位金牌。
func TestAggregateTeamRankProducesTeamMedal(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 11},
{entryID: 2, athleteID: 2, teamID: 1001, value: 12},
{entryID: 3, athleteID: 3, teamID: 1002, value: 13},
})
ev := individualEvent(1)
ev.EventType = EventTypeIndividualTeam
ev.Compare = CompareAsc // 团体平均成绩越小越好
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{
athleteResultChannel(1),
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
})
var teamRanked []RankedResult
for _, rr := range es.Ranked {
if rr.ChannelID != 2 {
continue
}
teamRanked = append(teamRanked, rr)
if rr.EntryID != 0 || rr.ResultID != 0 || rr.AthleteID != 0 {
t.Fatalf("团体聚合名次 = %+v,期望不对应单条报名项 / 成绩", rr)
}
if rr.SubjectKey != "t:"+strconv.FormatInt(rr.TeamID, 10) {
t.Fatalf("团体聚合名次 SubjectKey = %q,期望 t:%d", rr.SubjectKey, rr.TeamID)
}
}
if len(teamRanked) != 2 {
t.Fatalf("团体聚合名次 = %d 条,期望 2 条:%+v", len(teamRanked), teamRanked)
}
// 只拿团体通道的名次也能看出:1001 第 1 名(金),1002 第 2 名(银)。
aggOnly := BuildTeamStandings(StandingsInput{Teams: testTeams(1001, 1002), Ranked: teamRanked})
if aggOnly[0].TeamID != 1001 || aggOnly[0].Gold != 1 {
t.Fatalf("团体名次单独统计 = %+v,期望单位 1001 记 1 金", aggOnly)
}
if aggOnly[1].TeamID != 1002 || aggOnly[1].Silver != 1 {
t.Fatalf("团体名次单独统计 = %+v,期望单位 1002 记 1 银", aggOnly)
}
// 全量榜单:个人通道(1 金 1 银 1 铜)与团体通道(1 金 1 银)各记一枚,互不去重。
team, athlete := medalTotals(t, StandingsInput{Teams: testTeams(1001, 1002), Ranked: es.Ranked})
if team != [3]int{2, 2, 1} {
t.Fatalf("单位奖牌合计 = %v,期望 2 金 2 银 1 铜", team)
}
if athlete != [3]int{1, 1, 1} {
t.Fatalf("个人奖牌合计 = %v,期望 1/1/1(团体名次不进个人榜)", athlete)
}
}
+31 -9
View File
@@ -111,11 +111,18 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
}
breaks = append(breaks, b)
}
byEntry := make(map[int64]Result, len(in.Results))
for _, r := range in.Results {
byEntry[r.EntryID] = r
}
modes := make(map[int64]RecordBonusMode, len(in.Entries))
byID := make(map[int64]Entry, len(in.Entries))
for _, e := range in.Entries {
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
// 状态不正常的报名项 / 成绩不给加分:与名次分同一口径,理由见 recordBonusEntryRankable。
if !recordBonusEntryRankable(in, e, byEntry) {
continue
}
byID[e.ID] = e
if m, ok := recordBonusModeFor(in, e); ok {
modes[e.ID] = m
}
@@ -125,14 +132,6 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
return nil, nil
}
byID := make(map[int64]Entry, len(in.Entries))
for _, e := range in.Entries {
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
continue
}
byID[e.ID] = e
}
var out []LedgerEntry
for _, b := range bonuses {
if b.Points == 0 {
@@ -165,6 +164,29 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
return out, nil
}
// recordBonusEntryRankable 判断一条报名项现在还够不够拿破纪录加分。
//
// 判据与 collectCandidates 的可排名判据同源(报名项与成绩都走 rankable(),草稿不算),
// 不另写一套:名次分与破纪录加分是同一条成绩的两种产出,一边认「这条成绩还算数」、
// 另一边不认,就会出现退赛 / 弃权 / 失格的人拿不到名次分、却继续在个人榜与单位榜上拿加分,
// 已作废成绩重算时也会把加分再写一遍(P1-6)。
//
// 这里只判「还算不算数」,不动 record_breaks 本身:登记是现场发生过的事实,
// 状态改回来(批量恢复 / 成绩改回 ok)时同一个登记还要能把加分重新算出来。
func recordBonusEntryRankable(in EventScoreInput, e Entry, byEntry map[int64]Result) bool {
if !entryMatches(in, e) || !e.Status.rankable() {
return false
}
r, ok := byEntry[e.ID]
if !ok {
return false
}
if r.State == ResultStateDraft && !in.Options.IncludeDrafts {
return false
}
return r.Status.rankable()
}
// recordBonusModeFor 取报名项「命中通道」所属模板的破纪录口径。
// 命中通道按 channelBefore 的优先级挑选(athlete → team → 通道 id 升序),
// 与奖牌去重同一口径;没有可命中通道或模板缺失时返回 false,由调用方退回全局口径。
+109
View File
@@ -318,3 +318,112 @@ func TestRecordBonusLedgerFiltersByCategory(t *testing.T) {
t.Fatalf("别的组别的加分不应记入本组别,实际 %d 条", otherCategory)
}
}
// bonusLedgerOfEntry 取某报名项名下两种流水的分值:成绩分与破纪录加分。
func bonusLedgerOfEntry(es EventScore, entryID int64) (rankPoints, bonusPoints float64) {
for _, l := range es.Ledger {
if l.EntryID != entryID {
continue
}
switch l.SourceType {
case SourceResult:
rankPoints += l.Points
case SourceRecordBonus:
bonusPoints += l.Points
}
}
return rankPoints, bonusPoints
}
// TestRecordBonusFollowsRankableStatus 验证破纪录加分与名次分同一口径(P1-6):
// 报名项退赛 / 弃权 / 失格,或成绩被作废 / 未发布时都不该再有加分流水。
//
// 除了逐条钉期望值,还断言「名次分有没有,破纪录加分就有没有」——两条通道是同一条成绩的
// 两种产出,判据必须同源(EntryStatus.rankable / ResultStatus.rankable),
// 各写一套就会出现「退赛的人拿不到名次分、却继续拿加分」。
func TestRecordBonusFollowsRankableStatus(t *testing.T) {
cases := []struct {
name string
mutate func(entries *[]Entry, results *[]Result)
want bool
}{
{"正常参赛且成绩有效", func(*[]Entry, *[]Result) {}, true},
{"报名项退赛", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusScratched }, false},
{"报名项失格", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusDQ }, false},
{"报名项未出场", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusDNS }, false},
{"成绩作废", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusWithdrawn }, false},
{"成绩失格", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDQ }, false},
{"成绩未出场", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDNS }, false},
{"成绩未完赛", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDNF }, false},
{"成绩仍是草稿", func(_ *[]Entry, results *[]Result) { (*results)[0].State = ResultStateDraft }, false},
{"没有成绩行", func(_ *[]Entry, results *[]Result) { *results = nil }, false},
{"状态留空按正常处理", func(entries *[]Entry, results *[]Result) {
(*entries)[0].Status = ""
(*results)[0].Status = ""
}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := fixture(lineup(1))
tc.mutate(&entries, &results)
cfg := bonusConfig(RecordBonusMax)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
})
rankPoints, bonusPoints := bonusLedgerOfEntry(es, 1)
if tc.want && !nearlyEqual(bonusPoints, 2) {
t.Fatalf("加分 = %v,期望 2", bonusPoints)
}
if !tc.want && bonusPoints != 0 {
t.Fatalf("不该产生破纪录加分,得到 %v", bonusPoints)
}
if got := rankPoints > 0; got != (bonusPoints > 0) {
t.Fatalf("成绩分与破纪录加分必须同生同灭,得到 成绩分=%v 加分=%v", rankPoints, bonusPoints)
}
})
}
}
// TestRecordBonusComesBackAfterRestore 验证状态判据是「看当前状态」而不是「一次性作废」:
// 退赛 / 作废后再改回正常,同一个登记还要能把加分还回来(现场纠正误判就靠这条)。
func TestRecordBonusComesBackAfterRestore(t *testing.T) {
entries, results := fixture(lineup(1))
cfg := bonusConfig(RecordBonusMax)
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
}
entries[0].Status = EntryStatusScratched
if got := recordBonusPointsOf(t, in); got != 0 {
t.Fatalf("退赛后的加分 = %v,期望 0", got)
}
entries[0].Status = EntryStatusRegistered
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
t.Fatalf("恢复参赛后的加分 = %v,期望 2", got)
}
results[0].Status = ResultStatusWithdrawn
if got := recordBonusPointsOf(t, in); got != 0 {
t.Fatalf("成绩作废后的加分 = %v,期望 0", got)
}
results[0].Status = ResultStatusOK
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
t.Fatalf("成绩改回有效后的加分 = %v,期望 2", got)
}
}
+112 -1
View File
@@ -25,7 +25,10 @@ type EventScoreInput struct {
Results []Result
RecordBonus *RecordBonusConfig
RecordBreaks []RecordBreak
Options ScoreOptions
// TeamRanks 是按单位录入的团体名次,只有 scope=team + basis=manual 的通道读它。
// 与「团体报名项(athlete_id=0)的 results.rank」共同构成团体名次的全部来源。
TeamRanks []TeamManualRank
Options ScoreOptions
}
// EventScore 是一次小项计分的结果。
@@ -54,6 +57,15 @@ type RankedResult struct {
Points float64
ResultValue *float64
Status ResultStatus
// SubjectKey 是这条名次的主体标识:名次按 (通道, 组别, 主体) 唯一,
// 取值 'e:<entryId>' / 'a:<athleteId>' / 't:<teamId>',三者都是 0 时退化为 'ch:<channelId>'。
// 聚合与团体手工名次没有单条报名项,靠它才能唯一落库。
SubjectKey string
// 下面三个开关随名次一起携带,榜单侧(standings.go)不必回查库就能算出奖牌:
// 小项是否结算奖牌、通道是否发牌、个人奖牌是否并入单位奖牌榜。
AwardMedals bool
CountToMedals bool
CountToTeamMedals bool
}
// ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。
@@ -175,10 +187,14 @@ func entryMatches(in EventScoreInput, e Entry) bool {
// collectCandidates 过滤出本通道真正参与排名的报名项:
// 只取已发布、状态正常、且带有本通道所需数据的成绩。聚合通道不用本函数,见 scoreByAggregate。
// scope=team + basis=manual 的候选来源与其它通道不同(P0 的修法),转给 collectTeamManualCandidates。
func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error) {
if ch.Basis == BasisResult && in.Event.Compare == CompareNone {
return nil, ErrCompareRequired
}
if ch.Basis == BasisManual && ch.Scope == ScopeTeam {
return collectTeamManualCandidates(in, ch), nil
}
byEntry := make(map[int64]Result, len(in.Results))
for _, r := range in.Results {
@@ -222,6 +238,72 @@ func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error)
return out, nil
}
// collectTeamManualCandidates 是 scope=team + basis=manual 的候选来源,只有两处:
// 1. in.TeamRanks(按单位录入的团体名次):没有对应的报名项,用合成 Entry{TeamID: id} 承载;
// 2. 兜底:团体报名项(athlete_id == 0)的 results.rank,纯团体项目(篮球等)沿用原录入流程。
//
// 同一单位两个来源都有时以 TeamRanks 为准;rank <= 0 的团体名次视为未填 / 清空,直接忽略。
// 个人报名项(athlete_id != 0)的名次是个人名次,绝不进团体通道——否则双通道项目
// 第 2 次发布时会把刚写回 results.rank 的个人名次当成团体名次再发一次分(P0)。
func collectTeamManualCandidates(in EventScoreInput, ch ScoreChannel) []candidate {
type manualRank struct {
teamID int64
rank int
}
overrides := make([]manualRank, 0, len(in.TeamRanks))
overrideOf := make(map[int64]int, len(in.TeamRanks))
for _, tr := range in.TeamRanks {
if tr.TeamID == 0 || tr.Rank <= 0 {
continue
}
if _, dup := overrideOf[tr.TeamID]; dup {
continue
}
overrideOf[tr.TeamID] = tr.Rank
overrides = append(overrides, manualRank{teamID: tr.TeamID, rank: tr.Rank})
}
byEntry := make(map[int64]Result, len(in.Results))
for _, r := range in.Results {
byEntry[r.EntryID] = r
}
out := make([]candidate, 0, len(in.Entries)+len(overrides))
taken := make(map[int64]bool, len(overrides))
for _, e := range in.Entries {
if !entryMatches(in, e) || e.AthleteID != 0 || !e.Status.rankable() {
continue
}
r, ok := byEntry[e.ID]
if !ok {
continue
}
if r.State == ResultStateDraft && !in.Options.IncludeDrafts {
continue
}
if !r.Status.rankable() || r.Rank <= 0 {
continue
}
rankIn := r.Rank
if rank, ok := overrideOf[e.TeamID]; ok {
rankIn = rank
}
taken[e.TeamID] = true
out = append(out, candidate{entry: e, result: r, rankIn: rankIn})
}
for _, or := range overrides {
if taken[or.teamID] {
continue
}
// 合成报名项:ID=0 表示「不对应任何一条报名项」,团体名次的主体是单位。
out = append(out, candidate{
entry: Entry{TeamID: or.teamID},
rankIn: or.rank,
})
}
return out
}
func scoreByResult(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
cands, err := collectCandidates(in, ch)
if err != nil {
@@ -366,6 +448,10 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
Points: points,
Status: c.result.Status,
}
rr.SubjectKey = subjectKeyOf(rr)
rr.AwardMedals = in.Event.AwardMedals
rr.CountToMedals = ch.CountsToMedals()
rr.CountToTeamMedals = ch.CountsToTeamMedals()
if c.result.ResultValue != nil {
v := *c.result.ResultValue
rr.ResultValue = &v
@@ -474,6 +560,10 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
rr.AthleteName = c.entry.AthleteName
rr.Shares = wholeShare(c.entry.primaryTeamID())
}
rr.SubjectKey = subjectKeyOf(rr)
rr.AwardMedals = in.Event.AwardMedals
rr.CountToMedals = ch.CountsToMedals()
rr.CountToTeamMedals = ch.CountsToTeamMedals()
v := c.value
rr.ResultValue = &v
out = append(out, rr)
@@ -516,6 +606,11 @@ func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) {
// ledgerFromRanked 把名次明细转成积分流水:按通道 scope 决定记给个人还是单位;
// 0 分不产生流水,避免流水表被零分噪声撑大。
// 流水是否计入单位总分由通道的 countToTeam 决定(个人通道可以只记个人)。
//
// 团体手工名次与聚合名次没有对应的报名项与成绩(EntryID=0、ResultID=0),
// 仍要能落库:SourceType 与聚合流水一样是 result,sourceID 退化为通道 id,
// 由真实来源(通道 + 主体)唯一确定。写入侧按「EntryID=0 && ResultID=0 && source_type=result」
// 识别这类流水(httpapi 的 isAggregateLedger),按组别重算时会整项重算回填,条数不增不减。
func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult) []LedgerEntry {
var out []LedgerEntry
for _, rr := range ranked {
@@ -556,6 +651,22 @@ func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult
return out
}
// subjectKeyOf 计算一条名次明细的主体标识(channel_ranks.subject_key 同口径):
// 有报名项就用报名项(个人名次按报名项去重才不会被并列拆开),否则按主体(个人 / 单位),
// 全为空时退化为通道 id,保证 (通道, 组别, 主体) 的唯一索引不会撞键。
func subjectKeyOf(rr RankedResult) string {
switch {
case rr.EntryID != 0:
return fmt.Sprintf("e:%d", rr.EntryID)
case rr.Scope == ScopeAthlete && rr.AthleteID != 0:
return fmt.Sprintf("a:%d", rr.AthleteID)
case rr.TeamID != 0:
return fmt.Sprintf("t:%d", rr.TeamID)
default:
return fmt.Sprintf("ch:%d", rr.ChannelID)
}
}
// wholeShare 构造「主单位独占 100%」的归属份额。联队拆分未实现,这里只产出这一种口径。
func wholeShare(teamID int64) []TeamShare {
if teamID == 0 {
+2 -3
View File
@@ -160,9 +160,8 @@ func TestScopeAndLedgerAttribution(t *testing.T) {
})
t.Run("team 通道仅记单位", func(t *testing.T) {
ps := lineup(1)
ps[0].rank = 1
entries, results := fixture(ps)
// 团体手工通道的候选只能来自 TeamRanks 或团体报名项(athlete_id = 0)。
entries, results := fixture([]participant{{entryID: 1, teamID: 1001, rank: 1, noValue: true}})
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
+23 -4
View File
@@ -52,6 +52,12 @@ func rankMedal(rank int) int {
}
}
// medalEligible 是奖牌入选判据(devdocs 17 §4.5):小项结算奖牌、通道是奖牌通道、
// 名次在前三,三者同时成立才计入。三个开关随 RankedResult 携带,榜单不必回查库。
func medalEligible(rr RankedResult) bool {
return rr.AwardMedals && rr.CountToMedals && rankMedal(rr.Rank) != 0
}
// BuildTeamStandings 生成总积分榜。
// 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 单位编码。
func BuildTeamStandings(in StandingsInput) []StandingsRow {
@@ -243,12 +249,15 @@ func medalKeyOf(rr RankedResult, index int) medalKey {
return k
}
// pickMedalChannels 把按通道展开的名次明细去重成「每个报名项一条」:
// 只保留优先级最高的通道名次,否则双通道项目一条成绩会拿两枚奖牌。
// pickMedalChannels 先按奖牌入选判据过滤,再把按通道展开的名次明细去重成「每个主体一条」:
// 只保留优先级最高的通道名次(个人通道优先),否则双通道项目一条成绩会拿两枚奖牌。
func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
best := make(map[medalKey]RankedResult, len(ranked))
order := make([]medalKey, 0, len(ranked))
for i, rr := range ranked {
if !medalEligible(rr) {
continue
}
if excludeTeam && rr.Scope == ScopeTeam {
continue
}
@@ -270,11 +279,20 @@ func pickMedalChannels(ranked []RankedResult, excludeTeam bool) []RankedResult {
return out
}
// teamMedals 统计团体奖牌榜。按 Shares 逐份额记牌:当前只有「主单位独占 100%」一种口径,
// teamMedals 统计单位奖牌榜。按 Shares 逐份额记牌:当前只有「主单位独占 100%」一种口径,
// 将来若支持联队拆分,每个成员单位都会各记一枚。
//
// 两层开关(devdocs 17 §4.5):
// - 粗粒度总闸 StandingsInput.ExcludeTeamEventMedals:为 true 时团体项目的奖牌一律不进单位榜;
// - 通道开关 count_to_team_medals:scope=athlete 的通道置 false 时,它的个人奖牌不进单位榜。
//
// 两者是「与」的关系;scope=team 的奖牌本来就记在单位头上,不受通道开关影响。
func teamMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally)
for _, rr := range pickMedalChannels(in.Ranked, in.ExcludeTeamEventMedals) {
if rr.Scope != ScopeTeam && !rr.CountToTeamMedals {
continue
}
medal := rankMedal(rr.Rank)
if medal == 0 {
continue
@@ -295,7 +313,8 @@ func teamMedals(in StandingsInput) map[int64]medalTally {
return out
}
// athleteMedals 只统计个人项目(scope=athlete)的奖牌。
// athleteMedals 只统计个人项目(scope=athlete)的奖牌;
// 通道的 count_to_team_medals 与个人榜无关,只决定这枚奖牌算不算进单位榜。
func athleteMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally)
for _, rr := range pickMedalChannels(in.Ranked, false) {
+20 -7
View File
@@ -4,10 +4,13 @@ import "testing"
func mkRanked(teamID int64, scope Scope, rank int) RankedResult {
return RankedResult{
TeamID: teamID,
Shares: []TeamShare{{TeamID: teamID, Weight: 1}},
Scope: scope,
Rank: rank,
TeamID: teamID,
Shares: []TeamShare{{TeamID: teamID, Weight: 1}},
Scope: scope,
Rank: rank,
AwardMedals: true,
CountToMedals: true,
CountToTeamMedals: true,
}
}
@@ -187,7 +190,10 @@ func TestAthleteStandings(t *testing.T) {
Teams: teams, Athletes: athletes, Ledger: ledger,
Ranked: []RankedResult{{
TeamID: 1001, AthleteID: 1, Scope: ScopeTeam, Rank: 1,
Shares: []TeamShare{{TeamID: 1001, Weight: 1}},
Shares: []TeamShare{{TeamID: 1001, Weight: 1}},
AwardMedals: true,
CountToMedals: true,
CountToTeamMedals: true,
}},
})
for _, r := range rows {
@@ -201,7 +207,10 @@ func TestAthleteStandings(t *testing.T) {
func TestUnionMedals(t *testing.T) {
rr := RankedResult{
TeamID: 1, Rank: 1, Scope: ScopeAthlete,
Shares: []TeamShare{{TeamID: 1, Weight: 0.6}, {TeamID: 2, Weight: 0.4}},
Shares: []TeamShare{{TeamID: 1, Weight: 0.6}, {TeamID: 2, Weight: 0.4}},
AwardMedals: true,
CountToMedals: true,
CountToTeamMedals: true,
}
t.Run("拆分时两单位各记一金", func(t *testing.T) {
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: []RankedResult{rr}})
@@ -274,7 +283,10 @@ func TestMedalDedupAcrossChannels(t *testing.T) {
return RankedResult{
EventID: 1, CategoryID: 1, EntryID: 7, ChannelID: channelID, Scope: scope,
TeamID: 1, AthleteID: 101, Rank: rank,
Shares: []TeamShare{{TeamID: 1, Weight: 1}},
Shares: []TeamShare{{TeamID: 1, Weight: 1}},
AwardMedals: true,
CountToMedals: true,
CountToTeamMedals: true,
}
}
teams := testTeams(1)
@@ -332,6 +344,7 @@ func TestMedalDedupAcrossChannels(t *testing.T) {
a := RankedResult{
EventID: 1, CategoryID: 1, ChannelID: 1, Scope: ScopeAthlete, AthleteID: 101,
TeamID: 1, Rank: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}},
AwardMedals: true, CountToMedals: true, CountToTeamMedals: true,
}
b := a
b.ChannelID = 2
+26
View File
@@ -208,6 +208,9 @@ type Event struct {
Venue string `json:"venue,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
Remark string `json:"remark,omitempty"`
// AwardMedals 决定该小项是否结算奖牌;库中默认 1(开启),关闭后它的名次不产生任何奖牌。
// 积分不受影响:奖牌与积分是两套口径。
AwardMedals bool `json:"awardMedals"`
// AvgMode 团体成绩(basis=aggregate 通道)取平均时的分母口径;空值按 valid 处理。
AvgMode AvgMode `json:"avgMode"`
}
@@ -237,6 +240,19 @@ type ScoreChannel struct {
// CountToTeam 决定该通道产出的流水是否计入单位总分;nil 表示按缺省(计入)处理。
// 「个人+团体」项目的个人通道置 false:分只记在个人头上,不进单位总分。
CountToTeam *bool `json:"countToTeam,omitempty"`
// CountToMedals 决定该通道产出的名次是否发奖牌;nil 表示按缺省(发牌)处理。
CountToMedals *bool `json:"countToMedals,omitempty"`
// CountToTeamMedals 决定该通道的个人奖牌是否并入单位奖牌榜;nil 表示按缺省(并入)处理。
// 只对 scope=athlete 的通道有意义:scope=team 的名次本来就记在单位头上。
CountToTeamMedals *bool `json:"countToTeamMedals,omitempty"`
}
// TeamManualRank 是「按单位录入的团体名次」,只被 scope=team + basis=manual 的通道读取。
// 团体名次的来源只有两个:这里,以及团体报名项(athlete_id=0)的 results.rank;
// 同一单位两边都有时以这里为准。个人报名项的名次属于个人名次,绝不进团体通道。
type TeamManualRank struct {
TeamID int64 `json:"teamId"`
Rank int `json:"rank"`
}
// ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。
@@ -446,6 +462,16 @@ func (c ScoreChannel) countToTeam() bool {
return c.CountToTeam == nil || *c.CountToTeam
}
// CountsToMedals 未配置时按 true 处理:只有显式关闭的通道才不发奖牌。
func (c ScoreChannel) CountsToMedals() bool {
return c.CountToMedals == nil || *c.CountToMedals
}
// CountsToTeamMedals 未配置时按 true 处理:个人通道的奖牌默认也计入单位奖牌榜。
func (c ScoreChannel) CountsToTeamMedals() bool {
return c.CountToTeamMedals == nil || *c.CountToTeamMedals
}
func (c EventCategory) multiplier() float64 {
if c.Multiplier <= 0 {
return 1
+36 -4
View File
@@ -186,20 +186,52 @@ func LoadAthleteDetailWorkbook(db *sql.DB, opts Options) (AthleteDetailWorkbook,
return AthleteDetailWorkbook{Meta: meta, Sheets: sheets}, nil
}
// athleteDetailRankExpr 取一条个人流水的名次(devdocs 17 §11.3):按流水对应的通道与主体关联 channel_ranks。
//
// score_ledger 上没有通道列(域模型 LedgerEntry.ChannelID 没有落库),所以只能按流水自身能确定的主体来找:
// - 有 entry_id 的流水(成绩流水):按报名项关联,通道用「主通道」口径(scope=athlete 优先,其次 sort_order);
// - entry_id / result_id 都为空的流水(个人聚合名次):写入侧把 source_id 退化为通道 id,就拿它 + 运动员关联。
//
// 组别用该行能唯一确定的那一个(c = 报名项的组别,ca = 回推出来的组别);回推不出来时不猜,名次留空。
// channel_ranks 是发布 / 重算才重建的派生数据,没有对应行时回落到 results.rank,不把明细的名次列变成空白。
const athleteDetailRankExpr = `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 cr.category_id = COALESCE(c.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 = '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), COALESCE(r.rank, 0))`
func loadAthleteDetailRows(db *sql.DB) ([]AthleteDetailRow, error) {
// 聚合流水的组别回推与单位明细同一条思路:某个运动员在本小项只参加了一个组别时,那个组别就是答案;
// 多个组别时回推不出来,宁可留空也不猜(回推只对 entry_id / result_id 都为空的聚合流水生效)。
rows, err := db.Query(`
SELECT l.team_id, COALESCE(a.name, ''),
COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''),
COALESCE(r.rank, 0), COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''),
` + athleteDetailRankExpr + `, COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
l.points, l.source_type,
COALESCE(s.sort_order, 0), COALESCE(e.sort_order, 0), COALESCE(c.sort_order, 0),
COALESCE(l.event_id, 0), COALESCE(c.id, 0), l.id
COALESCE(s.sort_order, 0), COALESCE(e.sort_order, 0), COALESCE(c.sort_order, ca.sort_order, 0),
COALESCE(l.event_id, 0), COALESCE(c.id, ca.id, 0), l.id
FROM score_ledger l
LEFT JOIN athletes a ON a.id = l.athlete_id
LEFT JOIN events e ON e.id = l.event_id
LEFT JOIN sports s ON s.id = e.sport_id
LEFT JOIN entries en ON en.id = l.entry_id
LEFT JOIN event_categories c ON c.id = en.category_id
LEFT JOIN (
SELECT en2.athlete_id, en2.event_id, MIN(en2.category_id) AS category_id
FROM entries en2
WHERE en2.athlete_id IS NOT NULL
GROUP BY en2.athlete_id, en2.event_id
HAVING COUNT(DISTINCT en2.category_id) = 1
) aac ON aac.event_id = l.event_id AND aac.athlete_id = l.athlete_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 = aac.category_id
LEFT JOIN results r ON r.id = l.result_id
WHERE l.athlete_id IS NOT NULL AND l.athlete_id <> 0 AND l.voided = 0`)
if err != nil {
+7 -1
View File
@@ -371,7 +371,13 @@ type BaseDataRow struct {
// Line 是该行在工作表里的真实行号(1 起,含表头上方的标题行),行级报错直接用它。
Line int
// Cells 是「字段名 → 单元格原文(已去空白)」;Has 标记该字段的列有没有出现在表头里。
// 两者分开:列不存在 = 不改动(更新时沿用库里的值),列存在但为空 = 明确写空。
//
// 这里的 Has / 空格之分是留给上层的原料,具体怎么解释按列的性质定(解析只负责如实报告):
// - 文本列 / 年龄 / 布尔 / 权重 / 排序:列缺席 = 不动,列在而格子空 = 按该列口径处理
// (文本与年龄清空、布尔取「是」、权重取 1、排序保持原值)——与三处用户文档一致;
// - 枚举列(小项的 类型/结果模式/结果类型/比较/状态/平均口径、组别的 性别):
// 列缺席与格子空**都** = 不动,只有显式填值才覆盖。留空曾按「取系统默认」处理,
// 一次误清格就会把已发布小项打回待录入、把团体项目类型改成个人 —— 见 planEvents。
Cells map[string]string
Has map[string]bool
}
+1
View File
@@ -40,6 +40,7 @@ func TestColumnsFitContent(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedTieAndRelay(t, db)
seedChannelRanks(t, db)
cases := []struct {
name string
+24
View File
@@ -67,6 +67,30 @@ func seedEvent(t *testing.T, db *sql.DB) {
}
}
// seedChannelRanks 按已发布成绩补一份 channel_ranks:它是发布 / 重算时由计分引擎全量重建的
// 派生数据(devdocs 17 §五),而导出测试直接造库、不走发布流程,不补这一份就读不到任何名次。
// 口径与迁移前的读取口径一致:一条已发布且有排名的成绩 × 该小项每条适用通道(scope=team 或
// 报名的是个人)各出一行,subject_key 用报名项('e:<entryId>',与引擎一致)。
//
// 注意:TestExportStandingsMatchesSnapshot 不调用它——那份用例要求导出与 WS 快照同源,
// 而快照侧(internal/realtime)本轮尚未改读 channel_ranks,两边都还没有奖牌数据才对得上。
func seedChannelRanks(t *testing.T, db *sql.DB) {
t.Helper()
if _, err := db.Exec(`
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)
SELECT r.event_id, en.category_id, c.id, c.basis, c.scope, 'e:' || en.id,
en.id, r.id, en.athlete_id, en.team_id, r.rank, 0, r.result_value, r.status
FROM results r
JOIN entries en ON en.id = r.entry_id
JOIN event_score_channels c ON c.event_id = r.event_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0
AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)`); err != nil {
t.Fatalf("补 channel_ranks: %v", err)
}
}
func countRows(t *testing.T, db *sql.DB, table string) int {
t.Helper()
var n int
+73 -27
View File
@@ -276,23 +276,24 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
return out, nil
}
// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。
// 与 REST 的 buildRanked、快照的 loadRanked 同源:一条成绩在每个计分通道下各产出一条明细,
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌。
// 积分以 score_ledger 为准,这里只取名次,不需要重算分值。
// loadRanked 读取发布产出的通道名次(channel_ranks),供 domain 统计奖牌。
// 与 REST 的 buildRanked、快照的 loadRanked 同口径:每个计分通道各自一行名次,
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌(devdocs 17 §五)。
// 三个奖牌开关随行携带(events.award_medals + 通道的 count_to_medals / count_to_team_medals),
// domain 的榜单函数不必回查库;积分以 score_ledger 为准,这里只取名次,不需要重算分值。
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
rows, err := db.Query(`
SELECT r.id, r.event_id, en.category_id, r.rank, r.result_value, r.status,
en.id, en.team_id, en.athlete_id, COALESCE(a.name, ''), c.scope, c.id
FROM results r
JOIN entries en ON en.id = r.entry_id
JOIN events ev ON ev.id = r.event_id
JOIN event_score_channels c ON c.event_id = r.event_id
LEFT JOIN athletes a ON a.id = en.athlete_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
AND ev.scored = 1
AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)
ORDER BY r.id, c.sort_order, c.id`)
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
JOIN event_score_channels c ON c.id = cr.channel_id
LEFT JOIN athletes a ON a.id = cr.athlete_id
WHERE ev.scored = 1 AND 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, fmt.Errorf("export: 读取名次: %w", err)
}
@@ -301,22 +302,32 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
var out []domain.RankedResult
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
countToTeamMed 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, &countToTeamMed); err != nil {
return nil, fmt.Errorf("export: 扫描名次: %w", 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 = countToTeamMed != 0
out = append(out, rr)
}
if err := rows.Err(); err != nil {
@@ -445,8 +456,12 @@ func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
// includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished);
// 连成绩行都没有的报名项同样占位(NoResult)。
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
// 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次,
// 口径 = scope=athlete 优先,其次通道 sort_order 小者。
// channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道
// 落不了库)回落到 results.rank,保持名次列不空白。
query := `
SELECT COALESCE(r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
SELECT COALESCE(cr.rank, r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''),
COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
COALESCE((SELECT SUM(l.points) FROM score_ledger l
@@ -456,6 +471,13 @@ func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([
JOIN teams t ON t.id = en.team_id
LEFT JOIN athletes a ON a.id = en.athlete_id
LEFT JOIN results r ON r.entry_id = en.id
LEFT JOIN event_score_channels mc ON mc.id = (
SELECT c2.id FROM event_score_channels c2
WHERE c2.event_id = en.event_id
ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id
LIMIT 1)
LEFT JOIN channel_ranks cr ON cr.channel_id = mc.id AND cr.entry_id = en.id
AND cr.category_id = en.category_id
WHERE en.event_id = ?`
args := []any{eventID}
if categoryID != 0 {
@@ -548,6 +570,29 @@ func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) {
return t, nil
}
// teamDetailRankExpr 取一条单位流水的名次(devdocs 17 §11.3):按流水对应的通道与主体关联 channel_ranks。
//
// score_ledger 上没有通道列(域模型 LedgerEntry.ChannelID 没有落库),所以只能按流水自身能确定的主体来找:
// - 有 entry_id 的流水(成绩流水):按报名项关联,通道用「主通道」口径(scope=athlete 优先,其次 sort_order);
// - entry_id / result_id 都为空的流水(聚合名次与团体手工名次):写入侧把 source_id 退化为通道 id,
// 就拿它 + 主体(单位 / 运动员)关联。
//
// 组别用该行能唯一确定的那一个(c = 报名项的组别,ca = 回推出来的组别);回推不出来时不猜,名次留空。
// channel_ranks 是发布 / 重算才重建的派生数据,没有对应行时回落到 results.rank,不把明细的名次列变成空白。
const teamDetailRankExpr = `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 cr.category_id = COALESCE(c.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), COALESCE(r.rank, 0))`
func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
// 聚合通道(basis=aggregate,即「按队内平均」)的流水 entry_id / result_id 都为空,
// 从 entries 取不到组别。这类流水是「单位在本组别的成绩取平均」的产物,组别在算分时已经确定;
@@ -556,7 +601,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
// 回推只对聚合流水生效(与 isAggregateLedger 同判据),人工调整等其它无 entry_id 的流水不受影响。
rows, err := db.Query(`
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''),
COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''),
COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''),
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0)
FROM score_ledger l
LEFT JOIN events e ON e.id = l.event_id
@@ -575,7 +620,8 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
LEFT JOIN results r ON r.id = l.result_id
LEFT JOIN athletes a ON a.id = l.athlete_id
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(r.rank, 999999), l.id`,
ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order,
COALESCE(NULLIF(`+teamDetailRankExpr+`, 0), 999999), l.id`,
teamID, teamID)
if err != nil {
return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err)
+199
View File
@@ -66,6 +66,9 @@ func TestLoadRankedExcludesRankOnlyEvents(t *testing.T) {
t.Fatalf("造数据 %q: %v", s, err)
}
}
// scored=0 的小项照样有通道名次(引擎只排名不计分也会产出名次),
// 靠 loadRanked 的 scored 过滤把它们挡在奖牌之外。
seedChannelRanks(t, db)
ranked, err := loadRanked(db)
if err != nil {
@@ -77,6 +80,17 @@ func TestLoadRankedExcludesRankOnlyEvents(t *testing.T) {
}
}
if countRows(t, db, "channel_ranks") == 0 {
t.Fatal("用例前提不成立:channel_ranks 是空的,过滤没被真正考验")
}
var event2Ranks int
if err := db.QueryRow(`SELECT count(*) FROM channel_ranks WHERE event_id = 2`).Scan(&event2Ranks); err != nil {
t.Fatalf("统计小项 2 的通道名次: %v", err)
}
if event2Ranks == 0 {
t.Fatal("用例前提不成立:只排名的小项没有通道名次行,scored 过滤没被真正考验")
}
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("导出总表: %v", err)
@@ -101,6 +115,7 @@ func TestLoadStandingsHonoursTieBreak(t *testing.T) {
t.Fatalf("造数据 %q: %v", s, err)
}
}
seedChannelRanks(t, db)
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
@@ -263,6 +278,190 @@ func findAggregateRow(rows []TeamDetailRow) (TeamDetailRow, bool) {
return TeamDetailRow{}, false
}
// 三个奖牌开关随名次行从 events / event_score_channels 带出来(devdocs 17 §4.5):
// 关掉小项的 award_medals、或通道的 count_to_medals,奖牌就该消失;
// 只关 count_to_team_medals 时个人榜仍有牌、单位榜没有。
func TestLoadRankedCarriesMedalSwitches(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedChannelRanks(t, db)
ranked, err := loadRanked(db)
if err != nil {
t.Fatalf("读取名次: %v", err)
}
if len(ranked) == 0 {
t.Fatal("用例前提不成立:读不到任何名次")
}
for _, rr := range ranked {
if !rr.AwardMedals || !rr.CountToMedals || !rr.CountToTeamMedals {
t.Fatalf("三列默认全为 1,开关应全为 true: %+v", rr)
}
}
if gold := standingsGold(t, db, 1); gold != 1 {
t.Fatalf("基线 T1 金牌 = %d,期望 1", gold)
}
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE id = 1`); err != nil {
t.Fatalf("关闭小项奖牌结算: %v", err)
}
if gold := standingsGold(t, db, 1); gold != 0 {
t.Errorf("award_medals=0 时 T1 金牌 = %d,期望 0", gold)
}
// 奖牌明细与榜单同口径(medals.go 走 domain.BuildMedalStandings),关掉开关后也不该再出牌。
if sheets := medalSheetsOf(t, db); len(sheets) != 0 {
t.Errorf("award_medals=0 时奖牌明细仍出分表: %v", sheets)
}
if _, err := db.Exec(`UPDATE events SET award_medals = 1 WHERE id = 1`); err != nil {
t.Fatalf("恢复小项奖牌结算: %v", err)
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 0 WHERE event_id = 1`); err != nil {
t.Fatalf("关闭通道奖牌: %v", err)
}
if gold := standingsGold(t, db, 1); gold != 0 {
t.Errorf("count_to_medals=0 时 T1 金牌 = %d,期望 0", gold)
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_medals = 1 WHERE event_id = 1`); err != nil {
t.Fatalf("恢复通道奖牌: %v", err)
}
if _, err := db.Exec(`UPDATE event_score_channels SET count_to_team_medals = 0 WHERE event_id = 1`); err != nil {
t.Fatalf("关闭个人奖牌并入单位: %v", err)
}
if gold := standingsGold(t, db, 1); gold != 0 {
t.Errorf("count_to_team_medals=0 时 T1 单位金牌 = %d,期望 0", gold)
}
if gold := athleteGold(t, db, 1); gold != 1 {
t.Errorf("count_to_team_medals=0 时张三个人金牌 = %d,期望 1(只影响单位榜)", gold)
}
}
// medalSheetsOf 取奖牌明细里出了分表的单位 id(没有奖牌时为空)。
func medalSheetsOf(t *testing.T, db *sql.DB) []int64 {
t.Helper()
wb, err := LoadMedalDetailWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取奖牌明细: %v", err)
}
out := make([]int64, 0, len(wb.Sheets))
for _, s := range wb.Sheets {
out = append(out, s.Team.ID)
}
return out
}
// standingsGold / athleteGold 取导出总表里某个单位 / 运动员的金牌数。
func standingsGold(t *testing.T, db *sql.DB, teamID int64) int {
t.Helper()
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取总表: %v", err)
}
for _, row := range wb.Team {
if row.TeamID == teamID {
return row.Gold
}
}
return 0
}
func athleteGold(t *testing.T, db *sql.DB, athleteID int64) int {
t.Helper()
wb, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取总表: %v", err)
}
for _, row := range wb.Athlete {
if row.AthleteID == athleteID {
return row.Gold
}
}
return 0
}
// 分项成绩表的名次列取「主通道」名次(devdocs 17 §11.3):双通道项目里 scope=athlete 的通道优先,
// 一张报名项只显示一个名次;channel_ranks 里没有对应行时回落到 results.rank。
func TestEventRowsUseMainChannelRank(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 补一条团体通道,让「100米跑」变成双通道项目(与 TEAM_PULLUP 同形)。
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
t.Fatalf("补团体通道: %v", err)
}
seedChannelRanks(t, db)
// 个人名次只认 channel_ranks:把 results.rank 改坏,成绩表仍要显示个人通道的 1。
if _, err := db.Exec(`UPDATE results SET rank = 4 WHERE id = 1`); err != nil {
t.Fatalf("改坏 results.rank: %v", err)
}
wb, err := LoadEventWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("导出分项成绩: %v", err)
}
rows := eventRows(wb.Sheets[0].Rows)
if len(rows) != 2 {
t.Fatalf("行数 = %d,期望 2(一条报名项一行,不按通道复制)", len(rows))
}
// 行序按 results.rank 排(T1 被改坏成 4),所以按号码布找那一行。
byBib := make(map[string][]any, len(rows))
for _, r := range rows {
byBib[r[1].(string)] = r
}
if got := byBib["101"][0]; got != "1" {
t.Errorf("101 的名次列 = %v,期望主通道(个人通道)的 1", got)
}
if got := byBib["102"][0]; got != "2" {
t.Errorf("102 的名次列 = %v,期望 2", got)
}
}
// 团体手工 / 聚合这类没有成绩行的流水也要显示 channel_ranks 里的团体名次
// (迁移前这类流水的 rank 恒为 0,devdocs 17 §11.3)。
func TestTeamDetailShowsSubjectRank(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
// 团体手工通道:名次按单位录入,流水没有 entry_id / result_id,写入侧把 source_id 退化为通道 id。
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
t.Fatalf("补团体手工通道: %v", err)
}
var channelID int64
if err := db.QueryRow(`SELECT id FROM event_score_channels WHERE event_id = 1 AND scope = 'team'`).Scan(&channelID); err != nil {
t.Fatalf("读通道 id: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, team_id, rank, points)
VALUES (1, 1, ?, 'manual', 'team', 't:1', NULL, NULL, NULL, 1, 2, 0)`, channelID); err != nil {
t.Fatalf("造团体手工名次: %v", err)
}
// 流水与写入侧一致:source_id 退化为通道 id(见 domain.ledgerFromRanked)。
if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, count_to_team)
VALUES (1, NULL, 1, NULL, NULL, 'result', ?, 6, 1)`, channelID); err != nil {
t.Fatalf("造团体手工流水: %v", err)
}
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
if err != nil {
t.Fatalf("读取积分明细: %v", err)
}
var found bool
for _, r := range wb.Rows {
if r.Points != 6 {
continue
}
found = true
if r.Rank != 2 {
t.Errorf("团体手工流水的名次 = %d,期望 2(来自 channel_ranks)", r.Rank)
}
}
if !found {
t.Fatalf("团体手工流水没出现在明细里: %+v", wb.Rows)
}
}
// 「个人+团体」项目的个人通道流水(count_to_team = 0)不进单位总分,
// 导出侧与 REST / WS 快照必须同口径。
func TestLoadStandingsSkipsPersonalOnlyLedger(t *testing.T) {
+67 -10
View File
@@ -176,6 +176,12 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
if err != nil {
return MedalDetailWorkbook{}, err
}
// 聚合通道与团体手工名次没有对应的成绩(channel_ranks.result_id 为空),按成绩 id 查不到展示字段,
// 按小项 + 组别补一份兜底。
eventCtx, err := loadMedalEventContext(db)
if err != nil {
return MedalDetailWorkbook{}, err
}
teams, err := loadTeams(db, false)
if err != nil {
return MedalDetailWorkbook{}, err
@@ -188,7 +194,7 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
sheets := make([]MedalDetailSheet, 0, len(teams))
for _, t := range teams {
rows := medalRowsOfTeam(t.ID, hits, ctx)
rows := medalRowsOfTeam(t.ID, hits, ctx, eventCtx)
if len(rows) == 0 {
continue
}
@@ -197,11 +203,14 @@ func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, err
return MedalDetailWorkbook{Meta: meta, Sheets: sheets}, nil
}
// medalHit 是一枚奖牌:来自哪条成绩、什么名次、什么牌。
// medalHit 是一枚奖牌:来自哪个小项 / 组别的哪条成绩、什么名次、什么牌。
// 聚合名次与团体手工名次没有单条成绩(resultID 为 0),小项 + 组别是展示字段的兜底来源。
type medalHit struct {
resultID int64
rank int
medal string
eventID int64
categoryID int64
resultID int64
rank int
medal string
}
// medalHits 逐「报名项」判定获奖单位。
@@ -222,13 +231,16 @@ func medalHits(ranked []domain.RankedResult, excludeTeam bool) map[int64][]medal
rr := group[0]
for _, row := range rows {
for i := 0; i < row.Gold; i++ {
out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 1, medalGold})
out[row.TeamID] = append(out[row.TeamID], medalHit{
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 1, medal: medalGold})
}
for i := 0; i < row.Silver; i++ {
out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 2, medalSilver})
out[row.TeamID] = append(out[row.TeamID], medalHit{
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 2, medal: medalSilver})
}
for i := 0; i < row.Bronze; i++ {
out[row.TeamID] = append(out[row.TeamID], medalHit{rr.ResultID, 3, medalBronze})
out[row.TeamID] = append(out[row.TeamID], medalHit{
eventID: rr.EventID, categoryID: rr.CategoryID, resultID: rr.ResultID, rank: 3, medal: medalBronze})
}
}
}
@@ -327,12 +339,57 @@ func loadMedalContext(db *sql.DB) (map[int64]medalContext, error) {
return out, nil
}
// eventCategoryKey 是「小项 + 组别」的键。
type eventCategoryKey struct {
eventID int64
categoryID int64
}
// loadMedalEventContext 按 (小项, 组别) 建一份展示字段,给没有单条成绩的奖牌兜底:
// 聚合通道与团体手工名次在 channel_ranks 里 result_id 为空,按成绩 id 查不到来源,
// 只能回到小项 + 组别上取大项 / 小项 / 组别名(运动员/队留空——聚合名次不对应单个人)。
func loadMedalEventContext(db *sql.DB) (map[eventCategoryKey]medalContext, error) {
rows, err := db.Query(`
SELECT e.id, COALESCE(c.id, 0), COALESCE(s.name, ''), e.name, COALESCE(c.name, ''),
COALESCE(s.sort_order, 0), e.sort_order, COALESCE(c.sort_order, 0)
FROM events e
LEFT JOIN sports s ON s.id = e.sport_id
LEFT JOIN event_categories c ON c.event_id = e.id`)
if err != nil {
return nil, fmt.Errorf("export: 读取奖牌小项来源: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[eventCategoryKey]medalContext)
for rows.Next() {
var (
key eventCategoryKey
c medalContext
)
if err := rows.Scan(&key.eventID, &key.categoryID, &c.sportName, &c.eventName, &c.category,
&c.sportSort, &c.eventSort, &c.catSort); err != nil {
return nil, fmt.Errorf("export: 扫描奖牌小项来源: %w", err)
}
c.eventID = key.eventID
c.categoryID = key.categoryID
out[key] = c
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历奖牌小项来源: %w", err)
}
return out, nil
}
// medalRowsOfTeam 把某单位的奖牌拼成展示行,按大项/小项/组别/名次的既有顺序排列。
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext) []MedalDetailRow {
// 展示字段优先按成绩取(能带上运动员 / 接力队名单),取不到再按小项 + 组别兜底。
func medalRowsOfTeam(teamID int64, hits map[int64][]medalHit, ctx map[int64]medalContext, eventCtx map[eventCategoryKey]medalContext) []MedalDetailRow {
list := hits[teamID]
rows := make([]MedalDetailRow, 0, len(list))
for _, h := range list {
c := ctx[h.resultID]
c, ok := ctx[h.resultID]
if !ok {
c = eventCtx[eventCategoryKey{eventID: h.eventID, categoryID: h.categoryID}]
}
rows = append(rows, MedalDetailRow{
SportName: c.sportName,
EventName: c.eventName,
+55
View File
@@ -28,6 +28,7 @@ func TestExportMedalDetailXLSX(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db) // T1 第 1 名、T2 第 2 名
addBronzeEntry(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "奖牌.xlsx")
if err := ExportMedalDetailXLSX(db, dst, Options{}); err != nil {
@@ -127,6 +128,7 @@ func TestMedalDetailMatchesBoard(t *testing.T) {
t.Fatalf("造数据 %q: %v", s, err)
}
}
seedChannelRanks(t, db)
wb, err := LoadMedalDetailWorkbook(db, Options{})
if err != nil {
@@ -173,6 +175,58 @@ func TestMedalDetailMatchesBoard(t *testing.T) {
}
}
// 没有单条成绩的奖牌(团体手工 / 聚合名次,channel_ranks.result_id 为空)也要能印出
// 大项 / 小项 / 组别,并与团体奖牌榜逐单位对得上(devdocs 17 §11.3)。
func TestMedalDetailShowsSubjectOnlyMedal(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedChannelRanks(t, db)
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'team', 'manual', id FROM scoring_profiles WHERE code = 'team_top6'`); err != nil {
t.Fatalf("补团体手工通道: %v", err)
}
var channelID int64
if err := db.QueryRow(`SELECT id FROM event_score_channels WHERE event_id = 1 AND scope = 'team'`).Scan(&channelID); err != nil {
t.Fatalf("读通道 id: %v", err)
}
if _, err := db.Exec(`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, team_id, rank, points)
VALUES (1, 1, ?, 'manual', 'team', 't:2', NULL, NULL, NULL, 2, 1, 0)`, channelID); err != nil {
t.Fatalf("造团体手工名次: %v", err)
}
wb, err := LoadMedalDetailWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取奖牌明细: %v", err)
}
board, err := LoadStandingsWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取团体奖牌榜: %v", err)
}
for _, row := range board.Medal {
gold, silver, bronze := medalCountsOf(wb, row.TeamID)
if gold != row.Gold || silver != row.Silver || bronze != row.Bronze {
t.Errorf("单位 %d 奖牌明细 金%d/银%d/铜%d,榜单 金%d/银%d/铜%d",
row.TeamID, gold, silver, bronze, row.Gold, row.Silver, row.Bronze)
}
}
subjectOnly := 0
for _, s := range wb.Sheets {
for _, r := range s.Rows {
if r.SportName == "" || r.EventName == "" || r.Category == "" {
t.Errorf("奖牌明细缺大项 / 小项 / 组别: %+v", r)
}
if r.Participant == "" {
subjectOnly++
}
}
}
if subjectOnly != 1 {
t.Errorf("没有成绩的奖牌行数 = %d,期望 1(团体手工名次),明细 = %+v", subjectOnly, wb.Sheets)
}
}
// 没有奖牌(甚至没有小项)时不报错,返回只有表头的总表。
func TestExportMedalDetailEmptyEvent(t *testing.T) {
db := openEventDB(t)
@@ -204,6 +258,7 @@ func TestExportMedalDetailEmptyEvent(t *testing.T) {
func TestMedalDetailKeepsHiddenTeamWithMedals(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedChannelRanks(t, db)
silent := insertTeam(t, db, "H2", "被隐藏且无牌", "隐藏二", 4, false)
hideTeam(t, db, silent)
+1
View File
@@ -11,6 +11,7 @@ import (
func TestExportStandingsXLSX(t *testing.T) {
db := openEventDB(t)
seedEvent(t, db)
seedChannelRanks(t, db)
dst := filepath.Join(t.TempDir(), "总表.xlsx")
if err := ExportStandingsXLSX(db, dst, Options{}); err != nil {
+17
View File
@@ -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)
+27 -19
View File
@@ -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 {
+31 -41
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
}
+38 -23
View File
@@ -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)
}
}
+98 -24
View File
@@ -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
}
+695 -36
View File
@@ -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)
}
}
+10
View File
@@ -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 ""
}
+87
View File
@@ -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)
}
}
+59 -2
View File
@@ -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)
}
+108 -18
View File
@@ -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, &note, &e.AvgMode)
&scheduledAt, &venue, &e.SortOrder, &note, &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)
}
+196 -42
View File
@@ -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)
}
+72 -23
View File
@@ -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)
}
+6
View File
@@ -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)
}
}
+169
View File
@@ -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)
}
}
}
+174 -51
View File
@@ -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)
+4
View File
@@ -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))
+360
View File
@@ -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
}
+681
View File
@@ -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
}
+1 -1
View File
@@ -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("恢复后报名不该消失")
}
+19
View File
@@ -18,6 +18,7 @@ const (
ctxKeyUser ctxKey = iota
ctxKeyRequestID
ctxKeyAudit
ctxKeySession
)
// User 返回当前请求的身份;未经 Authenticate 中间件时是 guest。
@@ -33,6 +34,24 @@ func WithUser(ctx context.Context, u *auth.User) context.Context {
return context.WithValue(ctx, ctxKeyUser, u)
}
// Session 返回当前请求背后的会话;未登录或未经 Authenticate 中间件时为 nil。
//
// /ws 这类长连接要拿它做会话复查:握手时身份固化,会话被撤销后必须能对应到具体这一条连接。
func Session(ctx context.Context) *auth.Session {
if s, ok := ctx.Value(ctxKeySession).(*auth.Session); ok {
return s
}
return nil
}
// WithSession 把会话写入 context,供测试或非标准链路使用;nil 表示没有会话。
func WithSession(ctx context.Context, s *auth.Session) context.Context {
if s == nil {
return ctx
}
return context.WithValue(ctx, ctxKeySession, s)
}
// GetRequestID 返回本次请求的 request_id。
func GetRequestID(ctx context.Context) string {
if v, ok := ctx.Value(ctxKeyRequestID).(string); ok {
+5 -2
View File
@@ -106,11 +106,14 @@ func Authenticate(svc *auth.Service, logger *slog.Logger) func(http.Handler) htt
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := &auth.User{Role: auth.RoleGuest}
ctx := r.Context()
if c, err := r.Cookie(auth.CookieSession); err == nil && c.Value != "" {
found, _, err := svc.Lookup(r.Context(), c.Value)
found, sess, err := svc.Lookup(ctx, c.Value)
switch {
case err == nil:
u = found
// 记下会话:实时层靠它复查长连接背后的会话是否还有效(撤销后要断开)。
ctx = WithSession(ctx, sess)
case errors.Is(err, auth.ErrSessionNotFound):
// 会话缺失/过期是正常情况,静默按 guest 放行。
default:
@@ -118,7 +121,7 @@ func Authenticate(svc *auth.Service, logger *slog.Logger) func(http.Handler) htt
"err", err, "path", r.URL.Path, "requestId", GetRequestID(r.Context()))
}
}
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), u)))
next.ServeHTTP(w, r.WithContext(WithUser(ctx, u)))
})
}
}
+44
View File
@@ -5,6 +5,8 @@ import (
"database/sql"
"encoding/json"
"fmt"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
)
// currentVersion 返回服务端版本号,即 change_log 的最大 id;空表为 0。
@@ -109,3 +111,45 @@ func payloadJSON(payload string) json.RawMessage {
}
return json.RawMessage(payload)
}
// entitySettings 是赛事配置在 change_log.entity_type 里的取值;
// 它的载荷是完整的 dto.EventSettings,含只有 admin 能读的系统配置字段。
const entitySettings = "settings"
// redactChange 按连接身份裁剪一条变更的载荷,返回不与其它连接共享的新对象。
//
// 快照对非 admin 会套 WithoutAdminFields 砍掉 defaultProfileId / requireApproval /
// individualToTeamWeight,但 change 与 catchup 走的是另一条路径:不在这里做同样处理,
// 任何打开 /ws 的浏览器都能从增量里读到这三个字段,快照上的裁剪等于被旁路。
// 只动 settings 这一个实体,其余实体的载荷本来就是公开的赛事数据。
func redactChange(ch Change, settingsAdmin bool) Change {
if settingsAdmin || ch.Entity != entitySettings {
return ch
}
var settings dto.EventSettings
if err := json.Unmarshal(ch.Data, &settings); err != nil {
// 载荷不是 EventSettings 形状(历史数据 / 手工改库):宁可只下发空对象,
// 也不能把可能含系统配置的原文透给非 admin。
ch.Data = json.RawMessage(`{}`)
return ch
}
data, err := json.Marshal(settings.WithoutAdminFields())
if err != nil {
ch.Data = json.RawMessage(`{}`)
return ch
}
ch.Data = data
return ch
}
// redactChanges 批量裁剪;admin 连接原样返回,不做无谓的复制。
func redactChanges(changes []Change, settingsAdmin bool) []Change {
if settingsAdmin {
return changes
}
out := make([]Change, 0, len(changes))
for _, ch := range changes {
out = append(out, redactChange(ch, settingsAdmin))
}
return out
}
+42 -6
View File
@@ -33,25 +33,37 @@ type client struct {
userID int64
sessionRole string
sessionUserID int64
instanceID string
// sessionID 是握手时的会话 id(未登录为空):会话撤销后要据此复查并断开本连接。
sessionID string
instanceID string
// settingsAdmin 表示会话是否为 admin:recorder 也进管理端房间,但读不到系统配置。
settingsAdmin bool
ackVersion atomic.Int64 // 客户端已确认应用到的版本
lastSent atomic.Int64 // 服务端已发出的最大版本
slow atomic.Bool
// snapshotAt 是本连接最近一次发出整库快照的时间(unix 纳秒)。
// 它是「整库快照窗口」的记账点:resync 与不带 lastVersion 的 hello 都会走到发快照,
// 两者共用一个窗口,不能靠交替发把限流绕过去。
snapshotAt atomic.Int64
// snapshotPending 表示「有请求要过整库快照,但被窗口挡下了」:
// 心跳循环会在窗口过后补发一份,见 flushPendingSnapshot。
// 不能把被挡下的请求直接丢掉——大屏收到它不认识的变更时会主动 resync,
// 丢了就可能让那块屏一直停在旧数据上,直到下一条变更到来。
snapshotPending atomic.Bool
slow atomic.Bool
}
func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) *client {
func newClient(h *Hub, conn *websocket.Conn, remoteAddr string, id wsIdentity) *client {
return &client{
hub: h,
conn: conn,
remoteAddr: remoteAddr,
send: make(chan []byte, defaultSendBuffer),
done: make(chan struct{}),
sessionRole: sessionRole,
sessionUserID: sessionUserID,
settingsAdmin: settingsAdmin,
sessionRole: id.Role,
sessionUserID: id.UserID,
sessionID: id.SessionID,
settingsAdmin: id.SettingsAdmin,
}
}
@@ -85,6 +97,29 @@ func (c *client) sendEvent(event string, data any) bool {
return c.sendRaw(msg)
}
// allowSnapshot 按连接给「整库快照」记账:窗口内只放行一次。
//
// 触发整库快照的入口有两个(resync,以及不带 lastVersion 的 hello),它们共用这一个窗口:
// 一条 15 字节的帧就是一次 DB 事务 + 8 条查询,而赛事库是单连接(SetMaxOpenConns(1)),
// 快照读取还在全局发送锁里——不限流的话,一个写坏的客户端就能把库连接与所有人的推送一起堵住。
// 窗口(defaultSnapshotMinInterval)比客户端自己的 3s 冷却短,正常客户端按自己的节奏回来时
// 窗口已经过期,重连补齐行为因此不受影响。
func (c *client) allowSnapshot(minInterval time.Duration) bool {
if minInterval <= 0 {
return true
}
now := time.Now().UnixNano()
for {
last := c.snapshotAt.Load()
if last != 0 && now-last < int64(minInterval) {
return false
}
if c.snapshotAt.CompareAndSwap(last, now) {
return true
}
}
}
func (c *client) sendRaw(msg []byte) bool {
select {
case <-c.done:
@@ -163,6 +198,7 @@ func (c *client) handleMessage(ctx context.Context, data []byte) {
c.hub.log.Debug("忽略 hello 之前的 resync", "remote", c.remoteAddr)
return
}
// 整库快照的限流统一在 sendInitial 里判定(resync 与重复 hello 共用同一个窗口)。
c.hub.sendInitial(ctx, c, nil)
default:
+225 -32
View File
@@ -26,6 +26,17 @@ const (
defaultWriteTimeout = 10 * time.Second
defaultCloseTimeout = 2 * time.Second
defaultSendBuffer = 64
// defaultSnapshotMinInterval 是同一连接两次「整库快照」之间的最小间隔。
//
// 取 2.5s,比客户端自己的 3s 冷却(web/src/shared/realtime.ts 的 resyncCooldownMs)短半秒:
// 客户端按自己的节奏回来时(≥ 它的上次发送 + 3s),服务端的窗口必须已经过期,
// 否则两个等长的窗口会因收发延迟抖动而被随机判定成「超频」,把正常的补齐请求挡掉。
// 反过来,2.5s 也已经把放大面压到「每个连接每 2.5 秒最多一份整库快照」
// (一份 = 一个事务 + 8 条查询,赛事库还是单连接)。
defaultSnapshotMinInterval = 2500 * time.Millisecond
// sessionCheckTimeout 是会话复查查询的超时:系统库同样只有一条连接,
// 被长操作占住时不能把心跳循环一起卡住。
sessionCheckTimeout = 2 * time.Second
)
// slowCloseCode 是踢掉慢客户端时使用的自定义关闭码。
@@ -63,6 +74,16 @@ type Hub struct {
helloTO time.Duration
writeTO time.Duration
allowedOrigins []string
// snapshotMinInterval 是同一连接两次整库快照之间的最小间隔;<=0 表示不限流。
snapshotMinInterval time.Duration
// sessionChecker 复查长连接背后的会话是否还有效;未注入时只在握手时校验一次。
sessionChecker SessionChecker
}
// SessionChecker 判定一条会话当前是否仍然有效(未被撤销 / 未过期 / 用户未停用)。
// auth.Service 满足这个接口;测试可注入桩实现。
type SessionChecker interface {
SessionActive(ctx context.Context, sessionID string) (bool, error)
}
// NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。
@@ -73,19 +94,20 @@ func NewHub(db *sql.DB, log *slog.Logger) *Hub {
}
ctx, cancel := context.WithCancel(context.Background())
h := &Hub{
db: db,
log: log,
rooms: make(map[string]map[*client]struct{}),
clients: make(map[*client]struct{}),
instances: make(map[string]*instanceState),
ctx: ctx,
cancel: cancel,
heartbeatInterval: defaultHeartbeatInterval,
heartbeatReset: make(chan struct{}, 1),
maxCatchup: defaultMaxCatchup,
slowLag: defaultSlowLag,
helloTO: defaultHelloTimeout,
writeTO: defaultWriteTimeout,
db: db,
log: log,
rooms: make(map[string]map[*client]struct{}),
clients: make(map[*client]struct{}),
instances: make(map[string]*instanceState),
ctx: ctx,
cancel: cancel,
heartbeatInterval: defaultHeartbeatInterval,
heartbeatReset: make(chan struct{}, 1),
maxCatchup: defaultMaxCatchup,
slowLag: defaultSlowLag,
helloTO: defaultHelloTimeout,
writeTO: defaultWriteTimeout,
snapshotMinInterval: defaultSnapshotMinInterval,
}
if db != nil {
if v, err := h.currentVersion(context.Background()); err == nil {
@@ -177,6 +199,38 @@ func (h *Hub) SetAllowedOrigins(origins []string) {
h.mu.Unlock()
}
// SetSessionChecker 注入会话复查器:连接建立后周期性复查会话是否仍然有效,失效即断开。
//
// /ws 的身份在握手时固化,而会话随时可能被撤销(登出、改密、管理员重置密码、停用用户)。
// 不复查的话,那条长连接会一直以原角色收推送——被停用的人只要不关页面,管理端就还挂着。
func (h *Hub) SetSessionChecker(c SessionChecker) {
h.mu.Lock()
h.sessionChecker = c
h.mu.Unlock()
}
// SetSnapshotMinInterval 设置同一连接两次整库快照之间的最小间隔;<= 0 表示不限流(仅测试用)。
//
// 撞到窗口的请求不会拿到第二份全量:客户端本来就刚拿到过一份,心跳里的版本号会让它
// 在需要时重新发起;越过窗口后一切照旧。
func (h *Hub) SetSnapshotMinInterval(d time.Duration) {
h.mu.Lock()
h.snapshotMinInterval = d
h.mu.Unlock()
}
func (h *Hub) sessionCheckerValue() SessionChecker {
h.mu.RLock()
defer h.mu.RUnlock()
return h.sessionChecker
}
func (h *Hub) snapshotMinIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
return h.snapshotMinInterval
}
func (h *Hub) heartbeatIntervalValue() time.Duration {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -237,26 +291,45 @@ func (h *Hub) Handler() http.Handler {
defer h.connWG.Done()
// 身份只认会话:/ws 必须被 Authenticate 中间件包住,否则一律按 guest 处理。
role, userID, settingsAdmin := wsIdentity(r.Context())
h.serve(conn, r.RemoteAddr, role, userID, settingsAdmin)
h.serve(conn, r.RemoteAddr, wsIdentityFromContext(r.Context()))
})
}
// wsIdentity 从请求上下文取会话身份并折算成实时层角色。
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
// settingsAdmin 单独返回真实的 admin 判定:recorder 能进管理端房间,但读不到系统配置。
func wsIdentity(ctx context.Context) (string, int64, bool) {
u := middleware.User(ctx)
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
return RoleDisplay, 0, false
}
return RoleAdmin, u.ID, u.Role.Allows(auth.RoleAdmin)
// wsIdentity 是握手时从会话取到的身份;整条连接的生命周期都用它。
//
// sessionID 不进协议,只为会话复查保留:会话被撤销后要据此找到并断开对应的连接。
type wsIdentity struct {
Role string
UserID int64
SessionID string
SettingsAdmin bool
}
func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64, settingsAdmin bool) {
// wsIdentityFromContext 从请求上下文取会话身份并折算成实时层角色。
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
// settingsAdmin 单独返回真实的 admin 判定:recorder 能进管理端房间,但读不到系统配置。
func wsIdentityFromContext(ctx context.Context) wsIdentity {
id := wsIdentity{}
if sess := middleware.Session(ctx); sess != nil {
id.SessionID = sess.ID
}
u := middleware.User(ctx)
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
// 会话无效时 Authenticate 已经降级成 guest,这里拿不到会话 id,
// 因此降级后的连接不会被会话复查误伤。
id.Role = RoleDisplay
return id
}
id.Role = RoleAdmin
id.UserID = u.ID
id.SettingsAdmin = u.Role.Allows(auth.RoleAdmin)
return id
}
func (h *Hub) serve(conn *websocket.Conn, remoteAddr string, id wsIdentity) {
conn.SetReadLimit(1 << 20)
c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID, settingsAdmin)
c := newClient(h, conn, remoteAddr, id)
h.addClient(c)
ctx, cancel := context.WithCancel(h.ctx)
@@ -402,6 +475,8 @@ func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) {
// sendInitial 决定首包是 snapshot 还是 catchup:
// 没有 lastVersion、版本超前、或增量条数超阈值,一律回落全量快照。
//
// 两个入口共用这一条路径:hello(首包 / 翻页补的那条)与 resync。
func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) {
h.sendMu.Lock()
defer h.sendMu.Unlock()
@@ -419,34 +494,91 @@ func (h *Hub) sendInitial(ctx context.Context, c *client, lastVersion *int64) {
h.setVersion(cur)
if lastVersion == nil || *lastVersion <= 0 || *lastVersion > cur {
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
n, err := h.countChangesSince(ctx, *lastVersion)
if err != nil {
h.log.Warn("统计增量失败,回落快照", "err", err)
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
if n > h.maxCatchupValue() {
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
changes, err := h.changesSince(ctx, *lastVersion, 0)
if err != nil {
h.log.Warn("读取增量失败,回落快照", "err", err)
h.sendSnapshotLocked(ctx, c)
h.sendSnapshotGuarded(ctx, c)
return
}
if !c.sendEvent(EventCatchup, catchupPayload{FromVersion: *lastVersion, Version: cur, Changes: changes}) {
// catchup 与 change 一样按连接身份裁剪:重连补齐不能成为读系统配置的后门。
payload := catchupPayload{
FromVersion: *lastVersion,
Version: cur,
Changes: redactChanges(changes, c.settingsAdmin),
}
if !c.sendEvent(EventCatchup, payload) {
return
}
c.lastSent.Store(cur)
c.ackVersion.Store(cur)
}
// sendSnapshotGuarded 是「要发整库快照」的唯一出口(调用方须持有 sendMu,同 sendSnapshotLocked)。
//
// 整库快照的代价是一个 DB 事务 + 8 条查询,而赛事库是单连接(SetMaxOpenConns(1)),
// 读取还在全局发送锁里:不限流的话,一条 15 字节的帧(resync,或重复的不带 lastVersion 的
// hello)就能反复触发,把库连接与所有人的推送一起堵住。这里按连接记账,窗口内已经发过
// 整库快照就不再发第二份。
//
// 只拦「整库快照」这一种:带 lastVersion 的 hello 走 catchup(增量)不受影响;
// 每次断线重连都是新连接(新的 client,窗口为空),第一份快照照发。
//
// 被挡下的请求不丢弃,只在连接上挂一个标记,由心跳循环补发(见 flushPendingSnapshot):
// 请求方拿到的是「稍微晚一点的一份」,而不是「什么都没有」。
func (h *Hub) sendSnapshotGuarded(ctx context.Context, c *client) bool {
interval := h.snapshotMinIntervalValue()
if !c.allowSnapshot(interval) {
c.snapshotPending.Store(true)
h.log.Debug("整库快照窗口内,改为心跳补发", "remote", c.remoteAddr, "interval", interval.String())
return false
}
// 这一份已经满足了挂起的请求(否则它会在下个心跳里变成多余的第三份)。
c.snapshotPending.Store(false)
h.sendSnapshotLocked(ctx, c)
return true
}
// flushPendingSnapshot 在心跳里补发被窗口挡下的整库快照;返回 true 表示补发了一份。
//
// 规则:只有在「有待发标记」且「窗口确实已过」时才发,发完清标记——
// 不能做成每次心跳都发一份(那等于把限流从 2.5s 放大成心跳频率)。
// 最迟一个心跳周期(默认 10s)内一定补上,正常客户端感知不到差别。
func (h *Hub) flushPendingSnapshot(ctx context.Context, c *client) bool {
if !c.snapshotPending.Load() {
return false
}
h.sendMu.Lock()
defer h.sendMu.Unlock()
// 加锁后再看一次:期间可能有别的路径已经发过(那时标记已被清掉)。
if !c.snapshotPending.Load() {
return false
}
if !c.allowSnapshot(h.snapshotMinIntervalValue()) {
// 窗口还没过(刚有别的请求发过一份):留给下一个心跳。
return false
}
c.snapshotPending.Store(false)
h.log.Debug("心跳补发挂起的整库快照", "remote", c.remoteAddr)
h.sendSnapshotLocked(ctx, c)
return true
}
// sendSnapshotLocked 必须在持有 sendMu 时调用:快照读取与「已发送版本」的推进
// 必须原子完成,否则并发的 change 会插到快照前面。
func (h *Hub) sendSnapshotLocked(ctx context.Context, c *client) {
@@ -503,12 +635,13 @@ func (h *Hub) fanout(room string, changes []Change) {
}
last := c.lastSent.Load()
settingsAdmin := c.settingsAdmin
filtered := make([]Change, 0, len(changes))
for _, ch := range changes {
if ch.Version > 0 && ch.Version <= last {
continue
}
filtered = append(filtered, ch)
filtered = append(filtered, redactChange(ch, settingsAdmin))
}
if len(filtered) == 0 {
continue
@@ -671,6 +804,9 @@ func (h *Hub) beat() {
now := time.Now()
data := heartbeatPayload{Version: version, ServerTime: nowISO(now)}
// 挂起补发单独收集,等心跳全部发完再做:补发要现建一份快照(慢,且要抢 sendMu),
// 不能让排在后面的大屏连心跳都跟着等(落线判定的看门狗就在客户端那边盯着心跳)。
var pending []*client
for _, c := range h.roomClients("") {
c.mu.Lock()
greeted := c.greeted
@@ -678,8 +814,65 @@ func (h *Hub) beat() {
if !greeted {
continue
}
// 会话失效(撤销 / 过期 / 用户停用)的连接先断开,不再给它下发心跳。
if h.sessionRevoked(c) {
continue
}
c.sendEvent(EventHeartbeat, data)
h.touchInstance(c, now)
h.checkSlow(c, version)
if c.snapshotPending.Load() {
pending = append(pending, c)
}
}
for _, c := range pending {
h.flushPendingSnapshot(h.ctx, c)
}
}
// sessionRevoked 复查连接背后的会话是否仍然有效,失效即发起断开;返回 true 表示已断开。
//
// 会话撤销(登出 / 改密 / 管理员重置密码 / 停用用户)会删掉会话行或停用用户,
// 但 /ws 的身份在握手时就固化了:不复查的话,那条长连接会一直以原角色收推送,
// 只有写操作被 HTTP 侧拦住。复查挂在心跳上,最迟一个心跳周期(默认 10s)内断开。
//
// 只有带会话的连接需要复查:guest 大屏连接没有会话,撤销语义不适用。
// 查询失败(库忙 / 系统库被长操作占住)一律按「仍有效」处理并留痕——
// 一次查询抖动不该把正在比赛的大屏整片踢下线。
func (h *Hub) sessionRevoked(c *client) bool {
checker := h.sessionCheckerValue()
if checker == nil {
return false
}
c.mu.Lock()
sessionID, role := c.sessionID, c.role
c.mu.Unlock()
if sessionID == "" {
return false
}
ctx, cancel := context.WithTimeout(h.ctx, sessionCheckTimeout)
defer cancel()
active, err := checker.SessionActive(ctx, sessionID)
if err != nil {
h.log.Warn("复查会话失败,本次保留连接", "session", sessionID, "remote", c.remoteAddr, "err", err)
return false
}
if active {
return false
}
// 管理端连接按「登录失效」关闭:前端对所有 1008 会停止重连并引导重新登录,
// 这正是被撤销的会话应有的表现。大屏连接改用 1001 让它自动重连——
// 重连时握手会按会话失效降级成 guest(HTTP 侧本来也是这样),
// 现场大屏不该因为操作员登出而永久冻结。
code, reason := websocket.StatusGoingAway, "会话已失效,按游客身份重连"
if role == RoleAdmin {
code, reason = websocket.StatusPolicyViolation, "会话已失效,请重新登录"
}
h.log.Info("会话已失效,断开实时连接", "session", sessionID, "role", role, "remote", c.remoteAddr)
c.stop(code, reason)
return true
}
+493 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
@@ -12,6 +13,7 @@ import (
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
@@ -19,6 +21,7 @@ import (
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
@@ -40,12 +43,23 @@ func newTestEnv(t *testing.T) *testEnv {
// newTestEnvAs 注入会话身份,等价于真实链路里 Authenticate 中间件写进 context 的 User。
func newTestEnvAs(t *testing.T, u *auth.User) *testEnv {
t.Helper()
return newTestEnvAsSession(t, u, "")
}
// newTestEnvAsSession 在身份之外再注入会话 id,等价于 Authenticate 中间件写进 context 的
// *auth.Session:会话复查(撤销即断开)要按会话 id 判定,测试必须能给出这一条。
func newTestEnvAsSession(t *testing.T, u *auth.User, sessionID string) *testEnv {
t.Helper()
db := newTestDB(t)
h := NewHub(db, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})))
handler := h.Handler()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handler.ServeHTTP(w, r.WithContext(middleware.WithUser(r.Context(), u)))
ctx := middleware.WithUser(r.Context(), u)
if sessionID != "" {
ctx = middleware.WithSession(ctx, &auth.Session{ID: sessionID, UserID: u.ID})
}
handler.ServeHTTP(w, r.WithContext(ctx))
}))
t.Cleanup(func() {
@@ -179,6 +193,37 @@ func (c *wsClient) recvEvent(want string) json.RawMessage {
return nil
}
// awaitSnapshot 在 d 内等待一份整库快照(心跳等无关消息跳过);等到返回 true。
func (c *wsClient) awaitSnapshot(d time.Duration) bool {
c.t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
ev, _, ok := c.recvWithin(time.Until(deadline))
if !ok {
return false
}
if ev == EventSnapshot {
return true
}
}
return false
}
// noSnapshotWithin 断言 d 内没有整库快照;心跳等无关消息不算(连接断开则直接失败)。
func (c *wsClient) noSnapshotWithin(d time.Duration) {
c.t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
ev, _, ok := c.recvWithin(time.Until(deadline))
if !ok {
return
}
if ev == EventSnapshot {
c.t.Fatalf("这段时间内不该有整库快照")
}
}
}
// expectClosed 断言连接在 d 内被服务端关闭,且关闭前没有下发任何事件。
func (c *wsClient) expectClosed(d time.Duration) {
c.t.Helper()
@@ -459,9 +504,11 @@ func TestDuplicateDeliveryIgnored(t *testing.T) {
}
// 心跳携带版本号与服务器时间;版本落后时客户端 resync 能拿到最新快照。
// 本用例测的是协议本身(请求 → 响应),整库快照窗口另有专门用例,这里关闭限流。
func TestHeartbeatAndResync(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(0)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
@@ -486,8 +533,10 @@ func TestHeartbeatAndResync(t *testing.T) {
}
// 大屏实例注册表:instanceId、在线状态、当前页、最后心跳可查。
// 重复 hello 会回一份快照(窗口内属于限流范围,这里关掉限流以保持用例只关心注册表)。
func TestDisplayInstancesRegistry(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(0)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1", Page: "standings"})
c.recvEvent(EventSnapshot)
@@ -691,6 +740,7 @@ func TestSnapshotSettingsFullForAdmin(t *testing.T) {
// resync 在 hello 之前不可用:否则会绕过房间分配并提前清掉 hello 超时。
func TestResyncBeforeHelloIgnored(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(200 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, nil)
@@ -703,6 +753,9 @@ func TestResyncBeforeHelloIgnored(t *testing.T) {
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("hello 后应收到版本 1 的快照,得到 %d", snap.Version)
}
// hello 之后 resync 仍然可用,只是要与刚发出去的整库快照共用同一个窗口
// (生产默认窗口与客户端 3s 冷却一致;这里把它调短,免得测试睡 3 秒)。
time.Sleep(250 * time.Millisecond)
c.send(EventResync, struct{}{})
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("hello 后 resync 应拿到快照,得到 %d", snap.Version)
@@ -733,7 +786,7 @@ func TestInstanceRegistryPrunesStaleOffline(t *testing.T) {
// 读写 goroutine 的 panic 必须被兜住并断开该连接,而不是带走进程。
func TestGuardLoopRecoversPanic(t *testing.T) {
env := newTestEnv(t)
c := newClient(env.hub, nil, "test", RoleDisplay, 0, false)
c := newClient(env.hub, nil, "test", wsIdentity{Role: RoleDisplay})
func() {
defer c.guardLoop("读")
@@ -908,3 +961,441 @@ func TestDisplayInstancesReportScreen(t *testing.T) {
t.Fatalf("局域网浏览器不带屏号,应为空串,得到 %q", got)
}
}
// sessionCheckerFunc 把桩会话复查器写成普通函数:用例里只需回答「这条会话还在不在」。
type sessionCheckerFunc func(ctx context.Context, sessionID string) (bool, error)
func (f sessionCheckerFunc) SessionActive(ctx context.Context, sessionID string) (bool, error) {
return f(ctx, sessionID)
}
// ---------- 会话撤销:长连接必须跟着断开 ----------
// 会话被撤销(登出 / 改密 / 管理员重置密码 / 停用用户)后,已经建立的 /ws 连接必须被断开,
// 不能再以原角色继续收推送;管理端连接按 1008 关闭,前端据此停止重连并引导重新登录。
func TestSessionRevokedClosesAdminConnection(t *testing.T) {
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-1")
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
var revoked atomic.Bool
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
return !revoked.Load(), nil
}))
c := env.dial(t, &helloPayload{Role: RoleAdmin})
c.recvEvent(EventSnapshot)
// 会话仍有效时照常收推送:复查不能误伤在线用户。
insertChange(t, env.db, "teams", 1, ActionUpsert, `{"id":1}`)
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
c.recvEvent(EventChange)
// 撤销之后:最迟一个心跳周期内断开,关闭码为「登录失效」。
revoked.Store(true)
c.expectClosedWithCodeDraining(3*time.Second, websocket.StatusPolicyViolation)
// 断开后这条连接不再是任何广播的投递对象:后续变更推不到它。
waitFor(t, "失效连接退出", func() bool { return len(env.hub.roomClients("")) == 0 })
insertChange(t, env.db, "teams", 2, ActionUpsert, `{"id":2}`)
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
if n := len(env.hub.roomClients("")); n != 0 {
t.Fatalf("会话失效后不应再有投递对象,得到 %d 条连接", n)
}
}
// 大屏连接(role=display 但带会话)同样要断开,但用 1001:客户端会自动重连,
// 重连时握手按会话失效降级成 guest,现场大屏不该因为操作员登出而永久冻结。
func TestSessionRevokedClosesDisplayConnectionAsGoingAway(t *testing.T) {
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-2")
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
var revoked atomic.Bool
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
return !revoked.Load(), nil
}))
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "screen-1"})
c.recvEvent(EventSnapshot)
revoked.Store(true)
c.expectClosedWithCodeDraining(3*time.Second, websocket.StatusGoingAway)
}
// 没有会话的连接(局域网大屏)不做复查:撤销语义不适用于 guest,也不该白查一次库。
func TestSessionCheckSkippedWithoutSession(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
return false, nil // 一旦真被问到就会判死,但 guest 连接根本不该被问
}))
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "lan-1"})
c.recvEvent(EventSnapshot)
if ev, _, ok := c.recvWithin(300 * time.Millisecond); !ok || ev != EventHeartbeat {
t.Fatalf("guest 连接应继续收心跳,得到 %q ok=%v", ev, ok)
}
}
// 复查失败(系统库忙 / 被长操作占住)按「仍有效」处理:一次查询抖动不该把大屏整片踢下线。
func TestSessionCheckFailureKeepsConnection(t *testing.T) {
env := newTestEnvAsSession(t, &auth.User{ID: 9, Username: "boss", Role: auth.RoleAdmin, Active: true}, "sid-3")
env.hub.SetHeartbeatInterval(50 * time.Millisecond)
env.hub.SetSessionChecker(sessionCheckerFunc(func(context.Context, string) (bool, error) {
return false, errors.New("库忙")
}))
c := env.dial(t, &helloPayload{Role: RoleAdmin})
c.recvEvent(EventSnapshot)
if ev, _, ok := c.recvWithin(300 * time.Millisecond); !ok || ev != EventHeartbeat {
t.Fatalf("复查失败时应保留连接并继续心跳,得到 %q ok=%v", ev, ok)
}
}
// ---------- 增量载荷按角色裁剪 ----------
// fullSettingsPayload 是 settings 实体的变更载荷:既有 admin 专属的系统配置,
// 也有大屏要用的展示字段(公告、标题、榜单开关)。
const fullSettingsPayload = `{"event":{"name":"测试运动会","subtitle":null,"host":null,"logoPath":null,"primaryColor":null},` +
`"display":{"background":null,"logo":null,"title":"测试运动会","subtitle":null,"primaryColor":null,` +
`"rotateSeconds":15,"announce":"请注意检录","fontTitle":null,"fontBody":null,"liveEvents":[],"liveSource":"auto"},` +
`"standings":{"individualToTeamWeight":0.5,"rankingMode":"points","showIndividualBoard":true,` +
`"showIndividualMedals":true,"liveOnPublish":true},"defaultProfileId":1,"requireApproval":true}`
// assertSettingsPayload 校验一条 settings 载荷是否按身份裁过:
// 非 admin 不得出现默认积分模板 / 审核开关 / 个人折算系数,但公告与标题这类大屏字段必须保留。
func assertSettingsPayload(t *testing.T, raw json.RawMessage, wantFull bool) {
t.Helper()
var got dto.EventSettings
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("settings 载荷无法解析: %s", raw)
}
if wantFull {
if got.DefaultProfileID == nil || !got.RequireApproval || got.Standings.IndividualToTeamWeight != 0.5 {
t.Fatalf("admin 应拿到完整系统配置: %s", raw)
}
} else {
if got.DefaultProfileID != nil {
t.Fatalf("非 admin 不应看到 defaultProfileId: %s", raw)
}
if got.RequireApproval {
t.Fatalf("非 admin 不应看到 requireApproval: %s", raw)
}
if got.Standings.IndividualToTeamWeight != 0 {
t.Fatalf("非 admin 不应看到 individualToTeamWeight: %s", raw)
}
}
if got.Event.Name != "测试运动会" || got.Display.Announce == nil || *got.Display.Announce != "请注意检录" {
t.Fatalf("大屏展示字段不该被裁剪: %s", raw)
}
if got.Standings.RankingMode != "points" || !got.Standings.ShowIndividualBoard || !got.Standings.LiveOnPublish {
t.Fatalf("榜单展示开关不该被裁剪: %s", raw)
}
}
// 快照裁剪之外,change 与 catchup 两条增量路径也必须按角色裁剪:
// 否则任何打开 /ws 的浏览器都能从增量里读到系统配置,快照上的裁剪等于被旁路。
func TestSettingsChangeRedactedByRole(t *testing.T) {
for _, tc := range []struct {
name string
user *auth.User
role string
full bool
}{
{"guest", &auth.User{Role: auth.RoleGuest}, RoleDisplay, false},
{"recorder", &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true}, RoleAdmin, false},
{"admin", &auth.User{ID: 1, Username: "boss", Role: auth.RoleAdmin, Active: true}, RoleAdmin, true},
} {
t.Run(tc.name, func(t *testing.T) {
env := newTestEnvAs(t, tc.user)
// 一条历史变更:用来构造 catchup(重连补齐)。先垫一条无关变更,
// 让下面的 lastVersion 落在增量区间内(0 会走全量快照)。
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
old := insertChange(t, env.db, entitySettings, 0, ActionUpsert, fullSettingsPayload)
// change:在线连接收到一条新的 settings 变更。
live := env.dial(t, &helloPayload{Role: tc.role, InstanceID: tc.name + "-live"})
live.recvEvent(EventSnapshot)
insertChange(t, env.db, entitySettings, 0, ActionUpsert, fullSettingsPayload)
sent := loadChanges(t, env.hub, old, 0)
env.hub.Broadcast(sent)
got := decode[changePayload](t, live.recvEvent(EventChange))
if len(got.Changes) != 1 || got.Changes[0].Entity != entitySettings {
t.Fatalf("应收到一条 settings 变更,得到 %+v", got.Changes)
}
assertSettingsPayload(t, got.Changes[0].Data, tc.full)
// 裁剪必须产出新对象:就地改写共享的 Change 会让 admin 连接也拿到裁剪版。
if string(sent[0].Data) != fullSettingsPayload {
t.Fatalf("裁剪不应改写共享的 Change: %s", sent[0].Data)
}
// catchup:重连连接按 lastVersion 补齐,同样不能漏出系统配置。
catchupClient := env.dial(t, &helloPayload{
Role: tc.role, InstanceID: tc.name + "-catchup", LastVersion: ptrInt64(old - 1),
})
cp := decode[catchupPayload](t, catchupClient.recvEvent(EventCatchup))
var found bool
for _, ch := range cp.Changes {
if ch.Entity != entitySettings {
continue
}
found = true
assertSettingsPayload(t, ch.Data, tc.full)
}
if !found {
t.Fatalf("catchup 应包含 settings 变更,得到 %+v", cp.Changes)
}
})
}
}
// 载荷不是 EventSettings 形状时宁可只下发空对象,也不能把可能含系统配置的原文透给非 admin。
func TestSettingsChangeUnknownShapeRedacted(t *testing.T) {
env := newTestEnv(t)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
insertChange(t, env.db, entitySettings, 0, ActionUpsert, `"旧版扁平配置"`)
env.hub.Broadcast(loadChanges(t, env.hub, 0, 0))
got := decode[changePayload](t, c.recvEvent(EventChange))
if string(got.Changes[0].Data) != `{}` {
t.Fatalf("无法识别的 settings 载荷应降级成空对象,得到 %s", got.Changes[0].Data)
}
}
// ---------- 整库快照限流 ----------
//
// 整库快照有两个入口:resync,和不带 lastVersion 的 hello(首包 / 翻页补的那条)。
// 两者在 sendInitial 里共用同一个 per-connection 窗口,所以「交替发」绕不过去。
// 窗口内连发多次 resync 只出一份整库快照;越过窗口后恢复正常,
// 客户端「落后了要一次全量」的补齐行为不受影响。
func TestSnapshotThrottleCoversResync(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(300 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
// 新连接的第一份快照照发,并且它就是本窗口里的那一份。
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("新连接应收到版本 1 的快照,得到 %d", snap.Version)
}
for i := 0; i < 5; i++ {
c.send(EventResync, struct{}{})
}
if ev, _, ok := c.recvWithin(150 * time.Millisecond); ok {
t.Fatalf("同一窗口内不该有第二份整库快照,收到 %s", ev)
}
// 越过窗口后再连发 5 次:只放行一份。
time.Sleep(350 * time.Millisecond)
for i := 0; i < 5; i++ {
c.send(EventResync, struct{}{})
}
decode[Snapshot](t, c.recvEvent(EventSnapshot))
if ev, _, ok := c.recvWithin(150 * time.Millisecond); ok {
t.Fatalf("一个窗口只应放行一份整库快照,收到 %s", ev)
}
}
// 不带 lastVersion 的重复 hello 与 resync 等价:也是要一份整库快照,同样按窗口限流。
func TestSnapshotThrottleCoversRepeatHello(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(300 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
if snap := decode[Snapshot](t, c.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("新连接应收到版本 1 的快照,得到 %d", snap.Version)
}
for i := 0; i < 5; i++ {
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
}
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
t.Fatalf("窗口内重复 hello 只应回一份快照(首包那份),收到 %s", ev)
}
// 越过窗口后恢复:真实节奏下翻页补的那条 hello 远晚于窗口,照旧生效。
time.Sleep(350 * time.Millisecond)
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
decode[Snapshot](t, c.recvEvent(EventSnapshot))
}
// hello 与 resync 交替发也绕不过限流:两者共用一个窗口,而不是各记各的。
func TestSnapshotThrottleSharedByHelloAndResync(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(time.Minute)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
decode[Snapshot](t, c.recvEvent(EventSnapshot))
resync := func() { c.send(EventResync, struct{}{}) }
hello := func() { c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"}) }
resync()
hello()
resync()
hello()
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
t.Fatalf("交替 hello / resync 不应绕过整库快照窗口,收到 %s", ev)
}
}
// 不变式:带 lastVersion 的 hello 走 catchup(增量),与整库快照无关——
// 刚发过快照也不能把它挡住,而且它不会留下「待发快照」的挂起标记。
func TestSnapshotThrottleDoesNotAffectCatchup(t *testing.T) {
env := newTestEnv(t)
// 窗口与心跳都调短:万一 catchup 请求被误当成「要整库快照」,下面的
// noSnapshotWithin 立刻就能看见那次多余的补发。
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
snap := decode[Snapshot](t, c.recvEvent(EventSnapshot))
// 窗口内的第二条 hello 带 lastVersion:必须立刻拿到增量。
insertChange(t, env.db, "result", 2, ActionUpsert, `{"rank":2}`)
c.send(EventHello, helloPayload{
Role: RoleDisplay, InstanceID: "d1", LastVersion: ptrInt64(snap.Version),
})
cp := decode[catchupPayload](t, c.recvEvent(EventCatchup))
if cp.FromVersion != snap.Version || cp.Version != snap.Version+1 {
t.Fatalf("catchup 应为 %d → %d,得到 %d → %d",
snap.Version, snap.Version+1, cp.FromVersion, cp.Version)
}
if len(cp.Changes) != 1 || cp.Changes[0].ID != 2 {
t.Fatalf("catchup 应补上那一条新变更,得到 %+v", cp.Changes)
}
// catchup 不是整库快照:不该挂起、也不该在窗口过后补发。
c.noSnapshotWithin(400 * time.Millisecond)
}
// 不变式:窗口按连接记账。一条连接把自己的窗口占满,不影响别的连接的第一份快照;
// 断线重连也是新连接(新的 client,窗口为空),第一份快照同样照发。
func TestSnapshotThrottleIsScopedToConnection(t *testing.T) {
env := newTestEnv(t)
env.hub.SetSnapshotMinInterval(time.Minute)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
// A 连发 resync 把自己的窗口占满。
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
a.recvEvent(EventSnapshot)
for i := 0; i < 3; i++ {
a.send(EventResync, struct{}{})
}
if ev, _, ok := a.recvWithin(200 * time.Millisecond); ok {
t.Fatalf("A 的窗口内不该再有整库快照,收到 %s", ev)
}
// B 是新连接:第一份快照照发,不受 A 影响。
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "b"})
if snap := decode[Snapshot](t, b.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("B 作为新连接应立刻拿到快照,得到版本 %d", snap.Version)
}
// 断线重连同样是新连接:现场刷新页面 / 网络抖动都不该被限流影响。
a.close()
reconnected := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
if snap := decode[Snapshot](t, reconnected.recvEvent(EventSnapshot)); snap.Version != 1 {
t.Fatalf("重连应立刻拿到快照,得到版本 %d", snap.Version)
}
}
// ---------- 被窗口挡下的整库快照:挂起 + 心跳补发 ----------
// 窗口内连发多次 resync:当下不回(否则等于没限流),但请求被挂起,
// 窗口过后由心跳补一份——大屏不会因为限流静默停在旧数据上。
func TestPendingSnapshotFlushedByHeartbeat(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot) // 首包即时发,窗口从这一份开始记账
for i := 0; i < 3; i++ {
c.send(EventResync, struct{}{})
}
c.noSnapshotWithin(100 * time.Millisecond)
if !c.awaitSnapshot(2 * time.Second) {
t.Fatal("窗口过后应由心跳补发一份整库快照")
}
// 补发即清标记:之后的心跳不再重复发(否则限流就退化成心跳频率了)。
c.noSnapshotWithin(300 * time.Millisecond)
}
// 不带 lastVersion 的重复 hello 与 resync 走同一条挂起路径:也只补一份。
func TestPendingSnapshotFromRepeatHelloFlushedOnce(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
for i := 0; i < 5; i++ {
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
}
c.noSnapshotWithin(100 * time.Millisecond)
if !c.awaitSnapshot(2 * time.Second) {
t.Fatal("窗口过后应由心跳补发一份整库快照")
}
c.noSnapshotWithin(300 * time.Millisecond)
}
// hello 与 resync 交替触发时共用一个挂起标记:补发仍然只有一份。
func TestPendingSnapshotSharedByHelloAndResync(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
c := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.recvEvent(EventSnapshot)
for i := 0; i < 3; i++ {
c.send(EventHello, helloPayload{Role: RoleDisplay, InstanceID: "d1"})
c.send(EventResync, struct{}{})
}
c.noSnapshotWithin(100 * time.Millisecond)
if !c.awaitSnapshot(2 * time.Second) {
t.Fatal("窗口过后应由心跳补发一份整库快照")
}
c.noSnapshotWithin(300 * time.Millisecond)
}
// 补发的量级仍然是「每窗口一份」:两条连接各自挂起,各自只补一份。
func TestPendingSnapshotFlushIsPerConnection(t *testing.T) {
env := newTestEnv(t)
env.hub.SetHeartbeatInterval(40 * time.Millisecond)
env.hub.SetSnapshotMinInterval(150 * time.Millisecond)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
a := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "a"})
b := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "b"})
a.recvEvent(EventSnapshot)
b.recvEvent(EventSnapshot)
a.send(EventResync, struct{}{})
b.send(EventResync, struct{}{})
// 只对 A 做一次「当下不回」的断言:两条连接的静默窗口不能串行等待,
// 后一条会把自己的补发也等进去(B 的那份本来就该在窗口过后到)。
a.noSnapshotWithin(100 * time.Millisecond)
if !a.awaitSnapshot(2*time.Second) || !b.awaitSnapshot(2*time.Second) {
t.Fatal("两条挂起的连接都应在窗口过后各补一份快照")
}
a.noSnapshotWithin(300 * time.Millisecond)
b.noSnapshotWithin(300 * time.Millisecond)
}
+30 -21
View File
@@ -283,21 +283,22 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
return out, rows.Err()
}
// loadRanked 与 REST 的 buildRanked 同源:必须 join event_score_channels,
// 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌;
// loadRanked 与 REST 的 buildRanked、导出的 loadRanked 同源:数据源是发布/重算时
// 按通道全量重建的 channel_ranks,一条名次一行(多通道项目因此不会把一枚奖牌算成多枚,
// 没配通道的项目也不会有名次行)。三个奖牌开关随行携带,domain 这边不必回查库;
// 同时排除只排名不计分(scored=0)的项目,与导出侧口径一致。
func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) {
rows, err := tx.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)
ORDER BY r.id, c.sort_order, c.id`)
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, fmt.Errorf("读取奖牌名次: %w", err)
}
@@ -306,22 +307,30 @@ func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error)
var out []domain.RankedResult
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, fmt.Errorf("解析奖牌名次: %w", 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}}
// 与 REST 同口径:份额只有「主单位独占 100%」一种,team_id 为空时不造份额。
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()
+49 -10
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"testing"
@@ -65,8 +66,9 @@ func TestSnapshotWireContract(t *testing.T) {
cat1 := insertCategory(t, db, event1, "青年组")
athlete1 := insertAthlete(t, db, "张伟", team1)
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
insertChannel(t, db, event1, "athlete")
ch1 := insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertChannelRank(t, db, event1, cat1, ch1, entry1, result1, team1, athlete1, "athlete", "result", 1, 11.58)
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 9)
snap, err := env.hub.Snapshot(context.Background())
@@ -307,8 +309,9 @@ func TestSnapshotStandingsMedalSwitch(t *testing.T) {
event1 := insertEvent(t, db, "E1", "4x100 米接力")
cat1 := insertCategory(t, db, event1, "男子")
entry1 := insertEntry(t, db, event1, cat1, team1, 0)
insertChannel(t, db, event1, "team")
insertResult(t, db, event1, entry1, 1, "45.20", 45.20, "2026-09-09T10:00:00Z")
ch1 := insertChannel(t, db, event1, "team")
r1 := insertResult(t, db, event1, entry1, 1, "45.20", 45.20, "2026-09-09T10:00:00Z")
insertChannelRank(t, db, event1, cat1, ch1, entry1, r1, team1, 0, "team", "result", 1, 45.20)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
@@ -346,8 +349,9 @@ func TestSnapshotStandingsOnlyScoredChannels(t *testing.T) {
event2 := insertEvent(t, db, "E2", "团体项目")
cat2 := insertCategory(t, db, event2, "男子")
entry2 := insertEntry(t, db, event2, cat2, team2, 0)
insertChannel(t, db, event2, "team")
insertResult(t, db, event2, entry2, 1, "9.99", 9.99, "2026-09-09T10:00:01Z")
ch2 := insertChannel(t, db, event2, "team")
r2 := insertResult(t, db, event2, entry2, 1, "9.99", 9.99, "2026-09-09T10:00:01Z")
insertChannelRank(t, db, event2, cat2, ch2, entry2, r2, team2, 0, "team", "result", 1, 9.99)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
@@ -479,10 +483,43 @@ func insertEntry(t *testing.T, db *sql.DB, eventID, categoryID, teamID, athleteI
return lastID(t, res)
}
func insertChannel(t *testing.T, db *sql.DB, eventID int64, scope string) {
// insertChannel 建一条按成绩排名的通道,返回通道 id(写通道名次时要用)。
func insertChannel(t *testing.T, db *sql.DB, eventID int64, scope string) int64 {
t.Helper()
mustExec(t, db, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
res, err := db.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, ?, 'result', 1, 0)`, eventID, scope)
if err != nil {
t.Fatalf("插入计分通道: %v", err)
}
return lastID(t, res)
}
// insertChannelRank 写一条通道名次:大屏奖牌榜的读取来源(loadRanked 读 channel_ranks)。
// entryID 为 0 时主体是单位(subject_key 用 t:<teamID>),否则是报名项的 e:<entryID>。
func insertChannelRank(t *testing.T, db *sql.DB, eventID, categoryID, channelID, entryID, resultID, teamID, athleteID int64,
scope, basis string, rank int, value float64) {
t.Helper()
subject := fmt.Sprintf("e:%d", entryID)
if entryID == 0 {
subject = fmt.Sprintf("t:%d", teamID)
}
var entry, result, athlete, team any
if entryID != 0 {
entry = entryID
}
if resultID != 0 {
result = resultID
}
if athleteID != 0 {
athlete = athleteID
}
if teamID != 0 {
team = teamID
}
mustExec(t, db, `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 'ok')`,
eventID, categoryID, channelID, basis, scope, subject, entry, result, athlete, team, rank, value)
}
func insertResult(t *testing.T, db *sql.DB, eventID, entryID int64, rank int, text string, value float64, verifiedAt string) int64 {
@@ -537,13 +574,15 @@ func TestSnapshotTieBreakFromSettings(t *testing.T) {
// T1:银牌 + 10 分;T2:金牌 + 0 分。默认「总分优先」T1 在前,按金牌优先则 T2 在前。
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
insertChannel(t, db, event1, "athlete")
ch1 := insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 2, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertChannelRank(t, db, event1, cat1, ch1, entry1, result1, team1, athlete1, "athlete", "result", 2, 11.58)
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 10)
entry2 := insertEntry(t, db, event2, cat2, team2, athlete2)
insertChannel(t, db, event2, "athlete")
insertResult(t, db, event2, entry2, 1, "22.10", 22.10, "2026-09-09T10:00:01Z")
ch2 := insertChannel(t, db, event2, "athlete")
result2 := insertResult(t, db, event2, entry2, 1, "22.10", 22.10, "2026-09-09T10:00:01Z")
insertChannelRank(t, db, event2, cat2, ch2, entry2, result2, team2, athlete2, "athlete", "result", 1, 22.10)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
+21 -6
View File
@@ -85,9 +85,9 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
if !ok {
return nil, fmt.Errorf("项目 %s 的通道引用了不存在的模板 %q", code, ch.Profile)
}
if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team)
VALUES (?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i,
boolToInt(ch.CountsToTeam())); err != nil {
if _, err := tx.Exec(`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, string(ch.Scope), string(ch.Basis), profileID, i,
boolToInt(ch.CountsToTeam()), boolToInt(ch.CountsToMedals()), boolToInt(ch.CountsToTeamMedals())); err != nil {
return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err)
}
}
@@ -108,7 +108,7 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
// 4. 项目开关:只改 override 里显式给出的字段。
for _, ov := range next.EventOverrides {
eventID, ok := eventIDs[ov.EventCode]
if !ok || (ov.Scored == nil && ov.Multiplier == nil) {
if !ok || (ov.Scored == nil && ov.Multiplier == nil && ov.AwardMedals == nil) {
continue
}
scored, err := currentScored(tx, eventID)
@@ -125,8 +125,15 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) {
if ov.Multiplier != nil {
multiplier = *ov.Multiplier
}
if _, err := tx.Exec(`UPDATE events SET scored = ?, multiplier = ? WHERE id = ?`,
boolToInt(scored), multiplier, eventID); err != nil {
awardMedals, err := currentAwardMedals(tx, eventID)
if err != nil {
return nil, err
}
if ov.AwardMedals != nil {
awardMedals = *ov.AwardMedals
}
if _, err := tx.Exec(`UPDATE events SET scored = ?, multiplier = ?, award_medals = ? WHERE id = ?`,
boolToInt(scored), multiplier, boolToInt(awardMedals), eventID); err != nil {
return nil, fmt.Errorf("更新项目 %s: %w", ov.EventCode, err)
}
res.EventsUpdated++
@@ -241,6 +248,14 @@ func currentMultiplier(tx *sql.Tx, eventID int64) (float64, error) {
return v, nil
}
func currentAwardMedals(tx *sql.Tx, eventID int64) (bool, error) {
var v int
if err := tx.QueryRow(`SELECT award_medals FROM events WHERE id = ?`, eventID).Scan(&v); err != nil {
return false, fmt.Errorf("读取项目奖牌结算开关: %w", err)
}
return v != 0, nil
}
func boolToInt(v bool) int {
if v {
return 1
+7 -1
View File
@@ -32,7 +32,7 @@ func (p Profile) ToDomain(id int64) domain.ScoringProfile {
}
// ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。
// countToTeam 缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致。
// 三个开关缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致。
func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel {
out := domain.ScoreChannel{
EventID: eventID,
@@ -44,6 +44,12 @@ func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreC
if !c.CountsToTeam() {
out.CountToTeam = BoolPtr(false)
}
if !c.CountsToMedals() {
out.CountToMedals = BoolPtr(false)
}
if !c.CountsToTeamMedals() {
out.CountToTeamMedals = BoolPtr(false)
}
return out
}
+6
View File
@@ -252,6 +252,12 @@ func channelsEqual(a, b []Channel) bool {
if a[i].CountsToTeam() != b[i].CountsToTeam() {
return false
}
if a[i].CountsToMedals() != b[i].CountsToMedals() {
return false
}
if a[i].CountsToTeamMedals() != b[i].CountsToTeamMedals() {
return false
}
}
return true
}
+402
View File
@@ -0,0 +1,402 @@
package rules
import (
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 奖牌结算三个开关(devdocs 17 §三 / §六):
// - Channel.countToMedals / countToTeamMedals(缺省 true)
// - EventOverride.awardMedals(三态,nil = 不动该字段)
// 这里覆盖解析、落库、回读、差异比对四条路,保证「导出 → 导入」幂等。
const medalFlagsPackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 奖牌开关 }
profiles:
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: p1, countToTeam: false, countToMedals: false, countToTeamMedals: false }
eventOverrides:
- eventCode: EVT_IND
awardMedals: false
channels:
- { scope: athlete, basis: result, profile: p1 }
- eventCode: EVT_TEAM
channels:
- { scope: team, basis: manual, profile: p1 }
`
func TestParseMedalFlags(t *testing.T) {
pack, err := Parse([]byte(medalFlagsPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
ch := pack.ChannelTemplates[0].Channels[0]
if ch.CountToMedals == nil || *ch.CountToMedals {
t.Fatalf("countToMedals: false 应落到结构体,实际 %+v", ch.CountToMedals)
}
if ch.CountsToMedals() {
t.Error("countToMedals: false 时 CountsToMedals() 应为 false")
}
if ch.CountToTeamMedals == nil || *ch.CountToTeamMedals {
t.Fatalf("countToTeamMedals: false 应落到结构体,实际 %+v", ch.CountToTeamMedals)
}
if ch.CountsToTeamMedals() {
t.Error("countToTeamMedals: false 时 CountsToTeamMedals() 应为 false")
}
ind := pack.EventOverrides[0]
if ind.AwardMedals == nil || *ind.AwardMedals {
t.Fatalf("awardMedals: false 应落到结构体,实际 %+v", ind.AwardMedals)
}
if pack.EventOverrides[1].AwardMedals != nil {
t.Errorf("未写 awardMedals 时应保持 nil,实际 %v", *pack.EventOverrides[1].AwardMedals)
}
// 缺省即 true:不写三个键的通道既要保持 nil(不凭空造显式值),又要按 true 生效。
def := pack.EventOverrides[1].Channels
if def == nil || len(*def) != 1 {
t.Fatalf("EVT_TEAM 通道解析错误: %+v", def)
}
if (*def)[0].CountToMedals != nil || (*def)[0].CountToTeamMedals != nil {
t.Errorf("未写奖牌开关时应保持 nil,实际 %+v", (*def)[0])
}
if !(*def)[0].CountsToMedals() || !(*def)[0].CountsToTeamMedals() {
t.Error("未写奖牌开关时应按缺省 true 生效")
}
// 序列化后再解析必须完全一致(三个键要能出现在导出文件里)。
reparsed, err := Parse(mustMarshal(t, pack))
if err != nil {
t.Fatalf("重新 Parse: %v", err)
}
if !channelsEqual(pack.ChannelTemplates[0].Channels, reparsed.ChannelTemplates[0].Channels) {
t.Errorf("模板通道往返不一致:\n%+v\n%+v", pack.ChannelTemplates[0].Channels, reparsed.ChannelTemplates[0].Channels)
}
again := reparsed.EventOverrides[0]
if again.AwardMedals == nil || *again.AwardMedals {
t.Errorf("awardMedals 往返不一致: %+v", again.AwardMedals)
}
if reparsed.EventOverrides[1].AwardMedals != nil {
t.Errorf("未写的 awardMedals 往返后应保持 nil,实际 %v", *reparsed.EventOverrides[1].AwardMedals)
}
}
func TestChannelToDomainCarriesMedalFlags(t *testing.T) {
pack, err := Parse([]byte(medalFlagsPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
ch := pack.ChannelTemplates[0].Channels[0].ToDomain(3, 7, 0)
if ch.CountToMedals == nil || *ch.CountToMedals {
t.Fatalf("ToDomain 应带上 countToMedals: false,实际 %+v", ch.CountToMedals)
}
if ch.CountToTeamMedals == nil || *ch.CountToTeamMedals {
t.Fatalf("ToDomain 应带上 countToTeamMedals: false,实际 %+v", ch.CountToTeamMedals)
}
// 缺省的通道不落指针,与 countToTeam 同口径。
def := (*pack.EventOverrides[1].Channels)[0].ToDomain(3, 7, 0)
if def.CountToMedals != nil || def.CountToTeamMedals != nil {
t.Errorf("缺省通道不应写显式值: %+v", def)
}
}
// 库里 award_medals=0、别的都缺省的项目,导出时必须写成只带 awardMedals 的 override,
// 否则「导出 → 导入」会把关闭的奖牌开关丢掉。
func TestLoadExportsAwardMedalsOnlyOverride(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE code = 'EVT_IND'`); err != nil {
t.Fatalf("预置奖牌开关: %v", err)
}
snap, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
var ov *EventOverride
for i := range snap.Pack.EventOverrides {
if snap.Pack.EventOverrides[i].EventCode == "EVT_IND" {
ov = &snap.Pack.EventOverrides[i]
}
}
if ov == nil {
t.Fatalf("导出应包含 EVT_IND 的 override: %+v", snap.Pack.EventOverrides)
}
if ov.AwardMedals == nil || *ov.AwardMedals {
t.Fatalf("EVT_IND 应导出 awardMedals: false,实际 %+v", ov.AwardMedals)
}
if ov.Scored != nil || ov.Multiplier != nil || ov.Channels != nil {
t.Errorf("其余字段保持缺省,不该被一起写出来: %+v", ov)
}
// 导出结果重新导入必须无差异。
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("再次 Load: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs)
}
}
// 落库 → 直接查列 → Load 回读,断言与输入一致,并且导出再导入无差异。
func TestApplyMedalFlagsThenLoadRoundTrip(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
const packYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 奖牌开关 }
standings:
individualToTeamWeight: 1
tieBreak: [points, gold]
profiles:
- code: individual_top6
name: 个人项目前六名
awardRanks: 1
tieMode: skip
rules:
- { rank: 1, points: 10 }
- code: team_top6
name: 团体项目前六名
awardRanks: 1
tieMode: skip
rules:
- { rank: 1, points: 15 }
eventOverrides:
- eventCode: EVT_IND
awardMedals: false
channels:
- { scope: athlete, basis: result, profile: individual_top6, countToMedals: false, countToTeamMedals: false }
- eventCode: EVT_TEAM
channels:
- { scope: team, basis: manual, profile: team_top6, countToMedals: false }
`
next, err := Parse([]byte(packYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
// 小项级开关。
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_IND'`); got != 0 {
t.Errorf("EVT_IND award_medals = %d,期望 0", got)
}
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_TEAM'`); got != 1 {
t.Errorf("未写 awardMedals 的项目应保持缺省 1,实际 %d", got)
}
// 通道级开关:EVT_IND 的两个都关,EVT_TEAM 只关奖牌、团体奖牌并入照旧。
wants := map[string][3]int{
"EVT_IND": {1, 0, 0},
"EVT_TEAM": {1, 0, 1},
}
rows, err := db.Query(`SELECT e.code, c.count_to_team, c.count_to_medals, c.count_to_team_medals
FROM event_score_channels c JOIN events e ON e.id = c.event_id ORDER BY e.code, c.sort_order, c.id`)
if err != nil {
t.Fatalf("查询通道: %v", err)
}
defer func() { _ = rows.Close() }()
seen := map[string]int{}
for rows.Next() {
var code string
var countToTeam, countToMedals, countToTeamMedals int
if err := rows.Scan(&code, &countToTeam, &countToMedals, &countToTeamMedals); err != nil {
t.Fatalf("扫描通道: %v", err)
}
want, ok := wants[code]
if !ok {
t.Fatalf("出现预期外的通道: %s", code)
}
got := [3]int{countToTeam, countToMedals, countToTeamMedals}
if got != want {
t.Fatalf("%s 通道开关 = %v,期望 %v", code, got, want)
}
seen[code]++
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历通道: %v", err)
}
if seen["EVT_IND"] != 1 || seen["EVT_TEAM"] != 1 {
t.Fatalf("通道数异常: %v", seen)
}
// Load 回读:三个字段都要还原成「显式 false」而不是缺省 true。
snap, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
ind, ok := snap.EventByCode("EVT_IND")
if !ok {
t.Fatal("Load 结果里缺少 EVT_IND")
}
if ind.AwardMedals {
t.Error("Load 应读出 EVT_IND award_medals = 0")
}
if len(ind.Channels) != 1 {
t.Fatalf("EVT_IND 通道数 = %d,期望 1", len(ind.Channels))
}
if ind.Channels[0].CountsToMedals() || ind.Channels[0].CountsToTeamMedals() {
t.Errorf("EVT_IND 通道应按显式 false 读回: %+v", ind.Channels[0])
}
team, ok := snap.EventByCode("EVT_TEAM")
if !ok {
t.Fatal("Load 结果里缺少 EVT_TEAM")
}
if !team.AwardMedals {
t.Error("Load 应读出 EVT_TEAM award_medals = 1")
}
if team.Channels[0].CountsToMedals() {
t.Error("EVT_TEAM 通道 count_to_medals 应为 false")
}
if !team.Channels[0].CountsToTeamMedals() {
t.Error("EVT_TEAM 未关闭 count_to_team_medals,应保持 true")
}
// 导出再导入必须无差异:显式 false 与缺省 true 不能被读串。
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("再次 Load: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs)
}
}
// 只写 awardMedals 的 EventOverride 也要生效:判空若只看 scored/multiplier,这条会被整条跳过。
func TestApplyAwardMedalsOnlyOverride(t *testing.T) {
db := openEventDB(t)
seedEvents(t, db)
const packYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo, name: 只写奖牌开关 }
profiles:
- { code: individual_top6, name: 个人项目前六名, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 10 } ] }
- { code: team_top6, name: 团体项目前六名, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 15 } ] }
eventOverrides:
- eventCode: EVT_IND
awardMedals: false
- eventCode: EVT_TEAM
multiplier: 2
`
// 先把 EVT_TEAM 的奖牌开关也关掉,验证「override 不提该字段就不动它」。
if _, err := db.Exec(`UPDATE events SET award_medals = 0 WHERE code = 'EVT_TEAM'`); err != nil {
t.Fatalf("预置 EVT_TEAM 开关: %v", err)
}
next, err := Parse([]byte(packYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
res, err := Apply(db, &ChangeSet{Next: next})
if err != nil {
t.Fatalf("Apply: %v", err)
}
if res.EventsUpdated != 2 {
t.Errorf("项目更新数 = %d,期望 2(只写 awardMedals 的也要落库)", res.EventsUpdated)
}
if res.ChannelsWritten != 0 {
t.Errorf("没有模板/通道配置,不该重建通道,实际 %d", res.ChannelsWritten)
}
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_IND'`); got != 0 {
t.Errorf("EVT_IND award_medals = %d,期望 0", got)
}
if got := queryInt(t, db, `SELECT award_medals FROM events WHERE code = 'EVT_TEAM'`); got != 0 {
t.Errorf("override 未写 awardMedals 时不应改动该列,实际 %d", got)
}
if got := queryFloat(t, db, `SELECT multiplier FROM events WHERE code = 'EVT_TEAM'`); got != 2 {
t.Errorf("EVT_TEAM 权重 = %v,期望 2", got)
}
// 种子里的通道既没被删也没被改。
if got := countRows(t, db, "event_score_channels"); got != 1 {
t.Errorf("通道数 = %d,期望 1(未被规则包提到)", got)
}
}
// 通道只差奖牌开关也必须报差异,否则前端预览看不到、用户以为没改动。
func TestDiffDetectsMedalFlagChanges(t *testing.T) {
base := Channel{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}
cases := []struct {
name string
mut func(Channel) Channel
}{
{"countToMedals", func(c Channel) Channel { c.CountToMedals = BoolPtr(false); return c }},
{"countToTeamMedals", func(c Channel) Channel { c.CountToTeamMedals = BoolPtr(false); return c }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cur := currentSnapshot(t)
pack := *cur.Pack
pack.EventOverrides = []EventOverride{{
EventCode: "EVT_IND",
Channels: ChannelListPtr([]Channel{tc.mut(base)}),
}}
cs, err := Diff(cur, &pack)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if len(cs.Channels) != 1 || cs.Channels[0].EventCode != "EVT_IND" {
t.Fatalf("应报出 EVT_IND 的通道变化,实际 %+v", cs.Channels)
}
if cs.Channels[0].Kind != ChangeModified {
t.Errorf("变化类型 = %q,期望 modified", cs.Channels[0].Kind)
}
})
}
t.Run("缺省与显式 true 不算变化", func(t *testing.T) {
cur := currentSnapshot(t)
pack := *cur.Pack
pack.EventOverrides = []EventOverride{{
EventCode: "EVT_IND",
Channels: ChannelListPtr([]Channel{{
Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6",
CountToMedals: BoolPtr(true), CountToTeamMedals: BoolPtr(true),
}}),
}}
cs, err := Diff(cur, &pack)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if len(cs.Channels) != 0 {
t.Fatalf("显式 true 与缺省同义,不应报差异: %+v", cs.Channels)
}
})
}
+50 -36
View File
@@ -10,15 +10,16 @@ import (
// EventInfo 是展开通道模板所需的最小项目信息。
type EventInfo struct {
ID int64
Code string
Name string
SportCode string
EventType domain.EventType
ResultType domain.ResultType
Scored bool
Multiplier float64
Channels []Channel
ID int64
Code string
Name string
SportCode string
EventType domain.EventType
ResultType domain.ResultType
Scored bool
Multiplier float64
AwardMedals bool
Channels []Channel
}
// Snapshot 是赛事库当前的规则状态:规则包 + 项目目录。
@@ -64,10 +65,10 @@ func Load(db *sql.DB) (*Snapshot, error) {
return nil, err
}
// 库里只有逐项目的通道、没有模板表,因此导出时把「有通道/不计分/权重非 1」的项目
// 库里只有逐项目的通道、没有模板表,因此导出时把「有通道/不计分/不结算奖牌/权重非 1」的项目
// 全部写成 eventOverrides,保证「导出 → 导入」能还原当前配置。
for _, e := range events {
needOverride := len(e.Channels) > 0 || !e.Scored || e.Multiplier != 1
needOverride := len(e.Channels) > 0 || !e.Scored || !e.AwardMedals || e.Multiplier != 1
if !needOverride {
continue
}
@@ -78,6 +79,9 @@ func Load(db *sql.DB) (*Snapshot, error) {
if e.Multiplier != 1 {
ov.Multiplier = Float64Ptr(e.Multiplier)
}
if !e.AwardMedals {
ov.AwardMedals = BoolPtr(false)
}
if len(e.Channels) > 0 {
ov.Channels = ChannelListPtr(append([]Channel(nil), e.Channels...))
}
@@ -204,7 +208,7 @@ func settingByKey(db *sql.DB, key string) (string, error) {
}
func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
rows, err := db.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.scored, e.multiplier
rows, err := db.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.scored, e.multiplier, e.award_medals
FROM events e JOIN sports s ON s.id = e.sport_id
ORDER BY e.sort_order, e.code`)
if err != nil {
@@ -216,34 +220,36 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
index := map[int64]int{}
for rows.Next() {
var (
id int64
code, name string
sportCode string
eventType string
resultType string
scoredRaw int
multiplier float64
id int64
code, name string
sportCode string
eventType string
resultType string
scoredRaw int
multiplier float64
awardMedalsRaw int
)
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier); err != nil {
if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier, &awardMedalsRaw); err != nil {
return nil, fmt.Errorf("读取项目: %w", err)
}
index[id] = len(events)
events = append(events, EventInfo{
ID: id,
Code: code,
Name: name,
SportCode: sportCode,
EventType: domain.EventType(eventType),
ResultType: domain.ResultType(resultType),
Scored: scoredRaw != 0,
Multiplier: multiplier,
ID: id,
Code: code,
Name: name,
SportCode: sportCode,
EventType: domain.EventType(eventType),
ResultType: domain.ResultType(resultType),
Scored: scoredRaw != 0,
Multiplier: multiplier,
AwardMedals: awardMedalsRaw != 0,
})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("读取项目: %w", err)
}
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team
chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team, c.count_to_medals, c.count_to_team_medals
FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id
ORDER BY c.event_id, c.sort_order, c.id`)
if err != nil {
@@ -253,14 +259,16 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
for chRows.Next() {
var (
eventID int64
scope string
basis string
code string
sortOrd int
countToTeam int
eventID int64
scope string
basis string
code string
sortOrd int
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam); err != nil {
if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam, &countToMedals, &countToTeamMedals); err != nil {
return nil, fmt.Errorf("读取计分通道: %w", err)
}
idx, ok := index[eventID]
@@ -276,6 +284,12 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) {
if countToTeam == 0 {
ch.CountToTeam = BoolPtr(false)
}
if countToMedals == 0 {
ch.CountToMedals = BoolPtr(false)
}
if countToTeamMedals == 0 {
ch.CountToTeamMedals = BoolPtr(false)
}
events[idx].Channels = append(events[idx].Channels, ch)
}
if err := chRows.Err(); err != nil {
+19 -6
View File
@@ -104,11 +104,23 @@ type Channel struct {
// CountToTeam 决定该通道产出的流水是否计入单位总分;缺省 true。
// 「个人+团体」项目的个人通道写 false:分只记在个人头上,不进单位总分。
CountToTeam *bool `yaml:"countToTeam,omitempty" json:"countToTeam,omitempty"`
// CountToMedals 决定该通道产出的名次是否参与奖牌结算;缺省 true。
// 与项目级 events.award_medals 是「与」的关系(devdocs 17 §4.5)。
CountToMedals *bool `yaml:"countToMedals,omitempty" json:"countToMedals,omitempty"`
// CountToTeamMedals 决定该通道产出的个人奖牌是否并入单位奖牌榜;缺省 true。
// 团体通道(scope=team)的奖牌本来就记在单位头上,不受该开关影响。
CountToTeamMedals *bool `yaml:"countToTeamMedals,omitempty" json:"countToTeamMedals,omitempty"`
}
// CountsToTeam 返回补齐缺省值后的开关(缺省 true)。
func (c Channel) CountsToTeam() bool { return boolOrDefault(c.CountToTeam, true) }
// CountsToMedals 返回补齐缺省值后的奖牌结算开关(缺省 true)。
func (c Channel) CountsToMedals() bool { return boolOrDefault(c.CountToMedals, true) }
// CountsToTeamMedals 返回补齐缺省值后的「个人奖牌并入单位榜」开关(缺省 true)。
func (c Channel) CountsToTeamMedals() bool { return boolOrDefault(c.CountToTeamMedals, true) }
// ChannelMatch 是模板的适用条件;三个条件都为空时匹配全部(校验层会拒绝这种模板)。
type ChannelMatch struct {
EventType domain.EventType `yaml:"eventType,omitempty" json:"eventType,omitempty"`
@@ -124,14 +136,15 @@ type ChannelTemplate struct {
// EventOverride 是单个项目的例外配置。
//
// Scored / Multiplier 为 nil 表示「不涉及」;Channels 用指针是为了区分
// Scored / Multiplier / AwardMedals 为 nil 表示「不涉及」;Channels 用指针是为了区分
// 「没写 channels」(不动该项目的通道)与「channels: []」(清空通道)。
type EventOverride struct {
EventCode string `yaml:"eventCode" json:"eventCode"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Scored *bool `yaml:"scored,omitempty" json:"scored,omitempty"`
Multiplier *float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
Channels *[]Channel `yaml:"channels,omitempty" json:"channels,omitempty"`
EventCode string `yaml:"eventCode" json:"eventCode"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`
Scored *bool `yaml:"scored,omitempty" json:"scored,omitempty"`
Multiplier *float64 `yaml:"multiplier,omitempty" json:"multiplier,omitempty"`
AwardMedals *bool `yaml:"awardMedals,omitempty" json:"awardMedals,omitempty"`
Channels *[]Channel `yaml:"channels,omitempty" json:"channels,omitempty"`
}
// RecordBonus 是破纪录加分配置;本期 enabled=false,结构保留。
+1
View File
@@ -116,6 +116,7 @@ func TestMigrateEvent(t *testing.T) {
tables := []string{
"schema_migrations", "settings",
"teams", "athletes", "sports", "events", "event_categories", "event_score_channels",
"channel_ranks", "channel_manual_ranks",
"scoring_profiles", "scoring_profile_rules",
"entries", "results", "records", "record_breaks",
"score_ledger", "point_adjustments", "result_revisions", "change_log", "audit_logs",