fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件

This commit is contained in:
AskaEth committed 2026-09-09 20:57:58 +08:00
1 parent c121111f51
commit b80c64a115
39 files changed
+4217 -345

No files matched your search

+18 -3
View File
@@ -162,12 +162,13 @@ func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
return out
}
// RecomputeEventLedger 重算某个小项的流水:旧流水整项作废,再追加新流水。
// RecomputeEventLedger 重算某个小项的成绩分与破纪录加分:旧流水整项作废,再追加新流水。
// 人工调整不参与重算,原样保留——否则每重算一次就把人工扣/加分抹掉。
// 新旧集合一致时原样返回——重复发布同一组必须幂等,不能凭空多出流水。
func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerEntry, by, reason string) ([]LedgerEntry, error) {
active := make([]LedgerEntry, 0, len(existing))
for _, e := range existing {
if e.EventID == eventID && !e.Voided {
if e.EventID == eventID && !e.Voided && recomputableSource(e.SourceType) {
active = append(active, e)
}
}
@@ -175,10 +176,24 @@ func RecomputeEventLedger(existing []LedgerEntry, eventID int64, fresh []LedgerE
return append([]LedgerEntry(nil), existing...), nil
}
out, _ := VoidByEvent(existing, eventID, by, reason)
out := append([]LedgerEntry(nil), existing...)
for i := range out {
if out[i].Voided || out[i].EventID != eventID || !recomputableSource(out[i].SourceType) {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
}
return AppendLedger(out, fresh...)
}
// recomputableSource 判断流水来源是否属于「重算范围」:只有引擎产出的成绩分与破纪录加分,
// 人工调整由人负责,重算不得触碰。
func recomputableSource(t SourceType) bool {
return t == SourceResult || t == SourceRecordBonus
}
// ApplyLedgerOps 按给定顺序应用增量操作。
// 各操作只按来源/事件定位流水,互不依赖顺序,因此乱序到达也能收敛到同一结果。
func ApplyLedgerOps(existing []LedgerEntry, ops []LedgerOp) ([]LedgerEntry, error) {
+4
View File
@@ -84,6 +84,7 @@ func RecordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak) []RecordBonu
// recordBonusLedger 把破纪录加分写成流水。
// 加分与成绩分相互独立:不乘项目权重、也不乘组别权重(case 6 的口径)。
// 只处理本组别的报名项:上层按组别逐个调用 ScoreEvent,不加组别过滤会让同一笔加分每组各记一次。
func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
if in.RecordBonus == nil || !in.RecordBonus.Enabled || len(in.RecordBreaks) == 0 {
return nil, nil
@@ -103,6 +104,9 @@ func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
byID := make(map[int64]Entry, len(in.Entries))
for _, e := range in.Entries {
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
continue
}
byID[e.ID] = e
}
+45 -55
View File
@@ -12,6 +12,7 @@ const rankEpsilon = 1e-9
// ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。
type ScoreOptions struct {
// UnionAttribution 决定联队成绩的归属;空值等同 UnionWhole(整体记给主单位)。
// 未实现:生产链路没有赋值入口,联队份额也无处加载(见 UnionAttribution 注释)。
UnionAttribution UnionAttribution
// IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。
IncludeDrafts bool
@@ -57,26 +58,44 @@ type RankedResult struct {
}
// ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。
// 项目 scored=false 或没有通道时不产生任何流水(只排名不计分)。
// 计分与排名解耦:项目 scored=false 或没有通道时照样产出名次(Points=0),只是不写流水。
// 没有通道时按小项自身的录入方式(result_mode)推导排名口径,见 rankOnlyChannel。
func ScoreEvent(in EventScoreInput) (EventScore, error) {
var out EventScore
if !in.Event.Scored {
return out, nil
rankOnly := len(in.Channels) == 0
channels := sortedChannels(in.Channels)
if rankOnly {
channels = []ScoreChannel{rankOnlyChannel(in)}
}
for _, ch := range sortedChannels(in.Channels) {
profile, ok := in.Profiles[ch.ProfileID]
if !ok {
return EventScore{}, fmt.Errorf("%w: 通道 %d 引用模板 %d", ErrProfileNotFound, ch.ID, ch.ProfileID)
for _, ch := range channels {
// 不计分时不需要积分表:既不查模板,也不可能算出非零分。
profile := ScoringProfile{}
if in.Event.Scored && !rankOnly {
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 !in.Event.Scored {
for i := range ranked {
ranked[i].Points = 0
}
}
out.Ranked = append(out.Ranked, ranked...)
out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...)
if in.Event.Scored {
out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...)
}
}
if !in.Event.Scored {
return out, nil
}
bonus, err := recordBonusLedger(in)
if err != nil {
return EventScore{}, err
@@ -85,6 +104,20 @@ func ScoreEvent(in EventScoreInput) (EventScore, error) {
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...)
@@ -325,22 +358,17 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po
return rr
}
// scoreByAggregate 先把同一主体的成绩汇总成一个值,再按值排名(预留能力)。
// scoreByAggregate 先把同一主体的成绩汇总成一个值(求和),再按值排名(预留能力)。
func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
cands, err := collectCandidates(in, ch)
if err != nil {
return nil, err
}
method := ch.Aggregate
if method == "" {
method = AggregateSum
}
type bucket struct {
subject int64
entry Entry
values []float64
total float64
}
order := make([]int64, 0)
buckets := make(map[int64]*bucket)
@@ -358,13 +386,13 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
buckets[subject] = b
order = append(order, subject)
}
b.values = append(b.values, c.value)
b.total += c.value
}
agg := make([]candidate, 0, len(order))
for _, id := range order {
b := buckets[id]
c := candidate{entry: b.entry, subject: b.subject, value: aggregateValue(method, b.values)}
c := candidate{entry: b.entry, subject: b.subject, value: b.total}
c.entry.ID = 0 // 聚合结果不对应单条报名项
agg = append(agg, c)
}
@@ -408,44 +436,6 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil
return out, nil
}
func aggregateValue(method AggregateMethod, values []float64) float64 {
if len(values) == 0 {
return 0
}
switch method {
case AggregateAvg:
var sum float64
for _, v := range values {
sum += v
}
return sum / float64(len(values))
case AggregateMax:
best := values[0]
for _, v := range values[1:] {
if v > best {
best = v
}
}
return best
case AggregateMin:
best := values[0]
for _, v := range values[1:] {
if v < best {
best = v
}
}
return best
case AggregateCount:
return float64(len(values))
default:
var sum float64
for _, v := range values {
sum += v
}
return sum
}
}
func sortCandidatesByValue(cands []candidate, compare CompareMode) {
sort.SliceStable(cands, func(i, j int) bool {
if nearlyEqual(cands[i].value, cands[j].value) {
+102 -34
View File
@@ -2,12 +2,32 @@ package domain
import "sort"
// 榜单排序键,取值与规则包 standings.tieBreak 一致。
// 定义在 domain 是为了让 rules 包复用同一套键名,避免两边各写一份常量。
const (
TieBreakPoints = "points"
TieBreakGold = "gold"
TieBreakSilver = "silver"
TieBreakBronze = "bronze"
TieBreakTeamCode = "teamCode"
TieBreakName = "name"
)
// TieBreak 未配置时的口径,与 devdocs 02 §5.2 一致。
var (
defaultTeamTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode}
defaultAthleteTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakName}
)
// StandingsInput 是排名聚合的输入:流水 + 名次明细 + 单位/运动员基础信息。
type StandingsInput struct {
Teams []Team
Athletes []Athlete
Ledger []LedgerEntry
Ranked []RankedResult
// TieBreak 是排序键顺序(如 points/gold/silver/bronze/teamCode/name);
// 空表示用默认口径。配置里的键优先,其余按默认顺序补齐,保证结果全序。
TieBreak []string
// IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。
IndividualToTeamWeight float64
// ExcludeTeamEventMedals 为 true 时团体奖牌榜不计团体项目奖牌;默认计入。
@@ -33,7 +53,7 @@ func rankMedal(rank int) int {
}
// BuildTeamStandings 生成总积分榜。
// 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序:总分 → 金 → 银 → 铜 → 单位编码。
// 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 单位编码。
func BuildTeamStandings(in StandingsInput) []StandingsRow {
weight := effectiveWeight(in.IndividualToTeamWeight)
sums := SumByTeam(in.Ledger, weight)
@@ -62,7 +82,7 @@ func BuildTeamStandings(in StandingsInput) []StandingsRow {
}
}
sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j]) })
sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j], in.TieBreak) })
for i := range rows {
rows[i].Rank = i + 1
}
@@ -92,7 +112,7 @@ func BuildMedalStandings(in StandingsInput) []MedalRow {
}
// BuildAthleteStandings 生成个人积分奖牌榜。
// 排序:总分 → 金 → 银 → 铜 → 姓名;个人榜的奖牌只统计个人项目(团体项目奖牌归团体榜)。
// 排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 姓名;个人榜的奖牌只统计个人项目(团体项目奖牌归团体榜)。
func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow {
points := SumByAthlete(in.Ledger)
medals := athleteMedals(in)
@@ -154,7 +174,7 @@ func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow {
})
}
sort.SliceStable(rows, func(i, j int) bool { return lessAthleteRow(rows[i], rows[j]) })
sort.SliceStable(rows, func(i, j int) bool { return lessAthleteRow(rows[i], rows[j], in.TieBreak) })
for i := range rows {
rows[i].Rank = i + 1
}
@@ -237,43 +257,91 @@ func athleteMedals(in StandingsInput) map[int64]medalTally {
return out
}
func lessTeamRow(a, b StandingsRow) bool {
if !nearlyEqual(a.Points, b.Points) {
return a.Points > b.Points
// tieBreakKeys 把配置的排序键展开成实际比较顺序:配置优先,缺失的默认键补在后面,
// 保证两行在所有键上都相等时仍能由调用方的 ID 兜底分出先后。
func tieBreakKeys(cfg, def []string) []string {
if len(cfg) == 0 {
return def
}
if a.Gold != b.Gold {
return a.Gold > b.Gold
out := make([]string, 0, len(cfg)+len(def))
seen := make(map[string]bool, len(cfg)+len(def))
for _, key := range cfg {
if seen[key] {
continue
}
seen[key] = true
out = append(out, key)
}
if a.Silver != b.Silver {
return a.Silver > b.Silver
for _, key := range def {
if seen[key] {
continue
}
seen[key] = true
out = append(out, key)
}
if a.Bronze != b.Bronze {
return a.Bronze > b.Bronze
}
if a.Code != b.Code {
return a.Code < b.Code
return out
}
func lessTeamRow(a, b StandingsRow, tieBreak []string) bool {
for _, key := range tieBreakKeys(tieBreak, defaultTeamTieBreak) {
switch key {
case TieBreakPoints:
if !nearlyEqual(a.Points, b.Points) {
return a.Points > b.Points
}
case TieBreakGold:
if a.Gold != b.Gold {
return a.Gold > b.Gold
}
case TieBreakSilver:
if a.Silver != b.Silver {
return a.Silver > b.Silver
}
case TieBreakBronze:
if a.Bronze != b.Bronze {
return a.Bronze > b.Bronze
}
case TieBreakTeamCode:
if a.Code != b.Code {
return a.Code < b.Code
}
case TieBreakName:
if a.Name != b.Name {
return a.Name < b.Name
}
}
}
return a.TeamID < b.TeamID
}
func lessAthleteRow(a, b AthleteStandingsRow) bool {
if !nearlyEqual(a.Points, b.Points) {
return a.Points > b.Points
}
if a.Gold != b.Gold {
return a.Gold > b.Gold
}
if a.Silver != b.Silver {
return a.Silver > b.Silver
}
if a.Bronze != b.Bronze {
return a.Bronze > b.Bronze
}
if a.Name != b.Name {
return a.Name < b.Name
}
if a.TeamCode != b.TeamCode {
return a.TeamCode < b.TeamCode
func lessAthleteRow(a, b AthleteStandingsRow, tieBreak []string) bool {
for _, key := range tieBreakKeys(tieBreak, defaultAthleteTieBreak) {
switch key {
case TieBreakPoints:
if !nearlyEqual(a.Points, b.Points) {
return a.Points > b.Points
}
case TieBreakGold:
if a.Gold != b.Gold {
return a.Gold > b.Gold
}
case TieBreakSilver:
if a.Silver != b.Silver {
return a.Silver > b.Silver
}
case TieBreakBronze:
if a.Bronze != b.Bronze {
return a.Bronze > b.Bronze
}
case TieBreakTeamCode:
if a.TeamCode != b.TeamCode {
return a.TeamCode < b.TeamCode
}
case TieBreakName:
if a.Name != b.Name {
return a.Name < b.Name
}
}
}
return a.AthleteID < b.AthleteID
}
+14 -20
View File
@@ -98,17 +98,6 @@ const (
BasisAggregate Basis = "aggregate" // 按团体汇总(预留)
)
// AggregateMethod 是 aggregate 通道的汇总口径,默认 sum。
type AggregateMethod string
const (
AggregateSum AggregateMethod = "sum"
AggregateAvg AggregateMethod = "avg"
AggregateMax AggregateMethod = "max"
AggregateMin AggregateMethod = "min"
AggregateCount AggregateMethod = "count"
)
// TieMode 并列名次口径。
type TieMode string
@@ -136,11 +125,14 @@ const (
)
// UnionAttribution 联队(一条成绩归属多个单位)的归属口径。
// 未实现:库中没有保存联队份额的表,加载器也不会填充 Entry.TeamShares,
// ScoreOptions.UnionAttribution 在生产链路里没有任何赋值入口;
// 规则包校验也不接受相关配置。这里只保留数据结构,本期不启用(devdocs 01 §115)。
type UnionAttribution string
const (
UnionWhole UnionAttribution = "whole" // 整体记给主单位
UnionSplit UnionAttribution = "split" // 按 TeamShares 的份额拆分
UnionSplit UnionAttribution = "split" // 按 TeamShares 的份额拆分(未实现)
)
// AdjustmentCategory 人工调整类别。
@@ -230,14 +222,15 @@ type EventCategory struct {
// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。
// 一个项目可配 0..N 个通道;0 个表示只排名不计分。
// basis=aggregate 时固定按「同一主体成绩之和」汇总:汇总口径不可配置,
// 库里也没有对应的列,因此不再保留 avg/max/min/count 这类配置项。
type ScoreChannel struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Scope Scope `json:"scope"`
Basis Basis `json:"basis"`
ProfileID int64 `json:"profileId"`
Aggregate AggregateMethod `json:"aggregate,omitempty"` // 仅 basis=aggregate 时使用
SortOrder int `json:"sortOrder,omitempty"`
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Scope Scope `json:"scope"`
Basis Basis `json:"basis"`
ProfileID int64 `json:"profileId"`
SortOrder int `json:"sortOrder,omitempty"`
}
// ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。
@@ -259,6 +252,7 @@ type ScoringProfile struct {
}
// TeamShare 联队中某单位的份额;Weight 为 0..1 的相对份额。
// 未实现:没有建表、没有 loader 填充、也没有写入入口(见 UnionAttribution 注释)。
type TeamShare struct {
TeamID int64 `json:"teamId"`
Weight float64 `json:"weight"`
@@ -273,7 +267,7 @@ type Entry struct {
AthleteID int64 `json:"athleteId,omitempty"`
AthleteName string `json:"athleteName,omitempty"` // 冗余姓名,便于引擎直接产出个人榜
MemberNames []string `json:"memberNames,omitempty"`
TeamShares []TeamShare `json:"teamShares,omitempty"` // 跨单位联队;空表示只属 TeamID
TeamShares []TeamShare `json:"teamShares,omitempty"` // 跨单位联队;空表示只属 TeamID(联队未实现,见 UnionAttribution)
Bib string `json:"bib,omitempty"`
Seed string `json:"seed,omitempty"`
Status EntryStatus `json:"status"`