fix: 审查修复波次(P0x4 + P1x30+)

- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力
- realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份
- httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表
- web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换
- console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复
- docs: 用户文档三份 + README
- 主代理: 破纪录加分接线(levels 未加载导致恒 0)
This commit is contained in:
AskaEth committed 2026-09-09 21:06:40 +08:00
1 parent 8dae63ee70
commit 7f9701c465
61 files changed
+3844 -333

No files matched your search

+2 -2
View File
@@ -63,8 +63,8 @@ func teamManualChannel(profileID int64) ScoreChannel {
return ScoreChannel{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisManual, ProfileID: profileID}
}
func teamAggregateChannel(profileID int64, method AggregateMethod) ScoreChannel {
return ScoreChannel{ID: 3, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: profileID, Aggregate: method}
func teamAggregateChannel(profileID int64) ScoreChannel {
return ScoreChannel{ID: 3, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: profileID}
}
// participant 是构造「报名项 + 成绩」的最小描述。
+47
View File
@@ -227,3 +227,50 @@ func TestActiveLedger(t *testing.T) {
t.Fatalf("未作废流水 = %+v,期望只有 id=1", got)
}
}
// TestRecomputeEventLedgerKeepsAdjustments 验证重算只作废 result/record_bonus,
// 人工调整原样保留——否则每重算一次就把人工扣/加分抹掉。
func TestRecomputeEventLedgerKeepsAdjustments(t *testing.T) {
existing := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10},
{ID: 2, EventID: 1, SourceType: SourceRecordBonus, SourceID: 100, TeamID: 1, Points: 2},
{ID: 3, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "成绩更正")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
for _, e := range out {
if e.SourceType == SourceAdjustment {
if e.Voided {
t.Fatalf("人工调整被误作废: %+v", e)
}
}
}
if got := SumByTeam(out, 1)[1].Total; !nearlyEqual(got, 3) {
t.Fatalf("重算后总分 = %v,期望 6(新成绩分)- 3(调整保留)= 3", got)
}
}
// TestRecomputeEventLedgerIdempotentWithAdjustments 验证带人工调整时重复重算也幂等。
func TestRecomputeEventLedgerIdempotentWithAdjustments(t *testing.T) {
existing := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6},
{ID: 2, EventID: 1, SourceType: SourceAdjustment, SourceID: 7, TeamID: 1, Points: -3},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
out, err := RecomputeEventLedger(existing, 1, fresh, "judge", "重复发布")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != len(existing) {
t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(existing))
}
for _, e := range out {
if e.Voided {
t.Fatalf("幂等重算不应作废任何流水: %+v", e)
}
}
}
+38
View File
@@ -193,3 +193,41 @@ func TestRecordBonusLedger(t *testing.T) {
}
})
}
// TestRecordBonusLedgerFiltersByCategory 验证按「小项+组别」过滤:
// 上层逐组别调用 ScoreEvent,别的组别的破纪录登记不能在本组别重复记一次加分。
func TestRecordBonusLedgerFiltersByCategory(t *testing.T) {
entries, results := fixture(lineup(2))
entries[1].CategoryID = 2 // entry2 属于另一个组别
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},
{ID: 2, EventID: 1, EntryID: 2, AthleteID: 102, TeamID: 1002, Level: "meet", State: RecordBreakApproved},
},
})
var thisCategory, otherCategory int
for _, e := range es.Ledger {
if e.SourceType != SourceRecordBonus {
continue
}
switch e.EntryID {
case 1:
thisCategory++
case 2:
otherCategory++
}
}
if thisCategory != 1 {
t.Fatalf("本组别加分流水 = %d 条,期望 1", thisCategory)
}
if otherCategory != 0 {
t.Fatalf("别的组别的加分不应记入本组别,实际 %d 条", otherCategory)
}
}
+7 -4
View File
@@ -63,6 +63,9 @@ type RankedResult struct {
func ScoreEvent(in EventScoreInput) (EventScore, error) {
var out EventScore
rankOnly := len(in.Channels) == 0
// 只排名不计分:scored=false 或没有计分通道时都不产出任何流水(含破纪录加分)。
scoreEnabled := in.Event.Scored && !rankOnly
channels := sortedChannels(in.Channels)
if rankOnly {
channels = []ScoreChannel{rankOnlyChannel(in)}
@@ -71,7 +74,7 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
for _, ch := range channels {
// 不计分时不需要积分表:既不查模板,也不可能算出非零分。
profile := ScoringProfile{}
if in.Event.Scored && !rankOnly {
if scoreEnabled {
p, ok := in.Profiles[ch.ProfileID]
if !ok {
return EventScore{}, fmt.Errorf("%w: 通道 %d 引用模板 %d", ErrProfileNotFound, ch.ID, ch.ProfileID)
@@ -82,18 +85,18 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
if err != nil {
return EventScore{}, err
}
if !in.Event.Scored {
if !scoreEnabled {
for i := range ranked {
ranked[i].Points = 0
}
}
out.Ranked = append(out.Ranked, ranked...)
if in.Event.Scored {
if scoreEnabled {
out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...)
}
}
if !in.Event.Scored {
if !scoreEnabled {
return out, nil
}
bonus, err := recordBonusLedger(in)
+68 -16
View File
@@ -191,7 +191,7 @@ func TestScopeAndLedgerAttribution(t *testing.T) {
})
}
// TestAggregateChannel 覆盖预留的 aggregate 通道:先汇总、再排名。
// TestAggregateChannel 覆盖 aggregate 通道:先按主体汇总(求和)、再排名。
func TestAggregateChannel(t *testing.T) {
build := func() ([]Entry, []Result) {
return fixture([]participant{
@@ -202,19 +202,12 @@ func TestAggregateChannel(t *testing.T) {
}
cases := []struct {
name string
method AggregateMethod
compare CompareMode
wantA float64
wantB float64
}{
{"sum 升序", AggregateSum, CompareAsc, 15, 10},
{"avg 升序", AggregateAvg, CompareAsc, 15, 10},
{"max 升序", AggregateMax, CompareAsc, 15, 10},
{"min 升序", AggregateMin, CompareAsc, 15, 10},
{"count 降序", AggregateCount, CompareDesc, 15, 10},
{"count 升序", AggregateCount, CompareAsc, 10, 15},
{"sum 降序", AggregateSum, CompareDesc, 10, 15},
{"默认方法按 sum", "", CompareAsc, 15, 10},
{"求和后升序", CompareAsc, 15, 10},
{"求和后降序", CompareDesc, 10, 15},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -223,7 +216,7 @@ func TestAggregateChannel(t *testing.T) {
ev.Compare = tc.compare
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2, tc.method)},
Channels: []ScoreChannel{teamAggregateChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
@@ -242,7 +235,7 @@ func TestAggregateChannel(t *testing.T) {
})
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2, AggregateSum)},
Channels: []ScoreChannel{teamAggregateChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
@@ -320,7 +313,7 @@ func TestDraftState(t *testing.T) {
}
}
// TestScoredFlagAndNoChannels 验证「只排名不计分」与「没有通道」都不产生流水。
// TestScoredFlagAndNoChannels 验证「只排名不计分」:不产生流水,但必须照样产出名次。
func TestScoredFlagAndNoChannels(t *testing.T) {
cases := []struct {
name string
@@ -342,13 +335,50 @@ func TestScoredFlagAndNoChannels(t *testing.T) {
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
if len(es.Ledger) != 0 || len(es.Ranked) != 0 {
t.Fatalf("流水/名次应为空,实际 %d/%d", len(es.Ledger), len(es.Ranked))
if len(es.Ledger) != 0 {
t.Fatalf("只排名不计分不应产生流水,实际 %d 条: %+v", len(es.Ledger), es.Ledger)
}
if len(es.Ranked) != 3 {
t.Fatalf("名次条数 = %d,期望 3(排名与计分解耦)", len(es.Ranked))
}
for _, rr := range es.Ranked {
if rr.Points != 0 {
t.Errorf("只排名不计分时 Points = %v,期望 0", rr.Points)
}
}
if ranks := rankByEntry(es.Ranked); ranks[1] != 1 || ranks[2] != 2 || ranks[3] != 3 {
t.Fatalf("名次 = %v,期望 1/2/3", ranks)
}
})
}
}
// TestRankOnlyRespectsResultMode 验证没有通道时按小项自身的录入方式排名(manual 直接读录入名次)。
func TestRankOnlyRespectsResultMode(t *testing.T) {
ps := lineup(3)
for i := range ps {
ps[i].noValue = true
}
ps[0].rank, ps[1].rank, ps[2].rank = 2, 3, 1
entries, results := fixture(ps)
ev := individualEvent(1)
ev.Scored = false
ev.ResultMode = ResultModeManual
ev.Compare = CompareNone
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Entries: entries, Results: results,
})
if len(es.Ledger) != 0 {
t.Fatalf("只排名不计分不应产生流水,实际 %d 条", len(es.Ledger))
}
ranks := rankByEntry(es.Ranked)
if ranks[3] != 1 || ranks[1] != 2 || ranks[2] != 3 {
t.Fatalf("名次 = %v,期望 entry3→1 / entry1→2 / entry2→3", ranks)
}
}
// TestScoreEventErrors 覆盖配置类错误,避免错配置静默算错分。
func TestScoreEventErrors(t *testing.T) {
entries, results := fixture(lineup(1))
@@ -376,7 +406,7 @@ func TestScoreEventErrors(t *testing.T) {
}
noCompareAgg := noCompare
noCompareAgg.Channels = []ScoreChannel{teamAggregateChannel(1, AggregateSum)}
noCompareAgg.Channels = []ScoreChannel{teamAggregateChannel(1)}
if _, err := ScoreEvent(noCompareAgg); !errors.Is(err, ErrCompareRequired) {
t.Errorf("聚合 compare=none 错误 = %v,期望 ErrCompareRequired", err)
}
@@ -599,3 +629,25 @@ func TestMissingResultOrEntry(t *testing.T) {
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8, 0})
})
}
// TestNoChannelsSkipsRecordBonus 验证「0 个通道 = 只排名不计分」:
// 即使项目 scored=true 且破了纪录,也不写任何流水(破纪录加分也跳过)。
func TestNoChannelsSkipsRecordBonus(t *testing.T) {
entries, results := fixture(lineup(2))
cfg := bonusConfig(RecordBonusMax)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
})
if len(es.Ledger) != 0 {
t.Fatalf("没有通道时不应产生流水,实际 %d 条: %+v", len(es.Ledger), es.Ledger)
}
if len(es.Ranked) != 2 {
t.Fatalf("名次条数 = %d,期望 2", len(es.Ranked))
}
}
+40
View File
@@ -245,3 +245,43 @@ func TestSumByTeamIgnoresVoided(t *testing.T) {
t.Fatalf("总分 = %v,期望只算未作废的 10", got[1].Total)
}
}
// TestTeamStandingsTieBreakConfig 验证 StandingsInput.TieBreak 真正参与排序:
// 配置 [teamCode] 时即使总分落后也按单位编码在前;未配置时仍是总分优先。
func TestTeamStandingsTieBreakConfig(t *testing.T) {
ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 20)}
def := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: ledger})
if def[0].TeamID != 2 {
t.Fatalf("默认口径榜首 = 单位 %d,期望 2(总分 20 优先)", def[0].TeamID)
}
cfg := BuildTeamStandings(StandingsInput{
Teams: testTeams(1, 2), Ledger: ledger, TieBreak: []string{TieBreakTeamCode},
})
if cfg[0].TeamID != 1 {
t.Fatalf("配置 [teamCode] 榜首 = 单位 %d,期望 1(编码 A < B)", cfg[0].TeamID)
}
if cfg[0].Rank != 1 || cfg[1].Rank != 2 {
t.Fatalf("名次 = %d/%d,期望 1/2", cfg[0].Rank, cfg[1].Rank)
}
}
// TestAthleteStandingsTieBreakConfig 验证个人榜同样按配置排序。
func TestAthleteStandingsTieBreakConfig(t *testing.T) {
ledger := []LedgerEntry{ledgerForAthlete(1001, 1, 10), ledgerForAthlete(1002, 2, 10)}
athletes := []Athlete{{ID: 1, Name: "张三", TeamID: 1001}, {ID: 2, Name: "李四", TeamID: 1002}}
teams := []Team{{ID: 1001, Code: "B", Name: "二单位"}, {ID: 1002, Code: "A", Name: "一单位"}}
def := BuildAthleteStandings(StandingsInput{Teams: teams, Athletes: athletes, Ledger: ledger})
if def[0].AthleteID != 1 {
t.Fatalf("默认口径榜首 = 运动员 %d,期望 1(姓名 张三 < 李四)", def[0].AthleteID)
}
cfg := BuildAthleteStandings(StandingsInput{
Teams: teams, Athletes: athletes, Ledger: ledger, TieBreak: []string{TieBreakTeamCode},
})
if cfg[0].AthleteID != 2 {
t.Fatalf("配置 [teamCode] 榜首 = 运动员 %d,期望 2(单位编码 A < B)", cfg[0].AthleteID)
}
}
+6 -6
View File
@@ -225,12 +225,12 @@ type EventCategory struct {
// basis=aggregate 时固定按「同一主体成绩之和」汇总:汇总口径不可配置,
// 库里也没有对应的列,因此不再保留 avg/max/min/count 这类配置项。
type ScoreChannel struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Scope Scope `json:"scope"`
Basis Basis `json:"basis"`
ProfileID int64 `json:"profileId"`
SortOrder int `json:"sortOrder,omitempty"`
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Scope Scope `json:"scope"`
Basis Basis `json:"basis"`
ProfileID int64 `json:"profileId"`
SortOrder int `json:"sortOrder,omitempty"`
}
// ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。
+46 -1
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"context"
"io"
"net/http"
"net/url"
@@ -10,6 +11,7 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@@ -98,8 +100,23 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
name = raw
}
opts := export.ProjectExportOptions{Name: name, AppVersion: s.version}
if s.projectDir != "" {
// uploads 可能还没建过;只在目录存在时打包,否则 copyTree 会报错。
uploads := filepath.Join(s.projectDir, "uploads")
if info, err := os.Stat(uploads); err == nil && info.IsDir() {
opts.UploadsDir = uploads
}
}
rulesPath, cleanup, err := s.writeRulesTempFile(r.Context())
if err != nil {
return nil, err
}
defer cleanup()
opts.RulesPath = rulesPath
dst := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip")
if _, err := export.ExportProjectZip(s.ev, dst, export.ProjectExportOptions{Name: name}); err != nil {
if _, err := export.ExportProjectZip(s.ev, dst, opts); err != nil {
return nil, internal(err)
}
defer func() { _ = os.Remove(dst) }()
@@ -110,6 +127,33 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
return nil, errHandled
}
// writeRulesTempFile 把当前规则包导出成临时 YAML,供赛事项目包内嵌 rules.yaml。
// 返回的清理函数由调用方 defer,避免临时文件泄漏。
func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error) {
pack, err := rules.Export(s.ev, rules.Meta{})
if err != nil {
return "", func() {}, internal(err)
}
body, err := rules.Marshal(pack)
if err != nil {
return "", func() {}, internal(err)
}
f, err := os.CreateTemp("", "scoreboard-rules-*.yaml")
if err != nil {
return "", func() {}, internal(err)
}
if _, err := f.Write(body); err != nil {
_ = f.Close()
_ = os.Remove(f.Name())
return "", func() {}, internal(err)
}
if err := f.Close(); err != nil {
_ = os.Remove(f.Name())
return "", func() {}, internal(err)
}
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
}
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
if err := r.ParseMultipartForm(512 << 20); err != nil {
return nil, badRequest("解析上传失败", nil)
@@ -146,6 +190,7 @@ func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any,
if err != nil {
return nil, badRequest(err.Error(), nil)
}
s.audit(r.Context(), "project.import", "project", 0, nil, res)
return res, nil
}
+2 -10
View File
@@ -77,19 +77,11 @@ func (s *server) voidResultHandler(w http.ResponseWriter, r *http.Request) (any,
// ---------- 积分与排名 ----------
func (s *server) standingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
in, err := s.loadStandingsInput(r.Context())
if err != nil {
return nil, err
}
teams, err := s.teamMap(r.Context())
if err != nil {
return nil, err
}
return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil
return s.standingsSnapshot(r.Context(), s.ev)
}
func (s *server) athleteStandingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
in, err := s.loadStandingsInput(r.Context())
in, err := s.loadStandingsInput(r.Context(), s.ev)
if err != nil {
return nil, err
}
+102
View File
@@ -0,0 +1,102 @@
package httpapi
import (
"context"
"database/sql"
"net/http"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
// Realtime 是实时层的最小接口;未接入时写操作只落库,不推送。
type Realtime interface {
Broadcast(changes []realtime.Change)
LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]realtime.Change, error)
Instances() []realtime.Instance
}
// broadcastChanges 在写请求成功返回后,把本次请求写入 change_log 的变更推给实时层。
// 版本号取请求开始时的最大 id,因此只推送本次写入,不会重复补发历史变更。
func (s *server) broadcastChanges(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.realtime == nil || s.ev == nil || !isWriteMethod(r.Method) {
next.ServeHTTP(w, r)
return
}
before, ok := s.changeVersion(r.Context())
if !ok {
next.ServeHTTP(w, r)
return
}
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
if rec.status >= http.StatusBadRequest {
return
}
// 请求结束后 r.Context() 可能已被取消,推送不应因此中断。
ctx := context.WithoutCancel(r.Context())
changes, err := s.realtime.LoadChanges(ctx, before, 0)
if err != nil {
s.logger.Error("读取实时变更失败", "err", err)
return
}
if len(changes) > 0 {
s.realtime.Broadcast(changes)
}
})
}
// changeVersion 读取 change_log 当前最大 id;失败时调用方跳过本次推送。
func (s *server) changeVersion(ctx context.Context) (int64, bool) {
var v sql.NullInt64
if err := s.ev.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil {
s.logger.Error("读取变更版本失败", "err", err)
return 0, false
}
return v.Int64, true
}
func isWriteMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return false
default:
return true
}
}
// statusRecorder 记录响应状态码,用于判断写请求是否成功。
type statusRecorder struct {
http.ResponseWriter
status int
written bool
}
func (w *statusRecorder) WriteHeader(code int) {
if !w.written {
w.status = code
w.written = true
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusRecorder) Write(b []byte) (int, error) {
if !w.written {
w.written = true
}
return w.ResponseWriter.Write(b)
}
// displayInstancesHandler 返回在线大屏实例列表(在线看板)。
func (s *server) displayInstancesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
if s.realtime == nil {
return []realtime.Instance{}, nil
}
list := s.realtime.Instances()
if list == nil {
list = []realtime.Instance{}
}
return list, nil
}
+16 -5
View File
@@ -153,15 +153,26 @@ func (s *server) listTeams(ctx context.Context) ([]teamDTO, error) {
}
func (s *server) teamMap(ctx context.Context) (map[int64]teamDTO, error) {
list, err := s.listTeams(ctx)
return teamMapQ(ctx, s.ev)
}
// teamMapQ 与 teamMap 同义,但可在事务内使用,保证榜单快照与写操作看到同一状态。
func teamMapQ(ctx context.Context, q queryer) (map[int64]teamDTO, error) {
rows, err := q.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`)
if err != nil {
return nil, err
return nil, internal(err)
}
out := make(map[int64]teamDTO, len(list))
for _, t := range list {
defer func() { _ = rows.Close() }()
out := make(map[int64]teamDTO)
for rows.Next() {
t, err := scanTeam(rows)
if err != nil {
return nil, internal(err)
}
out[t.ID] = t
}
return out, nil
return out, rows.Err()
}
func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) {
+103 -16
View File
@@ -225,24 +225,24 @@ func boolDefault(v string, def bool) bool {
// ---------- standings ----------
func (s *server) loadStandingsInput(ctx context.Context) (domain.StandingsInput, error) {
teams, err := s.domainTeams(ctx)
func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) {
teams, err := s.domainTeams(ctx, q)
if err != nil {
return domain.StandingsInput{}, err
}
athletes, err := s.domainAthletes(ctx)
athletes, err := s.domainAthletes(ctx, q)
if err != nil {
return domain.StandingsInput{}, err
}
ledger, err := s.activeLedger(ctx)
ledger, err := s.activeLedger(ctx, q)
if err != nil {
return domain.StandingsInput{}, err
}
ranked, err := s.buildRanked(ctx)
ranked, err := s.buildRanked(ctx, q)
if err != nil {
return domain.StandingsInput{}, err
}
values, err := loadSettingsMap(ctx, s.ev)
values, err := loadSettingsMap(ctx, q)
if err != nil {
return domain.StandingsInput{}, err
}
@@ -259,8 +259,31 @@ func (s *server) loadStandingsInput(ctx context.Context) (domain.StandingsInput,
}, nil
}
func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) {
rows, err := s.ev.QueryContext(ctx,
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]standingRowDTO, error) {
in, err := s.loadStandingsInput(ctx, q)
if err != nil {
return nil, err
}
teams, err := teamMapQ(ctx, q)
if err != nil {
return nil, err
}
return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil
}
// bumpStandings 在写事务内把整张榜单写入 change_log;榜单变化后必须调用,否则大屏不会刷新。
func (s *server) bumpStandings(ctx context.Context, tx *sql.Tx, actorID int64) error {
snapshot, err := s.standingsSnapshot(ctx, tx)
if err != nil {
return err
}
return bumpVersion(ctx, tx, "standings", 0, "replace", snapshot, actorID)
}
func (s *server) domainTeams(ctx context.Context, q queryer) ([]domain.Team, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, code, name, short_name, color, sort_order, active FROM teams WHERE active = 1`)
if err != nil {
return nil, internal(err)
@@ -284,8 +307,8 @@ func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) {
return out, rows.Err()
}
func (s *server) domainAthletes(ctx context.Context) ([]domain.Athlete, error) {
rows, err := s.ev.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`)
func (s *server) domainAthletes(ctx context.Context, q queryer) ([]domain.Athlete, error) {
rows, err := q.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`)
if err != nil {
return nil, internal(err)
}
@@ -310,8 +333,8 @@ func (s *server) domainAthletes(ctx context.Context) ([]domain.Athlete, error) {
// buildRanked 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。
// 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致。
func (s *server) buildRanked(ctx context.Context) ([]domain.RankedResult, error) {
rows, err := s.ev.QueryContext(ctx,
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
@@ -495,9 +518,17 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
}
defer func() { _ = tx.Rollback() }()
var state string
var eventID int64
if err := tx.QueryRowContext(ctx, `SELECT state, event_id FROM record_breaks WHERE id = ?`, id).Scan(&state, &eventID); err != nil {
var (
state string
eventID int64
entryID int64
level string
resultVal sql.NullFloat64
resultText sql.NullString
)
if err := tx.QueryRowContext(ctx,
`SELECT state, event_id, entry_id, level, result_value, result_text FROM record_breaks WHERE id = ?`, id).
Scan(&state, &eventID, &entryID, &level, &resultVal, &resultText); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return recordBreakDTO{}, notFound("破纪录登记不存在")
}
@@ -514,9 +545,23 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "确认破纪录加分"); err != nil {
return recordBreakDTO{}, err
}
// 确认即更新纪录库,并把引擎算出的加分回填到登记上(devdocs/02 第 3 节)。
if err := upsertCurrentRecord(ctx, tx, eventID, entryID, level, resultVal, resultText); err != nil {
return recordBreakDTO{}, err
}
if _, err := tx.ExecContext(ctx,
`UPDATE record_breaks SET bonus_points = COALESCE((
SELECT SUM(points) FROM score_ledger
WHERE source_type = 'record_bonus' AND event_id = ? AND entry_id = ? AND voided = 0), 0)
WHERE id = ?`, eventID, entryID, id); err != nil {
return recordBreakDTO{}, internal(err)
}
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
return recordBreakDTO{}, err
}
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return recordBreakDTO{}, err
}
if err := tx.Commit(); err != nil {
return recordBreakDTO{}, internal(err)
}
@@ -524,6 +569,44 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
return s.getRecordBreak(ctx, id)
}
// upsertCurrentRecord 把确认后的破纪录写入 records:同小项同档位的旧纪录改为非当前,
// 新纪录标为 current(devdocs/04 records 表;source=current 表示本届产生)。
func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64, level string, value sql.NullFloat64, text sql.NullString) error {
var (
teamID int64
athleteID sql.NullInt64
athleteName sql.NullString
teamName sql.NullString
)
if err := tx.QueryRowContext(ctx,
`SELECT e.team_id, e.athlete_id, a.name, t.name
FROM entries e
LEFT JOIN athletes a ON a.id = e.athlete_id
LEFT JOIN teams t ON t.id = e.team_id
WHERE e.id = ?`, entryID).Scan(&teamID, &athleteID, &athleteName, &teamName); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return notFound("报名项不存在")
}
return internal(err)
}
holder := teamName.String
if athleteID.Valid && athleteName.String != "" {
holder = athleteName.String
}
if _, err := tx.ExecContext(ctx,
`UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`,
eventID, level); err != nil {
return internal(err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO records (event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), 1, 'current')`,
eventID, level, value, text, nullStr(&holder), nullID(teamID)); err != nil {
return internal(err)
}
return nil
}
// ---------- adjustments ----------
func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) {
@@ -614,6 +697,10 @@ func (s *server) createAdjustment(ctx context.Context, in adjustmentInput, userI
in.TeamID, nullI64(in.EventID), id, in.Delta, nullID(userID)); err != nil {
return adjustmentDTO{}, internal(err)
}
// 调整分进积分榜,必须写版本号,否则大屏不会刷新。
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return adjustmentDTO{}, err
}
if err := tx.Commit(); err != nil {
return adjustmentDTO{}, internal(err)
}
@@ -669,7 +756,7 @@ func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason st
nullID(userID), reason, id); err != nil {
return adjustmentDTO{}, internal(err)
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"adjustmentId": id}, userID); err != nil {
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return adjustmentDTO{}, err
}
if err := tx.Commit(); err != nil {
+58 -4
View File
@@ -83,7 +83,57 @@ func queryResults(ctx context.Context, q queryer, query string, args ...any) ([]
}
out = append(out, r)
}
return out, rows.Err()
if err := rows.Err(); err != nil {
return nil, internal(err)
}
if err := attachResultPoints(ctx, q, out); err != nil {
return nil, err
}
return out, nil
}
// attachResultPoints 给已发布成绩补上得分(该成绩产生的 result 流水合计)。
// 草稿没有流水,保持 nil;录入页据此预览发布后的分值变化。
func attachResultPoints(ctx context.Context, q queryer, items []resultDTO) error {
ids := make([]any, 0, len(items))
index := make(map[int64]int, len(items))
for i := range items {
if items[i].State != "published" {
continue
}
ids = append(ids, items[i].ID)
index[items[i].ID] = i
}
if len(ids) == 0 {
return nil
}
rows, err := q.QueryContext(ctx,
`SELECT result_id, SUM(points) FROM score_ledger
WHERE voided = 0 AND source_type = 'result' AND result_id IN (`+placeholders(len(ids))+`)
GROUP BY result_id`, ids...)
if err != nil {
return internal(err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
resultID int64
points float64
)
if err := rows.Scan(&resultID, &points); err != nil {
return internal(err)
}
if i, ok := index[resultID]; ok {
v := points
items[i].Points = &v
}
}
if err := rows.Err(); err != nil {
return internal(err)
}
return nil
}
func (s *server) getResult(ctx context.Context, id int64) (resultDTO, error) {
@@ -94,7 +144,11 @@ func (s *server) getResult(ctx context.Context, id int64) (resultDTO, error) {
if err != nil {
return resultDTO{}, internal(err)
}
return r, nil
items := []resultDTO{r}
if err := attachResultPoints(ctx, s.ev, items); err != nil {
return resultDTO{}, err
}
return items[0], nil
}
func (s *server) listResultsPage(ctx context.Context, eventID int64, state string, page, size int) (pageResult[resultDTO], error) {
@@ -194,8 +248,8 @@ func joinAnd(parts []string) string {
// ---------- ledger ----------
func (s *server) activeLedger(ctx context.Context) ([]domain.LedgerEntry, error) {
return queryLedger(ctx, s.ev, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
func (s *server) activeLedger(ctx context.Context, q queryer) ([]domain.LedgerEntry, error) {
return queryLedger(ctx, q, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, voided, created_at FROM score_ledger WHERE voided = 0`)
}
@@ -0,0 +1,532 @@
package httpapi_test
import (
"archive/zip"
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
)
// ---------- 实时层替身 ----------
type fakeRealtime struct {
db *sql.DB
mu sync.Mutex
batches [][]realtime.Change
instances []realtime.Instance
}
func (f *fakeRealtime) Broadcast(changes []realtime.Change) {
f.mu.Lock()
defer f.mu.Unlock()
f.batches = append(f.batches, changes)
}
func (f *fakeRealtime) LoadChanges(ctx context.Context, sinceVersion int64, limit int) ([]realtime.Change, error) {
q := `SELECT id, entity_type, entity_id, action, payload_json FROM change_log WHERE id > ? ORDER BY id`
args := []any{sinceVersion}
if limit > 0 {
q += ` LIMIT ?`
args = append(args, limit)
}
rows, err := f.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
out := make([]realtime.Change, 0)
for rows.Next() {
var (
ch realtime.Change
entity string
entityID sql.NullInt64
action string
payload string
)
if err := rows.Scan(&ch.Version, &entity, &entityID, &action, &payload); err != nil {
return nil, err
}
ch.Entity = entity
ch.ID = entityID.Int64
ch.Action = action
if json.Valid([]byte(payload)) {
ch.Data = json.RawMessage(payload)
} else {
ch.Data = json.RawMessage(`{}`)
}
out = append(out, ch)
}
return out, rows.Err()
}
func (f *fakeRealtime) Instances() []realtime.Instance { return f.instances }
func (f *fakeRealtime) firstBatch(t *testing.T) []realtime.Change {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
f.mu.Lock()
if len(f.batches) > 0 {
batch := f.batches[0]
f.mu.Unlock()
return batch
}
f.mu.Unlock()
time.Sleep(10 * time.Millisecond)
}
t.Fatal("等待实时广播超时")
return nil
}
func (e *testEnv) doRaw(client *http.Client, method, path string, body []byte, contentType string, csrf bool) (int, []byte) {
e.t.Helper()
req, err := http.NewRequest(method, e.server.URL+path, bytes.NewReader(body))
if err != nil {
e.t.Fatalf("构造请求: %v", err)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if csrf {
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
}
resp, err := client.Do(req)
if err != nil {
e.t.Fatalf("请求 %s %s: %v", method, path, err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, raw
}
// ---------- must_change_password ----------
// 必须改密的账号除改密/登出外一律禁止写操作,避免初始密码长期可用(devdocs/08 第 1 节)。
func TestMustChangePasswordBlocksWrites(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE id = ?`, userID); err != nil {
t.Fatalf("设置 must_change_password: %v", err)
}
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 读操作仍放行:前端要能渲染改密页。
if status, _ := env.do(client, http.MethodGet, "/api/auth/me", nil, false); status != http.StatusOK {
t.Fatalf("改密前读 me 应 200,得到 %d", status)
}
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "X", "name": "越权单位", "active": true,
}, true)
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("改密前写操作应 403 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
}
// 改密本身必须放行,否则用户永远无法解除限制。
status, env2 = env.do(client, http.MethodPost, "/api/auth/password", map[string]any{
"currentPassword": "recorder-pass",
"newPassword": "new-pass-123",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改密应 200,得到 %d %+v", status, env2.Error)
}
// 改密后重新登录,写操作恢复。
env.login(client, "recorder", "new-pass-123")
status, env2 = env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "X", "name": "越权单位", "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改密后写操作应 200,得到 %d %+v", status, env2.Error)
}
}
// 登出也必须放行,否则改密失败的用户无法退出。
func TestMustChangePasswordAllowsLogout(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE id = ?`, userID); err != nil {
t.Fatalf("设置 must_change_password: %v", err)
}
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
if status, env2 := env.do(client, http.MethodPost, "/api/auth/logout", nil, true); status != http.StatusOK {
t.Fatalf("改密前登出应 200,得到 %d %+v", status, env2.Error)
}
}
// ---------- X-Forwarded-For 伪造 ----------
// 默认不信任代理头:伪造 XFF 不能绕过登录限流(否则等于没有限流)。
func TestSpoofedForwardedForDoesNotBypassLoginLimit(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
for i := 0; i < 5; i++ {
status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", map[string]any{
"username": "recorder", "password": "wrong-pass",
}, false, map[string]string{"X-Forwarded-For": fmt.Sprintf("203.0.113.%d", i+1)})
if status != http.StatusUnauthorized {
t.Fatalf("第 %d 次错误登录应 401,得到 %d %+v", i+1, status, env2.Error)
}
}
status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", map[string]any{
"username": "recorder", "password": "wrong-pass",
}, false, map[string]string{"X-Forwarded-For": "203.0.113.250"})
if status != http.StatusTooManyRequests || env2.Error == nil || env2.Error.Code != "RATE_LIMITED" {
t.Fatalf("伪造 XFF 后第 6 次应被限流 429,得到 %d %+v", status, env2.Error)
}
}
// ---------- 流水幂等 ----------
// 重复发布不能累积破纪录加分流水,同时必须保留人工调整流水(devdocs/02 §6)。
func TestPublishDoesNotAccumulateRecordBonusAndKeepsAdjustments(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "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, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "requestId": "publish-1",
}, true); status != http.StatusOK {
t.Fatalf("首次发布应 200,得到 %d %+v", status, env2.Error)
}
// 模拟已存在的破纪录加分流水与人工调整流水。
env.exec(t, `INSERT INTO score_ledger (team_id, event_id, entry_id, source_type, source_id, points)
VALUES (?, ?, ?, 'record_bonus', ?, 5)`, ids.teamA, ids.event, ids.entryA, ids.entryA)
env.exec(t, `INSERT INTO score_ledger (team_id, source_type, source_id, points)
VALUES (?, 'adjustment', 1, 3)`, ids.teamA)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "requestId": "publish-2",
}, true); status != http.StatusOK {
t.Fatalf("再次发布应 200,得到 %d %+v", status, env2.Error)
}
var activeBonus, activeAdjust, activeResult int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger
WHERE voided = 0 AND source_type = 'record_bonus' AND event_id = ?`, ids.event).Scan(&activeBonus); err != nil {
t.Fatalf("统计破纪录流水: %v", err)
}
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger WHERE voided = 0 AND source_type = 'adjustment'`).Scan(&activeAdjust); err != nil {
t.Fatalf("统计调整流水: %v", err)
}
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM score_ledger
WHERE voided = 0 AND source_type = 'result' AND event_id = ?`, ids.event).Scan(&activeResult); err != nil {
t.Fatalf("统计成绩流水: %v", err)
}
if activeBonus != 0 {
t.Fatalf("重算应作废旧破纪录加分流水且不重复累积,得到 %d 条", activeBonus)
}
if activeAdjust != 1 {
t.Fatalf("重算不得作废人工调整,得到 %d 条", activeAdjust)
}
if activeResult != 2 {
t.Fatalf("重算后成绩流水应仍为 2 条,得到 %d", activeResult)
}
}
// ---------- standings 变更载荷 ----------
// standings 的 replace 载荷必须是整张榜单数组(devdocs/05 第 2 节),否则大屏会被清空。
func TestPublishChangeLogCarriesFullStandingsSnapshot(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "requestId": "publish-1",
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
var payload string
if err := env.ev.QueryRow(`SELECT payload_json FROM change_log
WHERE entity_type = 'standings' ORDER BY id DESC LIMIT 1`).Scan(&payload); err != nil {
t.Fatalf("读取 standings 变更: %v", err)
}
var rows []map[string]any
if err := json.Unmarshal([]byte(payload), &rows); err != nil {
t.Fatalf("standings 载荷应为数组: %v (%s)", err, payload)
}
if len(rows) != 2 {
t.Fatalf("standings 载荷应含 2 个单位,得到 %d (%s)", len(rows), payload)
}
if rows[0]["teamCode"] != "A" || rows[0]["totalPoints"].(float64) != 10 {
t.Fatalf("standings 载荷内容不对: %+v", rows[0])
}
// 发布前必须留备份(08 第 6 节):成功时写 system.backup 审计。
var backupAudits int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs
WHERE action = 'system.backup' AND after_json LIKE '%publish%'`).Scan(&backupAudits); err != nil {
t.Fatalf("查询备份审计: %v", err)
}
if backupAudits == 0 {
t.Fatalf("发布应写入发布前备份审计")
}
}
// ---------- 赛事包内容 ----------
// 赛事包必须内含 rules.yaml 与 uploads/(devdocs/04 §1.1),否则跨机无法复现。
func TestExportProjectZipIncludesRulesAndUploads(t *testing.T) {
projectDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(projectDir, "uploads"), 0o755); err != nil {
t.Fatalf("创建 uploads: %v", err)
}
if err := os.WriteFile(filepath.Join(projectDir, "uploads", "logo.txt"), []byte("logo"), 0o644); err != nil {
t.Fatalf("写 uploads 文件: %v", err)
}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.ProjectDir = projectDir })
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
resp, err := client.Get(env.server.URL + "/api/export/project.zip")
if err != nil {
t.Fatalf("导出赛事包: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出赛事包失败 status=%d err=%v", resp.StatusCode, err)
}
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("读取 zip: %v", err)
}
names := map[string]bool{}
for _, f := range zr.File {
names[f.Name] = true
}
for _, want := range []string{"meta.yaml", "event.db", "rules.yaml", "uploads/logo.txt"} {
if !names[want] {
t.Fatalf("赛事包缺少 %s,实际含 %v", want, names)
}
}
}
// ---------- result.points ----------
// 已发布成绩必须带得分,供录入页预览与审查页对比。
func TestPublishedResultsReturnPoints(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "requestId": "publish-1",
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
status, env2 := env.do(client, http.MethodGet,
fmt.Sprintf("/api/results?eventId=%d&state=published", ids.event), nil, false)
if status != http.StatusOK {
t.Fatalf("读取成绩应 200,得到 %d", status)
}
page := decodeData[struct {
Items []map[string]any `json:"items"`
}](t, env2)
items := page.Items
if len(items) != 2 {
t.Fatalf("应返回 2 条成绩,得到 %d", len(items))
}
for _, it := range items {
if it["points"] == nil {
t.Fatalf("已发布成绩应返回 points: %+v", it)
}
}
}
// ---------- records 表写入 ----------
// 批准破纪录必须更新纪录库的当前纪录(devdocs/02 §3、devdocs/04 records)。
func TestApproveRecordBreakUpsertsRecords(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")
status, env2 := env.do(client, http.MethodPost, "/api/record-breaks", map[string]any{
"eventId": ids.event, "entryId": ids.entryA, "level": "meet",
"resultValue": 10.5, "resultText": "10.50",
}, true)
if status != http.StatusOK {
t.Fatalf("登记破纪录应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
breakID := int64(created["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)
}
var (
holder sql.NullString
teamID sql.NullInt64
isCurrent bool
source string
)
err := env.ev.QueryRow(`SELECT holder_name, holder_team_id, is_current, source
FROM records WHERE event_id = ? AND level = 'meet'`, ids.event).
Scan(&holder, &teamID, &isCurrent, &source)
if err != nil {
t.Fatalf("查询纪录库: %v", err)
}
if !holder.Valid || holder.String != "张伟" {
t.Fatalf("纪录持有者应为运动员姓名,得到 %+v", holder)
}
if !teamID.Valid || teamID.Int64 != ids.teamA {
t.Fatalf("纪录单位应为 %d,得到 %+v", ids.teamA, teamID)
}
if !isCurrent || source != "current" {
t.Fatalf("新纪录应 is_current=1 source=current,得到 isCurrent=%v source=%q", isCurrent, source)
}
}
// ---------- 规则导入审计 ----------
// 规则导入成功后必须留审计(devdocs/02 §7.2、08 §3)。
func TestRulesImportWritesAudit(t *testing.T) {
env := newTestEnv(t)
env.seed(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
resp, err := client.Get(env.server.URL + "/api/rules/export")
if err != nil {
t.Fatalf("导出规则包: %v", err)
}
pack, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil || resp.StatusCode != http.StatusOK {
t.Fatalf("导出规则包失败 status=%d err=%v", resp.StatusCode, err)
}
status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", pack, "application/yaml", true)
if status != http.StatusOK {
t.Fatalf("导入规则包应 200,得到 %d %s", status, raw)
}
var count int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'rules.import'`).Scan(&count); err != nil {
t.Fatalf("查询审计: %v", err)
}
if count != 1 {
t.Fatalf("规则导入应写 1 条审计,得到 %d", count)
}
}
// ---------- 实时广播与在线看板 ----------
// 写事务提交后要把新变更推给实时层;大屏实例列表对 recorder 开放。
func TestWritesBroadcastChangesAndListDisplayInstances(t *testing.T) {
fake := &fakeRealtime{}
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Realtime = fake })
fake.db = env.ev
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
env.do(client, http.MethodPut, "/api/results/draft", map[string]any{
"eventId": ids.event,
"requestId": "draft-1",
"results": []map[string]any{
{"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"},
{"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"},
},
}, true)
if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{
"eventId": ids.event, "requestId": "publish-1",
}, true); status != http.StatusOK {
t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error)
}
batch := fake.firstBatch(t)
found := false
for _, ch := range batch {
if ch.Entity == "standings" {
found = true
var rows []map[string]any
if err := json.Unmarshal(ch.Data, &rows); err != nil || len(rows) == 0 {
t.Fatalf("广播的 standings 应为非空数组: %v %s", err, ch.Data)
}
}
}
if !found {
t.Fatalf("广播批次应包含 standings 变更: %+v", batch)
}
fake.instances = []realtime.Instance{{ID: "display-1", Online: true, Page: "standings"}}
status, env2 := env.do(client, http.MethodGet, "/api/display/instances", nil, false)
if status != http.StatusOK {
t.Fatalf("读取大屏实例应 200,得到 %d %+v", status, env2.Error)
}
list := decodeData[[]map[string]any](t, env2)
if len(list) != 1 || list[0]["id"] != "display-1" {
t.Fatalf("大屏实例列表不对: %+v", list)
}
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/display/instances", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读大屏实例应 403,得到 %d", status)
}
}
@@ -67,6 +67,7 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
if err := s.recomputeAllEvents(r.Context(), middlewareUserID(r)); err != nil {
return nil, internal(err)
}
s.audit(r.Context(), "rules.import", "rules", 0, nil, res)
return res, nil
}
@@ -101,6 +102,11 @@ func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) error {
_ = tx.Rollback()
return err
}
// 重算改动了积分,必须写版本号并推送,否则大屏停在旧比分。
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
+34 -11
View File
@@ -5,9 +5,13 @@ import (
"database/sql"
"encoding/json"
"errors"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
// queryer 让计分相关的加载函数既能走连接池(只读)也能走事务(发布时读写一致)。
@@ -224,16 +228,13 @@ func loadResultsQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]
}
func loadRecordBonusQ(ctx context.Context, q queryer) (domain.RecordBonusConfig, error) {
cfg := domain.RecordBonusConfig{Mode: domain.RecordBonusMax}
values, err := loadSettingsMap(ctx, q)
if err != nil {
return cfg, err
return domain.RecordBonusConfig{}, err
}
cfg.Enabled = values["record_bonus.enabled"] == "true"
if values["record_bonus.mode"] == string(domain.RecordBonusSum) {
cfg.Mode = domain.RecordBonusSum
}
return cfg, nil
// 统一走 rules 的解析:enabled / mode / levels 三个键必须一起读,
// 只读前两个会让 levelPoints 永远查不到档位,破纪录加分恒为 0。
return rules.RecordBonusFromSettings(values), nil
}
func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain.RecordBreak, error) {
@@ -328,8 +329,9 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64
}
// recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。
// 作废范围是引擎产出的成绩分与破纪录加分;人工调整由 point_adjustments 管理,绝不在此作废。
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
scope := `source_type = 'result' AND event_id = ?`
scope := `source_type IN ('result', 'record_bonus') AND event_id = ?`
args := []any{eventID}
if categoryID > 0 {
scope += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
@@ -390,6 +392,9 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
catID = *in.CategoryID
}
// 发布是不可逆的对外动作,先留一份快照;失败只记日志与审计,不阻塞发布(08 第 6 节)。
s.backupBeforePublish(ctx)
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
@@ -436,7 +441,7 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil {
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
@@ -447,6 +452,24 @@ func (s *server) publishResults(ctx context.Context, in publishPayload, userID i
return s.resultsByEvent(ctx, in.EventID)
}
// backupBeforePublish 在发布前留一份快照;失败只记日志与审计,不阻塞发布。
func (s *server) backupBeforePublish(ctx context.Context) {
if strings.TrimSpace(s.dataDir) == "" {
return
}
info, err := export.BackupBeforePublish(s.ev, export.BackupOptions{
Dir: filepath.Join(s.dataDir, "backups"),
Prefix: "event",
Reason: "publish",
})
if err != nil {
s.logger.Error("发布前备份失败", "err", err)
s.audit(ctx, "system.backup_failed", "backup", 0, nil, map[string]any{"reason": "publish", "error": err.Error()})
return
}
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"reason": "publish", "path": info.Path})
}
func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
@@ -495,7 +518,7 @@ func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": in.EventID}, userID); err != nil {
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
@@ -566,7 +589,7 @@ func (s *server) voidResult(ctx context.Context, resultID int64, reason string,
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil {
return resultDTO{}, err
}
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"eventId": eventID}, userID); err != nil {
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return resultDTO{}, err
}
if err := tx.Commit(); err != nil {
+27 -16
View File
@@ -33,21 +33,27 @@ type Options struct {
DataDir string
// Display 可选,实时层接入后传入。
Display DisplaySender
// Realtime 可选,实时层接入后传入:写事务提交后推送变更,并提供在线大屏实例。
Realtime Realtime
// WS 可选,实时层的 WebSocket 处理器,挂到 /ws。
WS http.Handler
// ProjectDir 可选,当前赛事项目目录(含 uploads/),用于赛事包导出。
ProjectDir string
// WebFS 可选,默认使用内嵌前端资源。
WebFS fs.FS
}
type server struct {
logger *slog.Logger
version string
sys *sql.DB
ev *sql.DB
auth *auth.Service
dataDir string
display DisplaySender
idem *idempotencyStore
logger *slog.Logger
version string
sys *sql.DB
ev *sql.DB
auth *auth.Service
dataDir string
projectDir string
display DisplaySender
realtime Realtime
idem *idempotencyStore
}
// New 组装并返回服务端的 HTTP 处理器。
@@ -62,14 +68,16 @@ func New(opts Options) http.Handler {
}
s := &server{
logger: logger,
version: opts.Version,
sys: opts.System,
ev: opts.Event,
auth: svc,
dataDir: opts.DataDir,
display: opts.Display,
idem: newIdempotencyStore(10 * time.Minute),
logger: logger,
version: opts.Version,
sys: opts.System,
ev: opts.Event,
auth: svc,
dataDir: opts.DataDir,
projectDir: opts.ProjectDir,
display: opts.Display,
realtime: opts.Realtime,
idem: newIdempotencyStore(10 * time.Minute),
}
mux := http.NewServeMux()
@@ -94,9 +102,11 @@ func New(opts Options) http.Handler {
}
var h http.Handler = mux
h = s.broadcastChanges(h)
h = middleware.NewAuditor(opts.Event, logger).Middleware(h)
h = middleware.RateLimitWrite(svc)(h)
h = middleware.CSRF(h)
h = middleware.MustChangePassword(h)
h = middleware.Authenticate(svc)(h)
h = middleware.Log(logger)(h)
h = middleware.Recover(logger)(h)
@@ -205,6 +215,7 @@ func (s *server) routes(mux *http.ServeMux) {
// 大屏控制与运维
mux.HandleFunc("POST /api/display/command", s.write(auth.RoleRecorder, s.displayCommandHandler))
mux.HandleFunc("GET /api/display/instances", s.read(auth.RoleRecorder, s.displayInstancesHandler))
mux.HandleFunc("GET /api/audit-logs", s.read(auth.RoleAdmin, s.listAuditLogsHandler))
mux.HandleFunc("POST /api/backup", s.write(auth.RoleAdmin, s.backupHandler))
+12 -3
View File
@@ -32,6 +32,12 @@ type testEnv struct {
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWith(t, nil)
}
// newTestEnvWith 在默认环境基础上允许测试注入 Options(例如实时层)。
func newTestEnvWith(t *testing.T, customize func(*httpapi.Options)) *testEnv {
t.Helper()
dir := t.TempDir()
@@ -60,15 +66,18 @@ func newTestEnv(t *testing.T) *testEnv {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
svc := auth.New(st.DB(), auth.Options{Logger: logger, BcryptCost: bcrypt.MinCost})
handler := httpapi.New(httpapi.Options{
opts := httpapi.Options{
Logger: logger,
Version: "test",
System: st.DB(),
Event: ev,
Auth: svc,
DataDir: filepath.Join(dir, "data"),
})
srv := httptest.NewServer(handler)
}
if customize != nil {
customize(&opts)
}
srv := httptest.NewServer(httpapi.New(opts))
t.Cleanup(srv.Close)
return &testEnv{t: t, server: srv, sys: st.DB(), ev: ev, auth: svc}
+79 -10
View File
@@ -3,9 +3,11 @@ package middleware
import (
"context"
"fmt"
"net"
"net/http"
"strings"
"sync/atomic"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
)
@@ -39,19 +41,86 @@ func GetRequestID(ctx context.Context) string {
return ""
}
// ClientIP 从代理头或连接地址解析客户端 IP。
func ClientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if first := strings.TrimSpace(strings.Split(xff, ",")[0]); first != "" {
return first
// trustedProxies 是可信代理网段白名单;默认空,表示任何代理头都不可信。
var trustedProxies atomic.Pointer[[]*net.IPNet]
// SetTrustedProxies 配置可信代理(IP 或 CIDR 列表);传空表示不信任任何代理头。
func SetTrustedProxies(entries []string) error {
nets := make([]*net.IPNet, 0, len(entries))
for _, raw := range entries {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
if strings.Contains(entry, "/") {
_, n, err := net.ParseCIDR(entry)
if err != nil {
return fmt.Errorf("middleware: 非法可信代理 %q: %w", entry, err)
}
nets = append(nets, n)
continue
}
ip := net.ParseIP(entry)
if ip == nil {
return fmt.Errorf("middleware: 非法可信代理 %q", entry)
}
bits := 128
if ip.To4() != nil {
bits = 32
}
nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
trustedProxies.Store(&nets)
return nil
}
func isTrustedProxy(ip net.IP) bool {
nets := trustedProxies.Load()
if nets == nil || ip == nil {
return false
}
for _, n := range *nets {
if n.Contains(ip) {
return true
}
}
if rip := strings.TrimSpace(r.Header.Get("X-Real-IP")); rip != "" {
return rip
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
return false
}
func remoteHost(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return r.RemoteAddr
return addr
}
return host
}
// ClientIP 解析客户端 IP。
// 只有当直连对端(RemoteAddr)属于可信代理白名单时才采信 X-Forwarded-For / X-Real-IP,
// 否则一律用 RemoteAddr——代理头是客户端可伪造的,据此限流等于没有限流。
func ClientIP(r *http.Request) string {
remote := net.ParseIP(remoteHost(r.RemoteAddr))
if remote == nil || !isTrustedProxy(remote) {
return remoteHost(r.RemoteAddr)
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
// 从右往左取第一个非代理地址:这是可信代理链上离服务端最近的真实客户端。
for i := len(parts) - 1; i >= 0; i-- {
ip := net.ParseIP(strings.TrimSpace(parts[i]))
if ip == nil || isTrustedProxy(ip) {
continue
}
return ip.String()
}
// 整条链路都是可信代理时,最左侧即原始客户端。
if ip := net.ParseIP(strings.TrimSpace(parts[0])); ip != nil {
return ip.String()
}
}
if ip := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ip != nil {
return ip.String()
}
return remoteHost(r.RemoteAddr)
}
@@ -0,0 +1,45 @@
package middleware
import (
"net/http/httptest"
"testing"
)
// 默认不信任任何代理头:X-Forwarded-For 不能改变客户端 IP,否则登录限流可被绕过。
func TestClientIPIgnoresForwardedHeadersByDefault(t *testing.T) {
t.Cleanup(func() { _ = SetTrustedProxies(nil) })
if err := SetTrustedProxies(nil); err != nil {
t.Fatalf("清空可信代理: %v", err)
}
r := httptest.NewRequest("POST", "/api/auth/login", nil)
r.RemoteAddr = "127.0.0.1:54321"
r.Header.Set("X-Forwarded-For", "203.0.113.9")
r.Header.Set("X-Real-IP", "203.0.113.10")
if got := ClientIP(r); got != "127.0.0.1" {
t.Fatalf("默认应使用 RemoteAddr,得到 %q", got)
}
}
// 仅当直连对端在可信代理白名单内时才采信代理头,并从右往左取第一个非代理地址。
func TestClientIPTrustsForwardedHeadersOnlyFromProxies(t *testing.T) {
t.Cleanup(func() { _ = SetTrustedProxies(nil) })
if err := SetTrustedProxies([]string{"10.0.0.0/8"}); err != nil {
t.Fatalf("设置可信代理: %v", err)
}
r := httptest.NewRequest("POST", "/api/auth/login", nil)
r.RemoteAddr = "10.0.0.5:12345"
r.Header.Set("X-Forwarded-For", "198.51.100.7, 10.0.0.6")
if got := ClientIP(r); got != "198.51.100.7" {
t.Fatalf("可信代理应解析出真实客户端,得到 %q", got)
}
// 直连对端不可信时,即便伪造代理头也以 RemoteAddr 为准。
r.RemoteAddr = "203.0.113.1:12345"
if got := ClientIP(r); got != "203.0.113.1" {
t.Fatalf("不可信对端应使用 RemoteAddr,得到 %q", got)
}
}
+23
View File
@@ -131,6 +131,29 @@ func RequireWrite(need auth.Role) func(http.Handler) http.Handler {
}
}
// MustChangePassword 阻止「必须修改密码」的账号执行写操作。
// 只放行改密与登出:否则用户会被永久锁在无法改密的死循环里(见 devdocs/08 第 1 节)。
func MustChangePassword(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := User(r.Context())
if u.Role == auth.RoleGuest || !u.MustChangePassword {
next.ServeHTTP(w, r)
return
}
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
switch r.URL.Path {
case "/api/auth/password", "/api/auth/logout":
next.ServeHTTP(w, r)
return
}
writeError(w, http.StatusForbidden, "AUTH_REQUIRED", "请先修改初始密码", nil)
})
}
// CSRF 校验双提交 Cookie:已登录的写请求必须带 X-CSRF-Token 且与 csrf_token Cookie 一致。
func CSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+4
View File
@@ -8,7 +8,11 @@ import (
)
// currentVersion 返回服务端版本号,即 change_log 的最大 id;空表为 0。
// NewHub 允许 db 为 nil(降级/测试),此时退回内存版本,避免空指针 panic。
func (h *Hub) currentVersion(ctx context.Context) (int64, error) {
if h.db == nil {
return h.Version(), nil
}
var v sql.NullInt64
if err := h.db.QueryRowContext(ctx, `SELECT MAX(id) FROM change_log`).Scan(&v); err != nil {
return 0, fmt.Errorf("读取 change_log 版本号: %w", err)
+80 -4
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
@@ -17,6 +18,8 @@ import (
"github.com/coder/websocket"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
@@ -28,12 +31,22 @@ type testEnv struct {
srv *httptest.Server
}
// newTestEnv 建一个未登录(guest)的服务端:默认身份与大屏端一致。
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
return newTestEnvAs(t, &auth.User{Role: auth.RoleGuest})
}
// newTestEnvAs 注入会话身份,等价于真实链路里 Authenticate 中间件写进 context 的 User。
func newTestEnvAs(t *testing.T, u *auth.User) *testEnv {
t.Helper()
db := newTestDB(t)
h := NewHub(db, slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})))
srv := httptest.NewServer(h.Handler())
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)))
}))
t.Cleanup(func() {
srv.Close()
@@ -165,6 +178,24 @@ func (c *wsClient) recvEvent(want string) json.RawMessage {
return nil
}
// expectClosed 断言连接在 d 内被服务端关闭,且关闭前没有下发任何事件。
func (c *wsClient) expectClosed(d time.Duration) {
c.t.Helper()
timer := time.NewTimer(d)
defer timer.Stop()
for {
select {
case msg, ok := <-c.msgs:
if !ok || msg.err != nil {
return
}
c.t.Fatalf("连接关闭前仍收到事件 %s", msg.event)
case <-timer.C:
c.t.Fatalf("等待服务端关闭连接超时")
}
}
}
func decode[T any](t *testing.T, raw json.RawMessage) T {
t.Helper()
var v T
@@ -458,12 +489,13 @@ func TestDisplayInstancesRegistry(t *testing.T) {
})
}
// 房间隔离:admin:<userId> 只发给对应管理端。
// 房间隔离:admin:<userId> 只发给对应管理端,且 userId 来自会话而非客户端自报。
func TestAdminRoomTargeting(t *testing.T) {
env := newTestEnv(t)
env := newTestEnvAs(t, &auth.User{ID: 7, Username: "boss", Role: auth.RoleAdmin, Active: true})
display := env.dial(t, &helloPayload{Role: RoleDisplay, InstanceID: "d1"})
display.recvEvent(EventSnapshot)
admin := env.dial(t, &helloPayload{Role: RoleAdmin, UserID: 7})
// hello 不再携带 userId:定向房间必须用会话里的 ID。
admin := env.dial(t, &helloPayload{Role: RoleAdmin})
admin.recvEvent(EventSnapshot)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
@@ -478,6 +510,50 @@ func TestAdminRoomTargeting(t *testing.T) {
}
}
// guest 自报 role=admin 必须被拒绝:管理端房间只能由会话授予。
func TestGuestCannotSubscribeAdminRoom(t *testing.T) {
env := newTestEnv(t)
c := env.dial(t, &helloPayload{Role: RoleAdmin})
c.expectClosed(3 * time.Second)
}
// guest 只能订阅 display 房间:即使 hello 里塞了 userId 也不能进入定向房间。
func TestGuestStaysInDisplayRoom(t *testing.T) {
env := newTestEnv(t)
c := env.dial(t, nil)
c.send(EventHello, map[string]any{"role": RoleDisplay, "userId": 99})
c.recvEvent(EventSnapshot)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
changes := loadChanges(t, env.hub, 0, 0)
env.hub.BroadcastTo(RoomAdmin, changes)
env.hub.BroadcastTo(AdminRoom(99), changes)
if ev, _, ok := c.recvWithin(300 * time.Millisecond); ok {
t.Fatalf("guest 不应收到管理端房间的消息,收到 %s", ev)
}
// display 房间的消息仍然正常到达。
env.hub.BroadcastTo(RoomDisplay, changes)
if got := decode[changePayload](t, c.recvEvent(EventChange)); got.Version != 1 {
t.Fatalf("大屏应收到 display 房间消息,版本得到 %d", got.Version)
}
}
// recorder 会话可以进管理端房间(大屏控制/数据同步都由管理端承担)。
func TestRecorderSessionJoinsAdminRoom(t *testing.T) {
env := newTestEnvAs(t, &auth.User{ID: 3, Username: "rec", Role: auth.RoleRecorder, Active: true})
c := env.dial(t, &helloPayload{Role: RoleAdmin})
c.recvEvent(EventSnapshot)
insertChange(t, env.db, "result", 1, ActionUpsert, `{"rank":1}`)
env.hub.BroadcastTo(RoomAdmin, loadChanges(t, env.hub, 0, 0))
if got := decode[changePayload](t, c.recvEvent(EventChange)); got.Version != 1 {
t.Fatalf("recorder 应进入管理端房间,版本得到 %d", got.Version)
}
}
// 慢客户端:ack 落后超过阈值即被识别并断开。
func TestSlowClientDetected(t *testing.T) {
env := newTestEnv(t)
+408 -38
View File
@@ -7,7 +7,7 @@ import (
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
)
// 快照必须与数据库同源:版本号取自 change_log,各列表取真实表,
@@ -16,21 +16,17 @@ func TestSnapshotContents(t *testing.T) {
env := newTestEnv(t)
db := env.db
mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `"测试运动会"`)
mustExec(t, db, `INSERT INTO settings(key, value) VALUES ('display.theme', 'dark')`)
mustExec(t, db, `INSERT INTO teams(code, name, short_name, sort_order, active) VALUES ('T1', '第一单位', '一队', 1, 1)`)
mustExec(t, db, `INSERT INTO sports(code, name) VALUES ('S1', '田径')`)
mustExec(t, db, `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米', 'individual', 'auto', 'time', 'asc', 'published')`)
mustExec(t, db, `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`)
mustExec(t, db, `INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`)
mustExec(t, db, `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`)
mustExec(t, db, `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status)
VALUES (1, 1, 1, '11.58', 11.58, 's', 'published', 'ok')`)
mustExec(t, db, `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'result', 1, 9)`)
mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `测试运动会`)
team1 := insertTeam(t, db, "T1", "第一单位", "一队")
event1 := insertEvent(t, db, "E1", "100米")
cat1 := insertCategory(t, db, event1, "男子")
athlete1 := insertAthlete(t, db, "张三", team1)
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 9)
mustExec(t, db, `INSERT INTO records(event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source)
VALUES (1, 'meet', 11.2, '11.20', '李四', 1, '2025-09-01', 1, 'history')`)
VALUES (?, 'meet', 11.2, '11.20', '李四', ?, '2025-09-01', 1, 'history')`, event1, team1)
version := insertChange(t, db, "result", 1, ActionUpsert, `{"rank":1}`)
snap, err := env.hub.Snapshot(context.Background())
@@ -40,11 +36,8 @@ func TestSnapshotContents(t *testing.T) {
if snap.Version != version {
t.Fatalf("快照版本应为 %d,得到 %d", version, snap.Version)
}
if got := string(snap.Settings["event.name"]); got != `"测试运动会"` {
t.Fatalf("settings JSON 值应透传,得到 %s", got)
}
if got := string(snap.Settings["display.theme"]); got != `"dark"` {
t.Fatalf("settings 纯文本应编码为 JSON 字符串,得到 %s", got)
if snap.Settings.Event.Name != "测试运动会" {
t.Fatalf("settings 应聚合出嵌套 event.name,得到 %+v", snap.Settings.Event)
}
if len(snap.Teams) != 1 || snap.Teams[0].Code != "T1" || !snap.Teams[0].Active {
@@ -53,28 +46,295 @@ func TestSnapshotContents(t *testing.T) {
if len(snap.Events) != 1 {
t.Fatalf("小项数应为 1,得到 %d", len(snap.Events))
}
ev := snap.Events[0]
if ev.Name != "100米" || ev.EventType != domain.EventTypeIndividual || !ev.Scored || ev.Compare != domain.CompareAsc {
t.Fatalf("小项不符: %+v", ev)
}
if len(snap.Standings) != 1 {
t.Fatalf("榜单行数应为 1,得到 %d", len(snap.Standings))
}
row := snap.Standings[0]
if row.TeamID != 1 || row.Points != 9 || row.Gold != 1 || row.Rank != 1 {
t.Fatalf("榜单不符: %+v", row)
}
if len(snap.Records) != 1 || snap.Records[0].HolderName != "李四" || snap.Records[0].ResultValue == nil {
t.Fatalf("纪录不符: %+v", snap.Records)
}
if len(snap.LatestResults) != 1 {
t.Fatalf("最新成绩数应为 1,得到 %d", len(snap.LatestResults))
}
// 快照的线上 JSON 形状必须与 devdocs/05 第 3 节、web/src/shared/types.ts 一致:
// settings 是嵌套对象、standings 用 totalPoints/teamCode/…、latestResults 按小项+组别分组。
// 只断言 Go 字段会漏掉 json 标签写错这类线上事故,所以这里直接检查序列化结果。
func TestSnapshotWireContract(t *testing.T) {
env := newTestEnv(t)
db := env.db
mustExec(t, db, `UPDATE settings SET value = ? WHERE key = 'event.name'`, `测试运动会`)
team1 := insertTeam(t, db, "T1", "第一单位", "一队")
event1 := insertEvent(t, db, "E1", "男子 100 米")
cat1 := insertCategory(t, db, event1, "青年组")
athlete1 := insertAthlete(t, db, "张伟", team1)
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 9)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
lr := snap.LatestResults[0]
if lr.ResultText != "11.58" || lr.TeamID != 1 || lr.AthleteID != 1 || lr.Rank != 1 || lr.State != "published" {
t.Fatalf("最新成绩不符: %+v", lr)
raw, err := json.Marshal(snap)
if err != nil {
t.Fatalf("序列化快照: %v", err)
}
var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil {
t.Fatalf("解析快照 JSON: %v", err)
}
// settings 必须是嵌套 EventSettings,不能再是点号扁平 map。
var settings map[string]json.RawMessage
if err := json.Unmarshal(top["settings"], &settings); err != nil {
t.Fatalf("settings 不是对象: %s", top["settings"])
}
if _, flat := settings["event.name"]; flat {
t.Fatalf("settings 仍是点号扁平 map: %s", top["settings"])
}
for _, key := range []string{"event", "display", "standings", "defaultProfileId", "requireApproval"} {
if _, ok := settings[key]; !ok {
t.Fatalf("settings 缺少嵌套字段 %s: %s", key, top["settings"])
}
}
var parsed dto.EventSettings
if err := json.Unmarshal(top["settings"], &parsed); err != nil {
t.Fatalf("按 EventSettings 解析失败: %v", err)
}
if parsed.Event.Name != "测试运动会" || parsed.Display.Title != "测试运动会" {
t.Fatalf("settings 默认值口径不符: %+v", parsed)
}
if parsed.Display.RotateSecond != 15 || parsed.Standings.IndividualToTeamWeight != 1 {
t.Fatalf("settings 默认值应为 REST 的 15s / 系数 1: %+v", parsed)
}
if parsed.DefaultProfileID == nil || *parsed.DefaultProfileID != 1 {
t.Fatalf("defaultProfileId 应由 scoring.default_profile 反查得到: %+v", parsed.DefaultProfileID)
}
// standings 必须是 totalPoints/teamCode/teamName/medalTotal/rankChange。
var rows []map[string]json.RawMessage
if err := json.Unmarshal(top["standings"], &rows); err != nil {
t.Fatalf("standings 不是数组: %s", top["standings"])
}
if len(rows) != 1 {
t.Fatalf("榜单行数应为 1,得到 %d", len(rows))
}
for _, key := range []string{"teamId", "teamCode", "teamName", "totalPoints", "medalTotal", "rankChange", "gold", "rank"} {
if _, ok := rows[0][key]; !ok {
t.Fatalf("standings 缺少契约字段 %s: %s", key, top["standings"])
}
}
for _, banned := range []string{"points", "code", "name", "medals"} {
if _, ok := rows[0][banned]; ok {
t.Fatalf("standings 仍是 domain.StandingsRow 的字段 %s: %s", banned, top["standings"])
}
}
var typed []dto.StandingRow
if err := json.Unmarshal(top["standings"], &typed); err != nil {
t.Fatalf("按 StandingRow 解析失败: %v", err)
}
if typed[0].TeamCode != "T1" || typed[0].TotalPoints != 9 || typed[0].MedalTotal != 1 {
t.Fatalf("榜单内容不符: %+v", typed[0])
}
if typed[0].RankChange != nil {
t.Fatalf("快照没有对比基准,rankChange 应为 null: %+v", typed[0].RankChange)
}
// latestResults 必须按小项 + 组别分组,且行里带契约字段。
var groups []map[string]json.RawMessage
if err := json.Unmarshal(top["latestResults"], &groups); err != nil {
t.Fatalf("latestResults 不是数组: %s", top["latestResults"])
}
if len(groups) != 1 {
t.Fatalf("分组数应为 1,得到 %d: %s", len(groups), top["latestResults"])
}
for _, key := range []string{"eventId", "eventCode", "eventName", "categoryId", "categoryName", "publishedAt", "rows"} {
if _, ok := groups[0][key]; !ok {
t.Fatalf("成绩分组缺少契约字段 %s: %s", key, top["latestResults"])
}
}
var typedGroups []dto.EventResultGroup
if err := json.Unmarshal(top["latestResults"], &typedGroups); err != nil {
t.Fatalf("按 EventResultGroup 解析失败: %v", err)
}
resultRows := typedGroups[0].Rows
if len(resultRows) != 1 || resultRows[0].EntryID != entry1 || resultRows[0].TeamCode != "T1" ||
resultRows[0].AthleteName == nil || *resultRows[0].AthleteName != "张伟" {
t.Fatalf("成绩行不符: %+v", resultRows)
}
}
// 同一小项 + 组别的多条成绩必须落进同一组,不同组别/小项拆成不同组,组内按名次排序。
func TestSnapshotLatestResultsGrouped(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
team2 := insertTeam(t, db, "T2", "第二单位", "")
event1 := insertEvent(t, db, "E1", "男子 100 米")
event2 := insertEvent(t, db, "E2", "女子 100 米")
catA := insertCategory(t, db, event1, "青年组")
catB := insertCategory(t, db, event1, "中年组")
catC := insertCategory(t, db, event2, "青年组")
a1 := insertAthlete(t, db, "张伟", team1)
a2 := insertAthlete(t, db, "李强", team2)
a3 := insertAthlete(t, db, "王磊", team1)
a4 := insertAthlete(t, db, "李娜", team2)
e1 := insertEntry(t, db, event1, catA, team1, a1)
e2 := insertEntry(t, db, event1, catA, team2, a2)
e3 := insertEntry(t, db, event1, catB, team1, a3)
e4 := insertEntry(t, db, event2, catC, team2, a4)
// 故意先写名次靠后的行,验证组内会按名次重排而不是按取数顺序。
insertResult(t, db, event1, e2, 2, "11.72", 11.72, "2026-09-09T10:00:02Z")
insertResult(t, db, event1, e1, 1, "11.58", 11.58, "2026-09-09T10:00:01Z")
insertResult(t, db, event1, e3, 1, "12.30", 12.30, "2026-09-09T10:00:03Z")
insertResult(t, db, event2, e4, 1, "13.04", 13.04, "2026-09-09T10:00:04Z")
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.LatestResults) != 3 {
t.Fatalf("分组数应为 3,得到 %d: %+v", len(snap.LatestResults), snap.LatestResults)
}
byCategory := make(map[int64]dto.EventResultGroup, len(snap.LatestResults))
for _, g := range snap.LatestResults {
byCategory[g.CategoryID] = g
}
groupA, ok := byCategory[catA]
if !ok {
t.Fatalf("缺少青年组分组: %+v", snap.LatestResults)
}
if groupA.EventID != event1 || groupA.EventCode != "E1" || groupA.EventName != "男子 100 米" || groupA.CategoryName != "青年组" {
t.Fatalf("分组元信息不符: %+v", groupA)
}
if groupA.PublishedAt == nil || *groupA.PublishedAt != "2026-09-09T10:00:02Z" {
t.Fatalf("分组发布时间应取组内最新一条: %+v", groupA.PublishedAt)
}
if len(groupA.Rows) != 2 {
t.Fatalf("青年组应有 2 行,得到 %d: %+v", len(groupA.Rows), groupA.Rows)
}
if groupA.Rows[0].EntryID != e1 || groupA.Rows[1].EntryID != e2 {
t.Fatalf("组内应按名次排序: %+v", groupA.Rows)
}
if groupA.Rows[0].TeamCode != "T1" || groupA.Rows[0].TeamName != "第一单位" || groupA.Rows[0].ResultText != "11.58" {
t.Fatalf("成绩行字段不符: %+v", groupA.Rows[0])
}
if len(byCategory[catB].Rows) != 1 || len(byCategory[catC].Rows) != 1 {
t.Fatalf("其余组别应各自成组: %+v", snap.LatestResults)
}
}
// 快照榜单口径必须与 REST 一致:个人流水按系数折算进单位总分。
func TestSnapshotStandingsAppliesIndividualWeight(t *testing.T) {
env := newTestEnv(t)
db := env.db
mustExec(t, db, `UPDATE settings SET value = '0.5' WHERE key = 'standings.individual_to_team_weight'`)
team1 := insertTeam(t, db, "T1", "第一单位", "")
team2 := insertTeam(t, db, "T2", "第二单位", "")
event1 := insertEvent(t, db, "E1", "100米")
cat1 := insertCategory(t, db, event1, "男子")
athlete1 := insertAthlete(t, db, "张三", team1)
entry1 := insertEntry(t, db, event1, cat1, team1, athlete1)
insertChannel(t, db, event1, "athlete")
result1 := insertResult(t, db, event1, entry1, 1, "11.58", 11.58, "2026-09-09T10:00:00Z")
// 团体报名项没有运动员,流水只记单位。
cat2 := insertCategory(t, db, event1, "团体")
entry2 := insertEntry(t, db, event1, cat2, team2, 0)
result2 := insertResult(t, db, event1, entry2, 1, "45.20", 45.20, "2026-09-09T10:00:01Z")
// 个人流水 10 分按 0.5 折算成 5;仅记单位的流水不受系数影响。
insertLedger(t, db, team1, athlete1, event1, entry1, result1, 10)
insertLedger(t, db, team2, 0, event1, entry2, result2, 4)
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
points := make(map[string]float64, len(snap.Standings))
for _, row := range snap.Standings {
points[row.TeamCode] = row.TotalPoints
}
if points["T1"] != 5 {
t.Fatalf("个人流水应按系数 0.5 折算,T1 应得 5 分,得到 %v", points)
}
if points["T2"] != 4 {
t.Fatalf("仅记单位的流水不应折算,T2 应得 4 分,得到 %v", points)
}
if len(snap.Standings) == 0 || snap.Standings[0].TeamCode != "T1" {
t.Fatalf("按总分排序应 T1 在前: %+v", snap.Standings)
}
}
// 团体奖牌开关与 REST 一致:关闭后团体项目奖牌不计入单位奖牌榜。
func TestSnapshotStandingsMedalSwitch(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
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")
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.Standings) != 1 || snap.Standings[0].Gold != 1 {
t.Fatalf("默认应计入团体项目奖牌: %+v", snap.Standings)
}
mustExec(t, db, `UPDATE settings SET value = 'false' WHERE key = 'standings.medal_counts_team_events'`)
snap, err = env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
if len(snap.Standings) != 1 || snap.Standings[0].Gold != 0 || snap.Standings[0].MedalTotal != 0 {
t.Fatalf("关闭团体奖牌后应清零: %+v", snap.Standings)
}
}
// 只统计配了计分通道的项目,且多通道不会把同一枚奖牌重复计入。
func TestSnapshotStandingsOnlyScoredChannels(t *testing.T) {
env := newTestEnv(t)
db := env.db
team1 := insertTeam(t, db, "T1", "第一单位", "")
team2 := insertTeam(t, db, "T2", "第二单位", "")
// event1 没有计分通道:即使有已发布名次也不应产生奖牌。
event1 := insertEvent(t, db, "E1", "表演项目")
cat1 := insertCategory(t, db, event1, "男子")
entry1 := insertEntry(t, db, event1, cat1, team1, 0)
insertResult(t, db, event1, entry1, 1, "1.00", 1, "2026-09-09T10:00:00Z")
// event2 配了计分通道:名次才会计入奖牌榜。
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")
snap, err := env.hub.Snapshot(context.Background())
if err != nil {
t.Fatalf("构建快照: %v", err)
}
medals := make(map[string]int, len(snap.Standings))
for _, row := range snap.Standings {
medals[row.TeamCode] = row.Gold
}
if medals["T1"] != 0 {
t.Fatalf("无计分通道的项目不应计奖牌,得到 %v", medals)
}
if medals["T2"] != 1 {
t.Fatalf("有计分通道的项目应计入奖牌,得到 %v", medals)
}
}
@@ -93,6 +353,16 @@ func TestEmptySnapshotMarshalsEmptyLists(t *testing.T) {
t.Fatalf("空快照应包含 %s,实际 %s", want, raw)
}
}
// 空库也要下发结构完整的 settings,前端才能安全地读 settings.event.name。
var parsed struct {
Settings dto.EventSettings `json:"settings"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("解析空快照: %v", err)
}
if parsed.Settings.Event.Name == "" || parsed.Settings.Display.RotateSecond == 0 {
t.Fatalf("空快照 settings 应带默认值: %+v", parsed.Settings)
}
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
@@ -101,3 +371,103 @@ func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
t.Fatalf("执行 SQL %q: %v", q, err)
}
}
// ---------- 造数助手 ----------
func insertTeam(t *testing.T, db *sql.DB, code, name, shortName string) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO teams(code, name, short_name, sort_order, active) VALUES (?, ?, ?, 0, 1)`,
code, name, shortName)
if err != nil {
t.Fatalf("插入单位 %s: %v", code, err)
}
return lastID(t, res)
}
func insertEvent(t *testing.T, db *sql.DB, code, name string) int64 {
t.Helper()
if _, err := db.Exec(`INSERT OR IGNORE INTO sports(code, name) VALUES ('S1', '田径')`); err != nil {
t.Fatalf("插入大项: %v", err)
}
res, err := db.Exec(`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, ?, ?, 'individual', 'auto', 'time', 'asc', 'published')`, code, name)
if err != nil {
t.Fatalf("插入小项 %s: %v", code, err)
}
return lastID(t, res)
}
func insertCategory(t *testing.T, db *sql.DB, eventID int64, name string) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO event_categories(event_id, name) VALUES (?, ?)`, eventID, name)
if err != nil {
t.Fatalf("插入组别 %s: %v", name, err)
}
return lastID(t, res)
}
func insertAthlete(t *testing.T, db *sql.DB, name string, teamID int64) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO athletes(name, gender, team_id) VALUES (?, '男', ?)`, name, teamID)
if err != nil {
t.Fatalf("插入运动员 %s: %v", name, err)
}
return lastID(t, res)
}
// insertEntry 在 athleteID 为 0 时写 NULL,表示团体项目报名项。
func insertEntry(t *testing.T, db *sql.DB, eventID, categoryID, teamID, athleteID int64) int64 {
t.Helper()
var athlete any
if athleteID != 0 {
athlete = athleteID
}
res, err := db.Exec(`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (?, ?, ?, ?, '101')`,
eventID, categoryID, teamID, athlete)
if err != nil {
t.Fatalf("插入报名项: %v", err)
}
return lastID(t, res)
}
func insertChannel(t *testing.T, db *sql.DB, eventID int64, scope string) {
t.Helper()
mustExec(t, db, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, ?, 'result', 1, 0)`, eventID, scope)
}
func insertResult(t *testing.T, db *sql.DB, eventID, entryID int64, rank int, text string, value float64, verifiedAt string) int64 {
t.Helper()
res, err := db.Exec(`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state, status, verified_at)
VALUES (?, ?, ?, ?, ?, 's', 'published', 'ok', ?)`, eventID, entryID, rank, text, value, verifiedAt)
if err != nil {
t.Fatalf("插入成绩: %v", err)
}
return lastID(t, res)
}
// insertLedger 写一条积分流水;athleteID 为 0 表示「仅记单位」。
func insertLedger(t *testing.T, db *sql.DB, teamID, athleteID, eventID, entryID, resultID int64, points float64) {
t.Helper()
var athlete, entry, result any
if athleteID != 0 {
athlete = athleteID
}
if entryID != 0 {
entry = entryID
}
if resultID != 0 {
result = resultID
}
mustExec(t, db, `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (?, ?, ?, ?, ?, 'result', 1, ?)`, teamID, athlete, eventID, entry, result, points)
}
func lastID(t *testing.T, res sql.Result) int64 {
t.Helper()
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("读取自增 id: %v", err)
}
return id
}
+4 -5
View File
@@ -55,7 +55,6 @@ func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreC
Scope: c.Scope,
Basis: c.Basis,
ProfileID: profileID,
Aggregate: c.Aggregate,
SortOrder: sortOrder,
}
}
@@ -63,10 +62,9 @@ func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreC
// ChannelFromDomain 把 domain 通道转回规则包类型;profileCode 由调用方按模板 ID 查出。
func ChannelFromDomain(c domain.ScoreChannel, profileCode string) Channel {
return Channel{
Scope: c.Scope,
Basis: c.Basis,
Profile: profileCode,
Aggregate: c.Aggregate,
Scope: c.Scope,
Basis: c.Basis,
Profile: profileCode,
}
}
@@ -82,6 +80,7 @@ func (p *RulePack) ProfileIndex() map[string]Profile {
// ToStandingsInput 把榜单口径映射成 domain 聚合输入。
func (s Standings) ToStandingsInput() domain.StandingsInput {
return domain.StandingsInput{
TieBreak: append([]string(nil), s.TieBreak...),
IndividualToTeamWeight: s.IndividualToTeamWeight,
ExcludeTeamEventMedals: !s.MedalCountsTeamEventsEnabled(),
}
+47
View File
@@ -181,6 +181,10 @@ func TestParseRejectsBadInput(t *testing.T) {
{"通道引用不存在的模板", strings.Replace(validPackYAML, "profile: individual_top6 }", "profile: ghost_top6 }", 1), ErrValidation},
{"排序键未知", strings.Replace(validPackYAML, "tieBreak: [points, gold, silver, bronze, teamCode]", "tieBreak: [points, platinum]", 1), ErrValidation},
{"启用破纪录却没有档位", strings.Replace(validPackYAML, "recordBonus:\n enabled: false\n mode: max\n levels:\n - { code: meet, name: 赛会纪录, points: 2 }", "recordBonus:\n enabled: true\n mode: max\n levels: []", 1), ErrValidation},
// aggregate 的汇总口径(avg/max/min/count)没有落库列,已从类型里移除;写了必须报错而不是静默丢弃。
{"通道 aggregate 口径已移除", strings.Replace(validPackYAML, "{ scope: team, basis: manual, profile: team_top6 }", "{ scope: team, basis: aggregate, profile: team_top6, aggregate: avg }", 1), nil},
// 联队归属未实现,规则包里不允许出现相关配置。
{"联队归属未实现", strings.Replace(validPackYAML, "standings:\n", "standings:\n unionAttribution: split\n", 1), nil},
}
for _, tc := range cases {
@@ -283,6 +287,9 @@ func TestProfileDomainConversion(t *testing.T) {
if in.IndividualToTeamWeight != 1 || in.ExcludeTeamEventMedals {
t.Errorf("榜单口径转换错误: %+v", in)
}
if !reflect.DeepEqual(in.TieBreak, pack.Standings.TieBreak) {
t.Errorf("tie-break 未传入聚合输入: %+v", in.TieBreak)
}
rb := pack.RecordBonus.ToDomain()
if rb.Enabled || rb.Mode != domain.RecordBonusMax || len(rb.Levels) != 1 {
@@ -292,3 +299,43 @@ func TestProfileDomainConversion(t *testing.T) {
t.Errorf("破纪录配置往返不一致: %+v", got)
}
}
// TestRecordBonusFromSettings 验证上层能从 settings 键值对读到破纪录档位:
// 只读 enabled/mode 而不读 levels,会让破纪录加分永远算 0。
func TestRecordBonusFromSettings(t *testing.T) {
settings := map[string]string{
settingRecordBonusEnabled: "true",
settingRecordBonusMode: "sum",
settingRecordBonusLevels: `[{"code":"meet","name":"赛会纪录","points":2},{"code":"city","name":"市级纪录","points":5}]`,
}
cfg := RecordBonusFromSettings(settings)
if !cfg.Enabled || cfg.Mode != domain.RecordBonusSum {
t.Fatalf("开关/口径错误: %+v", cfg)
}
if len(cfg.Levels) != 2 {
t.Fatalf("档位数 = %d,期望 2(levels 必须被解析)", len(cfg.Levels))
}
if cfg.Levels[1].Code != "city" || cfg.Levels[1].Points != 5 {
t.Fatalf("档位内容错误: %+v", cfg.Levels[1])
}
t.Run("缺省值", func(t *testing.T) {
cfg := RecordBonusFromSettings(map[string]string{})
if cfg.Enabled || cfg.Mode != domain.RecordBonusMax || len(cfg.Levels) != 0 {
t.Fatalf("缺省配置错误: %+v", cfg)
}
})
}
// TestRecordBonusSettingsRoundTrip 验证规则包写入 settings 后还能原样读回档位。
func TestRecordBonusSettingsRoundTrip(t *testing.T) {
pack, err := Parse([]byte(validPackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
got := RecordBonusFromSettings(settingsFromPack(pack))
want := pack.RecordBonus.ToDomain()
if !reflect.DeepEqual(got, want) {
t.Fatalf("settings 往返后破纪录配置不一致:\n%+v\n%+v", got, want)
}
}
+6
View File
@@ -90,6 +90,12 @@ func recordBonusFromSettings(m map[string]string) RecordBonus {
return rb
}
// RecordBonusFromSettings 从赛事库 settings 键值对还原破纪录加分配置(含档位)。
// 上层加载器必须经此函数读取 record_bonus.levels,否则档位缺失、破纪录加分恒为 0。
func RecordBonusFromSettings(m map[string]string) domain.RecordBonusConfig {
return recordBonusFromSettings(m).ToDomain()
}
func manualAdjustmentFromSettings(m map[string]string) ManualAdjustment {
return ManualAdjustment{
Enabled: settingBool(m, settingManualEnabled, false),
+13 -23
View File
@@ -9,13 +9,14 @@ const (
)
// 榜单排序键;规则包里写错键名要报错,而不是静默忽略。
// 取值来自 domain,保证领域层排序与规则包校验用的是同一套键名。
const (
TieBreakPoints = "points"
TieBreakGold = "gold"
TieBreakSilver = "silver"
TieBreakBronze = "bronze"
TieBreakTeamCode = "teamCode"
TieBreakName = "name"
TieBreakPoints = domain.TieBreakPoints
TieBreakGold = domain.TieBreakGold
TieBreakSilver = domain.TieBreakSilver
TieBreakBronze = domain.TieBreakBronze
TieBreakTeamCode = domain.TieBreakTeamCode
TieBreakName = domain.TieBreakName
)
// DefaultTieBreak 是未配置 tieBreak 时的口径:总分 → 金 → 银 → 铜 → 单位编码。
@@ -95,11 +96,11 @@ type Rule struct {
}
// Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。
// basis=aggregate 固定按同一主体成绩之和汇总;汇总口径不可配置(库里没有对应列)。
type Channel struct {
Scope domain.Scope `yaml:"scope" json:"scope"`
Basis domain.Basis `yaml:"basis" json:"basis"`
Profile string `yaml:"profile" json:"profile"`
Aggregate domain.AggregateMethod `yaml:"aggregate,omitempty" json:"aggregate,omitempty"`
Scope domain.Scope `yaml:"scope" json:"scope"`
Basis domain.Basis `yaml:"basis" json:"basis"`
Profile string `yaml:"profile" json:"profile"`
}
// ChannelMatch 是模板的适用条件;三个条件都为空时匹配全部(校验层会拒绝这种模板)。
@@ -188,31 +189,20 @@ func (p RulePack) Canonical() RulePack {
out.ChannelTemplates = make([]ChannelTemplate, len(p.ChannelTemplates))
for i, t := range p.ChannelTemplates {
out.ChannelTemplates[i] = t
out.ChannelTemplates[i].Channels = canonicalChannels(t.Channels)
out.ChannelTemplates[i].Channels = append([]Channel(nil), t.Channels...)
}
out.EventOverrides = make([]EventOverride, len(p.EventOverrides))
for i, o := range p.EventOverrides {
out.EventOverrides[i] = o
if o.Channels != nil {
cs := canonicalChannels(*o.Channels)
cs := append([]Channel(nil), (*o.Channels)...)
out.EventOverrides[i].Channels = &cs
}
}
return out
}
func canonicalChannels(in []Channel) []Channel {
out := make([]Channel, len(in))
for i, ch := range in {
out[i] = ch
if ch.Basis == domain.BasisAggregate && ch.Aggregate == "" {
out[i].Aggregate = domain.AggregateSum
}
}
return out
}
func boolOrDefault(p *bool, def bool) bool {
if p == nil {
return def
-9
View File
@@ -226,15 +226,6 @@ func validateChannels(l *issueList, path string, channels []Channel, profiles ma
} else if !profiles[ch.Profile] {
l.addf(cpath+".profile", "引用了不存在的积分模板 %q", ch.Profile)
}
switch ch.Aggregate {
case "":
case domain.AggregateSum, domain.AggregateAvg, domain.AggregateMax, domain.AggregateMin, domain.AggregateCount:
if ch.Basis != domain.BasisAggregate {
l.addf(cpath+".aggregate", "只有 basis=aggregate 的通道才配置 aggregate")
}
default:
l.addf(cpath+".aggregate", "未知的汇总口径 %q", ch.Aggregate)
}
// 落库有 UNIQUE(event_id, scope, basis) 约束,这里先挡住,避免导入时才炸。
key := string(ch.Scope) + "/" + string(ch.Basis)
if seen[key] {
+21 -8
View File
@@ -12,6 +12,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -21,6 +22,7 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/config"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
@@ -85,6 +87,15 @@ func main() {
hub := realtime.NewHub(eventDB, logger)
defer func() { _ = hub.Close() }()
// 可信代理:默认空,表示不采信任何代理头(见 devdocs/08 第 1 节)。
// 反向代理部署时用 SCOREBOARD_TRUSTED_PROXIES 指定代理 IP/CIDR,逗号分隔。
if raw := strings.TrimSpace(os.Getenv("SCOREBOARD_TRUSTED_PROXIES")); raw != "" {
if err := middleware.SetTrustedProxies(strings.Split(raw, ",")); err != nil {
logger.Error("可信代理配置非法", "err", err)
os.Exit(1)
}
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -102,14 +113,16 @@ func main() {
srv := &http.Server{
Addr: cfg.Server.Addr(),
Handler: httpapi.New(httpapi.Options{
Logger: logger,
Version: version,
System: st.DB(),
Event: eventDB,
Auth: authSvc,
DataDir: cfg.DataDir,
Display: displaySender{hub: hub},
WS: hub.Handler(),
Logger: logger,
Version: version,
System: st.DB(),
Event: eventDB,
Auth: authSvc,
DataDir: cfg.DataDir,
ProjectDir: filepath.Join(cfg.DataDir, "event_projs", *project),
Display: displaySender{hub: hub},
Realtime: hub,
WS: hub.Handler(),
}),
ReadHeaderTimeout: 10 * time.Second,
}