Files
Scoreboard/server/internal/domain/scoring.go
T
AskaEth 53a0cb225c feat: 备份恢复 / 图片清理 / 单位恢复 / 审计过滤 / 模板引擎 M1 + 两个导入缺陷修复
用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:

**新增能力**
- 备份恢复:`POST /api/backup/restore`(admin)。先把校验做在拍快照之前(只读打开 + integrity_check +
  关键表校验 + 异类库标志表互斥),再用 VACUUM INTO 拍「恢复前安全副本」,然后关连接、整文件替换、
  触发优雅重启;`-wal` 还在时直接拒绝(宁可失败也不让旧库 WAL 套到新库上);恢复后审计直写"恢复之后
  还在的那份库"(不走通用 Auditor,否则记录只会落在被替换掉的旧文件上)。顺带修掉:裸服务端连续恢复
  第二次会在拍副本处崩出一句看不懂的 500,现在回 503「上次恢复后尚未重启,请先重启服务端」。
- 安全副本保留策略:每类库保留最近 5 份,独立于 .db 的 20 份滚动清理。
- 图片管理:`DELETE /api/uploads/{name}`(被引用 409 并列出引用点)、`GET /api/uploads/orphans`、
  `POST /api/uploads/orphans/delete`(只删列出的孤儿,宁漏勿误),全部 admin(与字体删除同档);
  `deploy/backup.bat` 补上 uploads/ 归档(logs 不归档:可再生的诊断且会无上限增长)。
- 已彻底删除单位的恢复:`POST /api/teams/{id}/restore`(与 hideTeam 同档)。幂等;恢复不会把「停用」
  变成「启用」;真机确认恢复前后榜单逐字节一致、change_log 最大值不变(数据一行没动,不需要重算)。
  界面加「显示已彻底删除的单位」(仅 admin)与恢复按钮;非 admin 的删除确认框里写明"本账号无法自行恢复"。
- 审计日志按 `action` 过滤(子串模糊,输入里的 %/_/\ 转义成字面量)。
- 删除死表 recent_projects(新增 system 迁移 0006,未动已应用的迁移)。
- points_override 不再"设了没效果":写入侧 400 + 引擎侧守卫,提示改用「积分调整」。
- 联队份额空壳清理(UnionAttribution/UnionSplit/Entry.TeamShares/resolvedShares 等;TeamShare 类型保留,
  因为 export/realtime/奖牌流水三处在生产路径上构造它)。
- 自定义大屏模板 M1:插值引擎(parseTemplate/renderTemplate),任何情况都不抛异常、未定义→空串+警告、
  警告按 (code,path) 合并计数、无 eval/innerHTML,57 个新用例。

**两个真缺陷(前两个已随 0.1.6 出包)**
- 基础数据包导入「排序」列口径不一致:小项/组别留空保持原值,单位/大项留空被写成 0(plan 没从库里
  现值起手)→ 四张表统一为「留空保持原值」。
- 同类第二处:布尔列「整列被删掉」被当成"取默认值",会把库里所有停用的单位/大项静默改回启用 →
  改为「缺列 = 不动」(写侧与解析侧都收口,补了"缺列保持/空格子=是/显式否=停用"三组对照用例)。

**删死代码**
- createEntry / updateEntry / deleteEntry / entryEventID / checkCategoryInEvent / getEntry /
  normalizeEntryStatus / ExportBaseDataXLSX(全仓 grep 零引用)。⚠️ 随之记入文档:「组别必须属于该小项」
  这道校验现在只由导入路径"按小项+组别名解析"天然保证,将来若新增从请求体接收 categoryId 的接口,
  必须自己补校验(库层没有复合外键)。

**文档**
- devdocs 全面同步(01/02/03/04/05/06/07/09/11/12/13/README;含身份口径、延迟结算、大屏单栏新版式、
  新增路由与设置键、R18/R19 风险);docs 复核 + 第二轮(身份口径三个易写反的分支、图片删除、备份覆盖、
  审计过滤、单位恢复、去掉硬编码版本号、上线检查清单 +5 项);debug/使用说明 6 份逐份 diff 一致。

测试:服务端 13 包(连跑两遍)、前端 406 例、Console 28 例 + 18 文件语法检查 —— 全绿。
版本号 dev_0.1.6 → dev_0.1.7。
2026-09-11 19:26:36 +08:00

566 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package domain
import (
"fmt"
"math"
"sort"
)
// rankEpsilon 用于判定「成绩相同」。计时/测距保留两位小数,1e-9 足够区分。
const rankEpsilon = 1e-9
// ScoreOptions 控制引擎的可选行为;零值即本次运动会的口径。
type ScoreOptions struct {
// IncludeDrafts 为 true 时草稿成绩也参与计分;默认只算已发布(草稿不上屏也不计分)。
IncludeDrafts bool
}
// EventScoreInput 是一次小项(某组别)计分的全部输入,引擎只读不改。
type EventScoreInput struct {
Event Event
Category EventCategory
Channels []ScoreChannel
Profiles map[int64]ScoringProfile
Entries []Entry
Results []Result
RecordBonus *RecordBonusConfig
RecordBreaks []RecordBreak
Options ScoreOptions
}
// EventScore 是一次小项计分的结果。
type EventScore struct {
Ledger []LedgerEntry
Ranked []RankedResult
}
// RankedResult 记录「某条报名项在某通道下拿到的名次与原始积分」。
// Points 尚未乘个人→单位系数,因此可直接用于奖牌判定与分项成绩表。
// Shares 是该名次的归属单位;当前只有「整体归主单位」一种口径(联队未实现),
// 保留份额结构是因为导出 / 实时快照等消费方按份额遍历。
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 或没有通道时照样产出名次(Points=0),只是不写流水。
// 没有通道时按小项自身的录入方式(result_mode)推导排名口径,见 rankOnlyChannel。
func ScoreEvent(in EventScoreInput) (EventScore, error) {
// 引擎不读取 pointsOverride,静默忽略等于「设置成功但毫无效果」;
// 显式报错把问题暴露在计分环节,写入侧另有 400 拦截,两道都不放行。
for _, r := range in.Results {
if r.PointsOverride != nil {
return EventScore{}, fmt.Errorf("%w: 成绩 %d(报名项 %d)", ErrPointsOverrideUnsupported, r.ID, r.EntryID)
}
}
var out EventScore
rankOnly := len(in.Channels) == 0
// 只排名不计分:scored=false 或没有计分通道时都不产出任何流水(含破纪录加分)。
scoreEnabled := in.Event.Scored && !rankOnly
channels := sortedChannels(in.Channels)
if rankOnly {
channels = []ScoreChannel{rankOnlyChannel(in)}
}
for _, ch := range channels {
// 不计分时不需要积分表:既不查模板,也不可能算出非零分。
profile := ScoringProfile{}
if scoreEnabled {
p, ok := in.Profiles[ch.ProfileID]
if !ok {
return EventScore{}, fmt.Errorf("%w: 通道 %d 引用模板 %d", ErrProfileNotFound, ch.ID, ch.ProfileID)
}
profile = p
}
ranked, err := scoreChannel(in, ch, profile)
if err != nil {
return EventScore{}, err
}
if !scoreEnabled {
for i := range ranked {
ranked[i].Points = 0
}
}
out.Ranked = append(out.Ranked, ranked...)
if scoreEnabled {
out.Ledger = append(out.Ledger, ledgerFromRanked(in, ch, ranked)...)
}
}
if !scoreEnabled {
return out, nil
}
bonus, err := recordBonusLedger(in)
if err != nil {
return EventScore{}, err
}
out.Ledger = append(out.Ledger, bonus...)
return out, nil
}
// rankOnlyChannel 在没有计分通道时,按小项自身的录入方式推导排名口径:
// manual 直接录名次,其余按成绩自动排名;名次记给个人,团体/对抗项目记给单位。
// 「个人+团体」的双计靠两条通道表达,这条退化路径只排个人榜,故不入单位分支。
func rankOnlyChannel(in EventScoreInput) ScoreChannel {
basis := BasisResult
if in.Event.ResultMode == ResultModeManual {
basis = BasisManual
}
scope := ScopeAthlete
if in.Event.EventType == EventTypeTeam || in.Event.EventType == EventTypeMatch {
scope = ScopeTeam
}
return ScoreChannel{EventID: in.Event.ID, Scope: scope, Basis: basis}
}
// sortedChannels 固定通道处理顺序,保证同一份输入每次产出同样的流水顺序。
func sortedChannels(channels []ScoreChannel) []ScoreChannel {
out := append([]ScoreChannel(nil), channels...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].SortOrder != out[j].SortOrder {
return out[i].SortOrder < out[j].SortOrder
}
return out[i].ID < out[j].ID
})
return out
}
func scoreChannel(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
switch ch.Basis {
case BasisResult:
return scoreByResult(in, ch, profile)
case BasisManual:
return scoreByManual(in, ch, profile)
case BasisAggregate:
return scoreByAggregate(in, ch, profile)
default:
return nil, fmt.Errorf("%w: %q", ErrUnknownBasis, ch.Basis)
}
}
// candidate 是排名过程的中间态:一条可参与排名的报名项 + 它的比较值。
type candidate struct {
entry Entry
result Result
subject int64 // 聚合通道的排名主体(单位或运动员)
value float64 // 排序用比较值
rankIn int // manual 通道录入的名次
}
// entryMatches 判断报名项是否属于本次计分的小项与组别。
func entryMatches(in EventScoreInput, e Entry) bool {
if in.Event.ID != 0 && e.EventID != 0 && e.EventID != in.Event.ID {
return false
}
if in.Category.ID != 0 && e.CategoryID != in.Category.ID {
return false
}
return true
}
// collectCandidates 过滤出本通道真正参与排名的报名项:
// 只取已发布、状态正常、且带有本通道所需数据的成绩。聚合通道不用本函数,见 scoreByAggregate。
func collectCandidates(in EventScoreInput, ch ScoreChannel) ([]candidate, error) {
if ch.Basis == BasisResult && 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 !entryMatches(in, e) {
continue
}
if !e.Status.rankable() {
continue
}
r, ok := byEntry[e.ID]
if !ok {
continue
}
if r.State == ResultStateDraft && !in.Options.IncludeDrafts {
continue
}
if !r.Status.rankable() {
continue
}
c := candidate{entry: e, result: r}
switch ch.Basis {
case BasisManual:
if r.Rank <= 0 {
continue
}
c.rankIn = r.Rank
default: // result 按成绩值排序
if r.ResultValue == nil {
continue
}
c.value = *r.ResultValue
}
out = append(out, c)
}
return out, nil
}
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: wholeShare(c.entry.primaryTeamID()),
Rank: rank,
Points: points,
Status: c.result.Status,
}
if c.result.ResultValue != nil {
v := *c.result.ResultValue
rr.ResultValue = &v
}
return rr
}
// scoreByAggregate 实现 basis=aggregate:把同一主体(单位/运动员)在本组别的成绩取平均,
// 得到团体成绩后按项目的 compare 方向排名(并列沿用模板的 tieMode,默认顺延)。
// 平均值分母由项目的 avg_mode 决定:
// - valid:分子 = 有效成绩之和,分母 = 有有效成绩的人数;一个人都没成绩就不参与排名、不得分;
// - all:分子 = 有效成绩之和(没成绩的按 0),分母 = 该主体的报名人数。
// 没有报名项的主体不会成桶,自然不参与。
//
// 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致。
func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) {
if in.Event.Compare == CompareNone {
return nil, ErrCompareRequired
}
byEntry := make(map[int64]Result, len(in.Results))
for _, r := range in.Results {
byEntry[r.EntryID] = r
}
type bucket struct {
subject int64
entry Entry
sum float64 // 有效成绩之和
valid int // 有有效成绩的人数(valid 口径的分母)
roster int // 报名人数(all 口径的分母)
}
order := make([]int64, 0)
buckets := make(map[int64]*bucket)
for _, e := range in.Entries {
if !entryMatches(in, e) || !e.Status.rankable() {
continue
}
subject := e.TeamID
if ch.Scope == ScopeAthlete {
subject = e.AthleteID
}
if subject == 0 {
continue
}
b, ok := buckets[subject]
if !ok {
b = &bucket{subject: subject, entry: e}
buckets[subject] = b
order = append(order, subject)
}
b.roster++
r, found := byEntry[e.ID]
if v, ok := validResultValue(r, found, in.Options); ok {
b.sum += v
b.valid++
}
}
agg := make([]candidate, 0, len(order))
for _, id := range order {
b := buckets[id]
var value float64
if in.Event.avgMode() == AvgModeAll {
// 分母 = 报名人数:没成绩的人按 0 计入,也就是只累加有效成绩之和。
value = b.sum / float64(b.roster)
} else {
if b.valid == 0 {
// 一个人都没有效成绩的单位不参与团体排名,也不得分。
continue
}
value = b.sum / float64(b.valid)
}
c := candidate{entry: b.entry, subject: b.subject, value: value}
c.entry.ID = 0 // 聚合结果不对应单条报名项
agg = append(agg, c)
}
sortCandidatesByValue(agg, in.Event.Compare)
groups := groupByValue(agg)
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 = wholeShare(c.subject)
} else {
rr.AthleteID = c.subject
rr.AthleteName = c.entry.AthleteName
rr.Shares = wholeShare(c.entry.primaryTeamID())
}
v := c.value
rr.ResultValue = &v
out = append(out, rr)
}
}
return out, nil
}
func sortCandidatesByValue(cands []candidate, compare CompareMode) {
sort.SliceStable(cands, func(i, j int) bool {
if nearlyEqual(cands[i].value, cands[j].value) {
return cands[i].entry.ID < cands[j].entry.ID
}
if compare == CompareDesc {
return cands[i].value > cands[j].value
}
return cands[i].value < cands[j].value
})
}
func nearlyEqual(a, b float64) bool {
return math.Abs(a-b) <= rankEpsilon
}
// validResultValue 判断一条成绩是否算「有效成绩」:成绩存在、已发布(或允许草稿)、
// 状态正常,且有可比较的数值。聚合通道按 avg_mode 决定没成绩的人算不算进分母。
func validResultValue(r Result, found bool, opts ScoreOptions) (float64, bool) {
if !found {
return 0, false
}
if r.State == ResultStateDraft && !opts.IncludeDrafts {
return 0, false
}
if !r.Status.rankable() || r.ResultValue == nil {
return 0, false
}
return *r.ResultValue, true
}
// ledgerFromRanked 把名次明细转成积分流水:按通道 scope 决定记给个人还是单位;
// 0 分不产生流水,避免流水表被零分噪声撑大。
// 流水是否计入单位总分由通道的 countToTeam 决定(个人通道可以只记个人)。
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,
CountToTeam: ch.countToTeam(),
})
}
}
return out
}
// wholeShare 构造「主单位独占 100%」的归属份额。联队拆分未实现,这里只产出这一种口径。
func wholeShare(teamID int64) []TeamShare {
if teamID == 0 {
return nil
}
return []TeamShare{{TeamID: teamID, Weight: 1}}
}