## 需求
现场 6 项球类比赛里,有两个单位因参赛人数不足**合并成一个单位参赛**。
做法不是新建「联队单位」(那样榜单会多出第 8 个单位,且分和奖牌分不到原单位头上),
而是**一条报名挂多个单位、按份额拆**。
## 拍板口径
- 份额**可配、默认均分**
- 积分**平均拆、接受 .5**(团体第 1 名 15 分 → 两个单位各 7.5 分)
- 奖牌**各记一枚**(两个单位各 1 枚完整金牌,不是各 0.5 枚)
- 显示上把联队名字写全(形如 `第一分公司+第二分公司联队`),成员单位的 logo 都放
- 导入表里单位编码写 `A+B`
## 一、核心层(数据模型 + 引擎 + 存储 + 报名写入 + 导入解析)
- 迁移 `0024_entry_team_shares.sql`:新建 `entry_team_shares(entry_id, team_id, points_share,
medal_share, sort_order)`。**两个口径分开表达**是刻意的——积分要拆成小数(7.5)、奖牌要整枚
各记,用一个数表达就会把「7.5 分」和「半枚金牌」绑在一起,奖牌榜立刻错。
`entries.team_id` 保留为**主单位**;老数据 `INSERT OR IGNORE` 回填成「主单位独占 100%」。
- 引擎:`ResolveShares`(引擎与读侧共用)、`splitSharePoints`(按权重归一、**末位吞余数**,
各份之和恒等于原来那一份)、`TeamShare.Medal`(缺省整枚)。积分按权重拆,奖牌每个份额各记整枚。
- 报名写入:`PUT /api/entries/{id}/team-shares`(recorder 及以上,保存后当场重算该小项组别并推榜)。
- 导入:`team_code` 支持 `A+B`(主单位 = 加号左边,均分,奖牌各整枚);非法形态
(`A+` / `+B` / `A++B` / `A+A` / 成员 >5 / 单位不存在)**行级拦下、不落库**,给人话报错。
## 二、导出与导入模板
- 11 份导出件:联队写全成员单位(`第一分公司+第二分公司联队`),并注明**分拆给了哪几家、各多少**
(份额占比 + 各单位实得积分,取自按份额拆完的流水)。
- 单位奖牌明细:奖牌格写「金牌(联队 …,各单位各记 1 枚)」;含联队时副标题说明
「各单位奖牌数之和会大于实际颁发数」——这是口径 3 的必然结果,**不是 bug**。
- 按单位筛选导出把成员单位一起算上(否则 B 单位导自己的名单会看不到自己参加的那场联队比赛)。
- 分工作表模板:`A+B` 写法挂在「单位编码」表头的**单元格批注 + 输入提示**(中英各一份);
**表头文字、列序、12 列、工作表命名一个字未动**(动了模板就导不回去)。
## 三、前端与大屏
- 录入页 / 复核页「单位」列:联队显示 `联队 第一分公司 67%(权重 2) 第二分公司 33%`,
点该格打开「成员单位与份额」弹窗(加/删成员、改权重、主单位徽标、实时比例预览)。
**普通报名(单单位)的文案、元素、类名一字未动**,只多了一个 `@click` 入口与悬停提示。
- 大屏:分项成绩 / 实时赛况单位列显示联队全名并**放上两个单位的 logo**(单位列宽按该行的
logo 数留位);底栏同理。
## 四、验证(都留了证据)
- **老数据逐字节不变**(改算分引擎的硬门槛):引擎层 golden `a2aa1c97…`、接口层 golden
`aa6cea37…`,两个哈希由主会话**在修前树上亲手重跑确认**;导出层另有 11 份导出件逐行对拍,
差异 **0 处**。
- 前端 773 → **809 例**全绿(旧用例一行未改);服务端 14 包两轮全绿;Console 68 例全绿。
- 真机联调:录入页份额弹窗、大屏两个 logo、奖牌榜 A/B 各 1 金、写路径(移除成员 → 保存 → 200)。
## 五、版本
`dev_0.5.2` → **`dev_0.5.3`**(本批含数据迁移 0024 + 新功能,必须与上一包区分开);
安装器素材重生成(版本号烙在位图里,实测只有 `brand.bmp` 的 1x/2x 两张变化,其余 92 张逐字节未变)。
804 lines
27 KiB
Go
804 lines
27 KiB
Go
package domain
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
)
|
||
|
||
// rankEpsilon 用于判定「成绩相同」。计时/测距保留两位小数,1e-9 足够区分。
|
||
const rankEpsilon = 1e-9
|
||
|
||
// ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。
|
||
type ScoreOptions struct {
|
||
// 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
|
||
// 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:<entryId>' / 'a:<athleteId>' / 't:<teamId>',三者都是 0 时退化为 'ch:<channelId>'。
|
||
// 聚合与团体手工名次没有单条报名项,靠它才能唯一落库。
|
||
SubjectKey string
|
||
// 下面三个开关随名次一起携带,榜单侧(standings.go)不必回查库就能算出奖牌:
|
||
// 小项是否结算奖牌、通道是否发牌、个人奖牌是否并入单位奖牌榜。
|
||
AwardMedals bool
|
||
CountToMedals bool
|
||
CountToTeamMedals 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
|
||
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。
|
||
// 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:<id>`)换成报名项(`e:<id>`),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, rankedResult(in, ch, c, g.startRank, pts))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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 决定(个人通道可以只记个人)。
|
||
//
|
||
// 团体手工名次与聚合名次没有对应的报名项与成绩(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
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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
|
||
}
|