Files
Scoreboard/server/internal/httpapi/scoring.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

1136 lines
41 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 httpapi
import (
"context"
"database/sql"
"encoding/json"
"errors"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
// queryer 让计分相关的加载函数既能走连接池(只读)也能走事务(发布时读写一致)。
type queryer interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
// ---------- 加载:DB 行 → domain 输入 ----------
func loadEventQ(ctx context.Context, q queryer, eventID int64) (domain.Event, error) {
var (
e domain.Event
scheduledAt sql.NullString
venue, remark sql.NullString
awardMedals int
)
err := q.QueryRowContext(ctx,
`SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark, avg_mode,
award_medals
FROM events WHERE id = ?`, eventID).
Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType, &e.Compare,
&e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, &remark, &e.AvgMode,
&awardMedals)
e.AwardMedals = awardMedals != 0
if errors.Is(err, sql.ErrNoRows) {
return domain.Event{}, notFound("小项不存在")
}
if err != nil {
return domain.Event{}, internal(err)
}
e.ScheduledAt = scheduledAt.String
e.Venue = venue.String
e.Remark = remark.String
return e, nil
}
func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.EventCategory, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order
FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.EventCategory, 0)
for rows.Next() {
var (
c domain.EventCategory
gender sql.NullString
ageMin, ageMax sql.NullInt64
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder); err != nil {
return nil, internal(err)
}
c.Gender = gender.String
if ageMin.Valid {
v := int(ageMin.Int64)
c.AgeMin = &v
}
if ageMax.Valid {
v := int(ageMax.Int64)
c.AgeMax = &v
}
out = append(out, c)
}
return out, rows.Err()
}
func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team,
count_to_medals, count_to_team_medals
FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.ScoreChannel, 0)
for rows.Next() {
var (
c domain.ScoreChannel
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam,
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
// 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分 / 发奖牌)。
if countToTeam == 0 {
c.CountToTeam = boolPtr(false)
}
if countToMedals == 0 {
c.CountToMedals = boolPtr(false)
}
if countToTeamMedals == 0 {
c.CountToTeamMedals = boolPtr(false)
}
out = append(out, c)
}
return out, rows.Err()
}
// loadTeamRanksQ 读「按单位录入的团体名次」(channel_manual_ranks),按组别分组返回。
//
// 按组别分组是必须的:computeEventScore 会逐组别调用引擎,组别名次只在组别内有效,
// 一次查询整小项再分发,避免每个组别各发一条 SQL。
// 只取 scope=team + basis=manual 的行:这两列是这张表的定位键,
// 其它 scope/basis 的行属于别的通道形态,引擎的团体手工通道也不该看到它们。
func loadTeamRanksQ(ctx context.Context, q queryer, eventID, categoryID int64) (map[int64][]domain.TeamManualRank, error) {
query := `SELECT category_id, team_id, rank FROM channel_manual_ranks
WHERE event_id = ? AND scope = 'team' AND basis = 'manual'`
args := []any{eventID}
if categoryID > 0 {
query += ` AND category_id = ?`
args = append(args, categoryID)
}
query += ` ORDER BY category_id, rank, team_id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64][]domain.TeamManualRank)
for rows.Next() {
var (
catID int64
r domain.TeamManualRank
)
if err := rows.Scan(&catID, &r.TeamID, &r.Rank); err != nil {
return nil, internal(err)
}
out[catID] = append(out[catID], r)
}
return out, rows.Err()
}
func loadProfilesQ(ctx context.Context, q queryer, ids map[int64]bool) (map[int64]domain.ScoringProfile, error) {
out := make(map[int64]domain.ScoringProfile, len(ids))
for id := range ids {
var p domain.ScoringProfile
err := q.QueryRowContext(ctx,
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles WHERE id = ?`, id).
Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero)
if errors.Is(err, sql.ErrNoRows) {
return nil, badRequest("积分模板不存在", id)
}
if err != nil {
return nil, internal(err)
}
rows, err := q.QueryContext(ctx,
`SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, id)
if err != nil {
return nil, internal(err)
}
rules := make([]domain.ScoringRule, 0)
for rows.Next() {
var r domain.ScoringRule
if err := rows.Scan(&r.Rank, &r.Points); err != nil {
_ = rows.Close()
return nil, internal(err)
}
rules = append(rules, r)
}
err = rows.Err()
_ = rows.Close()
if err != nil {
return nil, internal(err)
}
p.Rules = rules
out[id] = p
}
return out, nil
}
func loadEntriesQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Entry, error) {
query := `SELECT e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
e.status, COALESCE(a.name, '')
FROM entries e
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE e.event_id = ?`
args := []any{eventID}
if categoryID > 0 {
query += ` AND e.category_id = ?`
args = append(args, categoryID)
}
query += ` ORDER BY e.id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Entry, 0)
for rows.Next() {
var (
e domain.Entry
athleteID sql.NullInt64
memberNames sql.NullString
)
if err := rows.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames, &e.Status, &e.AthleteName); err != nil {
return nil, internal(err)
}
if athleteID.Valid {
e.AthleteID = athleteID.Int64
}
e.MemberNames = parseNames(memberNames)
out = append(out, e)
}
return out, rows.Err()
}
func loadResultsQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Result, error) {
query := `SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit,
r.is_tie, r.points_override, r.status, r.state, r.note
FROM results r
JOIN entries e ON e.id = r.entry_id
WHERE r.event_id = ?`
args := []any{eventID}
if categoryID > 0 {
query += ` AND e.category_id = ?`
args = append(args, categoryID)
}
query += ` ORDER BY r.id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Result, 0)
for rows.Next() {
var (
r domain.Result
rank sql.NullInt64
text, unit, note sql.NullString
value, override sql.NullFloat64
)
if err := rows.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit,
&r.IsTie, &override, &r.Status, &r.State, &note); err != nil {
return nil, internal(err)
}
if rank.Valid {
r.Rank = int(rank.Int64)
}
r.ResultText = text.String
if value.Valid {
v := value.Float64
r.ResultValue = &v
}
r.Unit = unit.String
if override.Valid {
v := override.Float64
r.PointsOverride = &v
}
r.Note = note.String
out = append(out, r)
}
return out, rows.Err()
}
func loadRecordBonusQ(ctx context.Context, q queryer) (domain.RecordBonusConfig, error) {
values, err := loadSettingsMap(ctx, q)
if err != nil {
return domain.RecordBonusConfig{}, err
}
// 统一走 rules 的解析:enabled / mode / levels 三个键必须一起读,
// 只读前两个会让 levelPoints 永远查不到档位,破纪录加分恒为 0。
return rules.RecordBonusFromSettings(values), nil
}
func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain.RecordBreak, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state
FROM record_breaks WHERE event_id = ? AND state = 'approved'`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.RecordBreak, 0)
for rows.Next() {
var (
b domain.RecordBreak
value sql.NullFloat64
text sql.NullString
)
if err := rows.Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State); err != nil {
return nil, internal(err)
}
b.ResultValue = value.Float64
b.ResultText = text.String
out = append(out, b)
}
return out, rows.Err()
}
// ---------- 计分 ----------
// computeEventScore 计算某小项(或某组别)的流水与名次明细。
func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64) (domain.EventScore, error) {
ev, err := loadEventQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
cats, err := loadCategoriesQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
channels, err := loadChannelsQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
profileIDs := make(map[int64]bool)
for _, ch := range channels {
profileIDs[ch.ProfileID] = true
}
profiles, err := loadProfilesQ(ctx, q, profileIDs)
if err != nil {
return domain.EventScore{}, err
}
entries, err := loadEntriesQ(ctx, q, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
results, err := loadResultsQ(ctx, q, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
bonusCfg, err := loadRecordBonusQ(ctx, q)
if err != nil {
return domain.EventScore{}, err
}
breaks, err := loadRecordBreaksQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
teamRanks, err := loadTeamRanksQ(ctx, q, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
var total domain.EventScore
for _, cat := range cats {
if categoryID > 0 && cat.ID != categoryID {
continue
}
score, err := domain.ScoreEvent(domain.EventScoreInput{
Event: ev,
Category: cat,
Channels: channels,
Profiles: profiles,
Entries: entries,
Results: results,
RecordBonus: &bonusCfg,
RecordBreaks: breaks,
// 每个组别只拿自己组别的团体名次:名次在组别内有效,串组会算出别人的分。
TeamRanks: teamRanks[cat.ID],
})
if err != nil {
return domain.EventScore{}, mapDomainError(err)
}
total.Ledger = append(total.Ledger, score.Ledger...)
total.Ranked = append(total.Ranked, score.Ranked...)
}
return total, nil
}
// recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。
// 作废范围是引擎产出的成绩分与破纪录加分;人工调整由 point_adjustments 管理,绝不在此作废。
// 聚合通道的流水 entry_id/result_id 都为空(名次主体是单位/运动员,不对应单条报名项),
// 所以按组别重算时不能只靠 entry_id IN (...) 定位,否则 NULL 永不命中、旧流水存活,
// 重算再插一份导致该通道分数翻倍。聚合流水本身不归属组别,且重建通道会换掉通道 id
// (source_id 随之失效),故按 event_id 全量作废;下方补算整项聚合流水时会重新插入全量。
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) {
score, err := computeEventScore(ctx, tx, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
if err := writeLedgerTx(ctx, tx, score, eventID, categoryID, actorID, reason); err != nil {
return domain.EventScore{}, err
}
return score, nil
}
// writeLedgerTx 把已算好的 score 落成流水:先作废范围内旧流水,再插入新流水。
//
// 与 recomputeLedgerTx 拆开是为了「延迟结算」:那时名次与流水都要等整组录完才落,
// 但分数仍要能算出来(结算时才算,见 publishResults)。计算不读 score_ledger,
// 所以「先算后作废」与老的「先作废后算」结果完全一致。
func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID, actorID int64, reason string) error {
scope := `source_type IN ('result', 'record_bonus') AND event_id = ?`
args := []any{eventID}
if categoryID > 0 {
scope += ` AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?)
OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'))`
args = append(args, categoryID)
}
if _, err := tx.ExecContext(ctx,
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
WHERE voided = 0 AND `+scope,
append([]any{nullID(actorID), reason}, args...)...); err != nil {
return internal(err)
}
ledger := score.Ledger
if categoryID > 0 {
// 「无报名项归属」的流水无法按组别区分,上面已把本小项的全部这类流水一并作废;
// 这里补算整项的同类流水,避免按组别重算丢掉其它组别的得分。
hasSubject, err := hasSubjectChannel(ctx, tx, eventID)
if err != nil {
return err
}
if hasSubject {
all, err := computeEventScore(ctx, tx, eventID, 0)
if err != nil {
return err
}
ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger))
for _, l := range score.Ledger {
if !isSubjectLedger(l) {
ledger = append(ledger, l)
}
}
for _, l := range all.Ledger {
if isSubjectLedger(l) {
ledger = append(ledger, l)
}
}
}
}
for _, l := range ledger {
if _, err := tx.ExecContext(ctx,
`INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, count_to_team, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID),
l.SourceType, l.SourceID, l.Points, boolToInt(l.CountToTeam), nullID(actorID)); err != nil {
return internal(err)
}
}
// 名次与流水同源同事务:流水改了名次必然跟着变,分开落库就会出现「榜上有分、名次还是旧的」。
return writeChannelRanksTx(ctx, tx, score, eventID, categoryID)
}
// isSubjectLedger 判断流水是否来自「按主体排名」的通道,这类流水的 EntryID 与 ResultID 都为空:
//
// - 聚合通道(basis=aggregate):名次主体是单位(team)或运动员(athlete),
// 不对应任何一条报名项;
// - 团体手工通道(scope=team + basis=manual,主体来自 channel_manual_ranks):
// 录入的是单位名次,同样没有报名项与成绩行(走团体报名项兜底时才有 entry_id,
// 那种流水仍归属组别,不算在这里)。
//
// 判据必须涵盖这两类:按组别重算时它们都无法靠 entry_id 定位(见 writeLedgerTx 的作废范围),
// 少认一类就会让另一类旧流水作废不掉或补不回来,该通道的分数随之翻倍或丢失。
func isSubjectLedger(l domain.LedgerEntry) bool {
return l.SourceType == domain.SourceResult && l.EntryID == 0 && l.ResultID == 0
}
// hasSubjectChannel 判断小项是否配置了「按主体排名」的通道(聚合 / 团体手工);
// 没有时按组别重算无需补算整项流水。
func hasSubjectChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, error) {
var n int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM event_score_channels
WHERE event_id = ? AND (basis = 'aggregate' OR (scope = 'team' AND basis = 'manual'))`, eventID).Scan(&n); err != nil {
return false, internal(err)
}
return n > 0, nil
}
// writeChannelRanksTx 按引擎产出的名次明细全量重建 channel_ranks:先删范围内旧行,再逐条插入。
//
// 结算范围与流水一致:categoryID > 0 只重建该组别,否则重建整个小项。
// SubjectKey 是 (通道, 组别, 主体) 的唯一键,聚合与团体手工名次没有报名项,靠它落库。
// AwardMedals / CountToMedals / CountToTeamMedals 是读取侧 join events / event_score_channels
// 得到的,不落这张表;shares 不进表(当前只有「主单位独占 100%」一种口径,读取侧按 team_id 重建)。
func writeChannelRanksTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID int64) error {
if categoryID > 0 {
if _, err := tx.ExecContext(ctx,
`DELETE FROM channel_ranks
WHERE category_id = ? AND channel_id IN (SELECT id FROM event_score_channels WHERE event_id = ?)`,
categoryID, eventID); err != nil {
return internal(err)
}
} else if _, err := tx.ExecContext(ctx, `DELETE FROM channel_ranks WHERE event_id = ?`, eventID); err != nil {
return internal(err)
}
for _, rr := range score.Ranked {
// 没有通道(只排名不计分的小项走退化路径,通道 id 为 0)与非法名次都落不了这张表:
// 表上 channel_id 有外键、rank 有 CHECK(rank >= 1),硬插只会整笔事务失败。
if rr.ChannelID == 0 || rr.Rank <= 0 || rr.SubjectKey == "" {
continue
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO channel_ranks (event_id, category_id, channel_id, basis, scope, subject_key,
entry_id, result_id, athlete_id, team_id, rank, points, result_value, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
eventID, rr.CategoryID, rr.ChannelID, rr.Basis, rr.Scope, rr.SubjectKey,
nullID(rr.EntryID), nullID(rr.ResultID), nullID(rr.AthleteID), nullID(rr.TeamID),
rr.Rank, rr.Points, nullF64(rr.ResultValue), nullStr(resultStatusPtr(rr.Status))); err != nil {
return internal(err)
}
}
return nil
}
// resultStatusPtr 把成绩状态转成可空串:空状态落 NULL,与 results.status 的口径一致。
func resultStatusPtr(s domain.ResultStatus) *string {
if s == "" {
return nil
}
v := string(s)
return &v
}
// applyComputedRanks 把引擎算出的「个人名次」写回 results.rank。
//
// results.rank 的语义收窄为个人名次:只处理 scope=athlete 且带真实成绩行的名次。
// 团体通道(scope=team)与聚合 / 团体手工名次(ResultID=0)一律不碰这一列——
// 它们过去把个人名次覆盖成团体名次,双通道项目第二次发布会把同一份名次再发一次分(P0)。
//
// 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次),
// 其次通道 id 小者,保证同一份输入每次写回同一个值。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
best := make(map[int64]domain.RankedResult)
for _, rr := range ranked {
if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
cur, ok := best[rr.ResultID]
if !ok || preferComputedRank(rr, cur) {
best[rr.ResultID] = rr
}
}
for id, rr := range best {
if _, err := tx.ExecContext(ctx, `UPDATE results SET rank = ? WHERE id = ?`, rr.Rank, id); err != nil {
return internal(err)
}
}
return nil
}
// preferComputedRank 判断候选 a 是否比已选中的 b 更该写回 results.rank。
func preferComputedRank(a, b domain.RankedResult) bool {
if (a.Basis == domain.BasisResult) != (b.Basis == domain.BasisResult) {
return a.Basis == domain.BasisResult
}
return a.ChannelID < b.ChannelID
}
// ---------- 发布 / 撤销 / 作废 ----------
// publishableResultCond 判断一条 results 行是否承载了「可以上屏的成绩」:
// - 有解析出来的成绩值(计时 / 距离 / 计数 / 评分项目);
// - 有手工名次(resultMode=manual 或 compare=none 的项目,名次本身就是成绩);
// - 状态不是 ok(DNS / DQ / DNF / 退赛 也是现场明确给出的结果,要能上屏)。
//
// 空且状态为 ok 的行只是「还没跑 / 还没录」,发布时必须留在 draft:
// 分批比赛时若把没跑的人一起置为 published,大屏的最新成绩会出现空行,
// 而且编辑页会把整组当成「已发布」锁死,补录无门。
const publishableResultCond = `(result_value IS NOT NULL OR rank IS NOT NULL OR status <> 'ok')`
// publishableResult 是 publishableResultCond 的 Go 版判据,供分批结算的纯函数使用。
// 两处判据必须同源:SQL 那份决定哪些行能被发布,这份决定「这一条算不算已经出成绩」。
func publishableResult(r domain.Result) bool {
return r.ResultValue != nil || r.Rank > 0 || r.Status != domain.ResultStatusOK
}
// groupStats 统计某组「该出成绩的报名项数」与「已经有可发布成绩的项数」。
//
// 非 registered 的报名项(scratched / dq / dns)本来就不该等它出成绩:
// 计分把非 registered 视为 0 分且不占名次,等它只会让整组永远结算不了。
func groupStats(entries []domain.Entry, results []domain.Result) (total, published int) {
byEntry := make(map[int64]domain.Result, len(results))
for _, r := range results {
byEntry[r.EntryID] = r
}
for _, e := range entries {
if e.Status != domain.EntryStatusRegistered {
continue
}
total++
if r, ok := byEntry[e.ID]; ok && r.State == domain.ResultStatePublished && publishableResult(r) {
published++
}
}
return total, published
}
// groupOf 从整项的行里挑出某个组别的报名与成绩。
// 成绩表本身不带组别,只能靠报名项 id 反查,所以先收组内报名 id 再过滤成绩。
func groupOf(entries []domain.Entry, results []domain.Result, categoryID int64) ([]domain.Entry, []domain.Result) {
inGroup := make(map[int64]bool)
groupEntries := make([]domain.Entry, 0, len(entries))
for _, e := range entries {
if e.CategoryID != categoryID {
continue
}
inGroup[e.ID] = true
groupEntries = append(groupEntries, e)
}
groupResults := make([]domain.Result, 0, len(groupEntries))
for _, r := range results {
if inGroup[r.EntryID] {
groupResults = append(groupResults, r)
}
}
return groupEntries, groupResults
}
// scopeStats 按发布 / 结算的作用域累加 groupStats:categoryID > 0 只看该组别,
// categoryID = 0 看整项(逐组别累加,任何一组没录完都算没录完)。
func scopeStats(ctx context.Context, q queryer, eventID, categoryID int64) (total, published int, err error) {
entries, err := loadEntriesQ(ctx, q, eventID, 0)
if err != nil {
return 0, 0, err
}
results, err := loadResultsQ(ctx, q, eventID, 0)
if err != nil {
return 0, 0, err
}
categories := []int64{categoryID}
if categoryID == 0 {
cats, err := loadCategoriesQ(ctx, q, eventID)
if err != nil {
return 0, 0, err
}
categories = make([]int64, 0, len(cats))
for _, cat := range cats {
categories = append(categories, cat.ID)
}
}
for _, id := range categories {
groupEntries, groupResults := groupOf(entries, results, id)
t, p := groupStats(groupEntries, groupResults)
total += t
published += p
}
return total, published, nil
}
// scopeSettled 判断作用域内是否「所有批次都录完」,即可以结算。
// 没有报名项(total = 0)视为录完:没有要等的东西,结算一次把范围清干净。
func scopeSettled(ctx context.Context, q queryer, eventID, categoryID int64) (bool, error) {
total, published, err := scopeStats(ctx, q, eventID, categoryID)
if err != nil {
return false, err
}
return published == total, nil
}
// liveOnPublishQ 读「发布后是否立即结算」:缺键 / 脏值一律按开启处理,保证历史行为。
func liveOnPublishQ(ctx context.Context, q queryer) (bool, error) {
var raw string
err := q.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, dto.SettingKeyLiveOnPublish).Scan(&raw)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, internal(err)
}
return boolDefault(raw, true), nil
}
func (s *server) publishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
catID := int64(0)
if in.CategoryID != nil {
catID = *in.CategoryID
}
// 发布是不可逆的对外动作,先留一份快照;失败只记日志与审计,不阻塞发布(08 第 6 节)。
s.backupBeforePublish(ctx)
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
// 只发布「已有内容」的成绩行:现场分批比赛时(如 100 米预赛分 4 组、每组 8 条跑道),
// 只跑完一组就要先把这一组上屏,没跑/没录的行必须留在 draft,不能被一起发布。
// 判据见 publishableResultCond。名次与积分是否当场落库,取决于「发布后是否立即结算」
// (见下方 liveOnPublishQ):实时模式按已发布部分重算,后续再发一批会自动纳入。
setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'),
updated_at = datetime('now')
WHERE event_id = ? AND ` + publishableResultCond
args := []any{nullID(userID), in.EventID}
if catID > 0 {
setPublished += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
args = append(args, catID)
}
if _, err := tx.ExecContext(ctx, setPublished, args...); err != nil {
return nil, internal(err)
}
live, err := liveOnPublishQ(ctx, tx)
if err != nil {
return nil, err
}
settle := live
if !live {
// 延迟结算:等该 (小项, 组别) 所有批次都录完才结算一次。
// 结算时按整组重算,之前各批的成绩一起纳入,避免名次停留在分批算的口径。
settle, err = scopeSettled(ctx, tx, in.EventID, catID)
if err != nil {
return nil, err
}
}
if settle {
// 名次也要等结算才写:名次(results.rank 与 channel_ranks)只在这里落,
// 分批写名次会让奖牌列跟着每批发布跳动,而总分来自流水、不写流水就不动,
// 同一张榜上「有金牌但 0 分」自相矛盾。所以延迟期间只落成绩,名次一并等结算。
score, err := computeEventScore(ctx, tx, in.EventID, catID)
if err != nil {
return nil, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, err
}
if err := writeLedgerTx(ctx, tx, score, in.EventID, catID, userID, "发布重算"); err != nil {
return nil, err
}
}
if catID == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, ""); err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
// 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。
if settle {
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
s.audit(ctx, "result.publish", "event", in.EventID, before, after)
return s.resultsByEvent(ctx, in.EventID)
}
// backupBeforePublish 在发布前留一份快照;失败只记日志与审计,不阻塞发布。
func (s *server) backupBeforePublish(ctx context.Context) {
infos, err := s.backups.RunEvent("publish")
if err != nil {
if errors.Is(err, export.ErrNoDir) {
return // 未配置数据目录:静默跳过,不写失败审计
}
s.logger.Error("发布前备份失败", "err", err)
s.audit(ctx, "system.backup_failed", "backup", 0, nil, map[string]any{"reason": "publish", "error": err.Error()})
return
}
if len(infos) == 0 {
return
}
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"reason": "publish", "path": infos[0].Path})
}
func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
catID := int64(0)
if in.CategoryID != nil {
catID = *in.CategoryID
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
query := `UPDATE results SET state = 'draft', verified_by = NULL, verified_at = NULL, updated_at = datetime('now')
WHERE event_id = ? AND state = 'published'`
args := []any{in.EventID}
if catID > 0 {
query += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
args = append(args, catID)
}
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, internal(err)
}
// 撤销发布是回滚动作,延迟结算也要立刻重算:这一组已经没有已发布成绩,
// 留着上一次结算写下的流水就是「榜上有分、成绩却撤了」。重算本身会把旧流水作废、
// 按当前已发布内容重写,之后再次发布时判据回到「未结算」,行为自然衔接。
if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil {
return nil, err
}
if catID == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'finished' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
if err := recordRevision(ctx, tx, in.EventID, "unpublish", after, userID, ""); err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
s.audit(ctx, "result.unpublish", "event", in.EventID, before, after)
return s.resultsByEvent(ctx, in.EventID)
}
func (s *server) voidResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) {
if reason == "" {
return resultDTO{}, badRequest("reason 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return resultDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var (
before resultDTO
eventID int64
catID int64
)
row := tx.QueryRowContext(ctx,
`SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie,
r.points_override, r.status, r.state, r.recorded_by, r.recorded_at, r.verified_by, r.verified_at, r.note,
e.category_id
FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.id = ?`, resultID)
var (
rank, recordedBy, verifiedBy sql.NullInt64
text, unit, recordedAt, verifiedAt, note sql.NullString
value, override sql.NullFloat64
)
if err := row.Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie,
&override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, &note, &catID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return resultDTO{}, notFound("成绩不存在")
}
return resultDTO{}, internal(err)
}
before.EventID = eventID
before.Rank = intPtr(rank)
before.ResultText = text.String
before.ResultValue = f64Ptr(value)
before.Unit = unit.String
before.PointsOverride = f64Ptr(override)
before.RecordedBy = i64Ptr(recordedBy)
before.RecordedAt = strPtr(recordedAt)
before.VerifiedBy = i64Ptr(verifiedBy)
before.VerifiedAt = strPtr(verifiedAt)
before.Note = strPtr(note)
if _, err := tx.ExecContext(ctx,
`UPDATE results SET status = 'withdrawn', note = ?, updated_at = datetime('now') WHERE id = ?`,
reason, resultID); err != nil {
return resultDTO{}, internal(err)
}
// 作废的成绩不再占名次:results.rank 这一列必须让出来。作废行仍是 state='published',
// 而导出(分项成绩表)读的正是 results.rank,不清就会把作废行列成第 1 名、与真正的冠军并列;
// 整组全部作废时更是几条成绩都挂着第 1 名。与批量退赛同口径(entries_batch 显式清 rank)。
if _, err := tx.ExecContext(ctx, `UPDATE results SET rank = NULL WHERE id = ?`, resultID); err != nil {
return resultDTO{}, internal(err)
}
// 作废是回滚动作(改的是「谁还算数」,不是「录了多少」),与撤销发布、批量退赛/恢复/移出
// 同一口径:无条件重算并推榜,不看 standings.live_on_publish、也不看 scopeSettled。
// 否则没录完的组里作废一条成绩会留下一整套旧名次与旧流水(榜上有分、成绩却撤了),
// 被作废的人会一直挂到下一次结算。
// 这一刀不会把「未录完的组」提前变成「已结算」:结算判据只数已发布成绩,作废不写 state
// (仍是 published、仍算「已录出」),延迟窗口里其它批次照旧等录完才结算。
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason)
if err != nil {
return resultDTO{}, err
}
// 只重建流水不够:成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去;
// 奖牌榜与运动员榜看的是 channel_ranks,由 recomputeLedgerTx 一并重建。
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return resultDTO{}, err
}
after, err := scanResult(tx.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, resultID))
if err != nil {
return resultDTO{}, internal(err)
}
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "void", userID); err != nil {
return resultDTO{}, err
}
// 名次与流水刚改成当前口径,必须推榜单,否则大屏还挂着作废前的旧画面。
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return resultDTO{}, err
}
if err := tx.Commit(); err != nil {
return resultDTO{}, internal(err)
}
s.audit(ctx, "result.void", "result", resultID, before, after)
return s.getResult(ctx, resultID)
}
// ---------- 分批结算 ----------
// settlementGroupDTO 是「未结算的 (小项, 组别)」:延迟结算下已发布成绩还没进榜单的那一组。
// Required / Published 只数「该出成绩的报名项」,弃权 / 失格不计入,与结算判据同源。
type settlementGroupDTO struct {
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
CategoryName string `json:"categoryName"`
Required int `json:"required"`
Published int `json:"published"`
}
// settlementStatusDTO 是 GET /api/results/settlement 的线上结构。
type settlementStatusDTO struct {
LiveOnPublish bool `json:"liveOnPublish"`
Groups []settlementGroupDTO `json:"groups"`
}
// settlementStatus 列出范围内尚未结算的组别,供录入页提示「榜单还没反映这批」。
// 实时结算(默认)时清单恒为空:那种模式下每次发布都当场结算了。
func (s *server) settlementStatus(ctx context.Context, eventID, catID int64) (settlementStatusDTO, error) {
out := settlementStatusDTO{Groups: []settlementGroupDTO{}}
live, err := liveOnPublishQ(ctx, s.ev)
if err != nil {
return out, err
}
out.LiveOnPublish = live
if live {
return out, nil
}
categories, err := loadCategoriesQ(ctx, s.ev, eventID)
if err != nil {
return out, err
}
entries, err := loadEntriesQ(ctx, s.ev, eventID, 0)
if err != nil {
return out, err
}
results, err := loadResultsQ(ctx, s.ev, eventID, 0)
if err != nil {
return out, err
}
for _, cat := range categories {
if catID > 0 && cat.ID != catID {
continue
}
groupEntries, groupResults := groupOf(entries, results, cat.ID)
required, published := groupStats(groupEntries, groupResults)
// 一条都没发布 = 还没开始结算;已录完 = 下次发布就会结算(或被立即结算过)。
if required == 0 || published == 0 || published == required {
continue
}
out.Groups = append(out.Groups, settlementGroupDTO{
EventID: eventID,
CategoryID: cat.ID,
CategoryName: cat.Name,
Required: required,
Published: published,
})
}
return out, nil
}
// settleResults 立即结算:无视延迟结算设置,按当前已发布成绩重算积分流水并刷新榜单。
//
// 这是现场安全绳:只要有一条成绩永远录不出来(退赛却没标 scratched),
// 延迟结算就永远不会自动触发,必须有一个显式入口把已发布部分结算掉。
func (s *server) settleResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
catID := int64(0)
if in.CategoryID != nil {
catID = *in.CategoryID
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
score, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "立即结算")
if err != nil {
return nil, err
}
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return nil, err
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
s.audit(ctx, "result.settle", "event", in.EventID, before, after)
return s.resultsByEvent(ctx, in.EventID)
}
// ---------- 变更日志与版本 ----------
func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action string, results []resultDTO, actorID int64, message string) error {
snapshot, err := json.Marshal(results)
if err != nil {
return internal(err)
}
// 父节点 = 该小项最近一次版本,串成一条主线;撤销后再发布会自然从撤销点分出新分支。
var parentID sql.NullInt64
if err := tx.QueryRowContext(ctx,
`SELECT MAX(id) FROM result_revisions WHERE event_id = ?`, eventID).Scan(&parentID); err != nil {
return internal(err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, ledger_ids, actor_id, message)
VALUES (?, ?, ?, ?, '[]', ?, ?)`,
parentID, eventID, action, string(snapshot), nullID(actorID), message)
if err != nil {
return internal(err)
}
return nil
}
func recordChanges(ctx context.Context, tx *sql.Tx, entity string, items []resultDTO, action string, actorID int64) error {
for _, item := range items {
payload, err := json.Marshal(item)
if err != nil {
return internal(err)
}
if err := bumpVersion(ctx, tx, entity, item.ID, action, json.RawMessage(payload), actorID); err != nil {
return err
}
}
return nil
}
// bumpVersion 写一条 change_log;id 即实时协议的 version。
func bumpVersion(ctx context.Context, tx *sql.Tx, entityType string, entityID int64, action string, payload any, actorID int64) error {
raw := []byte("{}")
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return internal(err)
}
raw = b
}
var eid any
if entityID != 0 {
eid = entityID
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO change_log (entity_type, entity_id, action, payload_json, actor_id)
VALUES (?, ?, ?, ?, ?)`,
entityType, eid, action, string(raw), nullID(actorID)); err != nil {
return internal(err)
}
return nil
}
// audit 登记审计(由 middleware.Auditor 在请求成功后落库)。
func (s *server) audit(ctx context.Context, action, entityType string, entityID int64, before, after any) {
middleware.Record(ctx, middleware.AuditEntry{
Action: action,
EntityType: entityType,
EntityID: entityID,
Before: before,
After: after,
})
}