## 需求(主人原话)
> 记分通道团体名次的名次来源缺一个模式,添加一个「按队内最低分」。
> 有一个特殊项目是取团队的最后一人的成绩(最后一名冲线)来排名的。
## 口径(这里最容易写反,所以定死)
新增 `aggregate_mode = 'worst'`。**不叫 `min`/`max`** —— 它的方向是**派生**的,叫 `min` 会让
「越大越好」的那半项目被写错。**跟着项目的比较方向自动取**:
| 项目 `compare` | 含义 | 队内最差 = |
| --- | --- | --- |
| `asc`(越小越好,如时间) | 行军 / 越野 | **max** = 最后一名冲线的那个人 |
| `desc`(越大越好,如次数) | 引体向上 | **min** = 做得最少的那个 |
方向判据**各只有一处**:Go 的 `domain.worseValue`、前端预览的 `worseThan`,
两边都有 asc/desc 双向用例钉着。规则包里写 `min`/`max` 会被校验**拦下并报错**。
## 改动
- **迁移 `0027`**:SQLite 改不了已有的 `CHECK`,按官方做法**重建 `event_score_channels`**,
取值放开到 `avg/sum/worst`;⚠️ 重建时**先把子表 `channel_ranks` 寄存到临时表再原样灌回**
(父表被 DROP 会级联清空名次 —— 这一步漏了就会把已发布的名次全丢)。
- **引擎**:`scoreByAggregate` 支持第三种口径。
- **接口 / 规则包**:读、写、校验、快照、导出五处都认第三个取值。
- **界面**:「聚合口径」由**两个按钮变三个**(平均 / 总和 / **队内最低分**),
提示文案写明两种方向,不丢一个 `worst` 让人猜。
- **前端预览同步**(`web/src/admin/api/scoring.ts`)—— 前一版刚修完「预览与后端算分不一致」,
这次不允许再开一个新口子。
- 文档:`docs/使用手册.md`、`docs/升级说明.md`、`devdocs/18` 各补一处。
## 两条边界(都是刻意的)
- **弃权 / 失格不算参与,且它的成绩不代表队伍**:队里 5 / 6 / 弃赛(0) → 队内最差是 **5**,不是 0。
否则谁都可以挂一个弃赛的人在队里垫底。
- **一个有效成绩都没有的主体一律不参与排名**(`avg_mode` 在这一口径下不起作用)。
若按 0 参与:`asc` 里 0 是全场最快、`desc` 里 0 是全场最差 —— 两边都会让没完赛的队白拿名次。
## 验证
- **老口径逐字节不变**:新增 golden `TestAggregateModesLegacyUnchanged`(`avg` / `sum` 结果与改动前一致)。
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **953 例** 0 fail / Console 96 例 0 fail。
- 迁移用例覆盖:老行原样、子表名次一行不少、id 不漂、非法值仍拒、幂等、级联删除仍在。
## 版本
`dev_0.5.10` → **`dev_0.5.11`**(含数据迁移 `0027`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
1414 lines
57 KiB
Go
1414 lines
57 KiB
Go
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 / worst):avg(含缺字段的老库)保持零值,
|
||
// 由引擎按缺省 avg 处理,响应体里也不多出这个字段。
|
||
c.AggregateMode = domain.AggregateModeOf(aggregateMode)
|
||
// 只把显式关闭的通道落成指针,缺省时保持 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, ¬e); 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 是缺省口径(计分发布)的计分,供发布之外的各条重算路径使用。
|
||
//
|
||
// ⚠️ 口径只有**发布这一次动作**带得上(见 publishResults 的 RankOnly):本函数是「按此刻已发布
|
||
// 的成绩重算 = 计分」,被 立即结算 / 作废成绩 / 批量退赛 / 撤销发布 / 改队员与份额 / 改积分模板 /
|
||
// 规则包导入 共用。也就是说:一批「只更新名次」发布的成绩,在这些动作里照样会被算分——它们算的
|
||
// 本来就是「此刻 results 里已发布的成绩」,与改动前一致(改动前这些路径也一样会算)。
|
||
// 现场若要把某一组彻底排除在计分外,正确做法是把该组退回草稿(撤销发布),而不是只靠勾选。
|
||
func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64) (domain.EventScore, error) {
|
||
return computeEventScoreOpts(ctx, q, eventID, categoryID, domain.ScoreOptions{})
|
||
}
|
||
|
||
// computeEventScoreOpts 计算某小项(或某组别)的流水与名次明细,并带上引擎口径。
|
||
func computeEventScoreOpts(ctx context.Context, q queryer, eventID, categoryID int64, opts domain.ScoreOptions) (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],
|
||
Options: opts,
|
||
})
|
||
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)
|
||
}
|
||
|
||
// publishNote 是本条发布写进修订说明的文案:只更新名次时必须留一句——榜单一点没动,
|
||
// 事后复盘(变更历史)得能看出这一批是预赛口径,而不是「发布失败」或「没算分」。
|
||
// 计分发布照旧只带跳过提示,一个字符都不加(老行为不许多出文案)。
|
||
func publishNote(rankOnly bool, skipped int) string {
|
||
skip := skipNote(skipped)
|
||
if !rankOnly {
|
||
return skip
|
||
}
|
||
const rankOnlyNote = "本次按「只更新名次」发布:名次已更新,未计分、未发奖牌,榜单分数与已有积分不变"
|
||
if skip == "" {
|
||
return rankOnlyNote
|
||
}
|
||
return rankOnlyNote + ";" + skip
|
||
}
|
||
|
||
// 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 publishResultsPayload, 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 := computeEventScoreOpts(ctx, tx, in.EventID, catID, domain.ScoreOptions{RankOnly: in.RankOnly})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
|
||
return nil, err
|
||
}
|
||
if in.RankOnly {
|
||
// 只更新名次:名次已写回 results.rank(上面那一步),到此为止——流水与通道名次一个
|
||
// 字节都不动。
|
||
//
|
||
// 为什么不顺手重建通道名次:channel_ranks 只存名次,奖牌是「rank 在前三 + 小项结算
|
||
// 奖牌 + 通道发牌」算出来的,**不看分数**。预赛名次写进去 = 给预赛发奖牌;反过来,
|
||
// 本组上次若已按决赛计分发布过,重建会把那批名次(连同决赛的奖牌)抹掉,而决赛那份
|
||
// 分还留在流水里 —— 榜上就会「分还在、牌没了」。
|
||
//
|
||
// 为什么不顺手写流水:writeLedgerTx 会先把范围内的旧流水整批作废再写新的,
|
||
// 而决赛那份积分是有效的,不该被一次预赛发布抹掉。
|
||
//
|
||
// 于是本次发布只动 results.rank:录入页的名次列、大屏最新成绩、分项成绩表读的都是它。
|
||
// 名单/明细里「名次」关联不到 channel_ranks 时本来就回落到 results.rank(见
|
||
// breakdownRankExpr / athleteDetailRankExpr),所以能看到的都跟着更新。
|
||
} else 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, publishNote(in.RankOnly, skipped)); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
|
||
return nil, err
|
||
}
|
||
// 没结算就不推榜单:延迟期间榜单保持上一次结算的状态,大屏不该因为一批成绩而跳动。
|
||
// 只更新名次时榜单同样不推:分数与奖牌一个数字都没变,推一版「没变的榜单」只会让现场
|
||
// 以为榜单跟着这批成绩动了。成绩本身的变更由上面 recordChanges 推出去,大屏据此重拉名次。
|
||
if settle && !in.RankOnly {
|
||
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, ¬e, &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,
|
||
})
|
||
}
|