Files
Scoreboard/server/internal/domain/scoring.go
T
AskaEth 99c91f99f7 feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」

**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。

## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。

## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
  `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。

## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
  1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
  2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
  3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
  反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。

## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
  改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。

## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。

## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
  ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。

## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。

## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 14:05:48 +08:00

895 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// 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
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, 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
}