fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件
This commit is contained in:
1 parent
c121111f51
commit
b80c64a115
39 files changed
+4217
-345
No files matched your search
+2
-2
@@ -3,6 +3,8 @@ module git.yeij.top/AskaEth/Scoreboard/server
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/xuri/excelize/v2 v2.11.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.58.0
|
||||
)
|
||||
@@ -18,9 +20,7 @@ require (
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/excelize/v2 v2.11.0 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
|
||||
@@ -162,12 +162,13 @@ func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
|
||||
return out
|
||||
}
|
||||
|
||||
// RecomputeEventLedger 重算某个小项的流水:旧流水整项作废,再追加新流水。
|
||||
// RecomputeEventLedger 重算某个小项的成绩分与破纪录加分:旧流水整项作废,再追加新流水。
|
||||
// 人工调整不参与重算,原样保留——否则每重算一次就把人工扣/加分抹掉。
|
||||
// 新旧集合一致时原样返回——重复发布同一组必须幂等,不能凭空多出流水。
|
||||
func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerEntry, by, reason string) ([]LedgerEntry, error) {
|
||||
active := make([]LedgerEntry, 0, len(existing))
|
||||
for _, e := range existing {
|
||||
if e.EventID == eventID && !e.Voided {
|
||||
if e.EventID == eventID && !e.Voided && recomputableSource(e.SourceType) {
|
||||
active = append(active, e)
|
||||
}
|
||||
}
|
||||
@@ -175,10 +176,24 @@ func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerE
|
||||
return append([]LedgerEntry(nil), existing...), nil
|
||||
}
|
||||
|
||||
out, _ := VoidByEvent(existing, eventID, by, reason)
|
||||
out := append([]LedgerEntry(nil), existing...)
|
||||
for i := range out {
|
||||
if out[i].Voided || out[i].EventID != eventID || !recomputableSource(out[i].SourceType) {
|
||||
continue
|
||||
}
|
||||
out[i].Voided = true
|
||||
out[i].VoidedBy = by
|
||||
out[i].VoidReason = reason
|
||||
}
|
||||
return AppendLedger(out, fresh...)
|
||||
}
|
||||
|
||||
// recomputableSource 判断流水来源是否属于「重算范围」:只有引擎产出的成绩分与破纪录加分,
|
||||
// 人工调整由人负责,重算不得触碰。
|
||||
func recomputableSource(t SourceType) bool {
|
||||
return t == SourceResult || t == SourceRecordBonus
|
||||
}
|
||||
|
||||
// ApplyLedgerOps 按给定顺序应用增量操作。
|
||||
// 各操作只按来源/事件定位流水,互不依赖顺序,因此乱序到达也能收敛到同一结果。
|
||||
func ApplyLedgerOps(existing []LedgerEntry, ops []LedgerOp) ([]LedgerEntry, error) {
|
||||
|
||||
@@ -84,6 +84,7 @@ func RecordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak) []RecordBonu
|
||||
|
||||
// recordBonusLedger 把破纪录加分写成流水。
|
||||
// 加分与成绩分相互独立:不乘项目权重、也不乘组别权重(case 6 的口径)。
|
||||
// 只处理本组别的报名项:上层按组别逐个调用 ScoreEvent,不加组别过滤会让同一笔加分每组各记一次。
|
||||
func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
|
||||
if in.RecordBonus == nil || !in.RecordBonus.Enabled || len(in.RecordBreaks) == 0 {
|
||||
return nil, nil
|
||||
@@ -103,6 +104,9 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
|
||||
|
||||
byID := make(map[int64]Entry, len(in.Entries))
|
||||
for _, e := range in.Entries {
|
||||
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
|
||||
continue
|
||||
}
|
||||
byID[e.ID] = e
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const rankEpsilon = 1e-9
|
||||
// ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。
|
||||
type ScoreOptions struct {
|
||||
// UnionAttribution 决定联队成绩的归属;空值等同 UnionWhole(整体记给主单位)。
|
||||
// 未实现:生产链路没有赋值入口,联队份额也无处加载(见 UnionAttribution 注释)。
|
||||
UnionAttribution UnionAttribution
|
||||
// IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。
|
||||
IncludeDrafts bool
|
||||
@@ -57,26 +58,44 @@ type RankedResult struct {
|
||||
}
|
||||
|
||||
// ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。
|
||||
// 项目 scored=false 或没有通道时不产生任何流水(只排名不计分)。
|
||||
// 计分与排名解耦:项目 scored=false 或没有通道时照样产出名次(Points=0),只是不写流水。
|
||||
// 没有通道时按小项自身的录入方式(result_mode)推导排名口径,见 rankOnlyChannel。
|
||||
func ScoreEvent(in EventScoreInput) (EventScore, error) {
|
||||
var out EventScore
|
||||
if !in.Event.Scored {
|
||||
return out, nil
|
||||
rankOnly := len(in.Channels) == 0
|
||||
channels := sortedChannels(in.Channels)
|
||||
if rankOnly {
|
||||
channels = []ScoreChannel{rankOnlyChannel(in)}
|
||||
}
|
||||
|
||||
for _, ch := range sortedChannels(in.Channels) {
|
||||
profile, ok := in.Profiles[ch.ProfileID]
|
||||
if !ok {
|
||||
return EventScore{}, fmt.Errorf("%w: 通道 %d 引用模板 %d", ErrProfileNotFound, ch.ID, ch.ProfileID)
|
||||
for _, ch := range channels {
|
||||
// 不计分时不需要积分表:既不查模板,也不可能算出非零分。
|
||||
profile := ScoringProfile{}
|
||||
if in.Event.Scored && !rankOnly {
|
||||
p, ok := in.Profiles[ch.ProfileID]
|
||||
if !ok {
|
||||
return EventScore{}, fmt.Errorf("%w: 通道 %d 引用模板 %d", ErrProfileNotFound, ch.ID, ch.ProfileID)
|
||||
}
|
||||
profile = p
|
||||
}
|
||||
ranked, err := scoreChannel(in, ch, profile)
|
||||
if err != nil {
|
||||
return EventScore{}, err
|
||||
}
|
||||
if !in.Event.Scored {
|
||||
for i := range ranked {
|
||||
ranked[i].Points = 0
|
||||
}
|
||||
}
|
||||
out.Ranked = append(out.Ranked, ranked...)
|
||||
out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...)
|
||||
if in.Event.Scored {
|
||||
out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...)
|
||||
}
|
||||
}
|
||||
|
||||
if !in.Event.Scored {
|
||||
return out, nil
|
||||
}
|
||||
bonus, err := recordBonusLedger(in)
|
||||
if err != nil {
|
||||
return EventScore{}, err
|
||||
@@ -85,6 +104,20 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rankOnlyChannel 在没有计分通道时,按小项自身的录入方式推导排名口径:
|
||||
// manual 直接录名次,其余按成绩自动排名;名次记给个人,团体/对抗项目记给单位。
|
||||
func rankOnlyChannel(in EventScoreInput) ScoreChannel {
|
||||
basis := BasisResult
|
||||
if in.Event.ResultMode == ResultModeManual {
|
||||
basis = BasisManual
|
||||
}
|
||||
scope := ScopeAthlete
|
||||
if in.Event.EventType == EventTypeTeam || in.Event.EventType == EventTypeMatch {
|
||||
scope = ScopeTeam
|
||||
}
|
||||
return ScoreChannel{EventID: in.Event.ID, Scope: scope, Basis: basis}
|
||||
}
|
||||
|
||||
// sortedChannels 固定通道处理顺序,保证同一份输入每次产出同样的流水顺序。
|
||||
func sortedChannels(channels []ScoreChannel) []ScoreChannel {
|
||||
out := append([]ScoreChannel(nil), channels...)
|
||||
@@ -325,22 +358,17 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
|
||||
return rr
|
||||
}
|
||||
|
||||
// scoreByAggregate 先把同一主体的成绩汇总成一个值,再按值排名(预留能力)。
|
||||
// scoreByAggregate 先把同一主体的成绩汇总成一个值(求和),再按值排名(预留能力)。
|
||||
func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
|
||||
cands, err := collectCandidates(in, ch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
method := ch.Aggregate
|
||||
if method == "" {
|
||||
method = AggregateSum
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
subject int64
|
||||
entry Entry
|
||||
values []float64
|
||||
total float64
|
||||
}
|
||||
order := make([]int64, 0)
|
||||
buckets := make(map[int64]*bucket)
|
||||
@@ -358,13 +386,13 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
|
||||
buckets[subject] = b
|
||||
order = append(order, subject)
|
||||
}
|
||||
b.values = append(b.values, c.value)
|
||||
b.total += c.value
|
||||
}
|
||||
|
||||
agg := make([]candidate, 0, len(order))
|
||||
for _, id := range order {
|
||||
b := buckets[id]
|
||||
c := candidate{entry: b.entry, subject: b.subject, value: aggregateValue(method, b.values)}
|
||||
c := candidate{entry: b.entry, subject: b.subject, value: b.total}
|
||||
c.entry.ID = 0 // 聚合结果不对应单条报名项
|
||||
agg = append(agg, c)
|
||||
}
|
||||
@@ -408,44 +436,6 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func aggregateValue(method AggregateMethod, values []float64) float64 {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
switch method {
|
||||
case AggregateAvg:
|
||||
var sum float64
|
||||
for _, v := range values {
|
||||
sum += v
|
||||
}
|
||||
return sum / float64(len(values))
|
||||
case AggregateMax:
|
||||
best := values[0]
|
||||
for _, v := range values[1:] {
|
||||
if v > best {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
return best
|
||||
case AggregateMin:
|
||||
best := values[0]
|
||||
for _, v := range values[1:] {
|
||||
if v < best {
|
||||
best = v
|
||||
}
|
||||
}
|
||||
return best
|
||||
case AggregateCount:
|
||||
return float64(len(values))
|
||||
default:
|
||||
var sum float64
|
||||
for _, v := range values {
|
||||
sum += v
|
||||
}
|
||||
return sum
|
||||
}
|
||||
}
|
||||
|
||||
func sortCandidatesByValue(cands []candidate, compare CompareMode) {
|
||||
sort.SliceStable(cands, func(i, j int) bool {
|
||||
if nearlyEqual(cands[i].value, cands[j].value) {
|
||||
|
||||
@@ -2,12 +2,32 @@ package domain
|
||||
|
||||
import "sort"
|
||||
|
||||
// 榜单排序键,取值与规则包 standings.tieBreak 一致。
|
||||
// 定义在 domain 是为了让 rules 包复用同一套键名,避免两边各写一份常量。
|
||||
const (
|
||||
TieBreakPoints = "points"
|
||||
TieBreakGold = "gold"
|
||||
TieBreakSilver = "silver"
|
||||
TieBreakBronze = "bronze"
|
||||
TieBreakTeamCode = "teamCode"
|
||||
TieBreakName = "name"
|
||||
)
|
||||
|
||||
// TieBreak 未配置时的口径,与 devdocs 02 §5.2 一致。
|
||||
var (
|
||||
defaultTeamTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode}
|
||||
defaultAthleteTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakName}
|
||||
)
|
||||
|
||||
// StandingsInput 是排名聚合的输入:流水 + 名次明细 + 单位/运动员基础信息。
|
||||
type StandingsInput struct {
|
||||
Teams []Team
|
||||
Athletes []Athlete
|
||||
Ledger []LedgerEntry
|
||||
Ranked []RankedResult
|
||||
// TieBreak 是排序键顺序(如 points/gold/silver/bronze/teamCode/name);
|
||||
// 空表示用默认口径。配置里的键优先,其余按默认顺序补齐,保证结果全序。
|
||||
TieBreak []string
|
||||
// IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。
|
||||
IndividualToTeamWeight float64
|
||||
// ExcludeTeamEventMedals 为 true 时团体奖牌榜不计团体项目奖牌;默认计入。
|
||||
@@ -33,7 +53,7 @@ func rankMedal(rank int) int {
|
||||
}
|
||||
|
||||
// BuildTeamStandings 生成总积分榜。
|
||||
// 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序:总分 → 金 → 银 → 铜 → 单位编码。
|
||||
// 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 单位编码。
|
||||
func BuildTeamStandings(in StandingsInput) []StandingsRow {
|
||||
weight := effectiveWeight(in.IndividualToTeamWeight)
|
||||
sums := SumByTeam(in.Ledger, weight)
|
||||
@@ -62,7 +82,7 @@ func BuildTeamStandings(in StandingsInput) []StandingsRow {
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j]) })
|
||||
sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j], in.TieBreak) })
|
||||
for i := range rows {
|
||||
rows[i].Rank = i + 1
|
||||
}
|
||||
@@ -92,7 +112,7 @@ func BuildMedalStandings(in StandingsInput) []MedalRow {
|
||||
}
|
||||
|
||||
// BuildAthleteStandings 生成个人积分奖牌榜。
|
||||
// 排序:总分 → 金 → 银 → 铜 → 姓名;个人榜的奖牌只统计个人项目(团体项目奖牌归团体榜)。
|
||||
// 排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 姓名;个人榜的奖牌只统计个人项目(团体项目奖牌归团体榜)。
|
||||
func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow {
|
||||
points := SumByAthlete(in.Ledger)
|
||||
medals := athleteMedals(in)
|
||||
@@ -154,7 +174,7 @@ func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow {
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(rows, func(i, j int) bool { return lessAthleteRow(rows[i], rows[j]) })
|
||||
sort.SliceStable(rows, func(i, j int) bool { return lessAthleteRow(rows[i], rows[j], in.TieBreak) })
|
||||
for i := range rows {
|
||||
rows[i].Rank = i + 1
|
||||
}
|
||||
@@ -237,43 +257,91 @@ func athleteMedals(in StandingsInput) map[int64]medalTally {
|
||||
return out
|
||||
}
|
||||
|
||||
func lessTeamRow(a, b StandingsRow) bool {
|
||||
if !nearlyEqual(a.Points, b.Points) {
|
||||
return a.Points > b.Points
|
||||
// tieBreakKeys 把配置的排序键展开成实际比较顺序:配置优先,缺失的默认键补在后面,
|
||||
// 保证两行在所有键上都相等时仍能由调用方的 ID 兜底分出先后。
|
||||
func tieBreakKeys(cfg, def []string) []string {
|
||||
if len(cfg) == 0 {
|
||||
return def
|
||||
}
|
||||
if a.Gold != b.Gold {
|
||||
return a.Gold > b.Gold
|
||||
out := make([]string, 0, len(cfg)+len(def))
|
||||
seen := make(map[string]bool, len(cfg)+len(def))
|
||||
for _, key := range cfg {
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, key)
|
||||
}
|
||||
if a.Silver != b.Silver {
|
||||
return a.Silver > b.Silver
|
||||
for _, key := range def {
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, key)
|
||||
}
|
||||
if a.Bronze != b.Bronze {
|
||||
return a.Bronze > b.Bronze
|
||||
}
|
||||
if a.Code != b.Code {
|
||||
return a.Code < b.Code
|
||||
return out
|
||||
}
|
||||
|
||||
func lessTeamRow(a, b StandingsRow, tieBreak []string) bool {
|
||||
for _, key := range tieBreakKeys(tieBreak, defaultTeamTieBreak) {
|
||||
switch key {
|
||||
case TieBreakPoints:
|
||||
if !nearlyEqual(a.Points, b.Points) {
|
||||
return a.Points > b.Points
|
||||
}
|
||||
case TieBreakGold:
|
||||
if a.Gold != b.Gold {
|
||||
return a.Gold > b.Gold
|
||||
}
|
||||
case TieBreakSilver:
|
||||
if a.Silver != b.Silver {
|
||||
return a.Silver > b.Silver
|
||||
}
|
||||
case TieBreakBronze:
|
||||
if a.Bronze != b.Bronze {
|
||||
return a.Bronze > b.Bronze
|
||||
}
|
||||
case TieBreakTeamCode:
|
||||
if a.Code != b.Code {
|
||||
return a.Code < b.Code
|
||||
}
|
||||
case TieBreakName:
|
||||
if a.Name != b.Name {
|
||||
return a.Name < b.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return a.TeamID < b.TeamID
|
||||
}
|
||||
|
||||
func lessAthleteRow(a, b AthleteStandingsRow) bool {
|
||||
if !nearlyEqual(a.Points, b.Points) {
|
||||
return a.Points > b.Points
|
||||
}
|
||||
if a.Gold != b.Gold {
|
||||
return a.Gold > b.Gold
|
||||
}
|
||||
if a.Silver != b.Silver {
|
||||
return a.Silver > b.Silver
|
||||
}
|
||||
if a.Bronze != b.Bronze {
|
||||
return a.Bronze > b.Bronze
|
||||
}
|
||||
if a.Name != b.Name {
|
||||
return a.Name < b.Name
|
||||
}
|
||||
if a.TeamCode != b.TeamCode {
|
||||
return a.TeamCode < b.TeamCode
|
||||
func lessAthleteRow(a, b AthleteStandingsRow, tieBreak []string) bool {
|
||||
for _, key := range tieBreakKeys(tieBreak, defaultAthleteTieBreak) {
|
||||
switch key {
|
||||
case TieBreakPoints:
|
||||
if !nearlyEqual(a.Points, b.Points) {
|
||||
return a.Points > b.Points
|
||||
}
|
||||
case TieBreakGold:
|
||||
if a.Gold != b.Gold {
|
||||
return a.Gold > b.Gold
|
||||
}
|
||||
case TieBreakSilver:
|
||||
if a.Silver != b.Silver {
|
||||
return a.Silver > b.Silver
|
||||
}
|
||||
case TieBreakBronze:
|
||||
if a.Bronze != b.Bronze {
|
||||
return a.Bronze > b.Bronze
|
||||
}
|
||||
case TieBreakTeamCode:
|
||||
if a.TeamCode != b.TeamCode {
|
||||
return a.TeamCode < b.TeamCode
|
||||
}
|
||||
case TieBreakName:
|
||||
if a.Name != b.Name {
|
||||
return a.Name < b.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return a.AthleteID < b.AthleteID
|
||||
}
|
||||
@@ -98,17 +98,6 @@ const (
|
||||
BasisAggregate Basis = "aggregate" // 按团体汇总(预留)
|
||||
)
|
||||
|
||||
// AggregateMethod 是 aggregate 通道的汇总口径,默认 sum。
|
||||
type AggregateMethod string
|
||||
|
||||
const (
|
||||
AggregateSum AggregateMethod = "sum"
|
||||
AggregateAvg AggregateMethod = "avg"
|
||||
AggregateMax AggregateMethod = "max"
|
||||
AggregateMin AggregateMethod = "min"
|
||||
AggregateCount AggregateMethod = "count"
|
||||
)
|
||||
|
||||
// TieMode 并列名次口径。
|
||||
type TieMode string
|
||||
|
||||
@@ -136,11 +125,14 @@ const (
|
||||
)
|
||||
|
||||
// UnionAttribution 联队(一条成绩归属多个单位)的归属口径。
|
||||
// 未实现:库中没有保存联队份额的表,加载器也不会填充 Entry.TeamShares,
|
||||
// ScoreOptions.UnionAttribution 在生产链路里没有任何赋值入口;
|
||||
// 规则包校验也不接受相关配置。这里只保留数据结构,本期不启用(devdocs 01 §115)。
|
||||
type UnionAttribution string
|
||||
|
||||
const (
|
||||
UnionWhole UnionAttribution = "whole" // 整体记给主单位
|
||||
UnionSplit UnionAttribution = "split" // 按 TeamShares 的份额拆分
|
||||
UnionSplit UnionAttribution = "split" // 按 TeamShares 的份额拆分(未实现)
|
||||
)
|
||||
|
||||
// AdjustmentCategory 人工调整类别。
|
||||
@@ -230,14 +222,15 @@ type EventCategory struct {
|
||||
|
||||
// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。
|
||||
// 一个项目可配 0..N 个通道;0 个表示只排名不计分。
|
||||
// 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"`
|
||||
Aggregate AggregateMethod `json:"aggregate,omitempty"` // 仅 basis=aggregate 时使用
|
||||
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 积分模板的一条明细:名次 → 分值。分值一律来自配置。
|
||||
@@ -259,6 +252,7 @@ type ScoringProfile struct {
|
||||
}
|
||||
|
||||
// TeamShare 联队中某单位的份额;Weight 为 0..1 的相对份额。
|
||||
// 未实现:没有建表、没有 loader 填充、也没有写入入口(见 UnionAttribution 注释)。
|
||||
type TeamShare struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
Weight float64 `json:"weight"`
|
||||
@@ -273,7 +267,7 @@ type Entry struct {
|
||||
AthleteID int64 `json:"athleteId,omitempty"`
|
||||
AthleteName string `json:"athleteName,omitempty"` // 冗余姓名,便于引擎直接产出个人榜
|
||||
MemberNames []string `json:"memberNames,omitempty"`
|
||||
TeamShares []TeamShare `json:"teamShares,omitempty"` // 跨单位联队;空表示只属 TeamID
|
||||
TeamShares []TeamShare `json:"teamShares,omitempty"` // 跨单位联队;空表示只属 TeamID(联队未实现,见 UnionAttribution)
|
||||
Bib string `json:"bib,omitempty"`
|
||||
Seed string `json:"seed,omitempty"`
|
||||
Status EntryStatus `json:"status"`
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package dto
|
||||
|
||||
// EventResultGroup 是 snapshot.latestResults 的一组「小项 + 组别」成绩,
|
||||
// 与 web/src/shared/types.ts 的 EventResultGroup 对齐。
|
||||
type EventResultGroup struct {
|
||||
EventID int64 `json:"eventId"`
|
||||
EventCode string `json:"eventCode"`
|
||||
EventName string `json:"eventName"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
PublishedAt *string `json:"publishedAt"`
|
||||
Rows []EventResultRow `json:"rows"`
|
||||
}
|
||||
|
||||
// EventResultRow 是组内的一条成绩行;字段与 types.ts 的 EventResultRow 一一对应。
|
||||
type EventResultRow struct {
|
||||
EntryID int64 `json:"entryId"`
|
||||
Rank *int `json:"rank"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
TeamCode string `json:"teamCode"`
|
||||
TeamName string `json:"teamName"`
|
||||
ShortName *string `json:"shortName"`
|
||||
AthleteID *int64 `json:"athleteId"`
|
||||
AthleteName *string `json:"athleteName"`
|
||||
MemberNames []string `json:"memberNames"`
|
||||
ResultText string `json:"resultText"`
|
||||
ResultValue *float64 `json:"resultValue"`
|
||||
Unit string `json:"unit"`
|
||||
IsTie bool `json:"isTie"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package dto 存放实时层与 REST 层共用的线上数据结构与纯转换函数。
|
||||
//
|
||||
// 放在这里而不是 httpapi:realtime 与 httpapi 是同层出口,互相 import 会造成分层倒置;
|
||||
// 本包只依赖 domain 与标准库,不做任何 IO。
|
||||
package dto
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EventSettings 是 GET/PUT /api/settings 与 snapshot.settings 的线上结构,
|
||||
// 与 web/src/shared/types.ts 的 EventSettings 一一对应。
|
||||
type EventSettings struct {
|
||||
Event EventSettingsEvent `json:"event"`
|
||||
Display EventSettingsDisplay `json:"display"`
|
||||
Standings EventSettingsStandings `json:"standings"`
|
||||
DefaultProfileID *int64 `json:"defaultProfileId"`
|
||||
RequireApproval bool `json:"requireApproval"`
|
||||
}
|
||||
|
||||
// EventSettingsEvent 是赛事基本信息。
|
||||
type EventSettingsEvent struct {
|
||||
Name string `json:"name"`
|
||||
Subtitle *string `json:"subtitle"`
|
||||
Host *string `json:"host"`
|
||||
LogoPath *string `json:"logoPath"`
|
||||
PrimaryColor *string `json:"primaryColor"`
|
||||
}
|
||||
|
||||
// EventSettingsDisplay 是大屏展示配置。
|
||||
type EventSettingsDisplay struct {
|
||||
Background *string `json:"background"`
|
||||
Logo *string `json:"logo"`
|
||||
Title string `json:"title"`
|
||||
Subtitle *string `json:"subtitle"`
|
||||
PrimaryColor *string `json:"primaryColor"`
|
||||
RotateSecond int `json:"rotateSeconds"`
|
||||
}
|
||||
|
||||
// EventSettingsStandings 是榜单口径配置。
|
||||
type EventSettingsStandings struct {
|
||||
IndividualToTeamWeight float64 `json:"individualToTeamWeight"`
|
||||
RankingMode string `json:"rankingMode"`
|
||||
ShowIndividualBoard bool `json:"showIndividualBoard"`
|
||||
}
|
||||
|
||||
// ParseEventSettings 把 settings 表的点号键聚合成嵌套对象。
|
||||
//
|
||||
// 默认值必须与 REST 的 getSettings 逐条一致:大屏首包走 snapshot、管理端走 /api/settings,
|
||||
// 两边口径不同会让同一块屏刷新前后显示不一样的标题。
|
||||
// defaultProfileId 需要查 scoring_profiles 反查 code,属于 IO,由调用方补填。
|
||||
func ParseEventSettings(values map[string]string) EventSettings {
|
||||
var out EventSettings
|
||||
|
||||
out.Event.Name = orDefault(values["event.name"], "未命名赛事")
|
||||
out.Event.Subtitle = nilIfEmpty(values["event.subtitle"])
|
||||
out.Event.Host = nilIfEmpty(values["event.organizer"])
|
||||
out.Event.LogoPath = nilIfEmpty(values["event.logo_path"])
|
||||
out.Event.PrimaryColor = nilIfEmpty(values["display.theme_color"])
|
||||
|
||||
out.Display.Background = nilIfEmpty(values["display.background"])
|
||||
out.Display.Logo = nilIfEmpty(values["display.logo"])
|
||||
if out.Display.Logo == nil {
|
||||
out.Display.Logo = out.Event.LogoPath
|
||||
}
|
||||
out.Display.Title = orDefault(values["display.title"], out.Event.Name)
|
||||
out.Display.Subtitle = nilIfEmpty(values["display.subtitle"])
|
||||
if out.Display.Subtitle == nil {
|
||||
out.Display.Subtitle = out.Event.Subtitle
|
||||
}
|
||||
out.Display.PrimaryColor = out.Event.PrimaryColor
|
||||
out.Display.RotateSecond = atoiDefault(values["display.rotate_seconds"], 15)
|
||||
|
||||
out.Standings.IndividualToTeamWeight = atofDefault(values["standings.individual_to_team_weight"], 1)
|
||||
out.Standings.RankingMode = orDefault(values["standings.mode"], "points")
|
||||
out.Standings.ShowIndividualBoard = boolDefault(
|
||||
firstNonEmpty(values["standings.show_individual_board"], values["standings.show_individual_medals"]), true)
|
||||
|
||||
out.RequireApproval = boolDefault(values["results.review_required"], false)
|
||||
return out
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func atoiDefault(v string, def int) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func atofDefault(v string, def float64) float64 {
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func boolDefault(v string, def bool) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "true", "1", "yes":
|
||||
return true
|
||||
case "false", "0", "no":
|
||||
return false
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package dto
|
||||
|
||||
import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
|
||||
// StandingRow 是 GET /api/standings 与 snapshot.standings 的线上结构。
|
||||
//
|
||||
// 字段名与 web/src/shared/types.ts 的 StandingRow 对齐:大屏与管理端共用一份解析逻辑,
|
||||
// 不能再把 domain.StandingsRow 的 points/code/medals 直接下发。
|
||||
type StandingRow struct {
|
||||
TeamID int64 `json:"teamId"`
|
||||
TeamCode string `json:"teamCode"`
|
||||
TeamName string `json:"teamName"`
|
||||
ShortName *string `json:"shortName"`
|
||||
Color *string `json:"color"`
|
||||
LogoPath *string `json:"logoPath"`
|
||||
Rank int `json:"rank"`
|
||||
Gold int `json:"gold"`
|
||||
Silver int `json:"silver"`
|
||||
Bronze int `json:"bronze"`
|
||||
MedalTotal int `json:"medalTotal"`
|
||||
TotalPoints float64 `json:"totalPoints"`
|
||||
RankChange *int `json:"rankChange"`
|
||||
}
|
||||
|
||||
// StandingRows 把 domain 榜单转换成线上结构。
|
||||
// teams 用于补齐颜色/logo 等 domain 榜单没带的展示字段;rankChange 快照没有对比基准,与 REST 一致下发 null。
|
||||
func StandingRows(rows []domain.StandingsRow, teams []domain.Team) []StandingRow {
|
||||
byID := make(map[int64]domain.Team, len(teams))
|
||||
for _, t := range teams {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
|
||||
out := make([]StandingRow, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
t := byID[r.TeamID]
|
||||
row := StandingRow{
|
||||
TeamID: r.TeamID,
|
||||
TeamCode: r.Code,
|
||||
TeamName: r.Name,
|
||||
ShortName: nilIfEmpty(r.ShortName),
|
||||
Rank: r.Rank,
|
||||
Gold: r.Gold,
|
||||
Silver: r.Silver,
|
||||
Bronze: r.Bronze,
|
||||
MedalTotal: r.Medals,
|
||||
TotalPoints: r.Points,
|
||||
RankChange: nil,
|
||||
}
|
||||
if t.ID != 0 {
|
||||
row.Color = nilIfEmpty(t.Color)
|
||||
row.LogoPath = nilIfEmpty(t.LogoPath)
|
||||
if row.TeamCode == "" {
|
||||
row.TeamCode = t.Code
|
||||
}
|
||||
if row.TeamName == "" {
|
||||
row.TeamName = t.Name
|
||||
}
|
||||
if row.ShortName == nil {
|
||||
row.ShortName = nilIfEmpty(t.ShortName)
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -24,24 +24,30 @@ type client struct {
|
||||
closeCode websocket.StatusCode
|
||||
closeReason string
|
||||
|
||||
mu sync.Mutex
|
||||
greeted bool
|
||||
role string
|
||||
userID int64
|
||||
instanceID string
|
||||
mu sync.Mutex
|
||||
greeted bool
|
||||
// role/userID 是 hello 之后实际生效的身份;sessionRole/sessionUserID 是握手时从会话取到的上限,
|
||||
// 客户端自报的 role/userId 不能越过它。
|
||||
role string
|
||||
userID int64
|
||||
sessionRole string
|
||||
sessionUserID int64
|
||||
instanceID string
|
||||
|
||||
ackVersion atomic.Int64 // 客户端已确认应用到的版本
|
||||
lastSent atomic.Int64 // 服务端已发出的最大版本
|
||||
slow atomic.Bool
|
||||
}
|
||||
|
||||
func newClient(h *Hub, conn *websocket.Conn, remoteAddr string) *client {
|
||||
func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) *client {
|
||||
return &client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
remoteAddr: remoteAddr,
|
||||
send: make(chan []byte, defaultSendBuffer),
|
||||
done: make(chan struct{}),
|
||||
hub: h,
|
||||
conn: conn,
|
||||
remoteAddr: remoteAddr,
|
||||
send: make(chan []byte, defaultSendBuffer),
|
||||
done: make(chan struct{}),
|
||||
sessionRole: sessionRole,
|
||||
sessionUserID: sessionUserID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
)
|
||||
|
||||
// 默认参数;现场可通过 Set* 调整。
|
||||
@@ -210,14 +213,26 @@ func (h *Hub) Handler() http.Handler {
|
||||
h.mu.Unlock()
|
||||
defer h.connWG.Done()
|
||||
|
||||
h.serve(conn, r.RemoteAddr)
|
||||
// 身份只认会话:/ws 必须被 Authenticate 中间件包住,否则一律按 guest 处理。
|
||||
role, userID := wsIdentity(r.Context())
|
||||
h.serve(conn, r.RemoteAddr, role, userID)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Hub) serve(conn *websocket.Conn, remoteAddr string) {
|
||||
// wsIdentity 从请求上下文取会话身份并折算成实时层角色。
|
||||
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
|
||||
func wsIdentity(ctx context.Context) (string, int64) {
|
||||
u := middleware.User(ctx)
|
||||
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
|
||||
return RoleDisplay, 0
|
||||
}
|
||||
return RoleAdmin, u.ID
|
||||
}
|
||||
|
||||
func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) {
|
||||
conn.SetReadLimit(1 << 20)
|
||||
|
||||
c := newClient(h, conn, remoteAddr)
|
||||
c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID)
|
||||
h.addClient(c)
|
||||
|
||||
ctx, cancel := context.WithCancel(h.ctx)
|
||||
@@ -293,22 +308,42 @@ func (h *Hub) roomClients(room string) []*client {
|
||||
return out
|
||||
}
|
||||
|
||||
// handleHello 只信任会话身份:客户端自报的 role/userId 会被忽略,
|
||||
// 管理端房间必须由登录会话授予,未登录连接请求管理端直接断开。
|
||||
func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) {
|
||||
if p.Role != RoleDisplay && p.Role != RoleAdmin {
|
||||
c.mu.Lock()
|
||||
sessionRole, sessionUserID := c.sessionRole, c.sessionUserID
|
||||
c.mu.Unlock()
|
||||
|
||||
if p.Role != "" && p.Role != RoleDisplay && p.Role != RoleAdmin {
|
||||
h.log.Debug("hello 角色非法", "role", p.Role, "remote", c.remoteAddr)
|
||||
c.stop(websocket.StatusPolicyViolation, "role 非法")
|
||||
return
|
||||
}
|
||||
if p.Role == RoleAdmin && sessionRole != RoleAdmin {
|
||||
h.log.Warn("未登录连接请求管理端房间,已断开", "remote", c.remoteAddr)
|
||||
c.stop(websocket.StatusPolicyViolation, "无权订阅管理端房间")
|
||||
return
|
||||
}
|
||||
|
||||
// 不写 role 时按会话上限走;显式 role=display 允许管理端用户降级成大屏。
|
||||
role := sessionRole
|
||||
if p.Role == RoleDisplay {
|
||||
role = RoleDisplay
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.role = p.Role
|
||||
c.userID = p.UserID
|
||||
c.role = role
|
||||
c.userID = 0
|
||||
if role == RoleAdmin {
|
||||
c.userID = sessionUserID
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if p.Role == RoleAdmin {
|
||||
if role == RoleAdmin {
|
||||
rooms := []string{RoomAdmin}
|
||||
if p.UserID > 0 {
|
||||
rooms = append(rooms, AdminRoom(p.UserID))
|
||||
if c.userID > 0 {
|
||||
rooms = append(rooms, AdminRoom(c.userID))
|
||||
}
|
||||
h.join(c, rooms...)
|
||||
} else {
|
||||
|
||||
@@ -84,6 +84,9 @@ func encode(event string, data any) ([]byte, error) {
|
||||
}
|
||||
|
||||
// helloPayload 是 C→S 的 hello 载荷。
|
||||
//
|
||||
// role 只是「申请」:服务端以会话身份为准,未登录连接申请 admin 会被拒绝,
|
||||
// 因此这里没有 userId —— 定向房间的 userId 只能来自会话。
|
||||
// instanceId / page 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页」,
|
||||
// 老客户端不带这两个字段也能正常工作。
|
||||
type helloPayload struct {
|
||||
@@ -91,7 +94,6 @@ type helloPayload struct {
|
||||
LastVersion *int64 `json:"lastVersion,omitempty"`
|
||||
DisplayKey string `json:"displayKey,omitempty"`
|
||||
InstanceID string `json:"instanceId,omitempty"`
|
||||
UserID int64 `json:"userId,omitempty"`
|
||||
Page string `json:"page,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -4,24 +4,31 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||||
)
|
||||
|
||||
// latestResultsLimit 控制快照里「最新成绩」的条数,避免大屏首包过大。
|
||||
// latestResultsLimit 控制快照里「最新成绩」的行数上限,避免大屏首包过大。
|
||||
// 行会按「小项 + 组别」聚合后再下发,因此组数只会比这个值更少。
|
||||
const latestResultsLimit = 20
|
||||
|
||||
// Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。
|
||||
// 字段名与协议第 3 节一致。
|
||||
// 字段名与协议第 3 节一致;settings/standings/latestResults 的形状必须与 REST 同构,
|
||||
// 否则大屏收到首包就会按错误的键名渲染。
|
||||
type Snapshot struct {
|
||||
Version int64 `json:"version"`
|
||||
Settings map[string]json.RawMessage `json:"settings"`
|
||||
Teams []domain.Team `json:"teams"`
|
||||
Events []domain.Event `json:"events"`
|
||||
Standings []domain.StandingsRow `json:"standings"`
|
||||
Records []Record `json:"records"`
|
||||
LatestResults []LatestResult `json:"latestResults"`
|
||||
Version int64 `json:"version"`
|
||||
Settings dto.EventSettings `json:"settings"`
|
||||
Teams []domain.Team `json:"teams"`
|
||||
Events []domain.Event `json:"events"`
|
||||
Standings []dto.StandingRow `json:"standings"`
|
||||
Records []Record `json:"records"`
|
||||
LatestResults []dto.EventResultGroup `json:"latestResults"`
|
||||
}
|
||||
|
||||
// Record 是纪录榜的一行。
|
||||
@@ -38,23 +45,6 @@ type Record struct {
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// LatestResult 是已发布成绩的展示行,供大屏滚动与详情页使用。
|
||||
type LatestResult struct {
|
||||
ID int64 `json:"id"`
|
||||
EventID int64 `json:"eventId"`
|
||||
EntryID int64 `json:"entryId"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
AthleteID int64 `json:"athleteId,omitempty"`
|
||||
Rank int `json:"rank,omitempty"`
|
||||
ResultText string `json:"resultText,omitempty"`
|
||||
ResultValue *float64 `json:"resultValue,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
IsTie bool `json:"isTie,omitempty"`
|
||||
Status string `json:"status"`
|
||||
State string `json:"state"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
// Snapshot 读取一份一致的完整状态。
|
||||
//
|
||||
// 全部查询放在同一个只读事务里,且版本号取自同一事务,
|
||||
@@ -71,18 +61,21 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
snap := &Snapshot{
|
||||
Settings: map[string]json.RawMessage{},
|
||||
Teams: []domain.Team{},
|
||||
Events: []domain.Event{},
|
||||
Standings: []domain.StandingsRow{},
|
||||
Standings: []dto.StandingRow{},
|
||||
Records: []Record{},
|
||||
LatestResults: []LatestResult{},
|
||||
LatestResults: []dto.EventResultGroup{},
|
||||
}
|
||||
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&snap.Version); err != nil {
|
||||
return nil, fmt.Errorf("读取快照版本号: %w", err)
|
||||
}
|
||||
if err := loadSettings(ctx, tx, snap); err != nil {
|
||||
values, err := loadSettings(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fillSettings(ctx, tx, values, snap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadTeams(ctx, tx, snap); err != nil {
|
||||
@@ -91,7 +84,7 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
|
||||
if err := loadEvents(ctx, tx, snap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadStandings(ctx, tx, snap); err != nil {
|
||||
if err := loadStandings(ctx, tx, snap, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadRecords(ctx, tx, snap); err != nil {
|
||||
@@ -107,21 +100,46 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func loadSettings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT key, value FROM settings ORDER BY key`)
|
||||
// loadSettings 原样读出 settings 表的键值:榜单口径要用原始字符串,
|
||||
// 而线上结构需要聚合后的嵌套对象,两者由调用方分别消费。
|
||||
func loadSettings(ctx context.Context, tx *sql.Tx) (map[string]string, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT key, value FROM settings`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取赛事配置: %w", err)
|
||||
return nil, fmt.Errorf("读取赛事配置: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var key, value string
|
||||
if err := rows.Scan(&key, &value); err != nil {
|
||||
return fmt.Errorf("解析赛事配置: %w", err)
|
||||
return nil, fmt.Errorf("解析赛事配置: %w", err)
|
||||
}
|
||||
snap.Settings[key] = rawJSONOrString(value)
|
||||
out[key] = value
|
||||
}
|
||||
return rows.Err()
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// fillSettings 聚合成与 REST /api/settings 相同的嵌套结构。
|
||||
// defaultProfileId 在 settings 表里存的是模板 code,需要反查一次主键。
|
||||
func fillSettings(ctx context.Context, tx *sql.Tx, values map[string]string, snap *Snapshot) error {
|
||||
snap.Settings = dto.ParseEventSettings(values)
|
||||
|
||||
code := values["scoring.default_profile"]
|
||||
if code == "" {
|
||||
return nil
|
||||
}
|
||||
var id int64
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// 配置引用的模板已被删除:保持 null,与 REST 一致,由管理端提示重选。
|
||||
case err != nil:
|
||||
return fmt.Errorf("解析默认积分模板: %w", err)
|
||||
default:
|
||||
snap.Settings.DefaultProfileID = &id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
@@ -191,9 +209,9 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// loadStandings 复用 domain 的排名口径:积分来自流水,奖牌来自已发布名次,
|
||||
// 保证大屏看到的榜单与 REST /api/standings 一致。
|
||||
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
// loadStandings 复用 domain 的排名口径,且输入与 REST /api/standings 完全一致:
|
||||
// 个人流水按系数折算、团体奖牌开关生效、只有配了计分通道的项目才产出名次。
|
||||
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, values map[string]string) error {
|
||||
ledger, err := loadLedger(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -202,11 +220,14 @@ func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snap.Standings = domain.BuildTeamStandings(domain.StandingsInput{
|
||||
Teams: snap.Teams,
|
||||
Ledger: ledger,
|
||||
Ranked: ranked,
|
||||
rows := domain.BuildTeamStandings(domain.StandingsInput{
|
||||
Teams: snap.Teams,
|
||||
Ledger: ledger,
|
||||
Ranked: ranked,
|
||||
IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1),
|
||||
ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true),
|
||||
})
|
||||
snap.Standings = dto.StandingRows(rows, snap.Teams)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -235,13 +256,19 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// loadRanked 只取已发布的前三名,用于统计奖牌;名次明细的完整计算仍由成绩模块负责。
|
||||
// loadRanked 与 REST 的 buildRanked 同源:必须 join event_score_channels,
|
||||
// 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌。
|
||||
func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT e.team_id, COALESCE(e.athlete_id, 0), r.rank
|
||||
FROM results r JOIN entries e ON e.id = r.entry_id
|
||||
WHERE r.state = 'published' AND r.status = 'ok' AND r.rank BETWEEN 1 AND 3
|
||||
ORDER BY r.id`)
|
||||
SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status,
|
||||
e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id
|
||||
FROM results r
|
||||
JOIN entries e ON e.id = r.entry_id
|
||||
JOIN event_score_channels c ON c.event_id = r.event_id
|
||||
LEFT JOIN athletes a ON a.id = e.athlete_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
|
||||
AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)
|
||||
ORDER BY r.id, c.sort_order, c.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取奖牌名次: %w", err)
|
||||
}
|
||||
@@ -250,19 +277,23 @@ func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error)
|
||||
var out []domain.RankedResult
|
||||
for rows.Next() {
|
||||
var (
|
||||
teamID, athleteID int64
|
||||
rank int
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
rank int
|
||||
)
|
||||
if err := rows.Scan(&teamID, &athleteID, &rank); err != nil {
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
|
||||
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
|
||||
return nil, fmt.Errorf("解析奖牌名次: %w", err)
|
||||
}
|
||||
out = append(out, domain.RankedResult{
|
||||
TeamID: teamID,
|
||||
AthleteID: athleteID,
|
||||
Rank: rank,
|
||||
Scope: domain.ScopeTeam,
|
||||
Shares: []domain.TeamShare{{TeamID: teamID, Weight: 1}},
|
||||
})
|
||||
rr.Rank = rank
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
rr.ResultValue = &v
|
||||
}
|
||||
rr.AthleteID = athleteID.Int64
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
out = append(out, rr)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -299,50 +330,149 @@ func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。
|
||||
// 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。
|
||||
func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT r.id, r.event_id, r.entry_id, e.team_id, COALESCE(e.athlete_id, 0),
|
||||
COALESCE(r.rank, 0), COALESCE(r.result_text, ''), r.result_value,
|
||||
COALESCE(r.unit, ''), r.is_tie, r.status, r.state,
|
||||
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, '')
|
||||
FROM results r JOIN entries e ON e.id = r.entry_id
|
||||
WHERE r.state = 'published'
|
||||
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at) DESC, r.id DESC
|
||||
LIMIT ?`, latestResultsLimit)
|
||||
SELECT r.event_id, COALESCE(ev.code, ''), COALESCE(ev.name, ''),
|
||||
e.category_id, COALESCE(cat.name, ''),
|
||||
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''),
|
||||
r.entry_id, r.rank, e.team_id,
|
||||
COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''),
|
||||
e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''),
|
||||
COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status
|
||||
FROM results r
|
||||
JOIN entries e ON e.id = r.entry_id
|
||||
JOIN events ev ON ev.id = r.event_id
|
||||
LEFT JOIN event_categories cat ON cat.id = e.category_id
|
||||
LEFT JOIN teams t ON t.id = e.team_id
|
||||
LEFT JOIN athletes a ON a.id = e.athlete_id
|
||||
WHERE r.state = 'published'
|
||||
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC
|
||||
LIMIT ?`, latestResultsLimit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取最新成绩: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
type groupKey struct {
|
||||
eventID int64
|
||||
categoryID int64
|
||||
}
|
||||
index := make(map[groupKey]int)
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
lr LatestResult
|
||||
value sql.NullFloat64
|
||||
isTie int
|
||||
eventID, categoryID int64
|
||||
eventCode, eventName string
|
||||
categoryName string
|
||||
publishedAt string
|
||||
row dto.EventResultRow
|
||||
rank sql.NullInt64
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
athleteName string
|
||||
shortName string
|
||||
memberNames string
|
||||
isTie int
|
||||
)
|
||||
if err := rows.Scan(&lr.ID, &lr.EventID, &lr.EntryID, &lr.TeamID, &lr.AthleteID,
|
||||
&lr.Rank, &lr.ResultText, &value, &lr.Unit, &isTie, &lr.Status, &lr.State, &lr.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&eventID, &eventCode, &eventName, &categoryID, &categoryName, &publishedAt,
|
||||
&row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName,
|
||||
&athleteID, &athleteName, &memberNames,
|
||||
&row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil {
|
||||
return fmt.Errorf("解析最新成绩: %w", err)
|
||||
}
|
||||
if rank.Valid {
|
||||
v := int(rank.Int64)
|
||||
row.Rank = &v
|
||||
}
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
lr.ResultValue = &v
|
||||
row.ResultValue = &v
|
||||
}
|
||||
lr.IsTie = isTie != 0
|
||||
snap.LatestResults = append(snap.LatestResults, lr)
|
||||
if athleteID.Valid && athleteID.Int64 != 0 {
|
||||
v := athleteID.Int64
|
||||
row.AthleteID = &v
|
||||
}
|
||||
row.AthleteName = nilIfEmpty(athleteName)
|
||||
row.ShortName = nilIfEmpty(shortName)
|
||||
row.MemberNames = parseMemberNames(memberNames)
|
||||
row.IsTie = isTie != 0
|
||||
|
||||
key := groupKey{eventID: eventID, categoryID: categoryID}
|
||||
i, ok := index[key]
|
||||
if !ok {
|
||||
i = len(snap.LatestResults)
|
||||
index[key] = i
|
||||
snap.LatestResults = append(snap.LatestResults, dto.EventResultGroup{
|
||||
EventID: eventID,
|
||||
EventCode: eventCode,
|
||||
EventName: eventName,
|
||||
CategoryID: categoryID,
|
||||
CategoryName: categoryName,
|
||||
PublishedAt: nilIfEmpty(publishedAt),
|
||||
Rows: []dto.EventResultRow{},
|
||||
})
|
||||
}
|
||||
snap.LatestResults[i].Rows = append(snap.LatestResults[i].Rows, row)
|
||||
}
|
||||
return rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 行按发布时间倒序取回,组内需改回名次顺序;无名次的排最后。
|
||||
for i := range snap.LatestResults {
|
||||
sort.SliceStable(snap.LatestResults[i].Rows, func(a, b int) bool {
|
||||
ra, rb := snap.LatestResults[i].Rows[a], snap.LatestResults[i].Rows[b]
|
||||
switch {
|
||||
case ra.Rank == nil && rb.Rank == nil:
|
||||
return ra.EntryID < rb.EntryID
|
||||
case ra.Rank == nil:
|
||||
return false
|
||||
case rb.Rank == nil:
|
||||
return true
|
||||
default:
|
||||
return *ra.Rank < *rb.Rank
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rawJSONOrString 让 settings 的值保持原样:本来就是 JSON 的直接透传,
|
||||
// 纯文本则编码成 JSON 字符串,客户端统一按 JSON 解析。
|
||||
func rawJSONOrString(value string) json.RawMessage {
|
||||
if json.Valid([]byte(value)) {
|
||||
return json.RawMessage(value)
|
||||
// parseMemberNames 解析 entries.member_names;空值或坏 JSON 一律当作「无成员名单」。
|
||||
func parseMemberNames(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
quoted, err := json.Marshal(value)
|
||||
var out []string
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func atofDefault(v string, def float64) float64 {
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||||
if err != nil {
|
||||
return json.RawMessage(`null`)
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func boolDefault(v string, def bool) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "true", "1", "yes":
|
||||
return true
|
||||
case "false", "0", "no":
|
||||
return false
|
||||
default:
|
||||
return def
|
||||
}
|
||||
return quoted
|
||||
}
|
||||
Reference in new issue
Block a user