feat: M0-M2 骨架落地(服务端/前端/宿主/数据库/积分引擎)

- server: Go 单二进制骨架,embed 内嵌前端,配置/迁移/HTTP 路由
- store: system.db + event.db 两套迁移,业务表 + 种子数据
- domain: 积分引擎(并列三口径/计分通道/流水/排名/破纪录)+ 68 个测试
- web: Vite + Vue 三入口骨架 + shared 层(类型/API/WebSocket 客户端)
- console: Electron 宿主骨架(选屏/防熄屏/进程守护/托盘)
This commit is contained in:
AskaEth committed 2026-09-09 18:36:40 +08:00
1 parent a61d793d79
commit 7d5d94f815
76 files changed
+10131

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
module git.yeij.top/AskaEth/Scoreboard/server
go 1.26
require (
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.58.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
+54
View File
@@ -0,0 +1,54 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+89
View File
@@ -0,0 +1,89 @@
// Package config 负责加载服务端启动配置。
package config
import (
"fmt"
"log/slog"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// Config 是 config.yaml 的映射。
type Config struct {
Server Server `yaml:"server"`
DataDir string `yaml:"dataDir"`
Log Log `yaml:"log"`
}
// Server 是 HTTP 监听配置。
type Server struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
}
// Log 是日志配置。
type Log struct {
Level string `yaml:"level"`
}
// Default 返回一份可直接使用的默认配置。
func Default() *Config {
return &Config{
Server: Server{Host: "0.0.0.0", Port: 8080},
DataDir: "data",
Log: Log{Level: "info"},
}
}
// Load 读取配置文件;文件不存在时返回默认配置。
func Load(path string) (*Config, error) {
cfg := Default()
raw, err := os.ReadFile(path)
switch {
case err == nil:
if err := yaml.Unmarshal(raw, cfg); err != nil {
return nil, fmt.Errorf("解析配置文件 %s: %w", path, err)
}
case os.IsNotExist(err):
// 没有配置文件时使用默认值
default:
return nil, fmt.Errorf("读取配置文件 %s: %w", path, err)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
func (c *Config) validate() error {
if c.Server.Port <= 0 || c.Server.Port > 65535 {
return fmt.Errorf("server.port 非法: %d", c.Server.Port)
}
if strings.TrimSpace(c.DataDir) == "" {
return fmt.Errorf("dataDir 不能为空")
}
return nil
}
// Addr 返回 http.Server 使用的监听地址。
func (s Server) Addr() string {
return fmt.Sprintf("%s:%d", s.Host, s.Port)
}
// SlogLevel 把配置里的日志级别映射为 slog 级别。
func (l Log) SlogLevel() slog.Level {
switch strings.ToLower(strings.TrimSpace(l.Level)) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package domain 承载积分、排名、纪录等纯业务逻辑。
//
// 本包不 import 任何 IO 包(数据库、HTTP、WebSocket),
// 输入输出都是普通结构体,以便完整单测,也方便将来替换存储层。
package domain
+16
View File
@@ -0,0 +1,16 @@
package domain
import "errors"
// 引擎的哨兵错误:调用方(store / httpapi)据此区分「配置错」与「数据错」。
var (
ErrProfileNotFound = errors.New("domain: 计分通道引用的积分模板不存在")
ErrUnknownBasis = errors.New("domain: 未知的计分通道 basis")
ErrCompareRequired = errors.New("domain: basis=result 的通道要求小项配置 compare=asc/desc")
ErrLedgerNotFound = errors.New("domain: 积分流水不存在")
ErrLedgerAlreadyVoided = errors.New("domain: 积分流水已作废,不能重复作废")
ErrDuplicateLedgerID = errors.New("domain: 积分流水 id 重复")
ErrManualAdjustmentDisabled = errors.New("domain: 人工调整未启用")
ErrAdjustmentReasonRequired = errors.New("domain: 人工调整必须填写理由")
ErrUnknownLedgerOp = errors.New("domain: 未知的流水增量操作")
)
+200
View File
@@ -0,0 +1,200 @@
package domain
import "testing"
// ---------- 通用构造 ----------
func ptr(f float64) *float64 { return &f }
// individualTop6 是规则包里的个人项目前六名:10/8/6/3/2/1。
func individualTop6() ScoringProfile {
return ScoringProfile{
ID: 1, Code: "individual_top6", Name: "个人项目前六名",
AwardRanks: 6, LastPlaceZero: false, TieMode: TieSkip,
Rules: []ScoringRule{{1, 10}, {2, 8}, {3, 6}, {4, 3}, {5, 2}, {6, 1}},
}
}
// teamTop6 是规则包里的团体项目前六名:15/10/8/4/2/1。
func teamTop6() ScoringProfile {
return ScoringProfile{
ID: 2, Code: "team_top6", Name: "团体项目前六名",
AwardRanks: 6, LastPlaceZero: false, TieMode: TieSkip,
Rules: []ScoringRule{{1, 15}, {2, 10}, {3, 8}, {4, 4}, {5, 2}, {6, 1}},
}
}
// nineProfile 是测试矩阵 case 1..6 使用的八名表:9/7/6/5/4/3/2/1。
func nineProfile(tie TieMode) ScoringProfile {
return ScoringProfile{
ID: 3, Code: "nine", Name: "八名表",
AwardRanks: 8, LastPlaceZero: false, TieMode: tie,
Rules: []ScoringRule{{1, 9}, {2, 7}, {3, 6}, {4, 5}, {5, 4}, {6, 3}, {7, 2}, {8, 1}},
}
}
func testCategory() EventCategory {
return EventCategory{ID: 1, EventID: 1, Name: "男子组", Multiplier: 1}
}
func individualEvent(multiplier float64) Event {
return Event{
ID: 1, Code: "E1", Name: "100米跑", EventType: EventTypeIndividual,
Compare: CompareAsc, Scored: true, Multiplier: multiplier, Status: EventStatusPublished,
}
}
func teamEvent(multiplier float64) Event {
e := individualEvent(multiplier)
e.EventType = EventTypeTeam
e.Code = "E2"
return e
}
func athleteResultChannel(profileID int64) ScoreChannel {
return ScoreChannel{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: profileID}
}
func athleteManualChannel(profileID int64) ScoreChannel {
return ScoreChannel{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisManual, ProfileID: profileID}
}
func teamManualChannel(profileID int64) ScoreChannel {
return ScoreChannel{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisManual, ProfileID: profileID}
}
func teamAggregateChannel(profileID int64, method AggregateMethod) ScoreChannel {
return ScoreChannel{ID: 3, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: profileID, Aggregate: method}
}
// participant 是构造「报名项 + 成绩」的最小描述。
type participant struct {
entryID int64
athleteID int64
teamID int64
name string
value float64
noValue bool // true 表示这条成绩没有可比数值(如直接录名次的项目)
rank int
status ResultStatus
entryStatus EntryStatus
state ResultState
shares []TeamShare
}
func fixture(ps []participant) ([]Entry, []Result) {
entries := make([]Entry, 0, len(ps))
results := make([]Result, 0, len(ps))
for _, p := range ps {
entries = append(entries, Entry{
ID: p.entryID, EventID: 1, CategoryID: 1, TeamID: p.teamID,
AthleteID: p.athleteID, AthleteName: p.name,
Status: p.entryStatus, TeamShares: p.shares,
})
state := p.state
if state == "" {
state = ResultStatePublished
}
var value *float64
if !p.noValue {
value = ptr(p.value)
}
results = append(results, Result{
ID: p.entryID * 100, EventID: 1, EntryID: p.entryID,
Rank: p.rank, ResultValue: value, Status: p.status, State: state,
})
}
return entries, results
}
// lineup 生成 n 个参赛者:成绩 1..n,entryID 1..n。
func lineup(n int) []participant {
ps := make([]participant, 0, n)
for i := 1; i <= n; i++ {
ps = append(ps, participant{
entryID: int64(i), athleteID: int64(100 + i), teamID: int64(1000 + i),
name: "选手" + string(rune('A'+i-1)), value: float64(i),
})
}
return ps
}
// lineupValues 生成 n 个参赛者并指定成绩值,用于构造并列。
func lineupValues(values ...float64) []participant {
ps := make([]participant, 0, len(values))
for i, v := range values {
ps = append(ps, participant{
entryID: int64(i + 1), athleteID: int64(100 + i + 1), teamID: int64(1000 + i + 1),
name: "选手" + string(rune('A'+i)), value: v,
})
}
return ps
}
func mustScore(t *testing.T, in EventScoreInput) EventScore {
t.Helper()
es, err := ScoreEvent(in)
if err != nil {
t.Fatalf("ScoreEvent 返回错误: %v", err)
}
return es
}
func pointsByEntry(ledger []LedgerEntry) map[int64]float64 {
out := make(map[int64]float64)
for _, e := range ledger {
if e.Voided {
continue
}
out[e.EntryID] += e.Points
}
return out
}
func rankByEntry(ranked []RankedResult) map[int64]int {
out := make(map[int64]int, len(ranked))
for _, r := range ranked {
out[r.EntryID] = r.Rank
}
return out
}
func pointsByTeam(ledger []LedgerEntry) map[int64]float64 {
out := make(map[int64]float64)
for _, e := range ledger {
if e.Voided {
continue
}
out[e.TeamID] += e.Points
}
return out
}
// assertEntryPoints 断言 entryID 1..len(want) 的积分依次等于 want;缺键按 0 处理。
func assertEntryPoints(t *testing.T, got map[int64]float64, want []float64) {
t.Helper()
for i, w := range want {
id := int64(i + 1)
if !nearlyEqual(got[id], w) {
t.Errorf("entry %d 积分 = %v,期望 %v", id, got[id], w)
}
}
}
func testTeams(ids ...int64) []Team {
out := make([]Team, 0, len(ids))
for i, id := range ids {
out = append(out, Team{
ID: id, Code: string(rune('A' + i)), Name: "单位" + string(rune('A'+i)), Active: true,
})
}
return out
}
func ledgerFor(teamID int64, points float64) LedgerEntry {
return LedgerEntry{TeamID: teamID, SourceType: SourceResult, SourceID: teamID, Points: points}
}
func ledgerForAthlete(teamID, athleteID int64, points float64) LedgerEntry {
return LedgerEntry{TeamID: teamID, AthleteID: athleteID, SourceType: SourceResult, SourceID: athleteID, Points: points}
}
+256
View File
@@ -0,0 +1,256 @@
package domain
import (
"fmt"
"strings"
)
// LedgerOpKind 增量操作类型。
type LedgerOpKind string
const (
LedgerOpAppend LedgerOpKind = "append" // 追加一批流水
LedgerOpVoidSource LedgerOpKind = "void_source" // 按来源作废
)
// LedgerOp 是一条可乱序应用的积分变更,用于「增量同步」场景。
type LedgerOp struct {
Kind LedgerOpKind
Entries []LedgerEntry
SourceType SourceType
SourceID int64
By string
Reason string
}
// TeamPoints 一个单位的分项汇总;系数只在汇总时折算,历史流水保持原始分。
type TeamPoints struct {
Total float64 // 单位总分
TeamOnly float64 // 仅记单位的流水
Individual float64 // 记到个人的流水(原始分,未乘系数)
}
// AppendLedger 追加流水。只追加、不修改已有行;id 为 0 时按当前最大 id 续号。
func AppendLedger(existing []LedgerEntry, adds ...LedgerEntry) ([]LedgerEntry, error) {
out := make([]LedgerEntry, len(existing), len(existing)+len(adds))
copy(out, existing)
maxID := int64(0)
used := make(map[int64]bool, len(out)+len(adds))
for _, e := range out {
if e.ID > maxID {
maxID = e.ID
}
used[e.ID] = true
}
for _, a := range adds {
if a.ID == 0 {
continue
}
if used[a.ID] {
return nil, fmt.Errorf("%w: %d", ErrDuplicateLedgerID, a.ID)
}
used[a.ID] = true
if a.ID > maxID {
maxID = a.ID
}
}
for _, a := range adds {
if a.ID == 0 {
maxID++
a.ID = maxID
}
out = append(out, a)
}
return out, nil
}
// VoidLedger 作废一条流水;重复作废视为错误,避免掩盖重复撤销。
func VoidLedger(entries []LedgerEntry, id int64, by, reason string) ([]LedgerEntry, error) {
out := append([]LedgerEntry(nil), entries...)
for i := range out {
if out[i].ID != id {
continue
}
if out[i].Voided {
return nil, fmt.Errorf("%w: %d", ErrLedgerAlreadyVoided, id)
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
return out, nil
}
return nil, fmt.Errorf("%w: %d", ErrLedgerNotFound, id)
}
// VoidBySource 作废某来源产生的全部未作废流水,返回新的流水集合与作废条数。
// 找不到时是空操作:撤销发布重复执行应当幂等。
func VoidBySource(entries []LedgerEntry, sourceType SourceType, sourceID int64, by, reason string) ([]LedgerEntry, int) {
out := append([]LedgerEntry(nil), entries...)
count := 0
for i := range out {
if out[i].Voided || out[i].SourceType != sourceType || out[i].SourceID != sourceID {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
count++
}
return out, count
}
// VoidByEvent 作废某个小项的全部未作废流水(含成绩分、破纪录加分、人工调整)。
func VoidByEvent(entries []LedgerEntry, eventID int64, by, reason string) ([]LedgerEntry, int) {
out := append([]LedgerEntry(nil), entries...)
count := 0
for i := range out {
if out[i].Voided || out[i].EventID != eventID {
continue
}
out[i].Voided = true
out[i].VoidedBy = by
out[i].VoidReason = reason
count++
}
return out, count
}
// ActiveLedger 返回未作废的流水。
func ActiveLedger(entries []LedgerEntry) []LedgerEntry {
out := make([]LedgerEntry, 0, len(entries))
for _, e := range entries {
if !e.Voided {
out = append(out, e)
}
}
return out
}
// SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。
// weight <= 0 时按 1 处理,避免零值把个人分抹掉。
func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints {
if weight <= 0 {
weight = 1
}
out := make(map[int64]TeamPoints)
for _, e := range entries {
if e.Voided {
continue
}
p := out[e.TeamID]
if e.AthleteID == 0 {
p.TeamOnly += e.Points
} else {
p.Individual += e.Points
}
p.Total = p.TeamOnly + weight*p.Individual
out[e.TeamID] = p
}
return out
}
// SumByAthlete 汇总每个运动员的个人总分(原始分,不折算系数)。
func SumByAthlete(entries []LedgerEntry) map[int64]float64 {
out := make(map[int64]float64)
for _, e := range entries {
if e.Voided || e.AthleteID == 0 {
continue
}
out[e.AthleteID] += e.Points
}
return out
}
// 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 {
active = append(active, e)
}
}
if sameLedgerSet(active, fresh) {
return append([]LedgerEntry(nil), existing...), nil
}
out, _ := VoidByEvent(existing, eventID, by, reason)
return AppendLedger(out, fresh...)
}
// ApplyLedgerOps 按给定顺序应用增量操作。
// 各操作只按来源/事件定位流水,互不依赖顺序,因此乱序到达也能收敛到同一结果。
func ApplyLedgerOps(existing []LedgerEntry, ops []LedgerOp) ([]LedgerEntry, error) {
out := append([]LedgerEntry(nil), existing...)
for _, op := range ops {
switch op.Kind {
case LedgerOpAppend:
next, err := AppendLedger(out, op.Entries...)
if err != nil {
return nil, err
}
out = next
case LedgerOpVoidSource:
next, _ := VoidBySource(out, op.SourceType, op.SourceID, op.By, op.Reason)
out = next
default:
return nil, fmt.Errorf("%w: %q", ErrUnknownLedgerOp, op.Kind)
}
}
return out, nil
}
// AdjustmentsToLedger 把人工调整转成积分流水;本期默认关闭,启用后分值可正可负。
func AdjustmentsToLedger(cfg ManualAdjustmentConfig, adjustments []PointAdjustment) ([]LedgerEntry, error) {
if !cfg.Enabled {
return nil, ErrManualAdjustmentDisabled
}
out := make([]LedgerEntry, 0, len(adjustments))
for _, a := range adjustments {
if a.Voided {
continue
}
if cfg.ReasonRequired && strings.TrimSpace(a.Reason) == "" {
return nil, fmt.Errorf("%w: 调整 %d", ErrAdjustmentReasonRequired, a.ID)
}
out = append(out, LedgerEntry{
TeamID: a.TeamID,
EventID: a.EventID,
SourceType: SourceAdjustment,
SourceID: a.ID,
Points: a.Delta,
CreatedBy: a.CreatedBy,
CreatedAt: a.CreatedAt,
})
}
return out, nil
}
// sameLedgerSet 比较两组流水是否等价:只比「来源 + 归属 + 分值」,不比 id 与创建信息。
func sameLedgerSet(a, b []LedgerEntry) bool {
if len(a) != len(b) {
return false
}
am := ledgerSetMap(a)
bm := ledgerSetMap(b)
if len(am) != len(bm) {
return false
}
for k, v := range am {
w, ok := bm[k]
if !ok || !nearlyEqual(v, w) {
return false
}
}
return true
}
func ledgerSetMap(entries []LedgerEntry) map[string]float64 {
out := make(map[string]float64, len(entries))
for _, e := range entries {
key := fmt.Sprintf("%s|%d|%d|%d|%d", e.SourceType, e.SourceID, e.ChannelID, e.TeamID, e.AthleteID)
out[key] += e.Points
}
return out
}
+229
View File
@@ -0,0 +1,229 @@
package domain
import (
"errors"
"testing"
)
func TestAppendLedger(t *testing.T) {
t.Run("自动续号", func(t *testing.T) {
out, err := AppendLedger([]LedgerEntry{{ID: 3, Points: 1}}, LedgerEntry{Points: 2}, LedgerEntry{Points: 3})
if err != nil {
t.Fatalf("追加失败: %v", err)
}
if len(out) != 3 || out[1].ID != 4 || out[2].ID != 5 {
t.Fatalf("id 续号错误: %+v", out)
}
})
t.Run("保留已有行", func(t *testing.T) {
base := []LedgerEntry{{ID: 1, Points: 5}}
out, _ := AppendLedger(base, LedgerEntry{Points: 2})
if base[0].Points != 5 || len(out) != 2 {
t.Fatalf("原流水被改动: base=%+v out=%+v", base, out)
}
})
t.Run("显式 id 冲突报错", func(t *testing.T) {
if _, err := AppendLedger([]LedgerEntry{{ID: 1}}, LedgerEntry{ID: 1}); !errors.Is(err, ErrDuplicateLedgerID) {
t.Fatalf("错误 = %v,期望 ErrDuplicateLedgerID", err)
}
})
}
func TestVoidLedger(t *testing.T) {
base := []LedgerEntry{{ID: 1, Points: 5}, {ID: 2, Points: 3}}
t.Run("作废指定流水", func(t *testing.T) {
out, err := VoidLedger(base, 1, "admin", "录入错误")
if err != nil {
t.Fatalf("作废失败: %v", err)
}
if !out[0].Voided || out[0].VoidedBy != "admin" || out[0].VoidReason != "录入错误" {
t.Fatalf("作废信息不全: %+v", out[0])
}
if out[1].Voided {
t.Fatalf("误作废了其他流水: %+v", out[1])
}
if base[0].Voided {
t.Fatal("作废不应原地修改入参")
}
})
t.Run("重复作废报错", func(t *testing.T) {
out, _ := VoidLedger(base, 1, "admin", "x")
if _, err := VoidLedger(out, 1, "admin", "y"); !errors.Is(err, ErrLedgerAlreadyVoided) {
t.Fatalf("错误 = %v,期望 ErrLedgerAlreadyVoided", err)
}
})
t.Run("流水不存在报错", func(t *testing.T) {
if _, err := VoidLedger(base, 404, "admin", "x"); !errors.Is(err, ErrLedgerNotFound) {
t.Fatalf("错误 = %v,期望 ErrLedgerNotFound", err)
}
})
}
func TestVoidBySource(t *testing.T) {
base := []LedgerEntry{
{ID: 1, SourceType: SourceResult, SourceID: 100, Points: 10},
{ID: 2, SourceType: SourceResult, SourceID: 100, Points: 8},
{ID: 3, SourceType: SourceResult, SourceID: 200, Points: 6},
}
out, count := VoidBySource(base, SourceResult, 100, "admin", "撤销")
if count != 2 {
t.Fatalf("作废条数 = %d,期望 2", count)
}
if !out[0].Voided || !out[1].Voided || out[2].Voided {
t.Fatalf("作废范围错误: %+v", out)
}
t.Run("重复作废是空操作", func(t *testing.T) {
again, count := VoidBySource(out, SourceResult, 100, "admin", "再撤销")
if count != 0 {
t.Fatalf("重复作废条数 = %d,期望 0", count)
}
if len(again) != len(out) {
t.Fatalf("流水条数变了: %d", len(again))
}
})
}
func TestSumByTeam(t *testing.T) {
ledger := []LedgerEntry{
ledgerFor(1, 10),
ledgerForAthlete(1, 101, 10),
ledgerFor(2, 6),
}
got := SumByTeam(ledger, 1)
if !nearlyEqual(got[1].Total, 20) || !nearlyEqual(got[1].TeamOnly, 10) || !nearlyEqual(got[1].Individual, 10) {
t.Fatalf("单位1汇总 = %+v,期望 总分20 / 单位10 / 个人10", got[1])
}
if !nearlyEqual(got[2].Total, 6) {
t.Fatalf("单位2汇总 = %+v,期望 6", got[2])
}
half := SumByTeam(ledger, 0.5)
if !nearlyEqual(half[1].Total, 15) {
t.Fatalf("系数 0.5 时单位1总分 = %v,期望 15", half[1].Total)
}
}
func TestSumByAthlete(t *testing.T) {
ledger := []LedgerEntry{
ledgerForAthlete(1, 101, 10),
ledgerForAthlete(1, 101, 2),
ledgerFor(1, 100), // 仅记单位,不进个人榜
}
got := SumByAthlete(ledger)
if len(got) != 1 || !nearlyEqual(got[101], 12) {
t.Fatalf("个人汇总 = %+v,期望只有 101:12", got)
}
}
func TestRecomputeEventLedger(t *testing.T) {
old := []LedgerEntry{
{ID: 1, EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10},
{ID: 2, EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 1, Points: 8},
}
fresh := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 6}}
t.Run("有变化时旧流水作废并追加新流水", func(t *testing.T) {
out, err := RecomputeEventLedger(old, 1, fresh, "judge", "更正")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != 3 {
t.Fatalf("流水条数 = %d,期望 3(旧2 + 新1)", len(out))
}
if !out[0].Voided || out[1].Voided {
t.Fatalf("作废范围错误: %+v", out)
}
if !nearlyEqual(SumByTeam(out, 1)[1].Total, 14) {
t.Fatalf("重算后总分 = %v,期望 6 + 8 = 14", SumByTeam(out, 1)[1].Total)
}
})
t.Run("无变化时原样返回", func(t *testing.T) {
same := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}}
out, err := RecomputeEventLedger(old, 1, same, "judge", "重复发布")
if err != nil {
t.Fatalf("重算失败: %v", err)
}
if len(out) != len(old) {
t.Fatalf("流水条数 = %d,期望 %d(幂等)", len(out), len(old))
}
})
t.Run("只影响目标小项", func(t *testing.T) {
out, _ := RecomputeEventLedger(old, 1, fresh, "judge", "更正")
if out[1].Voided {
t.Fatalf("其他小项的流水被误作废: %+v", out[1])
}
})
}
func TestApplyLedgerOps(t *testing.T) {
a := []LedgerEntry{{EventID: 1, SourceType: SourceResult, SourceID: 100, TeamID: 1, Points: 10}}
b := []LedgerEntry{{EventID: 2, SourceType: SourceResult, SourceID: 200, TeamID: 2, Points: 8}}
t.Run("追加与作废可乱序收敛", func(t *testing.T) {
ops := []LedgerOp{
{Kind: LedgerOpAppend, Entries: b},
{Kind: LedgerOpAppend, Entries: a},
{Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: 100, By: "admin", Reason: "撤销"},
}
out, err := ApplyLedgerOps(nil, ops)
if err != nil {
t.Fatalf("应用失败: %v", err)
}
got := SumByTeam(out, 1)
if !nearlyEqual(got[1].Total, 0) || !nearlyEqual(got[2].Total, 8) {
t.Fatalf("最终状态 = %+v,期望 1:0 / 2:8", got)
}
})
t.Run("未知操作报错", func(t *testing.T) {
if _, err := ApplyLedgerOps(nil, []LedgerOp{{Kind: "magic"}}); !errors.Is(err, ErrUnknownLedgerOp) {
t.Fatalf("错误 = %v,期望 ErrUnknownLedgerOp", err)
}
})
}
func TestAdjustmentsToLedger(t *testing.T) {
adj := PointAdjustment{ID: 1, TeamID: 5, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规"}
t.Run("未启用时报错", func(t *testing.T) {
if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{}, []PointAdjustment{adj}); !errors.Is(err, ErrManualAdjustmentDisabled) {
t.Fatalf("错误 = %v,期望 ErrManualAdjustmentDisabled", err)
}
})
t.Run("启用后转成流水", func(t *testing.T) {
out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{adj})
if err != nil {
t.Fatalf("转换失败: %v", err)
}
if len(out) != 1 || out[0].SourceType != SourceAdjustment || !nearlyEqual(out[0].Points, -3) {
t.Fatalf("流水 = %+v,期望一条 -3 的 adjustment", out)
}
})
t.Run("理由必填", func(t *testing.T) {
bad := adj
bad.Reason = " "
if _, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{bad}); !errors.Is(err, ErrAdjustmentReasonRequired) {
t.Fatalf("错误 = %v,期望 ErrAdjustmentReasonRequired", err)
}
})
t.Run("已作废的调整被跳过", func(t *testing.T) {
voided := adj
voided.Voided = true
out, err := AdjustmentsToLedger(ManualAdjustmentConfig{Enabled: true}, []PointAdjustment{voided})
if err != nil || len(out) != 0 {
t.Fatalf("流水 = %+v err=%v,期望空", out, err)
}
})
}
func TestActiveLedger(t *testing.T) {
ledger := []LedgerEntry{{ID: 1, Points: 1}, {ID: 2, Points: 2, Voided: true}}
got := ActiveLedger(ledger)
if len(got) != 1 || got[0].ID != 1 {
t.Fatalf("未作废流水 = %+v,期望只有 id=1", got)
}
}
+457
View File
@@ -0,0 +1,457 @@
package domain
import "testing"
// 本文件逐条实现 devdocs/09-测试策略.md 第 2 节的 18 条用例矩阵。
// 用例名带编号,go test -v 一眼能看出哪条矩阵用例通过。
// case 1:8 人参赛正常排名 → 9/7/6/5/4/3/2/1。
func TestMatrix01_NormalRanking8(t *testing.T) {
entries, results := fixture(lineup(8))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(3)},
Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 6, 5, 4, 3, 2, 1})
}
// case 2:两人并列第 2(skip)→ 7/7/-/5/4/3/2/1。
func TestMatrix02_TieAtSecondSkip(t *testing.T) {
entries, results := fixture(lineupValues(1, 2, 2, 4, 5, 6, 7, 8))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(3)},
Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 7, 5, 4, 3, 2, 1})
ranks := rankByEntry(es.Ranked)
if ranks[2] != 2 || ranks[3] != 2 || ranks[4] != 4 {
t.Fatalf("并列名次错误:%v", ranks)
}
}
// case 3:两人并列第 2(average)→ 6.5/6.5/-/5/4/3/2/1。
func TestMatrix03_TieAtSecondAverage(t *testing.T) {
entries, results := fixture(lineupValues(1, 2, 2, 4, 5, 6, 7, 8))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(3)},
Profiles: map[int64]ScoringProfile{3: nineProfile(TieAverage)},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 6.5, 6.5, 5, 4, 3, 2, 1})
}
// case 4:三人并列第 3(skip)→ 6/6/6/-/-/3/2/1。
func TestMatrix04_ThreeWayTieAtThird(t *testing.T) {
entries, results := fixture(lineupValues(1, 2, 3, 3, 3, 6, 7, 8))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(3)},
Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 6, 6, 6, 3, 2, 1})
}
// case 5:团体项目双倍 → 18/14/12/…
func TestMatrix05_TeamEventDoubled(t *testing.T) {
ps := lineup(8)
for i := range ps {
ps[i].rank = i + 1
}
entries, results := fixture(ps)
es := mustScore(t, EventScoreInput{
Event: teamEvent(2), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(3)},
Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{18, 14, 12, 10, 8, 6, 4, 2})
}
// case 6:破纪录 + 双倍 → 成绩分双倍,破纪录加分不双倍。
func TestMatrix06_RecordBonusNotDoubled(t *testing.T) {
entries, results := fixture(lineup(3))
es := mustScore(t, EventScoreInput{
Event: individualEvent(2), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(3)},
Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)},
Entries: entries, Results: results,
RecordBonus: &RecordBonusConfig{
Enabled: true, Mode: RecordBonusMax,
Levels: []RecordLevel{{Code: "meet", Name: "赛会纪录", Points: 2}},
},
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
})
var bonus *LedgerEntry
for i := range es.Ledger {
if es.Ledger[i].SourceType == SourceRecordBonus {
bonus = &es.Ledger[i]
}
}
if bonus == nil {
t.Fatal("未产生破纪录加分流水")
}
if !nearlyEqual(bonus.Points, 2) {
t.Errorf("破纪录加分 = %v,期望 2(不随项目权重翻倍)", bonus.Points)
}
if got := pointsByEntry(es.Ledger)[1]; !nearlyEqual(got, 20) {
t.Errorf("entry1 合计 = %v,期望 18(成绩分)+ 2(加分)= 20", got)
}
}
// case 7:并列第一的奖牌计数 → 2 金 0 银 1 铜。
func TestMatrix07_TiedFirstMedalCount(t *testing.T) {
entries, results := fixture(lineupValues(1, 1, 3))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(3)},
Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)},
Entries: entries, Results: results,
})
rows := BuildTeamStandings(StandingsInput{
Teams: testTeams(1001, 1002, 1003), Ledger: es.Ledger, Ranked: es.Ranked,
})
var gold, silver, bronze int
for _, r := range rows {
gold += r.Gold
silver += r.Silver
bronze += r.Bronze
}
if gold != 2 || silver != 0 || bronze != 1 {
t.Fatalf("奖牌统计 = %d金 %d银 %d铜,期望 2/0/1", gold, silver, bronze)
}
}
// case 8:发布后撤销 → 流水作废,总分回退。
func TestMatrix08_UnpublishRevertsTotal(t *testing.T) {
entries, results := fixture(lineup(3))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
published := SumByTeam(es.Ledger, 1)
if !nearlyEqual(published[1001].Total, 10) {
t.Fatalf("发布后总分 = %v,期望 10", published[1001].Total)
}
voided, count := VoidByEvent(es.Ledger, 1, "admin", "撤销发布")
if count != len(es.Ledger) {
t.Fatalf("作废条数 = %d,期望 %d", count, len(es.Ledger))
}
after := SumByTeam(voided, 1)
for id, p := range after {
if p.Total != 0 {
t.Errorf("单位 %d 撤销后总分 = %v,期望 0", id, p.Total)
}
}
}
// case 9:修改已发布成绩 → 旧流水作废 + 新流水,审计可查。
func TestMatrix09_EditPublishedResult(t *testing.T) {
entries, results := fixture(lineup(3))
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}
first := mustScore(t, in)
// 改判:entry1 变成最后一名,entry2、entry3 各前进一位。
entries[0].ID = 1
results[0].ResultValue = ptr(10)
results[1].ResultValue = ptr(1)
results[2].ResultValue = ptr(2)
fresh := mustScore(t, in)
rebuilt, err := RecomputeEventLedger(first.Ledger, 1, fresh.Ledger, "judge", "成绩更正")
if err != nil {
t.Fatalf("重算流水失败: %v", err)
}
if len(rebuilt) <= len(first.Ledger) {
t.Fatalf("重算后流水条数 = %d,期望大于 %d(旧作废 + 新追加)", len(rebuilt), len(first.Ledger))
}
for _, e := range rebuilt {
if e.Voided && (e.VoidedBy != "judge" || e.VoidReason != "成绩更正") {
t.Errorf("作废流水缺少审计信息: %+v", e)
}
}
assertEntryPoints(t, pointsByEntry(rebuilt), []float64{6, 10, 8})
}
// case 10:弃权者不占名次 → 名次顺延。
func TestMatrix10_WithdrawnDoesNotOccupyRank(t *testing.T) {
ps := lineup(5)
ps[2].status = ResultStatusWithdrawn
entries, results := fixture(ps)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(3)},
Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 0, 6, 5})
if _, ok := rankByEntry(es.Ranked)[3]; ok {
t.Fatalf("弃权者不应出现在名次明细中: %v", rankByEntry(es.Ranked))
}
}
// case 11:报名不足 8 人 → 按实际名次取分。
func TestMatrix11_FewerThanAwardRanks(t *testing.T) {
entries, results := fixture(lineup(5))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(3)},
Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 6, 5, 4})
}
// case 12:人工扣分后排名变化 → tie-break 正确重排。
func TestMatrix12_ManualPenaltyReorders(t *testing.T) {
ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 10)}
in := StandingsInput{Teams: testTeams(1, 2), Ledger: ledger}
before := BuildTeamStandings(in)
if before[0].TeamID != 1 {
t.Fatalf("扣分前榜首 = 单位 %d,期望 1(同分时编码小者在前)", before[0].TeamID)
}
adjustments, err := AdjustmentsToLedger(
ManualAdjustmentConfig{Enabled: true, ReasonRequired: true},
[]PointAdjustment{{ID: 1, TeamID: 1, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规扣分"}},
)
if err != nil {
t.Fatalf("人工调整转换失败: %v", err)
}
in.Ledger = append(in.Ledger, adjustments...)
after := BuildTeamStandings(in)
if after[0].TeamID != 2 {
t.Fatalf("扣分后榜首 = 单位 %d,期望 2", after[0].TeamID)
}
if !nearlyEqual(after[0].Points, 10) || !nearlyEqual(after[1].Points, 7) {
t.Fatalf("扣分后总分 = %v/%v,期望 10/7", after[0].Points, after[1].Points)
}
}
// case 13:同分 tie-break → 金牌 → 银牌 → 铜牌 → 编码。
func TestMatrix13_TieBreakOrder(t *testing.T) {
cases := []struct {
name string
ranked []RankedResult
want int64
}{
{
name: "金牌优先",
ranked: []RankedResult{{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 1, Scope: ScopeAthlete}},
want: 2,
},
{
name: "银牌优先",
ranked: []RankedResult{
{TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 1, Scope: ScopeAthlete},
{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 1, Scope: ScopeAthlete},
{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 2, Scope: ScopeAthlete},
},
want: 2,
},
{
name: "铜牌优先",
ranked: []RankedResult{
{TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 3, Scope: ScopeAthlete},
{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 3, Scope: ScopeAthlete},
{TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 3, Scope: ScopeAthlete},
},
want: 1,
},
{
name: "编码兜底",
ranked: []RankedResult{},
want: 1,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rows := BuildTeamStandings(StandingsInput{
Teams: testTeams(1, 2),
Ledger: []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 10)},
Ranked: tc.ranked,
})
if rows[0].TeamID != tc.want {
t.Fatalf("榜首 = 单位 %d,期望 %d(%+v)", rows[0].TeamID, tc.want, rows)
}
})
}
}
// case 14:破纪录多级别 → 取最高档(max 模式)。
func TestMatrix14_RecordBonusMax(t *testing.T) {
cfg := RecordBonusConfig{
Enabled: true, Mode: RecordBonusMax,
Levels: []RecordLevel{
{Code: "meet", Points: 2},
{Code: "city", Points: 5},
{Code: "province", Points: 8},
},
}
breaks := []RecordBreak{
{ID: 1, EntryID: 7, EventID: 1, Level: "meet", State: RecordBreakApproved},
{ID: 2, EntryID: 7, EventID: 1, Level: "city", State: RecordBreakApproved},
{ID: 3, EntryID: 7, EventID: 1, Level: "province", State: RecordBreakApproved},
}
got := RecordBonusPoints(cfg, breaks)
if len(got) != 1 || !nearlyEqual(got[0].Points, 8) {
t.Fatalf("max 模式 = %+v,期望 8 分一条", got)
}
if len(got[0].Levels) != 3 {
t.Errorf("档位记录 = %v,期望记录全部 3 个档位", got[0].Levels)
}
}
// case 15:破纪录多级别(sum 模式)→ 累加。
func TestMatrix15_RecordBonusSum(t *testing.T) {
cfg := RecordBonusConfig{
Enabled: true, Mode: RecordBonusSum,
Levels: []RecordLevel{
{Code: "meet", Points: 2},
{Code: "city", Points: 5},
{Code: "province", Points: 8},
},
}
breaks := []RecordBreak{
{ID: 1, EntryID: 7, EventID: 1, Level: "meet", State: RecordBreakApproved},
{ID: 2, EntryID: 7, EventID: 1, Level: "city", State: RecordBreakApproved},
{ID: 3, EntryID: 7, EventID: 1, Level: "province", State: RecordBreakApproved},
}
got := RecordBonusPoints(cfg, breaks)
if len(got) != 1 || !nearlyEqual(got[0].Points, 15) {
t.Fatalf("sum 模式 = %+v,期望 15 分一条", got)
}
}
// case 16:一条成绩归属两个单位(联队)→ 按配置拆分或整体归属。
func TestMatrix16_UnionEntry(t *testing.T) {
ps := []participant{{
entryID: 1, athleteID: 101, teamID: 1001, name: "联队选手", value: 1,
shares: []TeamShare{{TeamID: 1001, Weight: 3}, {TeamID: 1002, Weight: 2}},
}}
entries, results := fixture(ps)
split := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
Options: ScoreOptions{UnionAttribution: UnionSplit},
})
byTeam := pointsByTeam(split.Ledger)
if !nearlyEqual(byTeam[1001], 6) || !nearlyEqual(byTeam[1002], 4) {
t.Fatalf("拆分归属 = %v,期望 1001:6 / 1002:4", byTeam)
}
whole := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
Options: ScoreOptions{UnionAttribution: UnionWhole},
})
byTeam = pointsByTeam(whole.Ledger)
if !nearlyEqual(byTeam[1001], 10) || byTeam[1002] != 0 {
t.Fatalf("整体归属 = %v,期望 1001:10 / 1002:0", byTeam)
}
}
// case 17:重复发布同一组 → 幂等,不产生重复流水。
func TestMatrix17_RepublishIdempotent(t *testing.T) {
entries, results := fixture(lineup(3))
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}
first := mustScore(t, in)
second := mustScore(t, in)
rebuilt, err := RecomputeEventLedger(first.Ledger, 1, second.Ledger, "admin", "重复发布")
if err != nil {
t.Fatalf("重算流水失败: %v", err)
}
if len(rebuilt) != len(first.Ledger) {
t.Fatalf("重复发布后流水条数 = %d,期望 %d(幂等)", len(rebuilt), len(first.Ledger))
}
before := SumByTeam(first.Ledger, 1)
after := SumByTeam(rebuilt, 1)
for id, p := range before {
if !nearlyEqual(after[id].Total, p.Total) {
t.Errorf("单位 %d 总分 = %v,期望 %v", id, after[id].Total, p.Total)
}
}
}
// case 18:乱序应用增量 → 最终状态一致。
func TestMatrix18_OutOfOrderIncrements(t *testing.T) {
makeScore := func(eventID int64) []LedgerEntry {
entries, results := fixture([]participant{{entryID: eventID, athleteID: eventID, teamID: eventID, value: 1}})
entries[0].EventID = eventID
results[0].EventID = eventID
return mustScore(t, EventScoreInput{
Event: Event{ID: eventID, Compare: CompareAsc, Scored: true, Multiplier: 1},
Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}).Ledger
}
a := makeScore(11)
b := makeScore(22)
appendA := LedgerOp{Kind: LedgerOpAppend, Entries: a}
appendB := LedgerOp{Kind: LedgerOpAppend, Entries: b}
voidA := LedgerOp{Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: a[0].SourceID, By: "admin", Reason: "撤销"}
orders := map[string][]LedgerOp{
"A→B→撤销A": {appendA, appendB, voidA},
"B→A→撤销A": {appendB, appendA, voidA},
"A→撤销A→B": {appendA, voidA, appendB},
}
var baseline map[int64]TeamPoints
for name, ops := range orders {
out, err := ApplyLedgerOps(nil, ops)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
got := SumByTeam(out, 1)
if baseline == nil {
baseline = got
continue
}
if len(got) != len(baseline) {
t.Fatalf("%s: 单位数 %d,期望 %d", name, len(got), len(baseline))
}
for id, p := range baseline {
if !nearlyEqual(got[id].Total, p.Total) {
t.Errorf("%s: 单位 %d 总分 = %v,期望 %v", name, id, got[id].Total, p.Total)
}
}
}
if !nearlyEqual(baseline[11].Total, 0) || !nearlyEqual(baseline[22].Total, 10) {
t.Fatalf("最终状态 = %+v,期望 A 作废 0 分、B 保留 10 分", baseline)
}
}
+136
View File
@@ -0,0 +1,136 @@
package domain
import "sort"
// levelPoints 按档位编码取分值;分值全部来自规则包,代码不内置任何数字。
func (c RecordBonusConfig) levelPoints(code string) (float64, bool) {
for _, l := range c.Levels {
if l.Code == code {
return l.Points, true
}
}
return 0, false
}
// RecordBonusPoints 把「已审核通过」的破纪录登记按报名项聚合:
// 同一人同时破多级时,max 取最高档、sum 累加,口径由配置决定。
// 未配置的档位视为无加分,避免脏 level 凭空加分。
func RecordBonusPoints(cfg RecordBonusConfig, breaks []RecordBreak) []RecordBonus {
if !cfg.Enabled || len(breaks) == 0 {
return nil
}
type group struct {
first RecordBreak
levels []string
points []float64
}
order := make([]int64, 0, len(breaks))
groups := make(map[int64]*group)
for _, b := range breaks {
if b.State != RecordBreakApproved {
continue
}
pts, ok := cfg.levelPoints(b.Level)
if !ok {
continue
}
g, exists := groups[b.EntryID]
if !exists {
g = &group{first: b}
groups[b.EntryID] = g
order = append(order, b.EntryID)
}
seen := false
for _, code := range g.levels {
if code == b.Level {
seen = true
break
}
}
if !seen {
g.levels = append(g.levels, b.Level)
g.points = append(g.points, pts)
}
}
out := make([]RecordBonus, 0, len(order))
for _, entryID := range order {
g := groups[entryID]
bonus := RecordBonus{
EntryID: entryID,
EventID: g.first.EventID,
AthleteID: g.first.AthleteID,
TeamID: g.first.TeamID,
Levels: g.levels,
}
if cfg.Mode == RecordBonusSum {
for _, p := range g.points {
bonus.Points += p
}
} else {
for _, p := range g.points {
if p > bonus.Points {
bonus.Points = p
}
}
}
out = append(out, bonus)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].EntryID < out[j].EntryID })
return out
}
// recordBonusLedger 把破纪录加分写成流水。
// 加分与成绩分相互独立:不乘项目权重、也不乘组别权重(case 6 的口径)。
func recordBonusLedger(in EventScoreInput) ([]LedgerEntry, error) {
if in.RecordBonus == nil || !in.RecordBonus.Enabled || len(in.RecordBreaks) == 0 {
return nil, nil
}
breaks := make([]RecordBreak, 0, len(in.RecordBreaks))
for _, b := range in.RecordBreaks {
if in.Event.ID != 0 && b.EventID != 0 && b.EventID != in.Event.ID {
continue
}
breaks = append(breaks, b)
}
bonuses := RecordBonusPoints(*in.RecordBonus, breaks)
if len(bonuses) == 0 {
return nil, nil
}
byID := make(map[int64]Entry, len(in.Entries))
for _, e := range in.Entries {
byID[e.ID] = e
}
var out []LedgerEntry
for _, b := range bonuses {
if b.Points == 0 {
continue
}
e, ok := byID[b.EntryID]
if !ok {
continue
}
athleteID := b.AthleteID
if athleteID == 0 {
athleteID = e.AthleteID
}
for _, s := range e.resolvedShares(in.Options.UnionAttribution) {
out = append(out, LedgerEntry{
TeamID: s.TeamID,
AthleteID: athleteID,
EventID: in.Event.ID,
CategoryID: in.Category.ID,
EntryID: e.ID,
SourceType: SourceRecordBonus,
SourceID: e.ID,
Points: b.Points * s.Weight,
})
}
}
return out, nil
}
+195
View File
@@ -0,0 +1,195 @@
package domain
import "testing"
func bonusConfig(mode RecordBonusMode) RecordBonusConfig {
return RecordBonusConfig{
Enabled: true, Mode: mode,
Levels: []RecordLevel{
{Code: "meet", Name: "赛会纪录", Points: 2},
{Code: "city", Name: "市级纪录", Points: 5},
{Code: "province", Name: "省级纪录", Points: 8},
},
}
}
func TestRecordBonusDisabled(t *testing.T) {
cfg := bonusConfig(RecordBonusMax)
cfg.Enabled = false
got := RecordBonusPoints(cfg, []RecordBreak{{EntryID: 1, Level: "meet", State: RecordBreakApproved}})
if got != nil {
t.Fatalf("未启用时 = %+v,期望 nil", got)
}
}
func TestRecordBonusStateFilter(t *testing.T) {
cases := []struct {
name string
state RecordBreakState
want float64
}{
{"已通过计入", RecordBreakApproved, 2},
{"待审核不计入", RecordBreakPending, 0},
{"已驳回不计入", RecordBreakRejected, 0},
{"状态空值不计入", "", 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "meet", State: tc.state}})
if tc.want == 0 {
if len(got) != 0 {
t.Fatalf("结果 = %+v,期望空", got)
}
return
}
if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) {
t.Fatalf("结果 = %+v,期望 %v 分", got, tc.want)
}
})
}
}
func TestRecordBonusUnknownLevel(t *testing.T) {
got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "galaxy", State: RecordBreakApproved}})
if len(got) != 0 {
t.Fatalf("未配置档位 = %+v,期望空(不能凭空加分)", got)
}
}
func TestRecordBonusModes(t *testing.T) {
breaks := []RecordBreak{
{ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved},
{ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved},
{ID: 3, EntryID: 1, Level: "province", State: RecordBreakApproved},
}
cases := []struct {
name string
mode RecordBonusMode
want float64
}{
{"max 取最高档", RecordBonusMax, 8},
{"sum 累加", RecordBonusSum, 15},
{"模式空值按 max", "", 8},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := RecordBonusPoints(bonusConfig(tc.mode), breaks)
if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) {
t.Fatalf("结果 = %+v,期望 %v", got, tc.want)
}
})
}
}
func TestRecordBonusGroupsByEntry(t *testing.T) {
breaks := []RecordBreak{
{ID: 1, EntryID: 2, Level: "meet", State: RecordBreakApproved},
{ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved},
{ID: 3, EntryID: 1, Level: "meet", State: RecordBreakApproved},
}
got := RecordBonusPoints(bonusConfig(RecordBonusMax), breaks)
if len(got) != 2 {
t.Fatalf("条数 = %d,期望 2(按报名项分组)", len(got))
}
if got[0].EntryID != 1 || got[1].EntryID != 2 {
t.Fatalf("顺序 = %+v,期望按 EntryID 升序", got)
}
if !nearlyEqual(got[0].Points, 5) || !nearlyEqual(got[1].Points, 2) {
t.Fatalf("分值 = %v/%v,期望 5/2", got[0].Points, got[1].Points)
}
}
func TestRecordBonusDedupLevel(t *testing.T) {
breaks := []RecordBreak{
{ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved},
{ID: 2, EntryID: 1, Level: "meet", State: RecordBreakApproved},
}
got := RecordBonusPoints(bonusConfig(RecordBonusSum), breaks)
if len(got) != 1 || !nearlyEqual(got[0].Points, 2) {
t.Fatalf("重复档位 = %+v,期望只计一次 2 分", got)
}
if len(got[0].Levels) != 1 {
t.Fatalf("档位列表 = %v,期望去重", got[0].Levels)
}
}
func TestRecordBonusNoBreaks(t *testing.T) {
if got := RecordBonusPoints(bonusConfig(RecordBonusMax), nil); got != nil {
t.Fatalf("无破纪录登记 = %+v,期望 nil", got)
}
}
func TestRecordBonusLedger(t *testing.T) {
entries, results := fixture(lineup(2))
base := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
}
t.Run("未启用不产生加分流水", func(t *testing.T) {
cfg := bonusConfig(RecordBonusMax)
cfg.Enabled = false
base.RecordBonus = &cfg
es := mustScore(t, base)
for _, e := range es.Ledger {
if e.SourceType == SourceRecordBonus {
t.Fatalf("不应有加分流水: %+v", e)
}
}
})
t.Run("启用后归属到人", func(t *testing.T) {
cfg := bonusConfig(RecordBonusMax)
base.RecordBonus = &cfg
es := mustScore(t, base)
var found bool
for _, e := range es.Ledger {
if e.SourceType != SourceRecordBonus {
continue
}
found = true
if e.TeamID != 1001 || e.AthleteID != 101 || !nearlyEqual(e.Points, 2) {
t.Fatalf("加分流水 = %+v,期望 1001/101 记 2 分", e)
}
}
if !found {
t.Fatal("未产生加分流水")
}
})
t.Run("联队按份额拆分加分", func(t *testing.T) {
ps := []participant{{
entryID: 1, athleteID: 101, teamID: 1001, value: 1,
shares: []TeamShare{{TeamID: 1001, Weight: 3}, {TeamID: 1002, Weight: 2}},
}}
e2, r2 := fixture(ps)
cfg := bonusConfig(RecordBonusMax)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: e2, Results: r2,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
Options: ScoreOptions{UnionAttribution: UnionSplit},
})
var bonus float64
for _, e := range es.Ledger {
if e.SourceType == SourceRecordBonus && e.TeamID == 1002 {
bonus += e.Points
}
}
if !nearlyEqual(bonus, 0.8) {
t.Fatalf("单位1002 的加分 = %v,期望 2 × 0.4 = 0.8", bonus)
}
})
}
+536
View File
@@ -0,0 +1,536 @@
package domain
import (
"fmt"
"math"
"sort"
)
// rankEpsilon 用于判定「成绩相同」。计时/测距保留两位小数,1e-9 足够区分。
const rankEpsilon = 1e-9
// ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。
type ScoreOptions struct {
// UnionAttribution 决定联队成绩的归属;空值等同 UnionWhole(整体记给主单位)。
UnionAttribution UnionAttribution
// 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
Options ScoreOptions
}
// EventScore 是一次小项计分的结果。
type EventScore struct {
Ledger []LedgerEntry
Ranked []RankedResult
}
// RankedResult 记录「某条报名项在某通道下拿到的名次与原始积分」。
// Points 尚未按联队份额拆分、也未乘个人→单位系数,因此可直接用于奖牌判定与分项成绩表。
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
}
// ScoreEvent 按项目的计分通道逐条计算名次与积分,输出流水与名次明细。
// 项目 scored=false 或没有通道时不产生任何流水(只排名不计分)。
func ScoreEvent(in EventScoreInput) (EventScore, error) {
var out EventScore
if !in.Event.Scored {
return out, nil
}
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)
}
ranked, err := scoreChannel(in, ch, profile)
if err != nil {
return EventScore{}, err
}
out.Ranked = append(out.Ranked, ranked...)
out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...)
}
bonus, err := recordBonusLedger(in)
if err != nil {
return EventScore{}, err
}
out.Ledger = append(out.Ledger, bonus...)
return out, nil
}
// 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 通道录入的名次
}
// collectCandidates 过滤出本通道真正参与排名的报名项:
// 只取已发布、状态正常、且带有本通道所需数据的成绩。
func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error) {
if ch.Basis == BasisResult && in.Event.Compare == CompareNone {
return nil, ErrCompareRequired
}
if ch.Basis == BasisAggregate && in.Event.Compare == CompareNone {
return nil, ErrCompareRequired
}
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 in.Event.ID != 0 && e.EventID != 0 && e.EventID != in.Event.ID {
continue
}
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
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 / aggregate 都按成绩值排序
if r.ResultValue == nil {
continue
}
c.value = *r.ResultValue
}
out = append(out, c)
}
return out, nil
}
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 {
lastRank := 0
for _, g := range groups {
if r := g.startRank + len(g.members) - 1; r > lastRank {
lastRank = r
}
}
var out []RankedResult
for _, g := range groups {
pts := groupPoints(profile, g.startRank, len(g.members), lastRank, 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。
func groupPoints(profile ScoringProfile, startRank, size, lastRank int, ev Event, cat EventCategory) float64 {
if startRank <= 0 || size <= 0 {
return 0
}
// lastPlaceZero 是对「最后一名」的覆盖;并列在末位的整组都按最后一名处理,
// 否则同组出现 0 与 1 两种分数,违背「并列同分」的语义。
if profile.LastPlaceZero && startRank <= lastRank && lastRank <= startRank+size-1 {
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: c.entry.resolvedShares(in.Options.UnionAttribution),
Rank: rank,
Points: points,
Status: c.result.Status,
}
if c.result.ResultValue != nil {
v := *c.result.ResultValue
rr.ResultValue = &v
}
return rr
}
// 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
}
order := make([]int64, 0)
buckets := make(map[int64]*bucket)
for _, c := range cands {
subject := c.entry.TeamID
if ch.Scope == ScopeAthlete {
subject = c.entry.AthleteID
}
if subject == 0 {
continue
}
b, ok := buckets[subject]
if !ok {
b = &bucket{subject: subject, entry: c.entry}
buckets[subject] = b
order = append(order, subject)
}
b.values = append(b.values, 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.entry.ID = 0 // 聚合结果不对应单条报名项
agg = append(agg, c)
}
sortCandidatesByValue(agg, in.Event.Compare)
groups := groupByValue(agg)
lastRank := 0
for _, g := range groups {
if r := g.startRank + len(g.members) - 1; r > lastRank {
lastRank = r
}
}
var out []RankedResult
for _, g := range groups {
pts := groupPoints(profile, g.startRank, len(g.members), lastRank, 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,
}
if ch.Scope == ScopeTeam {
rr.TeamID = c.subject
rr.Shares = []TeamShare{{TeamID: c.subject, Weight: 1}}
} else {
rr.AthleteID = c.subject
rr.AthleteName = c.entry.AthleteName
rr.Shares = c.entry.resolvedShares(in.Options.UnionAttribution)
}
v := c.value
rr.ResultValue = &v
out = append(out, rr)
}
}
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) {
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
}
// ledgerFromRanked 把名次明细转成积分流水:按通道 scope 决定记给个人还是单位,
// 联队按份额拆分;0 分不产生流水,避免流水表被零分噪声撑大。
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
}
for _, s := range rr.Shares {
pts := rr.Points * s.Weight
if pts == 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: pts,
})
}
}
return out
}
// resolvedShares 解析报名项的归属:默认整体归主单位,联队模式才按份额拆分。
func (e Entry) resolvedShares(mode UnionAttribution) []TeamShare {
shares := e.teamShares()
if len(shares) == 0 {
return nil
}
if mode != UnionSplit {
return []TeamShare{{TeamID: e.primaryTeamID(), Weight: 1}}
}
return normalizeShares(shares)
}
// normalizeShares 把份额归一化到总和 1,避免配置里权重不标准导致总分被放大。
func normalizeShares(shares []TeamShare) []TeamShare {
var sum float64
for _, s := range shares {
sum += s.Weight
}
out := make([]TeamShare, len(shares))
if sum <= 0 {
w := 1 / float64(len(shares))
for i, s := range shares {
out[i] = TeamShare{TeamID: s.TeamID, Weight: w}
}
return out
}
for i, s := range shares {
out[i] = TeamShare{TeamID: s.TeamID, Weight: s.Weight / sum}
}
return out
}
+601
View File
@@ -0,0 +1,601 @@
package domain
import (
"errors"
"testing"
)
// TestGroupPointsTieModes 直接验证「一组并列者拿多少分」的分支,覆盖三种口径与边界。
func TestGroupPointsTieModes(t *testing.T) {
ev := individualEvent(1)
cat := testCategory()
cases := []struct {
name string
profile ScoringProfile
start int
size int
last int
want float64
}{
{"skip 单人第1", nineProfile(TieSkip), 1, 1, 8, 9},
{"skip 单人第2", nineProfile(TieSkip), 2, 1, 8, 7},
{"skip 两人并列第2", nineProfile(TieSkip), 2, 2, 8, 7},
{"average 两人并列第2", nineProfile(TieAverage), 2, 2, 8, 6.5},
{"higher 两人并列第2", nineProfile(TieHigher), 2, 2, 8, 7},
{"skip 三人并列第3", nineProfile(TieSkip), 3, 3, 8, 6},
{"average 三人并列第3", nineProfile(TieAverage), 3, 3, 8, 5},
{"higher 三人并列第3", nineProfile(TieHigher), 3, 3, 8, 6},
{"average 两人并列第6", nineProfile(TieAverage), 6, 2, 8, 2.5},
{"higher 两人并列第6", nineProfile(TieHigher), 6, 2, 8, 3},
{"skip 超出录取名次", nineProfile(TieSkip), 9, 1, 9, 0},
{"空模板口径按 skip", ScoringProfile{AwardRanks: 8, Rules: nineProfile(TieSkip).Rules}, 2, 1, 8, 7},
{"lastPlaceZero 抹掉最后一名", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 1, 1, 1, 0},
{"lastPlaceZero 抹掉并列末位整组", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 2, 2, 3, 0},
{"权重相乘", nineProfile(TieSkip), 1, 1, 8, 9},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := groupPoints(tc.profile, tc.start, tc.size, tc.last, ev, cat)
if !nearlyEqual(got, tc.want) {
t.Fatalf("groupPoints = %v,期望 %v", got, tc.want)
}
})
}
t.Run("项目与组别权重相乘", func(t *testing.T) {
ev := individualEvent(2)
cat := EventCategory{ID: 1, Multiplier: 3}
got := groupPoints(nineProfile(TieSkip), 1, 1, 8, ev, cat)
if !nearlyEqual(got, 54) {
t.Fatalf("9 × 2 × 3 = %v,期望 54", got)
}
})
}
// TestAwardRanksAndLastPlaceZero 覆盖录取名次与「最后一名 0 分」的组合边界。
func TestAwardRanksAndLastPlaceZero(t *testing.T) {
cases := []struct {
name string
profile ScoringProfile
n int
want []float64
}{
{
"录取6名,7人参赛,末位不强制0",
ScoringProfile{AwardRanks: 6, Rules: individualTop6().Rules},
7, []float64{10, 8, 6, 3, 2, 1, 0},
},
{
"录取6名,7人参赛,末位强制0",
ScoringProfile{AwardRanks: 6, LastPlaceZero: true, Rules: individualTop6().Rules},
7, []float64{10, 8, 6, 3, 2, 1, 0},
},
{
"录取6名,5人参赛,末位不强制0",
ScoringProfile{AwardRanks: 6, Rules: individualTop6().Rules},
5, []float64{10, 8, 6, 3, 2},
},
{
"录取6名,5人参赛,末位强制0",
ScoringProfile{AwardRanks: 6, LastPlaceZero: true, Rules: individualTop6().Rules},
5, []float64{10, 8, 6, 3, 0},
},
{
"只录取3名",
ScoringProfile{AwardRanks: 3, Rules: individualTop6().Rules},
5, []float64{10, 8, 6, 0, 0},
},
{
"不配录取名次时以表为准",
ScoringProfile{Rules: individualTop6().Rules},
8, []float64{10, 8, 6, 3, 2, 1, 0, 0},
},
{
"仅1人参赛且末位强制0",
ScoringProfile{AwardRanks: 6, LastPlaceZero: true, Rules: individualTop6().Rules},
1, []float64{0},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := fixture(lineup(tc.n))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(9)},
Profiles: map[int64]ScoringProfile{9: tc.profile},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), tc.want)
})
}
}
// TestMultiplierCombinations 验证「积分表 × 项目权重 × 组别权重」,权重缺失按 1。
func TestMultiplierCombinations(t *testing.T) {
cases := []struct {
name string
eventMult float64
categoryMult float64
want float64
}{
{"默认 1×1", 1, 1, 10},
{"项目权重 2", 2, 1, 20},
{"组别权重 2", 1, 2, 20},
{"双权重 2×3", 2, 3, 60},
{"权重缺失按 1", 0, 0, 10},
{"项目权重 0.5", 0.5, 1, 5},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := fixture(lineup(1))
es := mustScore(t, EventScoreInput{
Event: individualEvent(tc.eventMult),
Category: EventCategory{ID: 1, Multiplier: tc.categoryMult},
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
if got := pointsByEntry(es.Ledger)[1]; !nearlyEqual(got, tc.want) {
t.Fatalf("积分 = %v,期望 %v", got, tc.want)
}
})
}
}
// TestScopeAndLedgerAttribution 验证通道 scope 决定流水记给个人还是单位。
func TestScopeAndLedgerAttribution(t *testing.T) {
t.Run("athlete 通道记到个人", func(t *testing.T) {
entries, results := fixture(lineup(1))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
e := es.Ledger[0]
if e.AthleteID != 101 || e.TeamID != 1001 {
t.Fatalf("流水归属 = 单位 %d / 个人 %d,期望 1001/101", e.TeamID, e.AthleteID)
}
})
t.Run("team 通道仅记单位", func(t *testing.T) {
ps := lineup(1)
ps[0].rank = 1
entries, results := fixture(ps)
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
if es.Ledger[0].AthleteID != 0 {
t.Fatalf("team 通道的 AthleteID = %d,期望 0", es.Ledger[0].AthleteID)
}
if !nearlyEqual(es.Ledger[0].Points, 15) {
t.Fatalf("团体第一名积分 = %v,期望 15", es.Ledger[0].Points)
}
})
t.Run("athlete 通道但报名项无运动员时退化为单位流水", func(t *testing.T) {
entries, results := fixture([]participant{{entryID: 1, teamID: 1001, value: 1}})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
if es.Ledger[0].AthleteID != 0 {
t.Fatalf("无运动员的报名项 AthleteID = %d,期望 0", es.Ledger[0].AthleteID)
}
})
}
// TestAggregateChannel 覆盖预留的 aggregate 通道:先汇总、再排名。
func TestAggregateChannel(t *testing.T) {
build := func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 1},
{entryID: 2, athleteID: 2, teamID: 1001, value: 2},
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
})
}
cases := []struct {
name string
method AggregateMethod
compare CompareMode
wantA float64
wantB float64
}{
{"sum 升序", AggregateSum, CompareAsc, 15, 10},
{"avg 升序", AggregateAvg, CompareAsc, 15, 10},
{"max 升序", AggregateMax, CompareAsc, 15, 10},
{"min 升序", AggregateMin, CompareAsc, 15, 10},
{"count 降序", AggregateCount, CompareDesc, 15, 10},
{"count 升序", AggregateCount, CompareAsc, 10, 15},
{"sum 降序", AggregateSum, CompareDesc, 10, 15},
{"默认方法按 sum", "", CompareAsc, 15, 10},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := build()
ev := teamEvent(1)
ev.Compare = tc.compare
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2, tc.method)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1001], tc.wantA) || !nearlyEqual(got[1002], tc.wantB) {
t.Fatalf("聚合排名 = %v,期望 1001:%v / 1002:%v", got, tc.wantA, tc.wantB)
}
})
}
t.Run("汇总后同分并列", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 1},
{entryID: 2, athleteID: 2, teamID: 1001, value: 3},
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
})
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2, AggregateSum)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1001], 15) || !nearlyEqual(got[1002], 15) {
t.Fatalf("同分并列 = %v,期望各 15", got)
}
})
}
// TestStatusExclusions 覆盖弃权 / DNS / DSQ 等「0 分且不占名次」的情形。
func TestStatusExclusions(t *testing.T) {
cases := []struct {
name string
status ResultStatus
entryStatus EntryStatus
state ResultState
want []float64
}{
{"成绩 ok", ResultStatusOK, "", "", []float64{10, 8, 6}},
{"成绩状态空值按 ok", "", "", "", []float64{10, 8, 6}},
{"成绩 DSQ", ResultStatusDQ, "", "", []float64{10, 0, 8}},
{"成绩 DNS", ResultStatusDNS, "", "", []float64{10, 0, 8}},
{"成绩 DNF", ResultStatusDNF, "", "", []float64{10, 0, 8}},
{"成绩 withdrawn", ResultStatusWithdrawn, "", "", []float64{10, 0, 8}},
{"报名 scratched", "", EntryStatusScratched, "", []float64{10, 0, 8}},
{"报名 dq", "", EntryStatusDQ, "", []float64{10, 0, 8}},
{"报名 dns", "", EntryStatusDNS, "", []float64{10, 0, 8}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ps := lineup(3)
ps[1].status = tc.status
ps[1].entryStatus = tc.entryStatus
ps[1].state = tc.state
entries, results := fixture(ps)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), tc.want)
})
}
}
// TestDraftState 验证草稿不参与计分,除非显式要求。
func TestDraftState(t *testing.T) {
cases := []struct {
name string
state ResultState
includeDrafts bool
want []float64
}{
{"草稿默认不计分", ResultStateDraft, false, []float64{10, 0, 8}},
{"草稿显式计入", ResultStateDraft, true, []float64{10, 8, 6}},
{"已发布正常计分", ResultStatePublished, false, []float64{10, 8, 6}},
{"状态空值按已发布", "", false, []float64{10, 8, 6}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ps := lineup(3)
ps[1].state = tc.state
entries, results := fixture(ps)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
Options: ScoreOptions{IncludeDrafts: tc.includeDrafts},
})
assertEntryPoints(t, pointsByEntry(es.Ledger), tc.want)
})
}
}
// TestScoredFlagAndNoChannels 验证「只排名不计分」与「没有通道」都不产生流水。
func TestScoredFlagAndNoChannels(t *testing.T) {
cases := []struct {
name string
scored bool
channels []ScoreChannel
}{
{"scored=false 配了通道也不计分", false, []ScoreChannel{athleteResultChannel(1)}},
{"scored=true 但没有通道", true, nil},
{"scored=false 且没有通道", false, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := fixture(lineup(3))
ev := individualEvent(1)
ev.Scored = tc.scored
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: tc.channels,
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
if len(es.Ledger) != 0 || len(es.Ranked) != 0 {
t.Fatalf("流水/名次应为空,实际 %d/%d", len(es.Ledger), len(es.Ranked))
}
})
}
}
// TestScoreEventErrors 覆盖配置类错误,避免错配置静默算错分。
func TestScoreEventErrors(t *testing.T) {
entries, results := fixture(lineup(1))
base := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(404)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}
if _, err := ScoreEvent(base); !errors.Is(err, ErrProfileNotFound) {
t.Errorf("模板缺失错误 = %v,期望 ErrProfileNotFound", err)
}
bad := base
bad.Channels = []ScoreChannel{{ID: 1, Scope: ScopeAthlete, Basis: "magic", ProfileID: 1}}
if _, err := ScoreEvent(bad); !errors.Is(err, ErrUnknownBasis) {
t.Errorf("未知 basis 错误 = %v,期望 ErrUnknownBasis", err)
}
noCompare := base
noCompare.Channels = []ScoreChannel{athleteResultChannel(1)}
noCompare.Event.Compare = CompareNone
if _, err := ScoreEvent(noCompare); !errors.Is(err, ErrCompareRequired) {
t.Errorf("compare=none 错误 = %v,期望 ErrCompareRequired", err)
}
noCompareAgg := noCompare
noCompareAgg.Channels = []ScoreChannel{teamAggregateChannel(1, AggregateSum)}
if _, err := ScoreEvent(noCompareAgg); !errors.Is(err, ErrCompareRequired) {
t.Errorf("聚合 compare=none 错误 = %v,期望 ErrCompareRequired", err)
}
}
// TestCompareDirections 验证 asc / desc 两种成绩排序方向。
func TestCompareDirections(t *testing.T) {
t.Run("asc 越小越好", func(t *testing.T) {
entries, results := fixture(lineupValues(12, 11, 13))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{8, 10, 6})
})
t.Run("desc 越大越好", func(t *testing.T) {
entries, results := fixture(lineupValues(12, 11, 13))
ev := individualEvent(1)
ev.Compare = CompareDesc
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{8, 6, 10})
})
}
// TestMultipleChannelsDoubleCount 复现「团体引体向上」的双通道:个人通道 + 团体通道都计分。
func TestMultipleChannelsDoubleCount(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 1},
{entryID: 2, athleteID: 2, teamID: 1001, value: 3},
{entryID: 3, athleteID: 3, teamID: 1002, value: 2},
{entryID: 4, athleteID: 4, teamID: 1002, value: 4},
{entryID: 5, teamID: 1001, rank: 1, noValue: true},
{entryID: 6, teamID: 1002, rank: 2, noValue: true},
})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1), teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
})
var personal, teamOnly int
for _, e := range es.Ledger {
if e.AthleteID != 0 {
personal++
} else {
teamOnly++
}
}
if personal != 4 || teamOnly != 2 {
t.Fatalf("流水构成 = %d 条个人 / %d 条单位,期望 4/2", personal, teamOnly)
}
got := SumByTeam(es.Ledger, 1)
// 单位1001:团体通道15 + 个人10 + 6 = 31;单位1002:10 + 8 + 3 = 21。
if !nearlyEqual(got[1001].Total, 31) || !nearlyEqual(got[1002].Total, 21) {
t.Fatalf("单位总分 = %v,期望 1001:31 / 1002:21", got)
}
}
// TestSameUnitMultipleAthletesBothScore 复现文档示例 2:同单位两人都计分。
func TestSameUnitMultipleAthletesBothScore(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 1},
{entryID: 2, athleteID: 2, teamID: 1002, value: 2},
{entryID: 3, athleteID: 3, teamID: 1003, value: 3},
{entryID: 4, athleteID: 4, teamID: 1004, value: 4},
{entryID: 5, athleteID: 5, teamID: 1001, value: 5},
})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
got := SumByTeam(es.Ledger, 1)
if !nearlyEqual(got[1001].Total, 12) {
t.Fatalf("单位总分 = %v,期望第1名10 + 第5名2 = 12", got[1001].Total)
}
}
// TestOneAthleteMultipleEvents 验证一人多项独立计分、个人总分累加。
func TestOneAthleteMultipleEvents(t *testing.T) {
entries1, results1 := fixture([]participant{{entryID: 1, athleteID: 1, teamID: 1001, value: 1}})
entries2, results2 := fixture([]participant{{entryID: 2, athleteID: 1, teamID: 1001, value: 2}})
entries2[0].EventID = 2
results2[0].EventID = 2
ledger := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries1, Results: results1,
}).Ledger
ledger = append(ledger, mustScore(t, EventScoreInput{
Event: Event{ID: 2, Compare: CompareAsc, Scored: true, Multiplier: 1},
Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries2, Results: results2,
}).Ledger...)
if got := SumByAthlete(ledger)[1]; !nearlyEqual(got, 20) {
t.Fatalf("个人总分 = %v,期望 10 + 10 = 20", got)
}
}
// TestUnionSharesNormalization 覆盖联队份额的归一化与退化情形。
func TestUnionSharesNormalization(t *testing.T) {
score := func(shares []TeamShare, mode UnionAttribution) map[int64]float64 {
entries, results := fixture([]participant{{
entryID: 1, athleteID: 1, teamID: 1001, value: 1, shares: shares,
}})
return pointsByTeam(mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
Options: ScoreOptions{UnionAttribution: mode},
}).Ledger)
}
cases := []struct {
name string
shares []TeamShare
mode UnionAttribution
want map[int64]float64
}{
{"份额 3:2 拆分为 6:4", []TeamShare{{1001, 3}, {1002, 2}}, UnionSplit, map[int64]float64{1001: 6, 1002: 4}},
{"份额 1:1 平分为 5:5", []TeamShare{{1001, 1}, {1002, 1}}, UnionSplit, map[int64]float64{1001: 5, 1002: 5}},
{"权重为 0 的份额被丢弃", []TeamShare{{1001, 0}, {1002, 1}}, UnionSplit, map[int64]float64{1002: 10}},
{"份额全非法时退回主单位", []TeamShare{{1001, 0}, {1002, 0}}, UnionSplit, map[int64]float64{1001: 10}},
{"无份额时归主单位", nil, UnionSplit, map[int64]float64{1001: 10}},
{"整体归属只记主单位", []TeamShare{{1001, 3}, {1002, 2}}, UnionWhole, map[int64]float64{1001: 10}},
{"归属口径空值等同整体", []TeamShare{{1001, 3}, {1002, 2}}, "", map[int64]float64{1001: 10}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := score(tc.shares, tc.mode)
for id, want := range tc.want {
if !nearlyEqual(got[id], want) {
t.Fatalf("单位 %d 得分 = %v,期望 %v(全部 %v)", id, got[id], want, got)
}
}
for id, p := range got {
if p != 0 {
if _, ok := tc.want[id]; !ok {
t.Fatalf("单位 %d 不应得分,实际 %v", id, p)
}
}
}
})
}
}
// TestTieDetectionEpsilon 验证「成绩相同」的判定精度。
func TestTieDetectionEpsilon(t *testing.T) {
t.Run("差值在容差内视为并列", func(t *testing.T) {
entries, results := fixture(lineupValues(12.01, 12.01+1e-10, 13))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 10, 6})
})
t.Run("差值超过容差不算并列", func(t *testing.T) {
entries, results := fixture(lineupValues(12.01, 12.02, 13))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8, 6})
})
}
// TestMissingResultOrEntry 验证缺成绩、成绩指向不存在的报名项都不会算出分。
func TestMissingResultOrEntry(t *testing.T) {
t.Run("没有成绩的报名项不计分", func(t *testing.T) {
entries, results := fixture(lineup(3))
results = results[:2] // entry3 还没有成绩
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8, 0})
})
t.Run("孤儿成绩被忽略", func(t *testing.T) {
entries, results := fixture(lineup(2))
results = append(results, Result{ID: 999, EventID: 1, EntryID: 404, ResultValue: ptr(0.5), State: ResultStatePublished})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8})
})
t.Run("别的组别的成绩不串场", func(t *testing.T) {
entries, results := fixture(lineup(3))
entries[2].CategoryID = 2
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8, 0})
})
}
+279
View File
@@ -0,0 +1,279 @@
package domain
import "sort"
// StandingsInput 是排名聚合的输入:流水 + 名次明细 + 单位/运动员基础信息。
type StandingsInput struct {
Teams []Team
Athletes []Athlete
Ledger []LedgerEntry
Ranked []RankedResult
// IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。
IndividualToTeamWeight float64
// ExcludeTeamEventMedals 为 true 时团体奖牌榜不计团体项目奖牌;默认计入。
ExcludeTeamEventMedals bool
}
type medalTally struct {
gold, silver, bronze int
}
// rankMedal 把名次映射成奖牌:1 金 / 2 银 / 3 铜,其余无名次。
func rankMedal(rank int) int {
switch rank {
case 1:
return 1
case 2:
return 2
case 3:
return 3
default:
return 0
}
}
// BuildTeamStandings 生成总积分榜。
// 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序:总分 → 金 → 银 → 铜 → 单位编码。
func BuildTeamStandings(in StandingsInput) []StandingsRow {
weight := effectiveWeight(in.IndividualToTeamWeight)
sums := SumByTeam(in.Ledger, weight)
medals := teamMedals(in)
rows := make([]StandingsRow, 0, len(in.Teams))
seen := make(map[int64]bool, len(in.Teams))
for _, t := range in.Teams {
if seen[t.ID] {
continue
}
seen[t.ID] = true
rows = append(rows, buildTeamRow(t, sums[t.ID], medals[t.ID]))
}
// 兜底:流水/奖牌里出现但单位表没提供的单位也要上榜,否则分数会被静默吞掉。
for id := range sums {
if !seen[id] {
seen[id] = true
rows = append(rows, buildTeamRow(Team{ID: id}, sums[id], medals[id]))
}
}
for id, m := range medals {
if !seen[id] {
seen[id] = true
rows = append(rows, buildTeamRow(Team{ID: id}, sums[id], m))
}
}
sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j]) })
for i := range rows {
rows[i].Rank = i + 1
}
return rows
}
// BuildMedalStandings 生成团体奖牌榜。
// 排序口径与总积分榜一致(总分优先),只是列不同,保证两块榜的单位顺序不会打架。
func BuildMedalStandings(in StandingsInput) []MedalRow {
teamRows := BuildTeamStandings(in)
out := make([]MedalRow, len(teamRows))
for i, r := range teamRows {
out[i] = MedalRow{
Rank: r.Rank,
TeamID: r.TeamID,
Code: r.Code,
Name: r.Name,
ShortName: r.ShortName,
Points: r.Points,
Gold: r.Gold,
Silver: r.Silver,
Bronze: r.Bronze,
Total: r.Gold + r.Silver + r.Bronze,
}
}
return out
}
// BuildAthleteStandings 生成个人积分奖牌榜。
// 排序:总分 → 金 → 银 → 铜 → 姓名;个人榜的奖牌只统计个人项目(团体项目奖牌归团体榜)。
func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow {
points := SumByAthlete(in.Ledger)
medals := athleteMedals(in)
names := make(map[int64]string, len(in.Athletes))
teams := make(map[int64]int64, len(in.Athletes))
for _, a := range in.Athletes {
names[a.ID] = a.Name
teams[a.ID] = a.TeamID
}
for _, rr := range in.Ranked {
if rr.AthleteID == 0 {
continue
}
if names[rr.AthleteID] == "" {
names[rr.AthleteID] = rr.AthleteName
}
if _, ok := teams[rr.AthleteID]; !ok {
teams[rr.AthleteID] = rr.TeamID
}
}
teamByID := make(map[int64]Team, len(in.Teams))
for _, t := range in.Teams {
teamByID[t.ID] = t
}
seen := make(map[int64]bool, len(points)+len(medals))
ids := make([]int64, 0, len(points)+len(medals))
add := func(id int64) {
if id == 0 || seen[id] {
return
}
seen[id] = true
ids = append(ids, id)
}
for id := range points {
add(id)
}
for id := range medals {
add(id)
}
rows := make([]AthleteStandingsRow, 0, len(ids))
for _, id := range ids {
t := teamByID[teams[id]]
m := medals[id]
rows = append(rows, AthleteStandingsRow{
AthleteID: id,
Name: names[id],
TeamID: teams[id],
TeamCode: t.Code,
TeamName: t.Name,
Points: points[id],
Gold: m.gold,
Silver: m.silver,
Bronze: m.bronze,
Medals: m.gold + m.silver + m.bronze,
})
}
sort.SliceStable(rows, func(i, j int) bool { return lessAthleteRow(rows[i], rows[j]) })
for i := range rows {
rows[i].Rank = i + 1
}
return rows
}
func effectiveWeight(weight float64) float64 {
if weight <= 0 {
return 1
}
return weight
}
func buildTeamRow(t Team, p TeamPoints, m medalTally) StandingsRow {
return StandingsRow{
TeamID: t.ID,
Code: t.Code,
Name: t.Name,
ShortName: t.ShortName,
Points: p.Total,
TeamOnlyPoints: p.TeamOnly,
IndividualPoints: p.Individual,
Gold: m.gold,
Silver: m.silver,
Bronze: m.bronze,
Medals: m.gold + m.silver + m.bronze,
}
}
// teamMedals 统计团体奖牌榜。联队拆分时两个单位各记一枚:
// 联合组队拿到的名次对每个成员单位都是「本单位取得的名次」。
func teamMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally)
for _, rr := range in.Ranked {
if rr.Scope == ScopeTeam && in.ExcludeTeamEventMedals {
continue
}
medal := rankMedal(rr.Rank)
if medal == 0 {
continue
}
for _, s := range rr.Shares {
t := out[s.TeamID]
switch medal {
case 1:
t.gold++
case 2:
t.silver++
case 3:
t.bronze++
}
out[s.TeamID] = t
}
}
return out
}
// athleteMedals 只统计个人项目(scope=athlete)的奖牌。
func athleteMedals(in StandingsInput) map[int64]medalTally {
out := make(map[int64]medalTally)
for _, rr := range in.Ranked {
if rr.Scope != ScopeAthlete || rr.AthleteID == 0 {
continue
}
medal := rankMedal(rr.Rank)
if medal == 0 {
continue
}
t := out[rr.AthleteID]
switch medal {
case 1:
t.gold++
case 2:
t.silver++
case 3:
t.bronze++
}
out[rr.AthleteID] = t
}
return out
}
func lessTeamRow(a, b StandingsRow) 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.Code != b.Code {
return a.Code < b.Code
}
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
}
return a.AthleteID < b.AthleteID
}
+247
View File
@@ -0,0 +1,247 @@
package domain
import "testing"
func mkRanked(teamID int64, scope Scope, rank int) RankedResult {
return RankedResult{
TeamID: teamID,
Shares: []TeamShare{{TeamID: teamID, Weight: 1}},
Scope: scope,
Rank: rank,
}
}
func mkAthleteRanked(athleteID, teamID int64, rank int) RankedResult {
r := mkRanked(teamID, ScopeAthlete, rank)
r.AthleteID = athleteID
return r
}
func TestTeamStandingsWeight(t *testing.T) {
ledger := []LedgerEntry{
ledgerFor(1, 10), // 仅记单位
ledgerForAthlete(1, 9, 10), // 记到个人
}
cases := []struct {
name string
weight float64
want float64
}{
{"系数 1", 1, 20},
{"系数 0.5", 0.5, 15},
{"系数 2", 2, 30},
{"系数 0 按 1 处理", 0, 20},
{"系数负数按 1 处理", -1, 20},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rows := BuildTeamStandings(StandingsInput{
Teams: testTeams(1), Ledger: ledger, IndividualToTeamWeight: tc.weight,
})
if !nearlyEqual(rows[0].Points, tc.want) {
t.Fatalf("总分 = %v,期望 %v", rows[0].Points, tc.want)
}
if !nearlyEqual(rows[0].TeamOnlyPoints, 10) || !nearlyEqual(rows[0].IndividualPoints, 10) {
t.Fatalf("分项 = %v/%v,期望 10/10", rows[0].TeamOnlyPoints, rows[0].IndividualPoints)
}
})
}
}
func TestTeamStandingsSortOrder(t *testing.T) {
cases := []struct {
name string
ranked []RankedResult
want int64
}{
{"总分优先", nil, 1},
{"金牌优先", []RankedResult{mkRanked(2, ScopeAthlete, 1)}, 2},
{
"银牌优先",
[]RankedResult{
mkRanked(1, ScopeAthlete, 1),
mkRanked(2, ScopeAthlete, 1),
mkRanked(2, ScopeAthlete, 2),
},
2,
},
{
"铜牌优先",
[]RankedResult{
mkRanked(1, ScopeAthlete, 1),
mkRanked(2, ScopeAthlete, 1),
mkRanked(1, ScopeAthlete, 2),
mkRanked(2, ScopeAthlete, 2),
mkRanked(2, ScopeAthlete, 3),
},
2,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 10)}
if tc.name == "总分优先" {
ledger[0].Points = 20
}
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: ledger, Ranked: tc.ranked})
if rows[0].TeamID != tc.want {
t.Fatalf("榜首 = 单位 %d,期望 %d", rows[0].TeamID, tc.want)
}
if rows[0].Rank != 1 || rows[1].Rank != 2 {
t.Fatalf("名次 = %d/%d,期望 1/2", rows[0].Rank, rows[1].Rank)
}
})
}
}
func TestTeamStandingsIncludesAllTeams(t *testing.T) {
t.Run("没有得分的单位也占一行", func(t *testing.T) {
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: []LedgerEntry{ledgerFor(1, 10)}})
if len(rows) != 2 {
t.Fatalf("行数 = %d,期望 2", len(rows))
}
if rows[1].TeamID != 2 || rows[1].Points != 0 {
t.Fatalf("第二行 = %+v,期望单位2零分", rows[1])
}
})
t.Run("流水里出现的未知单位也上榜", func(t *testing.T) {
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1), Ledger: []LedgerEntry{ledgerFor(9, 5)}})
if len(rows) != 2 || rows[0].TeamID != 9 {
t.Fatalf("行 = %+v,期望未知单位9以5分居首", rows)
}
})
}
func TestMedalStandings(t *testing.T) {
ranked := []RankedResult{
mkRanked(1, ScopeTeam, 1), // 团体项目金牌
mkRanked(2, ScopeAthlete, 1), // 个人项目金牌
mkRanked(2, ScopeAthlete, 3),
}
in := StandingsInput{Teams: testTeams(1, 2), Ranked: ranked}
rows := BuildMedalStandings(in)
if len(rows) != 2 {
t.Fatalf("行数 = %d,期望 2", len(rows))
}
var gold, silver, bronze, total int
for _, r := range rows {
gold += r.Gold
silver += r.Silver
bronze += r.Bronze
total += r.Total
}
if gold != 2 || silver != 0 || bronze != 1 || total != 3 {
t.Fatalf("奖牌合计 = %d/%d/%d(共%d),期望 2/0/1(共3)", gold, silver, bronze, total)
}
in.ExcludeTeamEventMedals = true
rows = BuildMedalStandings(in)
for _, r := range rows {
if r.TeamID == 1 && r.Gold != 0 {
t.Fatalf("排除团体奖牌后单位1金牌 = %d,期望 0", r.Gold)
}
}
}
func TestAthleteStandings(t *testing.T) {
ledger := []LedgerEntry{
ledgerForAthlete(1001, 1, 10),
ledgerForAthlete(1002, 2, 10),
}
athletes := []Athlete{
{ID: 1, Name: "张三", TeamID: 1001},
{ID: 2, Name: "李四", TeamID: 1002},
}
teams := []Team{
{ID: 1001, Code: "A", Name: "一单位"},
{ID: 1002, Code: "B", Name: "二单位"},
}
t.Run("金牌优先", func(t *testing.T) {
rows := BuildAthleteStandings(StandingsInput{
Teams: teams, Athletes: athletes, Ledger: ledger,
Ranked: []RankedResult{mkAthleteRanked(2, 1002, 1)},
})
if rows[0].AthleteID != 2 {
t.Fatalf("榜首 = %+v,期望运动员2(金牌优先)", rows)
}
})
t.Run("同分按姓名", func(t *testing.T) {
rows := BuildAthleteStandings(StandingsInput{Teams: teams, Athletes: athletes, Ledger: ledger})
if rows[0].AthleteID != 1 {
t.Fatalf("榜首 = %+v,期望运动员1(张三 < 李四)", rows)
}
})
t.Run("姓名与单位信息齐全", func(t *testing.T) {
rows := BuildAthleteStandings(StandingsInput{Teams: teams, Athletes: athletes, Ledger: ledger})
if rows[0].Name != "张三" || rows[0].TeamCode != "A" || rows[0].TeamName != "一单位" {
t.Fatalf("行 = %+v,期望姓名/单位信息齐全", rows[0])
}
})
t.Run("团体项目奖牌不计入个人榜", func(t *testing.T) {
rows := BuildAthleteStandings(StandingsInput{
Teams: teams, Athletes: athletes, Ledger: ledger,
Ranked: []RankedResult{{
TeamID: 1001, AthleteID: 1, Scope: ScopeTeam, Rank: 1,
Shares: []TeamShare{{TeamID: 1001, Weight: 1}},
}},
})
for _, r := range rows {
if r.AthleteID == 1 && r.Gold != 0 {
t.Fatalf("个人榜不应统计团体项目奖牌: %+v", r)
}
}
})
}
func TestUnionMedals(t *testing.T) {
rr := RankedResult{
TeamID: 1, Rank: 1, Scope: ScopeAthlete,
Shares: []TeamShare{{TeamID: 1, Weight: 0.6}, {TeamID: 2, Weight: 0.4}},
}
t.Run("拆分时两单位各记一金", func(t *testing.T) {
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: []RankedResult{rr}})
for _, r := range rows {
if r.Gold != 1 {
t.Fatalf("单位 %d 金牌 = %d,期望 1", r.TeamID, r.Gold)
}
}
})
t.Run("整体归属只记主单位", func(t *testing.T) {
rr := rr
rr.Shares = []TeamShare{{TeamID: 1, Weight: 1}}
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: []RankedResult{rr}})
if rows[0].TeamID != 1 || rows[0].Gold != 1 {
t.Fatalf("整体归属榜首 = %+v,期望单位1记1金", rows[0])
}
if rows[1].Gold != 0 {
t.Fatalf("单位2金牌 = %d,期望 0", rows[1].Gold)
}
})
}
func TestStandingsEmptyInput(t *testing.T) {
t.Run("无单位无流水", func(t *testing.T) {
if rows := BuildTeamStandings(StandingsInput{}); len(rows) != 0 {
t.Fatalf("行数 = %d,期望 0", len(rows))
}
})
t.Run("个人榜无数据", func(t *testing.T) {
if rows := BuildAthleteStandings(StandingsInput{}); len(rows) != 0 {
t.Fatalf("行数 = %d,期望 0", len(rows))
}
})
}
func TestSumByTeamIgnoresVoided(t *testing.T) {
ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(1, 5)}
ledger[1].Voided = true
got := SumByTeam(ledger, 1)
if !nearlyEqual(got[1].Total, 10) {
t.Fatalf("总分 = %v,期望只算未作废的 10", got[1].Total)
}
}
+513
View File
@@ -0,0 +1,513 @@
package domain
// 本文件只放类型与枚举:取值与 devdocs/04-数据模型.md 的字段约束一一对应。
// 这里不写任何计算,避免「结构」与「规则」互相纠缠。
// ---------- 枚举 ----------
// EventType 小项类型,仅用于展示归类,不参与积分计算。
type EventType string
const (
EventTypeIndividual EventType = "individual"
EventTypeTeam EventType = "team"
EventTypeMatch EventType = "match"
)
// ResultMode 成绩录入方式:auto 录成绩后自动排名,manual 直接录名次。
type ResultMode string
const (
ResultModeAuto ResultMode = "auto"
ResultModeManual ResultMode = "manual"
)
// ResultType 成绩的物理量类型,决定比较与展示方式。
type ResultType string
const (
ResultTypeTime ResultType = "time"
ResultTypeDistance ResultType = "distance"
ResultTypeCount ResultType = "count"
ResultTypeScore ResultType = "score"
ResultTypeNone ResultType = "none"
)
// CompareMode 排序方向;none 表示无法自动排名,必须走 manual 通道。
type CompareMode string
const (
CompareAsc CompareMode = "asc"
CompareDesc CompareMode = "desc"
CompareNone CompareMode = "none"
)
// EventStatus 小项进度。
type EventStatus string
const (
EventStatusPending EventStatus = "pending"
EventStatusOngoing EventStatus = "ongoing"
EventStatusFinished EventStatus = "finished"
EventStatusPublished EventStatus = "published"
)
// EntryStatus 报名项状态;非 registered 视为弃权/失格,0 分且不占名次。
type EntryStatus string
const (
EntryStatusRegistered EntryStatus = "registered"
EntryStatusScratched EntryStatus = "scratched"
EntryStatusDQ EntryStatus = "dq"
EntryStatusDNS EntryStatus = "dns"
)
// ResultStatus 成绩状态;非 ok 视为弃权/失格,0 分且不占名次。
type ResultStatus string
const (
ResultStatusOK ResultStatus = "ok"
ResultStatusDQ ResultStatus = "dq"
ResultStatusDNS ResultStatus = "dns"
ResultStatusDNF ResultStatus = "dnf"
ResultStatusWithdrawn ResultStatus = "withdrawn"
)
// ResultState 成绩发布状态;草稿不参与计分。
type ResultState string
const (
ResultStateDraft ResultState = "draft"
ResultStatePublished ResultState = "published"
)
// Scope 计分通道把名次分记给谁。
type Scope string
const (
ScopeAthlete Scope = "athlete"
ScopeTeam Scope = "team"
)
// Basis 名次从哪里来。
type Basis string
const (
BasisResult Basis = "result" // 按成绩自动排名
BasisManual Basis = "manual" // 直接录名次
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
const (
TieSkip TieMode = "skip" // 顺延:并列者同分,后续名次跳过被占用的位置
TieAverage TieMode = "average" // 平均:并列者平分所占位置的积分
TieHigher TieMode = "higher" // 就高:并列者都拿所占位置中的最高分
)
// RecordBonusMode 同时破多级纪录时的口径。
type RecordBonusMode string
const (
RecordBonusMax RecordBonusMode = "max" // 取最高档,不累加
RecordBonusSum RecordBonusMode = "sum" // 累加
)
// SourceType 积分流水来源,用于溯源与按来源作废。
type SourceType string
const (
SourceResult SourceType = "result"
SourceRecordBonus SourceType = "record_bonus"
SourceAdjustment SourceType = "adjustment"
)
// UnionAttribution 联队(一条成绩归属多个单位)的归属口径。
type UnionAttribution string
const (
UnionWhole UnionAttribution = "whole" // 整体记给主单位
UnionSplit UnionAttribution = "split" // 按 TeamShares 的份额拆分
)
// AdjustmentCategory 人工调整类别。
type AdjustmentCategory string
const (
AdjustmentPenalty AdjustmentCategory = "penalty"
AdjustmentBonus AdjustmentCategory = "bonus"
AdjustmentCorrection AdjustmentCategory = "correction"
)
// RecordBreakState 破纪录登记的审核状态。
type RecordBreakState string
const (
RecordBreakPending RecordBreakState = "pending"
RecordBreakApproved RecordBreakState = "approved"
RecordBreakRejected RecordBreakState = "rejected"
)
// ---------- 实体 ----------
// Team 参赛单位,积分榜上的行。
type Team struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
ShortName string `json:"shortName,omitempty"`
LogoPath string `json:"logoPath,omitempty"`
Color string `json:"color,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
Active bool `json:"active"`
}
// Athlete 运动员。
type Athlete struct {
ID int64 `json:"id"`
Name string `json:"name"`
Gender string `json:"gender,omitempty"`
TeamID int64 `json:"teamId"`
EmployeeNo string `json:"employeeNo,omitempty"`
Active bool `json:"active"`
}
// Sport 大项。
type Sport struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
ShortName string `json:"shortName,omitempty"`
DefaultProfileID int64 `json:"defaultProfileId,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
Active bool `json:"active"`
}
// Event 小项,积分计算的最小单位。
type Event struct {
ID int64 `json:"id"`
SportID int64 `json:"sportId"`
Code string `json:"code"`
Name string `json:"name"`
EventType EventType `json:"eventType"`
ResultMode ResultMode `json:"resultMode"`
ResultType ResultType `json:"resultType"`
Compare CompareMode `json:"compare"`
RecordEligible bool `json:"recordEligible"`
Scored bool `json:"scored"`
Multiplier float64 `json:"multiplier"`
Status EventStatus `json:"status"`
ScheduledAt string `json:"scheduledAt,omitempty"`
Venue string `json:"venue,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
Remark string `json:"remark,omitempty"`
}
// EventCategory 组别;名次在组别内有效,组别权重乘在成绩分上。
type EventCategory struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Name string `json:"name"`
Multiplier float64 `json:"multiplier"`
Gender string `json:"gender,omitempty"`
AgeMin *int `json:"ageMin,omitempty"`
AgeMax *int `json:"ageMax,omitempty"`
SortOrder int `json:"sortOrder,omitempty"`
}
// ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。
// 一个项目可配 0..N 个通道;0 个表示只排名不计分。
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"`
}
// ScoringRule 积分模板的一条明细:名次 → 分值。分值一律来自配置。
type ScoringRule struct {
Rank int `json:"rank"`
Points float64 `json:"points"`
}
// ScoringProfile 积分模板。
type ScoringProfile struct {
ID int64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
AwardRanks int `json:"awardRanks"`
LastPlaceZero bool `json:"lastPlaceZero"`
TieMode TieMode `json:"tieMode"`
RecordBonusMode RecordBonusMode `json:"recordBonusMode"`
Rules []ScoringRule `json:"rules"`
}
// TeamShare 联队中某单位的份额;Weight 为 0..1 的相对份额。
type TeamShare struct {
TeamID int64 `json:"teamId"`
Weight float64 `json:"weight"`
}
// Entry 报名项:某单位派某人/某队参加某小项。
type Entry struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
TeamID int64 `json:"teamId"`
AthleteID int64 `json:"athleteId,omitempty"`
AthleteName string `json:"athleteName,omitempty"` // 冗余姓名,便于引擎直接产出个人榜
MemberNames []string `json:"memberNames,omitempty"`
TeamShares []TeamShare `json:"teamShares,omitempty"` // 跨单位联队;空表示只属 TeamID
Bib string `json:"bib,omitempty"`
Seed string `json:"seed,omitempty"`
Status EntryStatus `json:"status"`
Remark string `json:"remark,omitempty"`
}
// Result 成绩;一条报名项一条成绩。
type Result struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
EntryID int64 `json:"entryId"`
Rank int `json:"rank,omitempty"`
ResultText string `json:"resultText,omitempty"`
ResultValue *float64 `json:"resultValue,omitempty"`
Unit string `json:"unit,omitempty"`
IsTie bool `json:"isTie,omitempty"`
PointsOverride *float64 `json:"pointsOverride,omitempty"`
Status ResultStatus `json:"status"`
State ResultState `json:"state"`
RecordedBy string `json:"recordedBy,omitempty"`
RecordedAt string `json:"recordedAt,omitempty"`
VerifiedBy string `json:"verifiedBy,omitempty"`
VerifiedAt string `json:"verifiedAt,omitempty"`
Note string `json:"note,omitempty"`
}
// LedgerEntry 积分流水,总分的唯一真相来源;只追加,纠错靠作废 + 追加。
type LedgerEntry struct {
ID int64 `json:"id"`
TeamID int64 `json:"teamId"`
AthleteID int64 `json:"athleteId,omitempty"` // 0 表示「仅记单位」
EventID int64 `json:"eventId,omitempty"`
CategoryID int64 `json:"categoryId,omitempty"`
EntryID int64 `json:"entryId,omitempty"`
ResultID int64 `json:"resultId,omitempty"`
ChannelID int64 `json:"channelId,omitempty"`
SourceType SourceType `json:"sourceType"`
SourceID int64 `json:"sourceId"`
Points float64 `json:"points"`
Voided bool `json:"voided"`
VoidedBy string `json:"voidedBy,omitempty"`
VoidReason string `json:"voidReason,omitempty"`
CreatedBy string `json:"createdBy,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
// StandingsRow 总积分榜的一行。
type StandingsRow struct {
Rank int `json:"rank"`
TeamID int64 `json:"teamId"`
Code string `json:"code"`
Name string `json:"name"`
ShortName string `json:"shortName,omitempty"`
Points float64 `json:"points"`
TeamOnlyPoints float64 `json:"teamOnlyPoints"`
IndividualPoints float64 `json:"individualPoints"`
Gold int `json:"gold"`
Silver int `json:"silver"`
Bronze int `json:"bronze"`
Medals int `json:"medals"`
}
// MedalRow 团体奖牌榜的一行。
type MedalRow struct {
Rank int `json:"rank"`
TeamID int64 `json:"teamId"`
Code string `json:"code"`
Name string `json:"name"`
ShortName string `json:"shortName,omitempty"`
Points float64 `json:"points"`
Gold int `json:"gold"`
Silver int `json:"silver"`
Bronze int `json:"bronze"`
Total int `json:"total"`
}
// AthleteStandingsRow 个人积分奖牌榜的一行。
type AthleteStandingsRow struct {
Rank int `json:"rank"`
AthleteID int64 `json:"athleteId"`
Name string `json:"name"`
TeamID int64 `json:"teamId"`
TeamCode string `json:"teamCode,omitempty"`
TeamName string `json:"teamName,omitempty"`
Points float64 `json:"points"`
Gold int `json:"gold"`
Silver int `json:"silver"`
Bronze int `json:"bronze"`
Medals int `json:"medals"`
}
// RecordLevel 破纪录的一个档位及其加分值(来自规则包配置)。
type RecordLevel struct {
Code string `json:"code"`
Name string `json:"name"`
Points float64 `json:"points"`
}
// RecordBonusConfig 破纪录加分配置。
type RecordBonusConfig struct {
Enabled bool `json:"enabled"`
Mode RecordBonusMode `json:"mode"`
Levels []RecordLevel `json:"levels"`
}
// RecordBreak 破纪录登记。
type RecordBreak struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
EntryID int64 `json:"entryId"`
AthleteID int64 `json:"athleteId,omitempty"`
TeamID int64 `json:"teamId"`
Level string `json:"level"`
ResultValue float64 `json:"resultValue"`
ResultText string `json:"resultText,omitempty"`
BonusPoints float64 `json:"bonusPoints,omitempty"` // 登记时预填值;实际以配置档位为准
State RecordBreakState `json:"state"`
}
// RecordBonus 一名参赛者(按报名项聚合)的破纪录加分结果。
type RecordBonus struct {
EntryID int64 `json:"entryId"`
EventID int64 `json:"eventId"`
AthleteID int64 `json:"athleteId,omitempty"`
TeamID int64 `json:"teamId"`
Levels []string `json:"levels"`
Points float64 `json:"points"`
}
// PointAdjustment 人工调整;本期关闭,但保留数据结构供以后的赛事启用。
type PointAdjustment struct {
ID int64 `json:"id"`
TeamID int64 `json:"teamId"`
EventID int64 `json:"eventId,omitempty"`
Delta float64 `json:"delta"`
Category AdjustmentCategory `json:"category"`
Reason string `json:"reason"`
Voided bool `json:"voided"`
CreatedBy string `json:"createdBy,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
// ManualAdjustmentConfig 人工调整开关。
type ManualAdjustmentConfig struct {
Enabled bool `json:"enabled"`
ReasonRequired bool `json:"reasonRequired"`
}
// ---------- 取值辅助 ----------
// multiplier 权重缺省为 1:配置里不写权重时按 1 处理,避免零值把分数乘没。
func (e Event) multiplier() float64 {
if e.Multiplier <= 0 {
return 1
}
return e.Multiplier
}
func (c EventCategory) multiplier() float64 {
if c.Multiplier <= 0 {
return 1
}
return c.Multiplier
}
// tieMode 未配置或配错时按 skip 处理(与文档默认一致)。
func (p ScoringProfile) tieMode() TieMode {
switch p.TieMode {
case TieAverage, TieHigher:
return p.TieMode
default:
return TieSkip
}
}
// bonusMode 未配置时按 max 处理(与文档默认一致)。
func (p ScoringProfile) bonusMode() RecordBonusMode {
if p.RecordBonusMode == RecordBonusSum {
return RecordBonusSum
}
return RecordBonusMax
}
// pointsAt 精确查名次分值;表中没有该名次就返回 0。
func (p ScoringProfile) pointsAt(rank int) (float64, bool) {
for _, r := range p.Rules {
if r.Rank == rank {
return r.Points, true
}
}
return 0, false
}
// rankable 判断报名项是否可参与排名;空值按 registered 处理,兼容未填状态的数据。
func (s EntryStatus) rankable() bool {
return s == "" || s == EntryStatusRegistered
}
// rankable 判断成绩是否可参与排名;空值按 ok 处理。
func (s ResultStatus) rankable() bool {
return s == "" || s == ResultStatusOK
}
// teamShares 返回报名项的归属份额;未配联队份额时就是「主单位独占 100%」。
// 份额全部非法(权重 <= 0)时退回主单位,宁可按主单位记分也不要静默丢分。
func (e Entry) teamShares() []TeamShare {
if len(e.TeamShares) == 0 {
if e.TeamID == 0 {
return nil
}
return []TeamShare{{TeamID: e.TeamID, Weight: 1}}
}
out := make([]TeamShare, 0, len(e.TeamShares))
for _, s := range e.TeamShares {
if s.TeamID != 0 && s.Weight > 0 {
out = append(out, s)
}
}
if len(out) == 0 {
if id := e.primaryTeamID(); id != 0 {
return []TeamShare{{TeamID: id, Weight: 1}}
}
return nil
}
return out
}
// primaryTeamID 是「整体归属」时接收全部分数的单位。
func (e Entry) primaryTeamID() int64 {
if e.TeamID != 0 {
return e.TeamID
}
if len(e.TeamShares) > 0 {
return e.TeamShares[0].TeamID
}
return 0
}
+168
View File
@@ -0,0 +1,168 @@
package domain
import "testing"
func TestEventMultiplierDefault(t *testing.T) {
cases := []struct {
in float64
want float64
}{
{0, 1},
{-3, 1},
{1, 1},
{2.5, 2.5},
}
for _, tc := range cases {
if got := (Event{Multiplier: tc.in}).multiplier(); got != tc.want {
t.Errorf("Event{Multiplier:%v}.multiplier() = %v,期望 %v", tc.in, got, tc.want)
}
}
}
func TestCategoryMultiplierDefault(t *testing.T) {
cases := []struct {
in float64
want float64
}{
{0, 1},
{-1, 1},
{3, 3},
}
for _, tc := range cases {
if got := (EventCategory{Multiplier: tc.in}).multiplier(); got != tc.want {
t.Errorf("EventCategory{Multiplier:%v}.multiplier() = %v,期望 %v", tc.in, got, tc.want)
}
}
}
func TestTieModeDefault(t *testing.T) {
cases := []struct {
in TieMode
want TieMode
}{
{TieSkip, TieSkip},
{TieAverage, TieAverage},
{TieHigher, TieHigher},
{"", TieSkip},
{"magic", TieSkip},
}
for _, tc := range cases {
if got := (ScoringProfile{TieMode: tc.in}).tieMode(); got != tc.want {
t.Errorf("tieMode(%q) = %q,期望 %q", tc.in, got, tc.want)
}
}
}
func TestBonusModeDefault(t *testing.T) {
cases := []struct {
in RecordBonusMode
want RecordBonusMode
}{
{RecordBonusMax, RecordBonusMax},
{RecordBonusSum, RecordBonusSum},
{"", RecordBonusMax},
{"magic", RecordBonusMax},
}
for _, tc := range cases {
if got := (ScoringProfile{RecordBonusMode: tc.in}).bonusMode(); got != tc.want {
t.Errorf("bonusMode(%q) = %q,期望 %q", tc.in, got, tc.want)
}
}
}
func TestProfilePointsAt(t *testing.T) {
p := individualTop6()
if got, ok := p.pointsAt(1); !ok || got != 10 {
t.Fatalf("pointsAt(1) = %v/%v,期望 10/true", got, ok)
}
if got, ok := p.pointsAt(7); ok || got != 0 {
t.Fatalf("pointsAt(7) = %v/%v,期望 0/false", got, ok)
}
}
func TestEntryTeamShares(t *testing.T) {
cases := []struct {
name string
entry Entry
want []TeamShare
}{
{"无份额归主单位", Entry{TeamID: 1}, []TeamShare{{1, 1}}},
{"无单位也无份额", Entry{}, nil},
{"保留正权重份额", Entry{TeamID: 1, TeamShares: []TeamShare{{1, 3}, {2, 2}}}, []TeamShare{{1, 3}, {2, 2}}},
{"过滤非法份额", Entry{TeamID: 1, TeamShares: []TeamShare{{1, 0}, {2, 1}}}, []TeamShare{{2, 1}}},
{"份额全非法退回主单位", Entry{TeamID: 1, TeamShares: []TeamShare{{1, 0}, {2, -1}}}, []TeamShare{{1, 1}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := tc.entry.teamShares()
if len(got) != len(tc.want) {
t.Fatalf("份额 = %+v,期望 %+v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("份额 = %+v,期望 %+v", got, tc.want)
}
}
})
}
}
func TestPrimaryTeamID(t *testing.T) {
if got := (Entry{TeamID: 7}).primaryTeamID(); got != 7 {
t.Errorf("主单位 = %d,期望 7", got)
}
if got := (Entry{TeamShares: []TeamShare{{9, 1}}}).primaryTeamID(); got != 9 {
t.Errorf("无 TeamID 时取首个份额 = %d,期望 9", got)
}
if got := (Entry{}).primaryTeamID(); got != 0 {
t.Errorf("无归属时 = %d,期望 0", got)
}
}
func TestRankableStatuses(t *testing.T) {
entryCases := []struct {
in EntryStatus
want bool
}{
{EntryStatusRegistered, true},
{"", true},
{EntryStatusScratched, false},
{EntryStatusDQ, false},
{EntryStatusDNS, false},
}
for _, tc := range entryCases {
if got := tc.in.rankable(); got != tc.want {
t.Errorf("EntryStatus(%q).rankable() = %v,期望 %v", tc.in, got, tc.want)
}
}
resultCases := []struct {
in ResultStatus
want bool
}{
{ResultStatusOK, true},
{"", true},
{ResultStatusDQ, false},
{ResultStatusDNS, false},
{ResultStatusDNF, false},
{ResultStatusWithdrawn, false},
}
for _, tc := range resultCases {
if got := tc.in.rankable(); got != tc.want {
t.Errorf("ResultStatus(%q).rankable() = %v,期望 %v", tc.in, got, tc.want)
}
}
}
func TestNormalizeShares(t *testing.T) {
got := normalizeShares([]TeamShare{{1, 3}, {2, 2}})
if !nearlyEqual(got[0].Weight, 0.6) || !nearlyEqual(got[1].Weight, 0.4) {
t.Fatalf("归一化 = %+v,期望 0.6/0.4", got)
}
got = normalizeShares([]TeamShare{{1, 1}, {2, 1}, {3, 1}})
for _, s := range got {
if !nearlyEqual(s.Weight, 1.0/3) {
t.Fatalf("平均拆分 = %+v,期望各 1/3", got)
}
}
}
+107
View File
@@ -0,0 +1,107 @@
// Package httpapi 提供 HTTP 接口与静态资源托管。
package httpapi
import (
"encoding/json"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/webui"
)
// Options 是构建路由所需的依赖。
type Options struct {
Logger *slog.Logger
Version string
}
// New 组装并返回服务端的 HTTP 处理器。
func New(opts Options) http.Handler {
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"version": opts.Version,
})
})
assets, err := webui.FS()
if err != nil {
logger.Error("前端资源不可用", "err", err)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "前端资源不可用", http.StatusServiceUnavailable)
})
} else {
mux.Handle("/", staticHandler(assets))
}
return withRequestLog(mux, logger)
}
// staticHandler 把 URL 映射到多页面前端产物。
//
// / → index.html
// /display → display.html
// /admin → admin.html
// /其他 → 按文件路径查找
func staticHandler(assets fs.FS) http.Handler {
files := http.FileServerFS(assets)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
switch p {
case "", "index.html":
http.ServeFileFS(w, r, assets, "index.html")
case "display", "display/":
http.ServeFileFS(w, r, assets, "display.html")
case "admin", "admin/":
http.ServeFileFS(w, r, assets, "admin.html")
default:
if _, err := fs.Stat(assets, p); err != nil {
http.NotFound(w, r)
return
}
files.ServeHTTP(w, r)
}
})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func withRequestLog(next http.Handler, logger *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Debug("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"dur", time.Since(start),
)
})
}
+105
View File
@@ -0,0 +1,105 @@
package store
import (
"database/sql"
"fmt"
"io/fs"
"log/slog"
"sort"
"git.yeij.top/AskaEth/Scoreboard/server/migrations"
)
const createMigrationsTable = `CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`
// Migrate 按文件名顺序迁移系统库(data/system.db)。
func (s *Store) Migrate() error {
fsys, err := fs.Sub(migrations.SystemFS, "system")
if err != nil {
return fmt.Errorf("定位系统库迁移目录: %w", err)
}
return migrate(s.db, fsys, "system")
}
// MigrateEvent 迁移赛事库(event.db)。赛事库由调用方自行打开,
// 本函数只负责执行 event/ 下的迁移,不持有连接。
func MigrateEvent(db *sql.DB) error {
fsys, err := fs.Sub(migrations.EventFS, "event")
if err != nil {
return fmt.Errorf("定位赛事库迁移目录: %w", err)
}
return migrate(db, fsys, "event")
}
// migrate 执行 fsys 中尚未应用的迁移,每个迁移在独立事务中完成。
func migrate(db *sql.DB, fsys fs.FS, label string) error {
if _, err := db.Exec(createMigrationsTable); err != nil {
return fmt.Errorf("创建 schema_migrations: %w", err)
}
applied, err := appliedMigrations(db)
if err != nil {
return err
}
names, err := fs.Glob(fsys, "*.sql")
if err != nil {
return err
}
sort.Strings(names)
for _, name := range names {
if applied[name] {
continue
}
body, err := fs.ReadFile(fsys, name)
if err != nil {
return fmt.Errorf("读取迁移 %s: %w", name, err)
}
if err := applyMigration(db, name, string(body)); err != nil {
return err
}
slog.Info("已应用迁移", "db", label, "name", name)
}
return nil
}
func appliedMigrations(db *sql.DB) (map[string]bool, error) {
rows, err := db.Query(`SELECT name FROM schema_migrations`)
if err != nil {
return nil, fmt.Errorf("查询迁移记录: %w", err)
}
defer func() { _ = rows.Close() }()
applied := make(map[string]bool)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
applied[name] = true
}
if err := rows.Err(); err != nil {
return nil, err
}
return applied, nil
}
func applyMigration(db *sql.DB, name, body string) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("开启事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(body); err != nil {
return fmt.Errorf("执行迁移 %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES (?)`, name); err != nil {
return fmt.Errorf("记录迁移 %s: %w", name, err)
}
return tx.Commit()
}
+49
View File
@@ -0,0 +1,49 @@
// Package store 负责数据库连接、迁移与仓储。
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
// Store 持有系统库(data/system.db)的连接。
type Store struct {
db *sql.DB
}
// Open 打开(必要时创建)数据目录下的 system.db。
func Open(dataDir string) (*Store, error) {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, fmt.Errorf("创建数据目录 %s: %w", dataDir, err)
}
path := filepath.Join(dataDir, "system.db")
dsn := "file:" + path +
"?_pragma=journal_mode(WAL)" +
"&_pragma=foreign_keys(1)" +
"&_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("打开数据库 %s: %w", path, err)
}
// SQLite 只允许单个写者,连接数收紧到 1 最省心。
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("连接数据库 %s: %w", path, err)
}
return &Store{db: db}, nil
}
// Close 关闭数据库连接。
func (s *Store) Close() error { return s.db.Close() }
// DB 暴露底层连接,供仓储层使用。
func (s *Store) DB() *sql.DB { return s.db }
+232
View File
@@ -0,0 +1,232 @@
package store
import (
"database/sql"
"path/filepath"
"testing"
)
// openEventDB 打开一个临时赛事库;DSN 与 store.Open 保持一致,便于验证外键约束。
func openEventDB(t *testing.T) *sql.DB {
t.Helper()
path := filepath.Join(t.TempDir(), "event.db")
dsn := "file:" + path +
"?_pragma=journal_mode(WAL)" +
"&_pragma=foreign_keys(1)" +
"&_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
return db
}
func TestMigrateSystem(t *testing.T) {
st, err := Open(t.TempDir())
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(); err != nil {
t.Fatalf("首次迁移: %v", err)
}
for _, table := range []string{"schema_migrations", "settings", "users", "sessions", "recent_projects"} {
assertTable(t, st.DB(), table)
}
if got := queryString(t, st.DB(), `SELECT value FROM settings WHERE key = 'app.name'`); got == "" {
t.Error("默认系统设置 app.name 缺失")
}
before := countRows(t, st.DB(), "schema_migrations")
if err := st.Migrate(); err != nil {
t.Fatalf("重复迁移: %v", err)
}
if after := countRows(t, st.DB(), "schema_migrations"); after != before {
t.Errorf("重复迁移应幂等,迁移记录 %d → %d", before, after)
}
}
func TestMigrateEvent(t *testing.T) {
db := openEventDB(t)
if err := MigrateEvent(db); err != nil {
t.Fatalf("首次迁移: %v", err)
}
tables := []string{
"schema_migrations", "settings",
"teams", "athletes", "sports", "events", "event_categories", "event_score_channels",
"scoring_profiles", "scoring_profile_rules",
"entries", "results", "records", "record_breaks",
"score_ledger", "point_adjustments", "result_revisions", "change_log", "audit_logs",
}
for _, table := range tables {
assertTable(t, db, table)
}
checkProfile(t, db, "individual_top6", "个人项目前六名", []float64{10, 8, 6, 3, 2, 1})
checkProfile(t, db, "team_top6", "团体项目前六名", []float64{15, 10, 8, 4, 2, 1})
if got := queryString(t, db, `SELECT value FROM settings WHERE key = 'event.name'`); got != "未命名赛事" {
t.Errorf("默认赛事设置 event.name = %q,期望 %q", got, "未命名赛事")
}
before := []int{
countRows(t, db, "schema_migrations"),
countRows(t, db, "scoring_profiles"),
countRows(t, db, "scoring_profile_rules"),
countRows(t, db, "settings"),
}
if err := MigrateEvent(db); err != nil {
t.Fatalf("重复迁移: %v", err)
}
after := []int{
countRows(t, db, "schema_migrations"),
countRows(t, db, "scoring_profiles"),
countRows(t, db, "scoring_profile_rules"),
countRows(t, db, "settings"),
}
for i, name := range []string{"schema_migrations", "scoring_profiles", "scoring_profile_rules", "settings"} {
if before[i] != after[i] {
t.Errorf("重复迁移应幂等,%s 行数 %d → %d", name, before[i], after[i])
}
}
}
func TestEventSchemaAcceptsCoreFlow(t *testing.T) {
db := openEventDB(t)
if err := MigrateEvent(db); err != nil {
t.Fatalf("迁移: %v", err)
}
mustExec(t, db, `INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`)
mustExec(t, db, `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`)
mustExec(t, db, `INSERT INTO events(sport_id, code, name) VALUES (1, 'E1', '100米跑')`)
mustExec(t, db, `INSERT INTO event_categories(event_id, name) VALUES (1, '男子')`)
mustExec(t, db, `INSERT INTO entries(event_id, category_id, team_id) VALUES (1, 1, 1)`)
mustExec(t, db, `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
VALUES (1, 1, 1, '11.58', 11.58, 's', 'published')`)
mustExec(t, db, `INSERT INTO score_ledger(team_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 'result', 1, 10)`)
if _, err := db.Exec(`INSERT INTO results(event_id, entry_id) VALUES (1, 1)`); err == nil {
t.Error("results.entry_id 应 UNIQUE")
}
if _, err := db.Exec(`INSERT INTO events(sport_id, code, name) VALUES (999, 'E9', '幽灵项目')`); err == nil {
t.Error("外键未生效:不存在的 sport_id 应被拒绝")
}
if _, err := db.Exec(`INSERT INTO athletes(name, gender) VALUES ('张三', '未知')`); err == nil {
t.Error("CHECK 未生效:非法性别应被拒绝")
}
}
func checkProfile(t *testing.T, db *sql.DB, code, name string, want []float64) {
t.Helper()
var (
gotName, tieMode, bonusMode string
awardRanks, lastPlaceZero int
)
err := db.QueryRow(
`SELECT name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles WHERE code = ?`, code,
).Scan(&gotName, &tieMode, &bonusMode, &awardRanks, &lastPlaceZero)
if err != nil {
t.Fatalf("查询模板 %s: %v", code, err)
}
if gotName != name {
t.Errorf("模板 %s 名称 = %q,期望 %q", code, gotName, name)
}
if tieMode != "skip" {
t.Errorf("模板 %s tie_mode = %q,期望 skip", code, tieMode)
}
if bonusMode != "max" {
t.Errorf("模板 %s record_bonus_mode = %q,期望 max", code, bonusMode)
}
if awardRanks != 6 {
t.Errorf("模板 %s award_ranks = %d,期望 6", code, awardRanks)
}
if lastPlaceZero != 0 {
t.Errorf("模板 %s last_place_zero = %d,期望 0", code, lastPlaceZero)
}
rows, err := db.Query(
`SELECT points FROM scoring_profile_rules
WHERE profile_id = (SELECT id FROM scoring_profiles WHERE code = ?)
ORDER BY rank`, code,
)
if err != nil {
t.Fatalf("查询模板 %s 明细: %v", code, err)
}
defer func() { _ = rows.Close() }()
var got []float64
for rows.Next() {
var points float64
if err := rows.Scan(&points); err != nil {
t.Fatalf("扫描模板 %s 明细: %v", code, err)
}
got = append(got, points)
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历模板 %s 明细: %v", code, err)
}
if len(got) != len(want) {
t.Fatalf("模板 %s 名次条数 = %d,期望 %d(%v)", code, len(got), len(want), want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("模板 %s 第 %d 名积分 = %v,期望 %v", code, i+1, got[i], want[i])
}
}
}
func assertTable(t *testing.T, db *sql.DB, table string) {
t.Helper()
var n int
if err := db.QueryRow(
`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table,
).Scan(&n); err != nil {
t.Fatalf("查询表 %s: %v", table, err)
}
if n != 1 {
t.Errorf("表 %s 不存在", table)
}
}
func mustExec(t *testing.T, db *sql.DB, query string) {
t.Helper()
if _, err := db.Exec(query); err != nil {
t.Fatalf("执行 %q: %v", query, err)
}
}
func countRows(t *testing.T, db *sql.DB, table string) int {
t.Helper()
var n int
if err := db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
t.Fatalf("统计 %s: %v", table, err)
}
return n
}
func queryString(t *testing.T, db *sql.DB, query string, args ...any) string {
t.Helper()
var v string
if err := db.QueryRow(query, args...).Scan(&v); err != nil {
t.Fatalf("查询 %q: %v", query, err)
}
return v
}
+17
View File
@@ -0,0 +1,17 @@
// Package webui 内嵌前端构建产物。
//
// 前端由 web/ 构建后输出到 assets/ 目录,Go 二进制通过本包把静态资源一并打包。
package webui
import (
"embed"
"io/fs"
)
//go:embed all:assets
var assets embed.FS
// FS 返回去掉 assets 前缀后的静态资源文件系统。
func FS() (fs.FS, error) {
return fs.Sub(assets, "assets")
}
+74
View File
@@ -0,0 +1,74 @@
// Command scoreboard 是赛事积分榜系统的服务端。
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/config"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// version 由构建时注入:-ldflags "-X main.version=..."
var version = "dev"
func main() {
configPath := flag.String("config", "config.yaml", "配置文件路径")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
os.Exit(1)
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.Log.SlogLevel()}))
slog.SetDefault(logger)
st, err := store.Open(cfg.DataDir)
if err != nil {
logger.Error("打开数据库失败", "err", err)
os.Exit(1)
}
defer func() { _ = st.Close() }()
if err := st.Migrate(); err != nil {
logger.Error("数据库迁移失败", "err", err)
os.Exit(1)
}
logger.Info("数据库就绪", "dataDir", cfg.DataDir)
srv := &http.Server{
Addr: cfg.Server.Addr(),
Handler: httpapi.New(httpapi.Options{Logger: logger, Version: version}),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
logger.Info("服务启动", "addr", srv.Addr, "version", version)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("服务异常退出", "err", err)
os.Exit(1)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
logger.Info("正在关闭…")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("关闭超时", "err", err)
}
}
+14
View File
@@ -0,0 +1,14 @@
// Package migrations 内嵌 SQL 迁移脚本。
package migrations
import "embed"
// SystemFS 内嵌系统库(data/system.db)的迁移:system/*.sql。
//
//go:embed system/*.sql
var SystemFS embed.FS
// EventFS 内嵌赛事库(data/event_projs/<项目名>/event.db)的迁移:event/*.sql。
//
//go:embed event/*.sql
var EventFS embed.FS
@@ -0,0 +1,6 @@
-- 赛事级配置:键值对,与系统库同名表结构一致,但各存各的。
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS teams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
short_name TEXT,
logo_path TEXT,
color TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))
);
CREATE INDEX IF NOT EXISTS idx_teams_sort_order ON teams(sort_order, code);
CREATE TABLE IF NOT EXISTS athletes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
gender TEXT CHECK (gender IN ('男', '女')),
team_id INTEGER REFERENCES teams(id),
employee_no TEXT,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))
);
CREATE INDEX IF NOT EXISTS idx_athletes_team_id ON athletes(team_id);
CREATE INDEX IF NOT EXISTS idx_athletes_name ON athletes(name);
@@ -0,0 +1,19 @@
CREATE TABLE IF NOT EXISTS scoring_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
tie_mode TEXT NOT NULL DEFAULT 'skip' CHECK (tie_mode IN ('skip', 'average', 'higher')),
record_bonus_mode TEXT NOT NULL DEFAULT 'max' CHECK (record_bonus_mode IN ('max', 'sum')),
-- 文档只在规则包里写了 awardRanks / lastPlaceZero,落库是为了让积分引擎不必再读 YAML。
award_ranks INTEGER NOT NULL DEFAULT 6 CHECK (award_ranks >= 0),
last_place_zero INTEGER NOT NULL DEFAULT 0 CHECK (last_place_zero IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS scoring_profile_rules (
profile_id INTEGER NOT NULL REFERENCES scoring_profiles(id),
rank INTEGER NOT NULL CHECK (rank >= 1),
points REAL NOT NULL,
PRIMARY KEY (profile_id, rank)
);
@@ -0,0 +1,64 @@
CREATE TABLE IF NOT EXISTS sports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
short_name TEXT,
default_profile_id INTEGER REFERENCES scoring_profiles(id),
sort_order INTEGER NOT NULL DEFAULT 0,
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))
);
CREATE INDEX IF NOT EXISTS idx_sports_sort_order ON sports(sort_order, code);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sport_id INTEGER NOT NULL REFERENCES sports(id),
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
event_type TEXT NOT NULL DEFAULT 'individual'
CHECK (event_type IN ('individual', 'team', 'match')),
result_mode TEXT NOT NULL DEFAULT 'auto'
CHECK (result_mode IN ('auto', 'manual')),
result_type TEXT NOT NULL DEFAULT 'none'
CHECK (result_type IN ('time', 'distance', 'count', 'score', 'none')),
compare TEXT NOT NULL DEFAULT 'none'
CHECK (compare IN ('asc', 'desc', 'none')),
record_eligible INTEGER NOT NULL DEFAULT 1 CHECK (record_eligible IN (0, 1)),
scored INTEGER NOT NULL DEFAULT 1 CHECK (scored IN (0, 1)),
multiplier REAL NOT NULL DEFAULT 1 CHECK (multiplier >= 0),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'ongoing', 'finished', 'published')),
scheduled_at TEXT,
venue TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
remark TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_sport_id ON events(sport_id);
CREATE INDEX IF NOT EXISTS idx_events_status ON events(status, sort_order);
CREATE TABLE IF NOT EXISTS event_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id),
name TEXT NOT NULL,
multiplier REAL NOT NULL DEFAULT 1 CHECK (multiplier >= 0),
gender TEXT CHECK (gender IN ('男', '女')),
age_min INTEGER,
age_max INTEGER,
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (event_id, name)
);
CREATE INDEX IF NOT EXISTS idx_event_categories_event_id ON event_categories(event_id);
CREATE TABLE IF NOT EXISTS event_score_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id),
scope TEXT NOT NULL CHECK (scope IN ('athlete', 'team')),
basis TEXT NOT NULL CHECK (basis IN ('result', 'manual', 'aggregate')),
profile_id INTEGER NOT NULL REFERENCES scoring_profiles(id),
sort_order INTEGER NOT NULL DEFAULT 0,
UNIQUE (event_id, scope, basis)
);
CREATE INDEX IF NOT EXISTS idx_event_score_channels_event_id ON event_score_channels(event_id);
@@ -0,0 +1,49 @@
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id),
category_id INTEGER NOT NULL REFERENCES event_categories(id),
team_id INTEGER NOT NULL REFERENCES teams(id),
athlete_id INTEGER REFERENCES athletes(id),
member_names TEXT,
bib TEXT,
seed TEXT,
status TEXT NOT NULL DEFAULT 'registered'
CHECK (status IN ('registered', 'scratched', 'dq', 'dns')),
remark TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (category_id, team_id, athlete_id)
);
CREATE INDEX IF NOT EXISTS idx_entries_event_id ON entries(event_id);
CREATE INDEX IF NOT EXISTS idx_entries_team_id ON entries(team_id);
CREATE INDEX IF NOT EXISTS idx_entries_athlete_id ON entries(athlete_id);
CREATE INDEX IF NOT EXISTS idx_entries_category_id ON entries(category_id);
-- athlete_id 为 NULL 时 UNIQUE 视为互不相等,团体项目的重复报名靠这条部分索引兜住。
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_team_unique
ON entries(category_id, team_id) WHERE athlete_id IS NULL;
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id),
entry_id INTEGER NOT NULL UNIQUE REFERENCES entries(id),
rank INTEGER CHECK (rank IS NULL OR rank >= 1),
result_text TEXT,
result_value REAL,
unit TEXT,
is_tie INTEGER NOT NULL DEFAULT 0 CHECK (is_tie IN (0, 1)),
points_override REAL,
status TEXT NOT NULL DEFAULT 'ok'
CHECK (status IN ('ok', 'dq', 'dns', 'dnf', 'withdrawn')),
state TEXT NOT NULL DEFAULT 'draft' CHECK (state IN ('draft', 'published')),
-- recorded_by / verified_by 指向系统库 users,跨库不能建外键。
recorded_by INTEGER,
recorded_at TEXT,
verified_by INTEGER,
verified_at TEXT,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_results_event_state ON results(event_id, state);
CREATE INDEX IF NOT EXISTS idx_results_event_rank ON results(event_id, rank);
+33
View File
@@ -0,0 +1,33 @@
CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id),
level TEXT NOT NULL CHECK (level IN ('meet', 'city', 'province', 'national')),
result_value REAL,
result_text TEXT,
holder_name TEXT,
holder_team_id INTEGER REFERENCES teams(id),
achieved_at TEXT,
is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0, 1)),
source TEXT NOT NULL DEFAULT 'history' CHECK (source IN ('history', 'current')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_records_event_level ON records(event_id, level, is_current);
CREATE TABLE IF NOT EXISTS record_breaks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES events(id),
entry_id INTEGER NOT NULL REFERENCES entries(id),
level TEXT NOT NULL CHECK (level IN ('meet', 'city', 'province', 'national')),
result_value REAL,
result_text TEXT,
bonus_points REAL NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'approved', 'rejected')),
-- approved_by 指向系统库 users,跨库不能建外键。
approved_by INTEGER,
approved_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_record_breaks_event_id ON record_breaks(event_id, state);
@@ -0,0 +1,38 @@
-- created_by / voided_by 指向系统库 users,跨库不能建外键,靠服务层校验。
CREATE TABLE IF NOT EXISTS score_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL REFERENCES teams(id),
athlete_id INTEGER REFERENCES athletes(id),
event_id INTEGER REFERENCES events(id),
entry_id INTEGER REFERENCES entries(id),
result_id INTEGER REFERENCES results(id),
source_type TEXT NOT NULL CHECK (source_type IN ('result', 'record_bonus', 'adjustment')),
source_id INTEGER NOT NULL,
points REAL NOT NULL,
voided INTEGER NOT NULL DEFAULT 0 CHECK (voided IN (0, 1)),
voided_by INTEGER,
voided_at TEXT,
void_reason TEXT,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_score_ledger_team_voided ON score_ledger(team_id, voided);
CREATE INDEX IF NOT EXISTS idx_score_ledger_source ON score_ledger(source_type, source_id);
CREATE INDEX IF NOT EXISTS idx_score_ledger_athlete ON score_ledger(athlete_id, voided);
CREATE TABLE IF NOT EXISTS point_adjustments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL REFERENCES teams(id),
delta REAL NOT NULL,
category TEXT NOT NULL CHECK (category IN ('penalty', 'bonus', 'correction')),
reason TEXT NOT NULL,
event_id INTEGER REFERENCES events(id),
voided INTEGER NOT NULL DEFAULT 0 CHECK (voided IN (0, 1)),
voided_by INTEGER,
voided_at TEXT,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_point_adjustments_team_voided ON point_adjustments(team_id, voided);
@@ -0,0 +1,15 @@
-- 成绩版本图谱:parent_id 形成 DAG,撤销后从撤销点分支,仅用于可视化。
CREATE TABLE IF NOT EXISTS result_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER REFERENCES result_revisions(id),
event_id INTEGER NOT NULL REFERENCES events(id),
action TEXT NOT NULL CHECK (action IN ('publish', 'unpublish', 'edit')),
snapshot_json TEXT NOT NULL,
ledger_ids TEXT NOT NULL DEFAULT '[]',
-- actor_id 指向系统库 users,跨库不能建外键。
actor_id INTEGER,
message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_result_revisions_event_id ON result_revisions(event_id, created_at);
@@ -0,0 +1,13 @@
-- id 就是实时协议的 version,必须单调递增、不重用,所以用 AUTOINCREMENT。
CREATE TABLE IF NOT EXISTS change_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id INTEGER,
action TEXT NOT NULL CHECK (action IN ('upsert', 'void', 'replace')),
payload_json TEXT NOT NULL DEFAULT '{}',
-- actor_id 指向系统库 users,跨库不能建外键。
actor_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_change_log_entity ON change_log(entity_type, entity_id);
@@ -0,0 +1,17 @@
-- 审计日志只读不可删;user_id 指向系统库 users,跨库不能建外键。
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
entity_type TEXT,
entity_id INTEGER,
before_json TEXT,
after_json TEXT,
ip TEXT,
request_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
+45
View File
@@ -0,0 +1,45 @@
-- 默认积分模板:个人 10/8/6/3/2/1,团体 15/10/8/4/2/1(见 02-积分与排名规则 第 2.1 节)。
INSERT OR IGNORE INTO scoring_profiles (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero)
VALUES ('individual_top6', '个人项目前六名', 'skip', 'max', 6, 0),
('team_top6', '团体项目前六名', 'skip', 'max', 6, 0);
INSERT OR IGNORE INTO scoring_profile_rules (profile_id, rank, points)
SELECT p.id, r.rank, r.points
FROM scoring_profiles p
JOIN (SELECT 1 AS rank, 10.0 AS points UNION ALL
SELECT 2, 8.0 UNION ALL
SELECT 3, 6.0 UNION ALL
SELECT 4, 3.0 UNION ALL
SELECT 5, 2.0 UNION ALL
SELECT 6, 1.0) r
WHERE p.code = 'individual_top6';
INSERT OR IGNORE INTO scoring_profile_rules (profile_id, rank, points)
SELECT p.id, r.rank, r.points
FROM scoring_profiles p
JOIN (SELECT 1 AS rank, 15.0 AS points UNION ALL
SELECT 2, 10.0 UNION ALL
SELECT 3, 8.0 UNION ALL
SELECT 4, 4.0 UNION ALL
SELECT 5, 2.0 UNION ALL
SELECT 6, 1.0) r
WHERE p.code = 'team_top6';
-- 赛事级默认配置;键名沿用 04-数据模型 的 event.name / display.rotate_seconds 风格。
INSERT OR IGNORE INTO settings (key, value) VALUES
('event.name', '未命名赛事'),
('event.subtitle', ''),
('event.organizer', ''),
('event.logo_path', ''),
('display.theme_color', '#1E88E5'),
('display.rotate_seconds', '15'),
('standings.mode', 'points'),
('standings.individual_to_team_weight', '1'),
('standings.show_individual_medals', 'true'),
('standings.medal_counts_team_events', 'true'),
('scoring.default_profile', 'individual_top6'),
('results.review_required', 'false'),
('record_bonus.enabled', 'false'),
('record_bonus.mode', 'max'),
('manual_adjustment.enabled', 'false'),
('manual_adjustment.reason_required', 'true');
+6
View File
@@ -0,0 +1,6 @@
-- 系统级配置:键值对
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
+16
View File
@@ -0,0 +1,16 @@
-- 登录用户。
-- role 不含 guest:guest 是「未登录」的默认身份,不落库(见 08-安全与可靠性 第 2 节)。
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'recorder' CHECK (role IN ('recorder', 'admin')),
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
must_change_password INTEGER NOT NULL DEFAULT 1 CHECK (must_change_password IN (0, 1)),
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(active);
@@ -0,0 +1,14 @@
-- 服务端会话;id 为随机字符串,token 只存哈希,便于强制下线。
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
ip TEXT,
user_agent TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
@@ -0,0 +1,11 @@
-- 最近打开的赛事项目;启动时取 last_opened_at 最新的一条自动打开。
CREATE TABLE IF NOT EXISTS recent_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project TEXT NOT NULL UNIQUE, -- data/event_projs/<项目名>
name TEXT NOT NULL DEFAULT '', -- 展示名,冗余自赛事库 settings
db_path TEXT NOT NULL, -- 相对数据根目录的 event.db 路径
open_count INTEGER NOT NULL DEFAULT 1,
last_opened_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_recent_projects_last_opened ON recent_projects(last_opened_at DESC);
@@ -0,0 +1,8 @@
-- 系统级默认配置;赛事级配置在 event.db 的 settings 里,两者不共用。
INSERT OR IGNORE INTO settings (key, value) VALUES
('app.name', '赛事积分榜系统'),
('app.locale', 'zh-CN'),
('security.session_hours', '12'),
('security.login_rate_per_minute', '5'),
('backup.interval_minutes', '5'),
('backup.keep', '20');