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:
1 parent
a61d793d79
commit
7d5d94f815
76 files changed
+10131
No files matched your search
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Package domain 承载积分、排名、纪录等纯业务逻辑。
|
||||
//
|
||||
// 本包不 import 任何 IO 包(数据库、HTTP、WebSocket),
|
||||
// 输入输出都是普通结构体,以便完整单测,也方便将来替换存储层。
|
||||
package domain
|
||||
@@ -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: 未知的流水增量操作")
|
||||
)
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
@@ -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,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');
|
||||
Reference in new issue
Block a user