Files
Scoreboard/server/internal/httpapi/scoring.go
T
AskaEth 7f9701c465 fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力
- realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份
- httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表
- web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换
- console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复
- docs: 用户文档三份 + README
- 主代理: 破纪录加分接线(levels 未加载导致恒 0)
2026-09-09 21:06:40 +08:00

672 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"context"
"database/sql"
"encoding/json"
"errors"
"path/filepath"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"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
)
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
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)
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
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
if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil {
return nil, internal(err)
}
out = append(out, c)
}
return out, rows.Err()
}
func loadProfilesQ(ctx context.Context, q queryer, ids map[int64]bool) (map[int64]domain.ScoringProfile, error) {
out := make(map[int64]domain.ScoringProfile, len(ids))
for id := range ids {
var p domain.ScoringProfile
err := q.QueryRowContext(ctx,
`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero
FROM scoring_profiles WHERE id = ?`, id).
Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero)
if errors.Is(err, sql.ErrNoRows) {
return nil, badRequest("积分模板不存在", id)
}
if err != nil {
return nil, internal(err)
}
rows, err := q.QueryContext(ctx,
`SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, id)
if err != nil {
return nil, internal(err)
}
rules := make([]domain.ScoringRule, 0)
for rows.Next() {
var r domain.ScoringRule
if err := rows.Scan(&r.Rank, &r.Points); err != nil {
_ = rows.Close()
return nil, internal(err)
}
rules = append(rules, r)
}
err = rows.Err()
_ = rows.Close()
if err != nil {
return nil, internal(err)
}
p.Rules = rules
out[id] = p
}
return out, nil
}
func loadEntriesQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Entry, error) {
query := `SELECT e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
e.status, COALESCE(a.name, '')
FROM entries e
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE e.event_id = ?`
args := []any{eventID}
if categoryID > 0 {
query += ` AND e.category_id = ?`
args = append(args, categoryID)
}
query += ` ORDER BY e.id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Entry, 0)
for rows.Next() {
var (
e domain.Entry
athleteID sql.NullInt64
memberNames sql.NullString
)
if err := rows.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames, &e.Status, &e.AthleteName); err != nil {
return nil, internal(err)
}
if athleteID.Valid {
e.AthleteID = athleteID.Int64
}
e.MemberNames = parseNames(memberNames)
out = append(out, e)
}
return out, rows.Err()
}
func loadResultsQ(ctx context.Context, q queryer, eventID, categoryID int64) ([]domain.Result, error) {
query := `SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit,
r.is_tie, r.points_override, r.status, r.state, r.note
FROM results r
JOIN entries e ON e.id = r.entry_id
WHERE r.event_id = ?`
args := []any{eventID}
if categoryID > 0 {
query += ` AND e.category_id = ?`
args = append(args, categoryID)
}
query += ` ORDER BY r.id`
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Result, 0)
for rows.Next() {
var (
r domain.Result
rank sql.NullInt64
text, unit, note sql.NullString
value, override sql.NullFloat64
)
if err := rows.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit,
&r.IsTie, &override, &r.Status, &r.State, &note); err != nil {
return nil, internal(err)
}
if rank.Valid {
r.Rank = int(rank.Int64)
}
r.ResultText = text.String
if value.Valid {
v := value.Float64
r.ResultValue = &v
}
r.Unit = unit.String
if override.Valid {
v := override.Float64
r.PointsOverride = &v
}
r.Note = note.String
out = append(out, r)
}
return out, rows.Err()
}
func loadRecordBonusQ(ctx context.Context, q queryer) (domain.RecordBonusConfig, error) {
values, err := loadSettingsMap(ctx, q)
if err != nil {
return domain.RecordBonusConfig{}, err
}
// 统一走 rules 的解析:enabled / mode / levels 三个键必须一起读,
// 只读前两个会让 levelPoints 永远查不到档位,破纪录加分恒为 0。
return rules.RecordBonusFromSettings(values), nil
}
func loadRecordBreaksQ(ctx context.Context, q queryer, eventID int64) ([]domain.RecordBreak, error) {
rows, err := q.QueryContext(ctx,
`SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state
FROM record_breaks WHERE event_id = ? AND state = 'approved'`, eventID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.RecordBreak, 0)
for rows.Next() {
var (
b domain.RecordBreak
value sql.NullFloat64
text sql.NullString
)
if err := rows.Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State); err != nil {
return nil, internal(err)
}
b.ResultValue = value.Float64
b.ResultText = text.String
out = append(out, b)
}
return out, rows.Err()
}
// ---------- 计分 ----------
// computeEventScore 计算某小项(或某组别)的流水与名次明细。
func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64) (domain.EventScore, error) {
ev, err := loadEventQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
cats, err := loadCategoriesQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
channels, err := loadChannelsQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
profileIDs := make(map[int64]bool)
for _, ch := range channels {
profileIDs[ch.ProfileID] = true
}
profiles, err := loadProfilesQ(ctx, q, profileIDs)
if err != nil {
return domain.EventScore{}, err
}
entries, err := loadEntriesQ(ctx, q, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
results, err := loadResultsQ(ctx, q, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
bonusCfg, err := loadRecordBonusQ(ctx, q)
if err != nil {
return domain.EventScore{}, err
}
breaks, err := loadRecordBreaksQ(ctx, q, eventID)
if err != nil {
return domain.EventScore{}, err
}
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,
})
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 管理,绝不在此作废。
func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, 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 = ?)`
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 domain.EventScore{}, internal(err)
}
score, err := computeEventScore(ctx, tx, eventID, categoryID)
if err != nil {
return domain.EventScore{}, err
}
for _, l := range score.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, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
l.TeamID, nullID(l.AthleteID), nullID(l.EventID), nullID(l.EntryID), nullID(l.ResultID),
l.SourceType, l.SourceID, l.Points, nullID(actorID)); err != nil {
return domain.EventScore{}, internal(err)
}
}
return score, nil
}
// applyComputedRanks 把引擎算出的名次写回 results,供成绩表与奖牌统计使用。
// 双通道项目一条成绩会有两个名次,这里优先取个人通道(scope=athlete)。
func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error {
best := make(map[int64]domain.RankedResult)
for _, rr := range ranked {
if rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
cur, ok := best[rr.ResultID]
if !ok || (cur.Scope == domain.ScopeTeam && rr.Scope == domain.ScopeAthlete) {
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
}
// ---------- 发布 / 撤销 / 作废 ----------
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
}
setPublished := `UPDATE results SET state = 'published', verified_by = ?, verified_at = datetime('now'),
updated_at = datetime('now')
WHERE event_id = ?`
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)
}
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
}
if catID == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'published' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
if err := recordRevision(ctx, tx, in.EventID, "publish", after, userID, ""); err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if 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) {
if strings.TrimSpace(s.dataDir) == "" {
return
}
info, err := export.BackupBeforePublish(s.ev, export.BackupOptions{
Dir: filepath.Join(s.dataDir, "backups"),
Prefix: "event",
Reason: "publish",
})
if err != nil {
s.logger.Error("发布前备份失败", "err", err)
s.audit(ctx, "system.backup_failed", "backup", 0, nil, map[string]any{"reason": "publish", "error": err.Error()})
return
}
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"reason": "publish", "path": info.Path})
}
func (s *server) unpublishResults(ctx context.Context, in publishPayload, userID int64) ([]resultDTO, error) {
if in.EventID == 0 {
return nil, badRequest("eventId 必填", nil)
}
catID := int64(0)
if in.CategoryID != nil {
catID = *in.CategoryID
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
before, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
query := `UPDATE results SET state = 'draft', verified_by = NULL, verified_at = NULL, updated_at = datetime('now')
WHERE event_id = ? AND state = 'published'`
args := []any{in.EventID}
if catID > 0 {
query += ` AND entry_id IN (SELECT id FROM entries WHERE category_id = ?)`
args = append(args, catID)
}
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, internal(err)
}
if _, err := recomputeLedgerTx(ctx, tx, in.EventID, catID, userID, "撤销发布"); err != nil {
return nil, err
}
if catID == 0 {
if _, err := tx.ExecContext(ctx, `UPDATE events SET status = 'finished' WHERE id = ?`, in.EventID); err != nil {
return nil, internal(err)
}
}
after, err := queryResults(ctx, tx, `SELECT `+resultColumns+` FROM results WHERE event_id = ?`, in.EventID)
if err != nil {
return nil, err
}
if err := recordRevision(ctx, tx, in.EventID, "unpublish", after, userID, ""); err != nil {
return nil, err
}
if err := recordChanges(ctx, tx, "result", after, "upsert", userID); err != nil {
return nil, err
}
if err := s.bumpStandings(ctx, tx, userID); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
s.audit(ctx, "result.unpublish", "event", in.EventID, before, after)
return s.resultsByEvent(ctx, in.EventID)
}
func (s *server) voidResult(ctx context.Context, resultID int64, reason string, userID int64) (resultDTO, error) {
if reason == "" {
return resultDTO{}, badRequest("reason 必填", nil)
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return resultDTO{}, internal(err)
}
defer func() { _ = tx.Rollback() }()
var (
before resultDTO
eventID int64
catID int64
)
row := tx.QueryRowContext(ctx,
`SELECT r.id, r.event_id, r.entry_id, r.rank, r.result_text, r.result_value, r.unit, r.is_tie,
r.points_override, r.status, r.state, r.recorded_by, r.recorded_at, r.verified_by, r.verified_at, r.note,
e.category_id
FROM results r JOIN entries e ON e.id = r.entry_id WHERE r.id = ?`, resultID)
var (
rank, recordedBy, verifiedBy sql.NullInt64
text, unit, recordedAt, verifiedAt, note sql.NullString
value, override sql.NullFloat64
)
if err := row.Scan(&before.ID, &eventID, &before.EntryID, &rank, &text, &value, &unit, &before.IsTie,
&override, &before.Status, &before.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, &note, &catID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return resultDTO{}, notFound("成绩不存在")
}
return resultDTO{}, internal(err)
}
before.EventID = eventID
before.Rank = intPtr(rank)
before.ResultText = text.String
before.ResultValue = f64Ptr(value)
before.Unit = unit.String
before.PointsOverride = f64Ptr(override)
before.RecordedBy = i64Ptr(recordedBy)
before.RecordedAt = strPtr(recordedAt)
before.VerifiedBy = i64Ptr(verifiedBy)
before.VerifiedAt = strPtr(verifiedAt)
before.Note = strPtr(note)
if _, err := tx.ExecContext(ctx,
`UPDATE results SET status = 'withdrawn', note = ?, updated_at = datetime('now') WHERE id = ?`,
reason, resultID); err != nil {
return resultDTO{}, internal(err)
}
if _, err := recomputeLedgerTx(ctx, tx, eventID, catID, userID, "作废成绩:"+reason); 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)
}
// ---------- 变更日志与版本 ----------
func recordRevision(ctx context.Context, tx *sql.Tx, eventID int64, action string, results []resultDTO, actorID int64, message string) error {
snapshot, err := json.Marshal(results)
if err != nil {
return internal(err)
}
// 父节点 = 该小项最近一次版本,串成一条主线;撤销后再发布会自然从撤销点分出新分支。
var parentID sql.NullInt64
if err := tx.QueryRowContext(ctx,
`SELECT MAX(id) FROM result_revisions WHERE event_id = ?`, eventID).Scan(&parentID); err != nil {
return internal(err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO result_revisions (parent_id, event_id, action, snapshot_json, ledger_ids, actor_id, message)
VALUES (?, ?, ?, ?, '[]', ?, ?)`,
parentID, eventID, action, string(snapshot), nullID(actorID), message)
if err != nil {
return internal(err)
}
return nil
}
func recordChanges(ctx context.Context, tx *sql.Tx, entity string, items []resultDTO, action string, actorID int64) error {
for _, item := range items {
payload, err := json.Marshal(item)
if err != nil {
return internal(err)
}
if err := bumpVersion(ctx, tx, entity, item.ID, action, json.RawMessage(payload), actorID); err != nil {
return err
}
}
return nil
}
// bumpVersion 写一条 change_log;id 即实时协议的 version。
func bumpVersion(ctx context.Context, tx *sql.Tx, entityType string, entityID int64, action string, payload any, actorID int64) error {
raw := []byte("{}")
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return internal(err)
}
raw = b
}
var eid any
if entityID != 0 {
eid = entityID
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO change_log (entity_type, entity_id, action, payload_json, actor_id)
VALUES (?, ?, ?, ?, ?)`,
entityType, eid, action, string(raw), nullID(actorID)); err != nil {
return internal(err)
}
return nil
}
// audit 登记审计(由 middleware.Auditor 在请求成功后落库)。
func (s *server) audit(ctx context.Context, action, entityType string, entityID int64, before, after any) {
middleware.Record(ctx, middleware.AuditEntry{
Action: action,
EntityType: entityType,
EntityID: entityID,
Before: before,
After: after,
})
}