package domain import ( "fmt" "math" "sort" ) // rankEpsilon 用于判定「成绩相同」。计时/测距保留两位小数,1e-9 足够区分。 const rankEpsilon = 1e-9 // ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。 type ScoreOptions struct { // UnionAttribution 决定联队成绩的归属;空值等同 UnionWhole(整体记给主单位)。 // 未实现:生产链路没有赋值入口,联队份额也无处加载(见 UnionAttribution 注释)。 UnionAttribution UnionAttribution // IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。 IncludeDrafts bool } // EventScoreInput 是一次小项(某组别)计分的全部输入,引擎只读不改。 type EventScoreInput struct { Event Event Category EventCategory Channels []ScoreChannel Profiles map[int64]ScoringProfile Entries []Entry Results []Result RecordBonus *RecordBonusConfig RecordBreaks []RecordBreak Options ScoreOptions } // EventScore 是一次小项计分的结果。 type EventScore struct { Ledger []LedgerEntry Ranked []RankedResult } // RankedResult 记录「某条报名项在某通道下拿到的名次与原始积分」。 // Points 尚未按联队份额拆分、也未乘个人→单位系数,因此可直接用于奖牌判定与分项成绩表。 type RankedResult struct { EntryID int64 ResultID int64 EventID int64 CategoryID int64 ChannelID int64 Scope Scope Basis Basis AthleteID int64 AthleteName string TeamID int64 Shares []TeamShare Rank int Points float64 ResultValue *float64 Status ResultStatus } // ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。 // 计分与排名解耦:项目 scored=false 或没有通道时照样产出名次(Points=0),只是不写流水。 // 没有通道时按小项自身的录入方式(result_mode)推导排名口径,见 rankOnlyChannel。 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)} } for _, ch := range channels { // 不计分时不需要积分表:既不查模板,也不可能算出非零分。 profile := ScoringProfile{} if scoreEnabled { 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 !scoreEnabled { for i := range ranked { ranked[i].Points = 0 } } out.Ranked = append(out.Ranked, ranked...) if scoreEnabled { out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...) } } if !scoreEnabled { return out, nil } bonus, err := recordBonusLedger(in) if err != nil { return EventScore{}, err } out.Ledger = append(out.Ledger, bonus...) 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...) sort.SliceStable(out, func(i, j int) bool { if out[i].SortOrder != out[j].SortOrder { return out[i].SortOrder < out[j].SortOrder } return out[i].ID < out[j].ID }) return out } func scoreChannel(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) { switch ch.Basis { case BasisResult: return scoreByResult(in, ch, profile) case BasisManual: return scoreByManual(in, ch, profile) case BasisAggregate: return scoreByAggregate(in, ch, profile) default: return nil, fmt.Errorf("%w: %q", ErrUnknownBasis, ch.Basis) } } // candidate 是排名过程的中间态:一条可参与排名的报名项 + 它的比较值。 type candidate struct { entry Entry result Result subject int64 // 聚合通道的排名主体(单位或运动员) value float64 // 排序用比较值 rankIn int // manual 通道录入的名次 } // entryMatches 判断报名项是否属于本次计分的小项与组别。 func entryMatches(in EventScoreInput, e Entry) bool { if in.Event.ID != 0 && e.EventID != 0 && e.EventID != in.Event.ID { return false } if in.Category.ID != 0 && e.CategoryID != in.Category.ID { return false } return true } // collectCandidates 过滤出本通道真正参与排名的报名项: // 只取已发布、状态正常、且带有本通道所需数据的成绩。聚合通道不用本函数,见 scoreByAggregate。 func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error) { if ch.Basis == BasisResult && in.Event.Compare == CompareNone { return nil, ErrCompareRequired } byEntry := make(map[int64]Result, len(in.Results)) for _, r := range in.Results { byEntry[r.EntryID] = r } out := make([]candidate, 0, len(in.Entries)) for _, e := range in.Entries { if !entryMatches(in, e) { continue } if !e.Status.rankable() { continue } r, ok := byEntry[e.ID] if !ok { continue } if r.State == ResultStateDraft && !in.Options.IncludeDrafts { continue } if !r.Status.rankable() { continue } c := candidate{entry: e, result: r} switch ch.Basis { case BasisManual: if r.Rank <= 0 { continue } c.rankIn = r.Rank default: // result 按成绩值排序 if r.ResultValue == nil { continue } c.value = *r.ResultValue } out = append(out, c) } return out, nil } func scoreByResult(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) { cands, err := collectCandidates(in, ch) if err != nil { return nil, err } sortCandidatesByValue(cands, in.Event.Compare) return rankGroups(in, ch, profile, groupByValue(cands)), nil } func scoreByManual(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) { cands, err := collectCandidates(in, ch) if err != nil { return nil, err } sort.SliceStable(cands, func(i, j int) bool { if cands[i].rankIn != cands[j].rankIn { return cands[i].rankIn < cands[j].rankIn } return cands[i].entry.ID < cands[j].entry.ID }) return rankGroups(in, ch, profile, groupByManualRank(cands)), nil } // tieGroup 是一组并列的参赛者;startRank 是它们共同展示的名次。 type tieGroup struct { startRank int members []candidate } // groupByValue 按成绩值分组:值相同即并列,名次按「占位顺推」计算(skip 口径)。 func groupByValue(cands []candidate) []tieGroup { var groups []tieGroup pos := 1 for i := 0; i < len(cands); { j := i + 1 for j < len(cands) && nearlyEqual(cands[j].value, cands[i].value) { j++ } groups = append(groups, tieGroup{startRank: pos, members: cands[i:j]}) pos += j - i i = j } return groups } // groupByManualRank 按录入名次分组:录入名次就是展示名次,不做顺推改写。 func groupByManualRank(cands []candidate) []tieGroup { var groups []tieGroup for i := 0; i < len(cands); { j := i + 1 for j < len(cands) && cands[j].rankIn == cands[i].rankIn { j++ } groups = append(groups, tieGroup{startRank: cands[i].rankIn, members: cands[i:j]}) i = j } return groups } // rankGroups 给每组分配积分并产出名次明细。 func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, groups []tieGroup) []RankedResult { lastRank := 0 for _, g := range groups { if r := g.startRank + len(g.members) - 1; r > lastRank { lastRank = r } } var out []RankedResult for _, g := range groups { pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category) for _, c := range g.members { out = append(out, rankedResult(in, ch, c, g.startRank, pts)) } } return out } // groupPoints 计算一组并列者拿到的分。 // 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1。 func groupPoints(profile ScoringProfile, startRank, size, lastRank int, ev Event, cat EventCategory) float64 { if startRank <= 0 || size <= 0 { return 0 } // lastPlaceZero 是对「最后一名」的覆盖;并列在末位的整组都按最后一名处理, // 否则同组出现 0 与 1 两种分数,违背「并列同分」的语义。 if profile.LastPlaceZero && startRank <= lastRank && lastRank <= startRank+size-1 { return 0 } switch profile.tieMode() { case TieAverage: var sum float64 for r := startRank; r < startRank+size; r++ { p, _ := basePoints(profile, r, ev, cat) sum += p } return sum / float64(size) case TieHigher: best := 0.0 for r := startRank; r < startRank+size; r++ { if p, ok := basePoints(profile, r, ev, cat); ok && p > best { best = p } } return best default: // TieSkip p, _ := basePoints(profile, startRank, ev, cat) return p } } // basePoints 查表得分并乘项目权重、组别权重;超出录取名次或表中无名次一律 0。 func basePoints(profile ScoringProfile, rank int, ev Event, cat EventCategory) (float64, bool) { if rank <= 0 { return 0, false } if profile.AwardRanks > 0 && rank > profile.AwardRanks { return 0, false } p, ok := profile.pointsAt(rank) if !ok { return 0, false } return p * ev.multiplier() * cat.multiplier(), true } func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, points float64) RankedResult { rr := RankedResult{ EntryID: c.entry.ID, ResultID: c.result.ID, EventID: in.Event.ID, CategoryID: in.Category.ID, ChannelID: ch.ID, Scope: ch.Scope, Basis: ch.Basis, AthleteID: c.entry.AthleteID, AthleteName: c.entry.AthleteName, TeamID: c.entry.primaryTeamID(), Shares: c.entry.resolvedShares(in.Options.UnionAttribution), Rank: rank, Points: points, Status: c.result.Status, } if c.result.ResultValue != nil { v := *c.result.ResultValue rr.ResultValue = &v } return rr } // scoreByAggregate 实现 basis=aggregate:把同一主体(单位/运动员)在本组别的成绩取平均, // 得到团体成绩后按项目的 compare 方向排名(并列沿用模板的 tieMode,默认顺延)。 // 平均值分母由项目的 avg_mode 决定: // - valid:分子 = 有效成绩之和,分母 = 有有效成绩的人数;一个人都没成绩就不参与排名、不得分; // - all:分子 = 有效成绩之和(没成绩的按 0),分母 = 该主体的报名人数。 // 没有报名项的主体不会成桶,自然不参与。 // // 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致。 func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) { if in.Event.Compare == CompareNone { return nil, ErrCompareRequired } byEntry := make(map[int64]Result, len(in.Results)) for _, r := range in.Results { byEntry[r.EntryID] = r } type bucket struct { subject int64 entry Entry sum float64 // 有效成绩之和 valid int // 有有效成绩的人数(valid 口径的分母) roster int // 报名人数(all 口径的分母) } order := make([]int64, 0) buckets := make(map[int64]*bucket) for _, e := range in.Entries { if !entryMatches(in, e) || !e.Status.rankable() { continue } subject := e.TeamID if ch.Scope == ScopeAthlete { subject = e.AthleteID } if subject == 0 { continue } b, ok := buckets[subject] if !ok { b = &bucket{subject: subject, entry: e} buckets[subject] = b order = append(order, subject) } b.roster++ r, found := byEntry[e.ID] if v, ok := validResultValue(r, found, in.Options); ok { b.sum += v b.valid++ } } agg := make([]candidate, 0, len(order)) for _, id := range order { b := buckets[id] var value float64 if in.Event.avgMode() == AvgModeAll { // 分母 = 报名人数:没成绩的人按 0 计入,也就是只累加有效成绩之和。 value = b.sum / float64(b.roster) } else { if b.valid == 0 { // 一个人都没有效成绩的单位不参与团体排名,也不得分。 continue } value = b.sum / float64(b.valid) } c := candidate{entry: b.entry, subject: b.subject, value: value} c.entry.ID = 0 // 聚合结果不对应单条报名项 agg = append(agg, c) } sortCandidatesByValue(agg, in.Event.Compare) groups := groupByValue(agg) lastRank := 0 for _, g := range groups { if r := g.startRank + len(g.members) - 1; r > lastRank { lastRank = r } } var out []RankedResult for _, g := range groups { pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category) for _, c := range g.members { rr := RankedResult{ EventID: in.Event.ID, CategoryID: in.Category.ID, ChannelID: ch.ID, Scope: ch.Scope, Basis: ch.Basis, TeamID: c.entry.primaryTeamID(), Rank: g.startRank, Points: pts, } if ch.Scope == ScopeTeam { rr.TeamID = c.subject rr.Shares = []TeamShare{{TeamID: c.subject, Weight: 1}} } else { rr.AthleteID = c.subject rr.AthleteName = c.entry.AthleteName rr.Shares = c.entry.resolvedShares(in.Options.UnionAttribution) } v := c.value rr.ResultValue = &v out = append(out, rr) } } return out, nil } func sortCandidatesByValue(cands []candidate, compare CompareMode) { sort.SliceStable(cands, func(i, j int) bool { if nearlyEqual(cands[i].value, cands[j].value) { return cands[i].entry.ID < cands[j].entry.ID } if compare == CompareDesc { return cands[i].value > cands[j].value } return cands[i].value < cands[j].value }) } func nearlyEqual(a, b float64) bool { return math.Abs(a-b) <= rankEpsilon } // validResultValue 判断一条成绩是否算「有效成绩」:成绩存在、已发布(或允许草稿)、 // 状态正常,且有可比较的数值。聚合通道按 avg_mode 决定没成绩的人算不算进分母。 func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) { if !found { return 0, false } if r.State == ResultStateDraft && !opts.IncludeDrafts { return 0, false } if !r.Status.rankable() || r.ResultValue == nil { return 0, false } return *r.ResultValue, true } // ledgerFromRanked 把名次明细转成积分流水:按通道 scope 决定记给个人还是单位, // 联队按份额拆分;0 分不产生流水,避免流水表被零分噪声撑大。 // 流水是否计入单位总分由通道的 countToTeam 决定(个人通道可以只记个人)。 func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult) []LedgerEntry { var out []LedgerEntry for _, rr := range ranked { if rr.Points == 0 { continue } athleteID := int64(0) if rr.Scope == ScopeAthlete { athleteID = rr.AthleteID } sourceID := rr.ResultID if sourceID == 0 { sourceID = rr.EntryID } if sourceID == 0 { sourceID = ch.ID } for _, s := range rr.Shares { pts := rr.Points * s.Weight if pts == 0 { continue } out = append(out, LedgerEntry{ TeamID: s.TeamID, AthleteID: athleteID, EventID: in.Event.ID, CategoryID: in.Category.ID, EntryID: rr.EntryID, ResultID: rr.ResultID, ChannelID: ch.ID, SourceType: SourceResult, SourceID: sourceID, Points: pts, CountToTeam: ch.countToTeam(), }) } } return out } // resolvedShares 解析报名项的归属:默认整体归主单位,联队模式才按份额拆分。 func (e Entry) resolvedShares(mode UnionAttribution) []TeamShare { shares := e.teamShares() if len(shares) == 0 { return nil } if mode != UnionSplit { return []TeamShare{{TeamID: e.primaryTeamID(), Weight: 1}} } return normalizeShares(shares) } // normalizeShares 把份额归一化到总和 1,避免配置里权重不标准导致总分被放大。 func normalizeShares(shares []TeamShare) []TeamShare { var sum float64 for _, s := range shares { sum += s.Weight } out := make([]TeamShare, len(shares)) if sum <= 0 { w := 1 / float64(len(shares)) for i, s := range shares { out[i] = TeamShare{TeamID: s.TeamID, Weight: w} } return out } for i, s := range shares { out[i] = TeamShare{TeamID: s.TeamID, Weight: s.Weight / sum} } return out }