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) } return out, rows.Err() } func loadResultsQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Result, error) { query := `SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie, r.points_override, r.status, r.state, r.note FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.event_id = ?` args := []any{eventID} if categoryID > 0 { query += ` AND e.category_id = ?` args = append(args, categoryID) } query += ` ORDER BY r.id` rows, err := q.QueryContext(ctx, query, args...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]domain.Result, 0) for rows.Next() { var ( r domain.Result rank sql.NullInt64 text, unit, note sql.NullString value, override sql.NullFloat64 ) if err := rows.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit, &r.IsTie, &override, &r.Status, &r.State, ¬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 计算某小项(或某组别)的流水与名次明细。 func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64) (domain.EventScore, error) { ev, err := loadEventQ(ctx, q, eventID) if err != nil { return domain.EventScore{}, err } cats, err := loadCategoriesQ(ctx, q, eventID) if err != nil { return domain.EventScore{}, err } channels, err := loadChannelsQ(ctx, q, eventID) if err != nil { return domain.EventScore{}, err } profileIDs := make(map[int64]bool) for _, ch := range channels { profileIDs[ch.ProfileID] = true } profiles, err := loadProfilesQ(ctx, q, profileIDs) if err != nil { return domain.EventScore{}, err } entries, err := loadEntriesQ(ctx, q, eventID, categoryID) if err != nil { return domain.EventScore{}, err } results, err := loadResultsQ(ctx, q, eventID, categoryID) if err != nil { return domain.EventScore{}, err } bonusCfg, err := loadRecordBonusQ(ctx, q) if err != nil { return domain.EventScore{}, err } breaks, err := loadRecordBreaksQ(ctx, q, eventID) if err != nil { return domain.EventScore{}, err } teamRanks, err := loadTeamRanksQ(ctx, q, eventID, categoryID) if err != nil { return domain.EventScore{}, err } var total domain.EventScore for _, cat := range cats { if categoryID > 0 && cat.ID != categoryID { continue } score, err := domain.ScoreEvent(domain.EventScoreInput{ Event: ev, Category: cat, Channels: channels, Profiles: profiles, Entries: entries, Results: results, RecordBonus: &bonusCfg, RecordBreaks: breaks, // 每个组别只拿自己组别的团体名次:名次在组别内有效,串组会算出别人的分。 TeamRanks: teamRanks[cat.ID], }) if err != nil { return domain.EventScore{}, mapDomainError(err) } total.Ledger = append(total.Ledger, score.Ledger...) total.Ranked = append(total.Ranked, score.Ranked...) } return total, nil } // recomputeLedgerTx 作废范围内旧流水并写入新流水(流水只追加、不修改)。 // 作废范围是引擎产出的成绩分与破纪录加分;人工调整由 point_adjustments 管理,绝不在此作废。 // 聚合通道的流水 entry_id/result_id 都为空(名次主体是单位/运动员,不对应单条报名项), // 所以按组别重算时不能只靠 entry_id IN (...) 定位,否则 NULL 永不命中、旧流水存活, // 重算再插一份导致该通道分数翻倍。聚合流水本身不归属组别,且重建通道会换掉通道 id // (source_id 随之失效),故按 event_id 全量作废;下方补算整项聚合流水时会重新插入全量。 func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) { score, err := computeEventScore(ctx, tx, eventID, categoryID) if err != nil { return domain.EventScore{}, err } if err := writeLedgerTx(ctx, tx, score, eventID, categoryID, actorID, reason); err != nil { return domain.EventScore{}, err } return score, nil } // writeLedgerTx 把已算好的 score 落成流水:先作废范围内旧流水,再插入新流水。 // // 与 recomputeLedgerTx 拆开是为了「延迟结算」:那时名次与流水都要等整组录完才落, // 但分数仍要能算出来(结算时才算,见 publishResults)。计算不读 score_ledger, // 所以「先算后作废」与老的「先作废后算」结果完全一致。 func writeLedgerTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID, actorID int64, reason string) error { scope := `source_type IN ('result', 'record_bonus') AND event_id = ?` args := []any{eventID} if categoryID > 0 { scope += ` AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?) OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'))` args = append(args, categoryID) } if _, err := tx.ExecContext(ctx, `UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ? WHERE voided = 0 AND `+scope, append([]any{nullID(actorID), reason}, args...)...); err != nil { return internal(err) } ledger := score.Ledger if categoryID > 0 { // 「无报名项归属」的流水无法按组别区分,上面已把本小项的全部这类流水一并作废; // 这里补算整项的同类流水,避免按组别重算丢掉其它组别的得分。 hasSubject, err := hasSubjectChannel(ctx, tx, eventID) if err != nil { return err } if hasSubject { all, err := computeEventScore(ctx, tx, eventID, 0) if err != nil { return err } ledger = make([]domain.LedgerEntry, 0, len(score.Ledger)+len(all.Ledger)) for _, l := range score.Ledger { if !isSubjectLedger(l) { ledger = append(ledger, l) } } for _, l := range all.Ledger { if isSubjectLedger(l) { ledger = append(ledger, l) } } } } for _, l := range ledger { if _, err := tx.ExecContext(ctx, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, count_to_team, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID), l.SourceType, l.SourceID, l.Points, boolToInt(l.CountToTeam), nullID(actorID)); err != nil { return internal(err) } } // 名次与流水同源同事务:流水改了名次必然跟着变,分开落库就会出现「榜上有分、名次还是旧的」。 return writeChannelRanksTx(ctx, tx, score, eventID, categoryID) } // isSubjectLedger 判断流水是否来自「按主体排名」的通道,这类流水的 EntryID 与 ResultID 都为空: // // - 聚合通道(basis=aggregate):名次主体是单位(team)或运动员(athlete), // 不对应任何一条报名项; // - 团体手工通道(scope=team + basis=manual,主体来自 channel_manual_ranks): // 录入的是单位名次,同样没有报名项与成绩行(走团体报名项兜底时才有 entry_id, // 那种流水仍归属组别,不算在这里)。 // // 判据必须涵盖这两类:按组别重算时它们都无法靠 entry_id 定位(见 writeLedgerTx 的作废范围), // 少认一类就会让另一类旧流水作废不掉或补不回来,该通道的分数随之翻倍或丢失。 func isSubjectLedger(l domain.LedgerEntry) bool { return l.SourceType == domain.SourceResult && l.EntryID == 0 && l.ResultID == 0 } // hasSubjectChannel 判断小项是否配置了「按主体排名」的通道(聚合 / 团体手工); // 没有时按组别重算无需补算整项流水。 func hasSubjectChannel(ctx context.Context, tx *sql.Tx, eventID int64) (bool, error) { var n int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM event_score_channels WHERE event_id = ? AND (basis = 'aggregate' OR (scope = 'team' AND basis = 'manual'))`, eventID).Scan(&n); err != nil { return false, internal(err) } return n > 0, nil } // writeChannelRanksTx 按引擎产出的名次明细全量重建 channel_ranks:先删范围内旧行,再逐条插入。 // // 结算范围与流水一致:categoryID > 0 只重建该组别,否则重建整个小项。 // SubjectKey 是 (通道, 组别, 主体) 的唯一键,聚合与团体手工名次没有报名项,靠它落库。 // AwardMedals / CountToMedals / CountToTeamMedals 是读取侧 join events / event_score_channels // 得到的,不落这张表;shares 不进表(当前只有「主单位独占 100%」一种口径,读取侧按 team_id 重建)。 func writeChannelRanksTx(ctx context.Context, tx *sql.Tx, score domain.EventScore, eventID, categoryID int64) error { if categoryID > 0 { if _, err := tx.ExecContext(ctx, `DELETE FROM channel_ranks WHERE category_id = ? AND channel_id IN (SELECT id FROM event_score_channels WHERE event_id = ?)`, categoryID, eventID); err != nil { return internal(err) } } else if _, err := tx.ExecContext(ctx, `DELETE FROM channel_ranks WHERE event_id = ?`, eventID); err != nil { return internal(err) } for _, rr := range score.Ranked { // 没有通道(只排名不计分的小项走退化路径,通道 id 为 0)与非法名次都落不了这张表: // 表上 channel_id 有外键、rank 有 CHECK(rank >= 1),硬插只会整笔事务失败。 if rr.ChannelID == 0 || rr.Rank <= 0 || rr.SubjectKey == "" { continue } if _, err := tx.ExecContext(ctx, `INSERT INTO channel_ranks (event_id, category_id, channel_id, basis, scope, subject_key, entry_id, result_id, athlete_id, team_id, rank, points, result_value, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, eventID, rr.CategoryID, rr.ChannelID, rr.Basis, rr.Scope, rr.SubjectKey, nullID(rr.EntryID), nullID(rr.ResultID), nullID(rr.AthleteID), nullID(rr.TeamID), rr.Rank, rr.Points, nullF64(rr.ResultValue), nullStr(resultStatusPtr(rr.Status))); err != nil { return internal(err) } } return nil } // resultStatusPtr 把成绩状态转成可空串:空状态落 NULL,与 results.status 的口径一致。 func resultStatusPtr(s domain.ResultStatus) *string { if s == "" { return nil } v := string(s) return &v } // applyComputedRanks 把引擎算出的「个人名次」写回 results.rank。 // // results.rank 的语义收窄为个人名次:只处理 scope=athlete 且带真实成绩行的名次。 // 团体通道(scope=team)与聚合 / 团体手工名次(ResultID=0)一律不碰这一列—— // 它们过去把个人名次覆盖成团体名次,双通道项目第二次发布会把同一份名次再发一次分(P0)。 // // 这一列还是 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)。 // // 同一 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 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, ¬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, }) }