package domain import ( "fmt" "math" "sort" ) // rankEpsilon 用于判定「成绩相同」。计时/测距保留两位小数,1e-9 足够区分。 const rankEpsilon = 1e-9 // ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。 type ScoreOptions struct { // IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。 IncludeDrafts bool // RankOnly 为 true 时本次只产出名次、不产出任何流水分值(发布侧的「只更新名次」)。 // // 与「小项没有计分通道」那条退化路径同一个出口:名次照算(预赛排名次是刚需), // 但每条名次的 Points 归零、Ledger 为空、破纪录加分也不产生。 // 区别只在于通道**仍然逐条照原样参与排名**——退化路径会把小项压成一条合成通道, // 那会改掉名次口径与主体,不是这里想要的。 RankOnly bool } // EventScoreInput 是一次小项(某组别)计分的全部输入,引擎只读不改。 type EventScoreInput struct { Event Event Category EventCategory Channels []ScoreChannel Profiles map[int64]ScoringProfile Entries []Entry Results []Result RecordBonus *RecordBonusConfig RecordBreaks []RecordBreak // TeamRanks 是按单位录入的团体名次,只有 scope=team + basis=manual 的通道读它。 // 与「团体报名项(athlete_id=0)的 results.rank」共同构成团体名次的全部来源。 TeamRanks []TeamManualRank Options ScoreOptions } // EventScore 是一次小项计分的结果。 type EventScore struct { Ledger []LedgerEntry Ranked []RankedResult } // RankedResult 记录「某条报名项在某通道下拿到的名次与原始积分」。 // Points 尚未乘个人→单位系数,因此可直接用于奖牌判定与分项成绩表。 // // Shares 是该名次的归属单位份额:报名项配了联队就是多条(如 A、B 各半),否则是主单位独占 // 一条;积分按 Weight 拆、奖牌按 CountsMedal 整枚各记(见 TeamShare)。名次本身只有一条, // 分数与奖牌的下发(拆成几份、记给谁)全部由 Shares 决定——流水侧在 ledgerFromRanked 拆成多行, // 奖牌侧在 standings.teamMedals 逐份额记牌。 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 // SubjectKey 是这条名次的主体标识:名次按 (通道, 组别, 主体) 唯一, // 取值 'e:' / 'a:' / 't:',三者都是 0 时退化为 'ch:'。 // 聚合与团体手工名次没有单条报名项,靠它才能唯一落库。 SubjectKey string // 下面三个开关随名次一起携带,榜单侧(standings.go)不必回查库就能算出奖牌: // 小项是否结算奖牌、通道是否发牌、个人奖牌是否并入单位奖牌榜。 AwardMedals bool CountToMedals bool CountToTeamMedals bool // MemberCopy 为 true 表示这条名次是「团体报名的队员各拿一份」的个人副本(devdocs/20): // 分记到 AthleteID 头上(个人榜),但**不进单位维度**——流水恒为 count_to_team=false, // 单位奖牌按报名项去重时也只认报名项那一份。 // // 它只在「引擎产出 → 落库」这一段有意义:channel_ranks 不存这个开关,落库后认出这些行 // 靠 subject_key 的 'e:<报名项>:a:<运动员>' 形状与 athlete_id 关联,而下游(榜单、导出、 // 快照)本来就不需要它——它们看的是「按报名项去重」与 athlete_id 这两件既有事实。 MemberCopy bool } // ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。 // 计分与排名解耦:项目 scored=false 或没有通道时照样产出名次(Points=0),只是不写流水。 // 没有通道时按小项自身的录入方式(result_mode)推导排名口径,见 rankOnlyChannel。 func ScoreEvent(in EventScoreInput) (EventScore, error) { // 引擎不读取 pointsOverride,静默忽略等于「设置成功但毫无效果」; // 显式报错把问题暴露在计分环节,写入侧另有 400 拦截,两道都不放行。 for _, r := range in.Results { if r.PointsOverride != nil { return EventScore{}, fmt.Errorf("%w: 成绩 %d(报名项 %d)", ErrPointsOverrideUnsupported, r.ID, r.EntryID) } } var out EventScore noChannels := len(in.Channels) == 0 // 只排名不计分:scored=false、没有计分通道、或本次声明「只更新名次」时都不产出任何流水 // (含破纪录加分)。 scoreEnabled := in.Event.Scored && !noChannels && !in.Options.RankOnly channels := sortedChannels(in.Channels) if noChannels { 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。 // scope=team + basis=manual 的候选来源与其它通道不同(P0 的修法),转给 collectTeamManualCandidates。 func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error) { if ch.Basis == BasisResult && in.Event.Compare == CompareNone { return nil, ErrCompareRequired } if ch.Basis == BasisManual && ch.Scope == ScopeTeam { return collectTeamManualCandidates(in, ch), nil } byEntry := make(map[int64]Result, len(in.Results)) for _, r := range in.Results { 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 } // collectTeamManualCandidates 是 scope=team + basis=manual 的候选来源,只有两处: // 1. in.TeamRanks(按单位录入的团体名次):没有对应的报名项,用合成 Entry{TeamID: id} 承载; // 2. 兜底:团体报名项(athlete_id == 0)的 results.rank,纯团体项目(篮球等)沿用原录入流程。 // // 同一单位两个来源都有时以 TeamRanks 为准;rank <= 0 的团体名次视为未填 / 清空,直接忽略。 // 个人报名项(athlete_id != 0)的名次是个人名次,绝不进团体通道——否则双通道项目 // 第 2 次发布时会把刚写回 results.rank 的个人名次当成团体名次再发一次分(P0)。 func collectTeamManualCandidates(in EventScoreInput, ch ScoreChannel) []candidate { type manualRank struct { teamID int64 rank int } overrides := make([]manualRank, 0, len(in.TeamRanks)) overrideOf := make(map[int64]int, len(in.TeamRanks)) for _, tr := range in.TeamRanks { if tr.TeamID == 0 || tr.Rank <= 0 { continue } if _, dup := overrideOf[tr.TeamID]; dup { continue } overrideOf[tr.TeamID] = tr.Rank overrides = append(overrides, manualRank{teamID: tr.TeamID, rank: tr.Rank}) } byEntry := make(map[int64]Result, len(in.Results)) for _, r := range in.Results { byEntry[r.EntryID] = r } out := make([]candidate, 0, len(in.Entries)+len(overrides)) taken := make(map[int64]bool, len(overrides)) for _, e := range in.Entries { if !entryMatches(in, e) || e.AthleteID != 0 || !e.Status.rankable() { continue } r, ok := byEntry[e.ID] if !ok { continue } if r.State == ResultStateDraft && !in.Options.IncludeDrafts { continue } if !r.Status.rankable() || r.Rank <= 0 { continue } rankIn := r.Rank if rank, ok := overrideOf[e.TeamID]; ok { rankIn = rank } taken[e.TeamID] = true out = append(out, candidate{entry: e, result: r, rankIn: rankIn}) } for _, or := range overrides { if taken[or.teamID] { continue } // 联队:该主单位在本组别报了联队(团体行 + 多单位份额)时,这条按单位录的名次就是 // 联队的名次,份额取自那条报名——否则 A+B 拿到的名次会整份记在 A 头上,B 一分没有。 // 没有联队报名(老数据、普通报名)时仍走下面的合成报名项,名次主体照样是单位。 if joint, ok := jointTeamEntry(in, or.teamID); ok { out = append(out, candidate{entry: joint, rankIn: or.rank}) continue } // 合成报名项:ID=0 表示「不对应任何一条报名项」,团体名次的主体是单位。 out = append(out, candidate{ entry: Entry{TeamID: or.teamID}, rankIn: or.rank, }) } return out } // jointTeamEntry 找「主单位 teamID 在本组别的联队报名」:团体行(athlete_id=0)+ 配了多个单位的份额。 // // 团体名次是按单位录的(channel_manual_ranks 的主体只有单位),联队份额只能挂在报名上, // 所以录名次时得回头找这条报名。找不到(含只配了一个单位的普通报名)就返回 false, // 调用方退回「主单位独占 100%」的合成报名项。 // // 为什么把「只有一个单位」也排除:那样与独占 100% 等价,却会把名次的身份从单位(subject_key // `t:`)换成报名项(`e:`),channel_ranks 的既有行会跟着变——老数据必须逐字节不变。 // // 同一个组别同一主单位最多只有一条团体行(entries 的唯一索引兜着),真出现多条且份额不一致时 // 退回独占:宁可少拆,也不能随便挑一条把分拆错。 func jointTeamEntry(in EventScoreInput, teamID int64) (Entry, bool) { var found Entry ok := false for _, e := range in.Entries { if e.AthleteID != 0 || e.TeamID != teamID || !entryMatches(in, e) || !e.Status.rankable() { continue } if len(e.TeamShares) < 2 { continue } if ok && !sameShares(found.TeamShares, e.TeamShares) { return Entry{}, false } found, ok = e, true } return found, ok } // sameShares 判断两组份额是否一致(单位集合与各自权重、奖牌开关都比)。 func sameShares(a, b []TeamShare) bool { if len(a) != len(b) { return false } for i := range a { if a[i].TeamID != b[i].TeamID || !nearlyEqual(a[i].Weight, b[i].Weight) || a[i].CountsMedal() != b[i].CountsMedal() { return false } } return true } 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 { // lastPlace 是本组别被占用的最大名次档,也就是「末位」那一档:skip 口径下各组的占位 // 连续铺满 1..N,它正好等于实际可排名人数 N。末位归零只抹这一档,见 groupPoints。 lastPlace := 0 for _, g := range groups { if r := g.startRank + len(g.members) - 1; r > lastPlace { lastPlace = r } } var out []RankedResult for _, g := range groups { pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, in.Event, in.Category) for _, c := range g.members { out = append(out, entryRankedResults(in, ch, c, g.startRank, pts)...) } } return out } // entryRankedResults 产出一条报名的**全部**名次明细:第一条是报名项自己那份(单位维度只认它, // 老数据也只有这一条),后面是每个队员的个人副本(团队报名才有,每人一份完整的个人分)。 // // 顺序是有意的:报名项那一份排在最前。channel_ranks 按 (…, 名次, id) 读出、奖牌去重按报名项 // 取同键里的第一条,于是「单位维度读到的是哪一份」与升级前完全一致。 func entryRankedResults(in EventScoreInput, ch ScoreChannel, c candidate, rank int, points float64) []RankedResult { base := rankedResult(in, ch, c, rank, points) members := memberAthletes(ch, c.entry) if len(members) == 0 { return []RankedResult{base} } out := make([]RankedResult, 0, len(members)+1) out = append(out, base) for _, m := range members { out = append(out, memberCopy(base, m)) } return out } // memberAthletes 判断这条名次要不要给队员各发一份:只有**个人通道**(scope=athlete)才发。 // 团体通道(scope=team)的名次本来就记在单位头上,队员不参与;带运动员的报名(个人报名) // 自己就是那个人的身份,没有队员这回事。 func memberAthletes(ch ScoreChannel, e Entry) []EntryAthlete { if ch.Scope != ScopeAthlete || e.AthleteID != 0 { return nil } return e.Athletes } // memberCopy 把「报名项那一份名次」复制成某个队员的个人分:分数、名次、归属单位与份额全部照抄, // 只把身份换成这个队员,并打上 MemberCopy 标记(落库时按它走「只记个人」的流水)。 // // 照抄 TeamID 与 Shares 是**必须的**:单位维度按报名项去重(standings.medalKeyOf), // 无论同键里的哪一份胜出,单位拿到的分与牌都是同一份;换成队员自己的单位或另造份额, // 就会在「谁在去重里胜出」这件事上分叉,联队报名尤其容易给错单位记牌。 func memberCopy(base RankedResult, m EntryAthlete) RankedResult { cp := base cp.MemberCopy = true cp.AthleteID = m.AthleteID cp.AthleteName = m.Name cp.SubjectKey = memberSubjectKey(base.EntryID, m.AthleteID) return cp } // memberSubjectKey 是队员个人副本的主体标识:'e:<报名项>:a:<运动员>'。 // // 必须带上报名项:同一个运动员可以同时出现在同组别的两条报名里(例如一个单位的一队与二队 // 各带了他),只用 'a:<运动员>' 会撞 channel_ranks 的 (通道, 组别, 主体) 唯一索引, // 整次发布直接 500。既有名次键(e: / a: / t: / ch:)一个字符都没变,老行不受影响。 func memberSubjectKey(entryID, athleteID int64) string { return fmt.Sprintf("e:%d:a:%d", entryID, athleteID) } // groupPoints 计算一组并列者拿到的分。 // 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1; // lastPlace 是本组别被占用的最大名次档(末位那一档,skip 口径下等于实际可排名人数 N)。 func groupPoints(profile ScoringProfile, startRank, size, lastPlace int, ev Event, cat EventCategory) float64 { if startRank <= 0 || size <= 0 { return 0 } // lastPlaceZero 只把「末位那一档」置零:判据是起始名次**正好落在这档**。 // 这种组必然只有一个人(startRank == lastPlace 意味着 size == 1,因为 lastPlace 是所有组 // 占位名次的最大值)。并列组若从更靠前的名次起步、区间跨过末位,组内多数人本可拿到录取线内的分, // 整组归零会把他们一起抹掉——7 人里三人并列第 5(占 5/6/7)时,第 5 名的 2 分与第 6 名的 1 分 // 都该照发,而他们并不是「末位那一档」。 if profile.LastPlaceZero && startRank == lastPlace { 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: ResolveShares(c.entry.TeamShares, c.entry.primaryTeamID()), Rank: rank, Points: points, Status: c.result.Status, } rr.SubjectKey = subjectKeyOf(rr) rr.AwardMedals = in.Event.AwardMedals rr.CountToMedals = ch.CountsToMedals() rr.CountToTeamMedals = ch.CountsToTeamMedals() if c.result.ResultValue != nil { v := *c.result.ResultValue rr.ResultValue = &v } return rr } // scoreByAggregate 实现 basis=aggregate:把同一主体(单位/运动员)在本组别的成绩汇成一个值, // 得到团体成绩后按项目的 compare 方向排名(并列沿用模板的 tieMode,默认顺延)。 // // 汇总口径(通道的 aggregate_mode,缺省 avg): // - avg:团体成绩 = 成员成绩的平均值; // - sum:团体成绩 = 成员成绩的总和。 // // avg 的平均值分母由项目的 avg_mode 决定: // - valid:分子 = 有效成绩之和,分母 = 有有效成绩的人数;一个人都没成绩就不参与排名、不得分; // - all:分子 = 有效成绩之和(没成绩的按 0),分母 = 该主体的报名人数。 // 没有报名项的主体不会成桶,自然不参与。 // // sum 只累加有效成绩(没成绩的人按 0 计入和不计入是一样的和),所以 avg_mode 在这里只剩一个作用: // 决定「一个有效成绩都没有的单位算不算 0 分参与排名」——valid 不参与、all 参与,与 avg 完全一致。 // // 弃权 / 失格(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++ } } sumMode := ch.aggregateMode() == AggregateSum allMode := in.Event.avgMode() == AvgModeAll agg := make([]candidate, 0, len(order)) for _, id := range order { b := buckets[id] if b.valid == 0 && !allMode { // 一个人都没有效成绩的单位不参与团体排名,也不得分(两种汇总口径都一样)。 continue } var value float64 switch { case sumMode: // 求和口径:只累加有效成绩(没成绩按 0 计入与否不改变和)。 value = b.sum case allMode: // 分母 = 报名人数:没成绩的人按 0 计入,也就是只累加有效成绩之和。 value = b.sum / float64(b.roster) default: 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) // 与 rankGroups 同一个口径:lastPlace = 被占用的最大名次档(聚合结果同样是 skip 占位)。 lastPlace := 0 for _, g := range groups { if r := g.startRank + len(g.members) - 1; r > lastPlace { lastPlace = r } } var out []RankedResult for _, g := range groups { pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, 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, } // 聚合名次不对应任何一条报名项(EntryID=0,主体是单位或运动员),读取侧 // (channel_ranks → buildRanked)没有报名项可以回查份额,只能按主体单位独占; // 引擎必须同口径,否则「流水按份额拆了、奖牌榜还按单位独占」,两处对不上。 // 因此联队 + 聚合通道不拆份额(已记进 devdocs/19 的未决项:聚合按主单位分桶)。 if ch.Scope == ScopeTeam { rr.TeamID = c.subject rr.Shares = wholeShare(c.subject) } else { rr.AthleteID = c.subject rr.AthleteName = c.entry.AthleteName rr.Shares = wholeShare(c.entry.primaryTeamID()) } rr.SubjectKey = subjectKeyOf(rr) rr.AwardMedals = in.Event.AwardMedals rr.CountToMedals = ch.CountsToMedals() rr.CountToTeamMedals = ch.CountsToTeamMedals() v := c.value rr.ResultValue = &v out = append(out, rr) } } 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 决定(个人通道可以只记个人)。 // // 队员的个人副本走另一条路(memberLedger):每个队员一整份分、只记个人(count_to_team=false), // 于是单位总分与单位奖牌一个字节都不动——那是本单最容易错的地方,见 memberLedger。 // // 团体手工名次与聚合名次没有对应的报名项与成绩(EntryID=0、ResultID=0), // 仍要能落库:SourceType 与聚合流水一样是 result,sourceID 退化为通道 id, // 由真实来源(通道 + 主体)唯一确定。写入侧按「EntryID=0 && ResultID=0 && source_type=result」 // 识别这类流水(httpapi 的 isAggregateLedger),按组别重算时会整项重算回填,条数不增不减。 func ledgerFromRanked(in EventScoreInput, ch ScoreChannel, ranked []RankedResult) []LedgerEntry { var out []LedgerEntry for _, rr := range ranked { if rr.Points == 0 { continue } sourceID := rr.ResultID if sourceID == 0 { sourceID = rr.EntryID } if sourceID == 0 { sourceID = ch.ID } if rr.MemberCopy { out = append(out, memberLedger(rr, in, ch, sourceID)) continue } athleteID := int64(0) if rr.Scope == ScopeAthlete { athleteID = rr.AthleteID } split := splitSharePoints(rr.Points, rr.Shares) for i, s := range rr.Shares { if split[i] == 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: split[i], CountToTeam: ch.countToTeam(), }) } } return out } // memberLedger 是队员个人副本的流水:**只记个人**(CountToTeam 恒为 false), // 每个队员一整份分,不按份额拆(联队那套是「拆」,这里是「复制」,别抄混)。 // // 为什么恒为 false、而不是照抄通道开关:单位维度只认报名项那一份流水(它照抄通道开关), // 队员这两份若也计入,单位总分就会变成 N 份(双打就是双倍),单位奖牌同理。 // SumByTeam 遇到 count_to_team=0 直接跳过,所以单位总分、单位明细、单位奖牌全都不会看到它们; // 而 SumByAthlete 不看这个开关,队员的个人分照拿(这也正是「双打不进单位、但上个人榜」的实现)。 func memberLedger(rr RankedResult, in EventScoreInput, ch ScoreChannel, sourceID int64) LedgerEntry { return LedgerEntry{ TeamID: rr.TeamID, AthleteID: rr.AthleteID, EventID: in.Event.ID, CategoryID: in.Category.ID, EntryID: rr.EntryID, ResultID: rr.ResultID, ChannelID: ch.ID, SourceType: SourceResult, SourceID: sourceID, Points: rr.Points, CountToTeam: false, } } // subjectKeyOf 计算一条名次明细的主体标识(channel_ranks.subject_key 同口径): // 有报名项就用报名项(个人名次按报名项去重才不会被并列拆开),否则按主体(个人 / 单位), // 全为空时退化为通道 id,保证 (通道, 组别, 主体) 的唯一索引不会撞键。 func subjectKeyOf(rr RankedResult) string { switch { case rr.EntryID != 0: return fmt.Sprintf("e:%d", rr.EntryID) case rr.Scope == ScopeAthlete && rr.AthleteID != 0: return fmt.Sprintf("a:%d", rr.AthleteID) case rr.TeamID != 0: return fmt.Sprintf("t:%d", rr.TeamID) default: return fmt.Sprintf("ch:%d", rr.ChannelID) } } // ResolveShares 把「报名项配的份额」解析成最终归属份额:配了(联队)就用配置, // 没配就是主单位独占 100%,与升级前逐字段一致。读侧(channel_ranks → RankedResult) // 与引擎共用它,避免两边各写一份「没有份额时怎么办」。 func ResolveShares(configured []TeamShare, teamID int64) []TeamShare { if len(configured) > 0 { return configured } return wholeShare(teamID) } // wholeShare 构造「主单位独占 100%」的归属份额:没有联队配置时的唯一口径。 func wholeShare(teamID int64) []TeamShare { if teamID == 0 { return nil } return []TeamShare{{TeamID: teamID, Weight: 1}} } // splitSharePoints 把一条成绩的积分按份额拆给各成员单位,返回值与 shares 一一对应。 // // 权重是相对值(默认各 1 = 均分),先按权重之和归一:联队的两个单位各 1 份时总分照旧, // 2:1 时也拆成两单位之和等于原来那一份分——**份额只改「分记在谁头上」,不改变总分**。 // 没有配份额(老数据、普通报名)时是单条独占:total·1/1,结果与升级前逐位相同。 // // 最后一份取「总分减去前面各份」的余数,而不是再算一次乘法:2:1 这种除不尽的权重 // 各份直接算会剩下 1e-15 级零头,榜单上就会印出 9.999999999999998 这种数。 // // 权重 <= 0 按 1 处理:库里有 CHECK(points_share > 0) 兜着,这里是引擎侧的兜底, // 与其它地方的「零值按 1」口径(multiplier / effectiveWeight)一致。 func splitSharePoints(total float64, shares []TeamShare) []float64 { out := make([]float64, len(shares)) if len(shares) == 0 { return out } sum := 0.0 for _, s := range shares { sum += shareWeight(s) } acc := 0.0 for i, s := range shares { if i == len(shares)-1 { out[i] = total - acc break } out[i] = total * shareWeight(s) / sum acc += out[i] } return out } // shareWeight 取份额的积分权重,零值与负值按 1 处理(见 splitSharePoints)。 func shareWeight(s TeamShare) float64 { if s.Weight <= 0 { return 1 } return s.Weight }