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)
This commit is contained in:
1 parent
8dae63ee70
commit
7f9701c465
61 files changed
+3844
-333
No files matched your search
@@ -225,24 +225,24 @@ func boolDefault(v string, def bool) bool {
|
||||
|
||||
// ---------- standings ----------
|
||||
|
||||
func (s *server) loadStandingsInput(ctx context.Context) (domain.StandingsInput, error) {
|
||||
teams, err := s.domainTeams(ctx)
|
||||
func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) {
|
||||
teams, err := s.domainTeams(ctx, q)
|
||||
if err != nil {
|
||||
return domain.StandingsInput{}, err
|
||||
}
|
||||
athletes, err := s.domainAthletes(ctx)
|
||||
athletes, err := s.domainAthletes(ctx, q)
|
||||
if err != nil {
|
||||
return domain.StandingsInput{}, err
|
||||
}
|
||||
ledger, err := s.activeLedger(ctx)
|
||||
ledger, err := s.activeLedger(ctx, q)
|
||||
if err != nil {
|
||||
return domain.StandingsInput{}, err
|
||||
}
|
||||
ranked, err := s.buildRanked(ctx)
|
||||
ranked, err := s.buildRanked(ctx, q)
|
||||
if err != nil {
|
||||
return domain.StandingsInput{}, err
|
||||
}
|
||||
values, err := loadSettingsMap(ctx, s.ev)
|
||||
values, err := loadSettingsMap(ctx, q)
|
||||
if err != nil {
|
||||
return domain.StandingsInput{}, err
|
||||
}
|
||||
@@ -259,8 +259,31 @@ func (s *server) loadStandingsInput(ctx context.Context) (domain.StandingsInput,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
|
||||
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
|
||||
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]standingRowDTO, error) {
|
||||
in, err := s.loadStandingsInput(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
teams, err := teamMapQ(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return standingRowsDTO(domain.BuildTeamStandings(in), teams), nil
|
||||
}
|
||||
|
||||
// bumpStandings 在写事务内把整张榜单写入 change_log;榜单变化后必须调用,否则大屏不会刷新。
|
||||
func (s *server) bumpStandings(ctx context.Context, tx *sql.Tx, actorID int64) error {
|
||||
snapshot, err := s.standingsSnapshot(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return bumpVersion(ctx, tx, "standings", 0, "replace", snapshot, actorID)
|
||||
}
|
||||
|
||||
func (s *server) domainTeams(ctx context.Context, q queryer) ([]domain.Team, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT id, code, name, short_name, color, sort_order, active FROM teams WHERE active = 1`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
@@ -284,8 +307,8 @@ func (s *server) domainTeams(ctx context.Context) ([]domain.Team, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *server) domainAthletes(ctx context.Context) ([]domain.Athlete, error) {
|
||||
rows, err := s.ev.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`)
|
||||
func (s *server) domainAthletes(ctx context.Context, q queryer) ([]domain.Athlete, error) {
|
||||
rows, err := q.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
@@ -310,8 +333,8 @@ func (s *server) domainAthletes(ctx context.Context) ([]domain.Athlete, error) {
|
||||
|
||||
// buildRanked 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。
|
||||
// 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致。
|
||||
func (s *server) buildRanked(ctx context.Context) ([]domain.RankedResult, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedResult, error) {
|
||||
rows, err := q.QueryContext(ctx,
|
||||
`SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status,
|
||||
e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id
|
||||
FROM results r
|
||||
@@ -495,9 +518,17 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var state string
|
||||
var eventID int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT state, event_id FROM record_breaks WHERE id = ?`, id).Scan(&state, &eventID); err != nil {
|
||||
var (
|
||||
state string
|
||||
eventID int64
|
||||
entryID int64
|
||||
level string
|
||||
resultVal sql.NullFloat64
|
||||
resultText sql.NullString
|
||||
)
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT state, event_id, entry_id, level, result_value, result_text FROM record_breaks WHERE id = ?`, id).
|
||||
Scan(&state, &eventID, &entryID, &level, &resultVal, &resultText); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return recordBreakDTO{}, notFound("破纪录登记不存在")
|
||||
}
|
||||
@@ -514,9 +545,23 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "确认破纪录加分"); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
// 确认即更新纪录库,并把引擎算出的加分回填到登记上(devdocs/02 第 3 节)。
|
||||
if err := upsertCurrentRecord(ctx, tx, eventID, entryID, level, resultVal, resultText); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE record_breaks SET bonus_points = COALESCE((
|
||||
SELECT SUM(points) FROM score_ledger
|
||||
WHERE source_type = 'record_bonus' AND event_id = ? AND entry_id = ? AND voided = 0), 0)
|
||||
WHERE id = ?`, eventID, entryID, id); err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
@@ -524,6 +569,44 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
return s.getRecordBreak(ctx, id)
|
||||
}
|
||||
|
||||
// upsertCurrentRecord 把确认后的破纪录写入 records:同小项同档位的旧纪录改为非当前,
|
||||
// 新纪录标为 current(devdocs/04 records 表;source=current 表示本届产生)。
|
||||
func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64, level string, value sql.NullFloat64, text sql.NullString) error {
|
||||
var (
|
||||
teamID int64
|
||||
athleteID sql.NullInt64
|
||||
athleteName sql.NullString
|
||||
teamName sql.NullString
|
||||
)
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT e.team_id, e.athlete_id, a.name, t.name
|
||||
FROM entries e
|
||||
LEFT JOIN athletes a ON a.id = e.athlete_id
|
||||
LEFT JOIN teams t ON t.id = e.team_id
|
||||
WHERE e.id = ?`, entryID).Scan(&teamID, &athleteID, &athleteName, &teamName); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return notFound("报名项不存在")
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
holder := teamName.String
|
||||
if athleteID.Valid && athleteName.String != "" {
|
||||
holder = athleteName.String
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`,
|
||||
eventID, level); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO records (event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), 1, 'current')`,
|
||||
eventID, level, value, text, nullStr(&holder), nullID(teamID)); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- adjustments ----------
|
||||
|
||||
func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) {
|
||||
@@ -614,6 +697,10 @@ func (s *server) createAdjustment(ctx context.Context, in adjustmentInput, userI
|
||||
in.TeamID, nullI64(in.EventID), id, in.Delta, nullID(userID)); err != nil {
|
||||
return adjustmentDTO{}, internal(err)
|
||||
}
|
||||
// 调整分进积分榜,必须写版本号,否则大屏不会刷新。
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return adjustmentDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return adjustmentDTO{}, internal(err)
|
||||
}
|
||||
@@ -669,7 +756,7 @@ func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason st
|
||||
nullID(userID), reason, id); err != nil {
|
||||
return adjustmentDTO{}, internal(err)
|
||||
}
|
||||
if err := bumpVersion(ctx, tx, "standings", 0, "replace", map[string]any{"adjustmentId": id}, userID); err != nil {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return adjustmentDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
|
||||
Reference in new issue
Block a user