Files
Scoreboard/server/internal/domain/scoring.go
T
AskaEth c5ce1878fc feat(publish): 「以决赛成绩发布」开关 —— 非决赛发布只更新名次、不计分;版本 dev_0.5.8
## 需求(主人原话)
> 只有在最后一轮(决赛)的成绩才算分,你给发布按钮边上带一个选项,勾选之后**当前小项/组以决赛成绩发布**,
> 不然的话就**只更新名次(非决赛) 不加分**。

**设计好处**:不引入「轮次」模型,而是**在已有的「发布」动作上加一个口径** —— 分轮次比赛时,同一批人各轮成绩各发各的,
榜单只认决赛那一轮的分。引擎本来就有一条「只排名不计分」的通路,这次是**把它在发布入口露出来**,没有另造算分分支。

## 契约
`POST /api/results/publish` 请求体新增 **`rankOnly: boolean`**:
- **缺省 / `false`** = 计分发布,**与改动前逐字节一致**(老客户端不带这个字段走现状路径);
- **`true`** = 本次只更新名次。

**只更新名次时写**:`results.state/verified_by/verified_at`(照旧)、`results.rank`(预赛排名次是刚需)、
整小项录完时的 `events.status`、`result_revisions`(message 写明本次口径)、`change_log(entity=result)`(大屏据此重拉名次)。
**不写**:`score_ledger`(**既不加,也不作废任何已有流水**)、`channel_ranks`(不重建也不删除)、榜单快照版本。

⇒ 于是会出现预期的「**名次变了、分还是旧的**」(本组上次按决赛发布过时)—— 确认框与录入页常驻提示都点名说了这件事。

## 界面
- 录入页「发布」按钮**左边**:勾选框「**以决赛成绩发布(勾选才计分发牌)**」,**默认不勾**;
  旁边徽标随状态切「**只更新名次(不计分)**」(警示色)/「**计分发布(算分发牌)**」(绿色)。
- 确认框:标题切「确认按决赛成绩发布该组?」/「确认发布该组(只更新名次)?」;不勾时不再报「影响 N 个单位、最大分差 X 分」;
  已有在榜分时加一句「本组已有 X 分在榜…那份积分与奖牌会原样保留、不随名次变动」。
- **常驻「在榜口径」提示**:系统不记「上次勾了什么」,判据用**榜面事实**(已发布成绩的流水合计)——
  「本组在榜上只有名次(上次是按只更新名次发布的)」/「本组在榜上是已计分的成绩(已有 X 分来自上一次按决赛发布)」。
- **发布成功后勾选复位**:勾选是「这一次发布」的口径,不是页面设置(免得换小项时把预赛当决赛算分)。
- ⚠️ **审核页是第二个发布入口**,同样加了口径 —— 否则从审核页发布预赛会被**悄悄算成分**。

## 兼容性(回答「能不能兼容现有数据包」)
- **没有任何数据库变更、没有迁移**:纯行为改动,多带一个请求字段。
- 旧客户端 / 旧数据包 / 旧库照旧工作;不带该字段时与改动前**逐字节一致**(用改动前采集的整份写库 golden 对拍证明)。
- 反向(用新版导出的项目包去旧版本导入)仍是不支持的,与之前几版一致。

## 验证
- **先红后绿**:改动前跑新用例,红在「只更新名次的发布动了已有流水」(贴出 `score_ledger` 前后对比);
- 另有 `rank_only_heal_test.go` 钉住「只更新名次发布过的库,**启动自愈不许把它算成分**」;
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **944 例** 0 fail / Console 68 例 0 fail。

## 文档
`docs/升级说明.md` 新增 `dev_0.5.8` 一节。

## 版本
`dev_0.5.7` → **`dev_0.5.8`**(无数据迁移);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 22:57:03 +08:00

903 lines
33 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
// 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:<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
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:<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
}