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:
1 parent
8dae63ee70
commit
7f9701c465
61 files changed
+3844
-333
No files matched your search
@@ -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 是构造「报名项 + 成绩」的最小描述。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 积分模板的一条明细:名次 → 分值。分值一律来自配置。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user