Files
Scoreboard/server/internal/httpapi/scoring.go
T
AskaEth 99c91f99f7 feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」

**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。

## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。

## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
  `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。

## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
  1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
  2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
  3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
  反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。

## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
  改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。

## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。

## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
  ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。

## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。

## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 14:05:48 +08:00

1371 lines
53 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"
"fmt"
"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, aggregate_mode, 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
aggregateMode string
countToTeam int
countToMedals int
countToTeamMedals int
)
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &aggregateMode, &countToTeam,
&countToMedals, &countToTeamMedals); err != nil {
return nil, internal(err)
}
// 汇总口径只认 sum:avg(含缺字段的老库)保持零值,由引擎按缺省 avg 处理。
if aggregateMode == string(domain.AggregateSum) {
c.AggregateMode = domain.AggregateSum
}
// 只把显式关闭的通道落成指针,缺省时保持 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)
}
if err := rows.Err(); err != nil {
return nil, err
}
// 联队份额随报名一起进引擎:引擎不看库,取分只认 Entry.TeamShares。
shares, err := loadEntryTeamSharesQ(ctx, q, eventID)
if err != nil {
return nil, err
}
// 队员(devdocs/20)同理随报名进引擎:每个队员各拿一份完整的个人分(见 domain 的 memberCopy)。
members, err := loadEntryAthletesQ(ctx, q, eventID)
if err != nil {
return nil, err
}
for i := range out {
out[i].TeamShares = shares[out[i].ID]
out[i].Athletes = members[out[i].ID]
}
return out, nil
}
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 同样不进表——读取侧按 entry_id 回查报名份额(entry_team_shares),
// 回查不到(没有报名项的名次、没配份额的老数据)与引擎一样退回「主单位独占 100%」。
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)。
//
// 这一列还是 manual 通道的**输入**(个人手工名次走它、团体报名项的团体名次也走它兜底),
// 所以命中「人工名次的录入位」时必须让路,否则同一份数据第二次发布会算出另一个结果:
//
// - 小项配了 [athlete, manual]:每一条成绩的 results.rank 都是操作员填的输入,
// 自动名次覆盖它 → 人工通道下一轮读到的是自动名次(工单 A-2:A 队 18 → 20 分);
// - 小项配了 [team, manual] 且该行是**团体报名项**(AthleteID == 0):它的 results.rank
// 是「单位名次」而不是个人名次(domain.collectTeamManualCandidates 的兜底来源),
// 写进自动的个人名次后会被当成团体名次再发一遍团体分(工单 A-1:10/8/6 → 25/18/14)。
//
// 队员的个人副本(MemberCopy)一律不写回:它们是报名项那一份的复制品,写回等于让「同一条报名
// 的每个队员」轮流改写同一个录入位——团体报名项的 results.rank 是团体手工通道的输入,
// 被队员副本覆盖后,第二次发布会把自动名次当成团体名次再发一遍分(A-1 的同一个坑)。
// 该写的那一份是报名项自己(上面第一条规则已经管着它),一条成绩始终只有一个写入者。
//
// 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次),
// 其次通道 id 小者,保证同一份输入每次写回同一个值。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
inputs, err := manualRankInputEvents(ctx, tx, ranked)
if err != nil {
return err
}
best := make(map[int64]domain.RankedResult)
for _, rr := range ranked {
if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
if rr.MemberCopy {
continue
}
if in := inputs[rr.EventID]; in.athleteManual || (in.teamManual && rr.AthleteID == 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
}
// manualRankInputs 描述一个小项把 results.rank 当作「人工名次录入位」的两种通道形态。
type manualRankInputs struct {
athleteManual bool // [athlete, manual]:每条成绩的 results.rank 都是人工录入的个人名次
teamManual bool // [team, manual]:团体报名项的 results.rank 是人工录入的团体名次
}
// manualRankInputEvents 查出这些名次所属小项里,哪些把 results.rank 当输入用。
// 只查 ranked 里出现的小项:没有名次可写回时不需要这一趟查询,
// 而「有没有 manual 通道」必须看库(不能只看 ranked——名次还没录时该通道不产出明细)。
func manualRankInputEvents(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) (map[int64]manualRankInputs, error) {
if len(ranked) == 0 {
return nil, nil
}
out := make(map[int64]manualRankInputs)
for _, rr := range ranked {
if rr.EventID == 0 {
continue
}
if _, done := out[rr.EventID]; done {
continue
}
var athlete, team int
if err := tx.QueryRowContext(ctx,
`SELECT COALESCE(MAX(CASE WHEN scope = 'athlete' AND basis = 'manual' THEN 1 ELSE 0 END), 0),
COALESCE(MAX(CASE WHEN scope = 'team' AND basis = 'manual' THEN 1 ELSE 0 END), 0)
FROM event_score_channels WHERE event_id = ?`, rr.EventID).Scan(&athlete, &team); err != nil {
return nil, internal(err)
}
out[rr.EventID] = manualRankInputs{athleteManual: athlete != 0, teamManual: team != 0}
}
return out, 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,大屏的最新成绩会出现空行,
// 而且编辑页会把整组当成「已发布」锁死,补录无门。
//
// 落在这一档里的还有「填了文本却没有成绩值」的草稿(客户端没算出 resultValue):
// 同样发不出去,但外表上像是录过了,所以发布时要单独计数并写进修订(countTextOnlyDrafts),
// 不能让它静默消失。
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
}
// eventFullyPublished 判断整小项是否「所有该出成绩的报名项都已发布」。
//
// 判据与结算同源(scopeStats):退赛 / 缺赛 / 失格的报名项不进分母,因为它们本来就不等成绩。
// 必须要求 total > 0——一条报名项都没有的小项没有任何成绩可发,标成「已发布」是假信息。
func eventFullyPublished(ctx context.Context, q queryer, eventID int64) (bool, error) {
total, published, err := scopeStats(ctx, q, eventID, 0)
if err != nil {
return false, err
}
return total > 0 && published == total, nil
}
// countTextOnlyDrafts 数出「这次发布带不上、看起来却已经录过」的草稿:
// 有成绩文本,却没有成绩值、没有手工名次、状态也还是 ok。
//
// 这批行按 publishableResultCond 永远发不出去(缺的正是那个比较值),而发布接口照回 200:
// 客户端只送 resultText 时成绩就这样静默漏掉。纯空行(连文本都没有)不算——
// 分批比赛里「还没跑」是正常状态,一起数进去只会淹没真正的问题。
func countTextOnlyDrafts(ctx context.Context, q queryer, eventID, categoryID int64) (int, error) {
query := `SELECT COUNT(*) FROM results r
JOIN entries e ON e.id = r.entry_id
WHERE r.event_id = ? AND r.state = 'draft'
AND r.result_value IS NULL AND r.rank IS NULL AND r.status = 'ok'
AND TRIM(COALESCE(r.result_text, '')) <> ''`
args := []any{eventID}
if categoryID > 0 {
query += ` AND e.category_id = ?`
args = append(args, categoryID)
}
var n int
if err := q.QueryRowContext(ctx, query, args...).Scan(&n); err != nil {
return 0, internal(err)
}
return n, nil
}
// skipNote 把「跳过多少条只填了文本的草稿」写成给现场看的一句话,没有跳过时为空串
// (空串不落显眼文案,变更历史里也不会多出一行无用说明)。
func skipNote(skipped int) string {
if skipped == 0 {
return ""
}
return fmt.Sprintf("跳过 %d 条只填了成绩文本、没有成绩值的草稿(本次未上屏,补上成绩值后重新发布)", skipped)
}
// 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)
}
// 没被带上的「只有成绩文本」的草稿要报出来:它们按 publishableResultCond 永远发不出去
// (既没有成绩值、也没有手工名次、状态还是 ok),而接口照样回 200。
// 客户端只送 resultText 时成绩就这样静默漏掉,现场几乎发现不了;数出来写进本次修订
// (变更历史会显示),并留一条服务端日志。纯空行不算——分批比赛里「还没跑」是正常状态。
skipped, err := countTextOnlyDrafts(ctx, tx, in.EventID, catID)
if err != nil {
return nil, err
}
if skipped > 0 {
s.logger.Warn("发布跳过了只填成绩文本的草稿",
"eventId", in.EventID, "categoryId", catID, "count", skipped)
}
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
}
}
// 小项进度(events.status)里的 published 只有一种含义:整项都发完了。
// 整项发布(catID == 0)由调用方明确声明,照旧直接写;按组别发布则要先确认整项所有
// 该出成绩的报名项都已发布——录入页永远带 categoryId,少了这一步,「一组一组发完」的
// 小项在界面上会一直停在「待录入」,侧栏与仪表盘的已发布计数也永远漏掉它。
// 还没发完时一个字符都不写:一组发布 ≠ 整项已发布,把状态写成 published 只会制造
// 「已发布」的假象(大屏 / 导出虽然不读这一列,人读)。
done := catID == 0
if !done {
done, err = eventFullyPublished(ctx, tx, in.EventID)
if err != nil {
return nil, err
}
}
if done {
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, skipNote(skipped)); 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
}
// 小项进度与发布对称地退回:整项撤销到 finished(与既有行为一致);
// 按组别撤销只在整项此前被标成 published 时降级——这次撤销让整项不再是「全部已发布」,
// 状态不跟着退,界面就会写着「已发布」而其中一组其实已经撤回。
// pending / ongoing 是人工设的进度,这里不覆盖。
revert := `UPDATE events SET status = 'finished' WHERE id = ?`
if catID > 0 {
revert += ` AND status = 'published'`
}
if _, err := tx.ExecContext(ctx, revert, 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)
}
// loadResultForAction 读一条成绩及其所属 (小项, 组别),供「作废 / 重新录入」这类单条成绩
// 动作使用:两者要读的字段完全一样,各自写一遍扫描迟早会漂移。
func loadResultForAction(ctx context.Context, q queryer, resultID int64) (resultDTO, int64, int64, error) {
var (
before resultDTO
eventID, catID int64
rank, recordedBy, verifiedBy sql.NullInt64
text, unit, recordedAt, verifiedAt, note sql.NullString
value, override sql.NullFloat64
)
err := q.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).
Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie,
&override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, &note, &catID)
if errors.Is(err, sql.ErrNoRows) {
return resultDTO{}, 0, 0, notFound("成绩不存在")
}
if err != nil {
return resultDTO{}, 0, 0, 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)
return before, eventID, catID, nil
}
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() }()
before, eventID, catID, err := loadResultForAction(ctx, tx, resultID)
if err != nil {
return resultDTO{}, err
}
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)
}
// reopenResult 把一条成绩退回草稿(A16)。现场「手滑填错」时,「作废本条」只把它从榜上撤掉,
// 录入员仍然改不了它(作废行不是草稿,saveDraft 只认草稿);本接口把状态退回 draft 并复位成
// 一条干净的成绩,录入员直接改、再重新发布本组即可——不需要「撤销发布」整组把别人的成绩也翻下来。
//
// 重算口径与作废完全一致:这是回滚类动作(改的是「这条成绩算不算数」),无条件重算该
// (小项, 组别) 并推榜,不受 standings.live_on_publish 与延迟结算约束。否则退回草稿后
// 旧名次与旧流水还留在榜上,等于这条成绩「既不算数又还在计分」。
func (s *server) reopenResult(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() }()
before, eventID, catID, err := loadResultForAction(ctx, tx, resultID)
if err != nil {
return resultDTO{}, err
}
// 已经是草稿就没有「退回」可言,硬做只会把录入员正在改的内容清掉(下面会复位 status / rank)。
if before.State == string(domain.ResultStateDraft) {
return resultDTO{}, badRequest("该成绩已是草稿,可直接修改", nil)
}
// status 复位成 ok:作废写下的 withdrawn 只是「这条不算数」的标记,退回草稿后它就是一条
// 待重新录入的空成绩,留着 withdrawn 会让录入页按异常状态显示。
if _, err := tx.ExecContext(ctx,
`UPDATE results SET state = 'draft', status = 'ok', rank = NULL, note = ?, updated_at = datetime('now')
WHERE id = ?`, reason, resultID); err != nil {
return resultDTO{}, internal(err)
}
score, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "重新录入:"+reason)
if err != nil {
return resultDTO{}, err
}
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)
}
// change_log.action 的 CHECK 只认 upsert / void / replace(0009),退回草稿是这一行的
// 状态更新,所以走 upsert;「重新录入」这个语义记在审计(result.reopen)里。
if err := recordChanges(ctx, tx, "result", []resultDTO{after}, "upsert", 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.reopen", "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)
}
// 父节点 = 该小项当前的 MAX(id),即上一条版本:一个小项串成**一条链**。
// 撤销后再发布也是接在链尾(新行 id 最大,就是上一条),**不会从撤销点分出分支**——
// result_revisions 实际不是 DAG(P2-C06)。每个小项的第一条版本 parent_id 为 NULL。
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)
}
// 这条 INSERT 不再写 ledger_ids:该列从来没有读取侧(变更历史只读 snapshot / action /
// message),前端却按它渲染「积分流水 N 条」,于是每次发布都在变更历史里显示 0 条——
// 一句对用户可见的错信息。收口方向是连同前端那一栏一起删掉,写入侧先与它脱钩:
// 列上有 NOT NULL DEFAULT '[]',不写照样满足约束,删列迁移落地后这条语句也依然成立。
_, err = tx.ExecContext(ctx,
`INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, 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,
})
}