Files
Scoreboard/server/internal/realtime/snapshot.go
T
AskaEth 13f1f03cd0 fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
2026-09-09 22:16:58 +08:00

505 lines
16 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 realtime
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
)
// latestResultsLimit 控制快照里「最新成绩」的行数上限,避免大屏首包过大。
// 行会按「小项 + 组别」聚合后再下发,因此组数只会比这个值更少。
const latestResultsLimit = 20
// Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。
// 字段名与协议第 3 节一致;settings/standings/latestResults 的形状必须与 REST 同构,
// 否则大屏收到首包就会按错误的键名渲染。
type Snapshot struct {
Version int64 `json:"version"`
Settings dto.EventSettings `json:"settings"`
Teams []dto.Team `json:"teams"`
Events []dto.Event `json:"events"`
Standings []dto.StandingRow `json:"standings"`
Records []Record `json:"records"`
LatestResults []dto.EventResultGroup `json:"latestResults"`
}
// Record 是纪录榜的一行。
type Record struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
Level string `json:"level"`
ResultValue *float64 `json:"resultValue,omitempty"`
ResultText string `json:"resultText,omitempty"`
HolderName string `json:"holderName,omitempty"`
HolderTeamID int64 `json:"holderTeamId,omitempty"`
AchievedAt string `json:"achievedAt,omitempty"`
IsCurrent bool `json:"isCurrent"`
Source string `json:"source"`
}
// Snapshot 读取一份一致的完整状态。
//
// 全部查询放在同一个只读事务里,且版本号取自同一事务,
// 否则快照里的数据与版本号会错位,重连补齐时就会漏变更。
func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
if h.db == nil {
return nil, fmt.Errorf("实时中心未绑定赛事库")
}
tx, err := h.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("开启快照事务: %w", err)
}
defer func() { _ = tx.Rollback() }()
snap := &Snapshot{
Teams: []dto.Team{},
Events: []dto.Event{},
Standings: []dto.StandingRow{},
Records: []Record{},
LatestResults: []dto.EventResultGroup{},
}
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&snap.Version); err != nil {
return nil, fmt.Errorf("读取快照版本号: %w", err)
}
values, err := loadSettings(ctx, tx)
if err != nil {
return nil, err
}
if err := fillSettings(ctx, tx, values, snap); err != nil {
return nil, err
}
teams, err := loadTeams(ctx, tx)
if err != nil {
return nil, err
}
snap.Teams = dto.TeamsFromDomain(teams)
if err := loadEvents(ctx, tx, snap); err != nil {
return nil, err
}
if err := loadStandings(ctx, tx, snap, teams, values); err != nil {
return nil, err
}
if err := loadRecords(ctx, tx, snap); err != nil {
return nil, err
}
if err := loadLatestResults(ctx, tx, snap); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("提交快照事务: %w", err)
}
return snap, nil
}
// loadSettings 原样读出 settings 表的键值:榜单口径要用原始字符串,
// 而线上结构需要聚合后的嵌套对象,两者由调用方分别消费。
func loadSettings(ctx context.Context, tx *sql.Tx) (map[string]string, error) {
rows, err := tx.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, fmt.Errorf("读取赛事配置: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]string)
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return nil, fmt.Errorf("解析赛事配置: %w", err)
}
out[key] = value
}
return out, rows.Err()
}
// fillSettings 聚合成与 REST /api/settings 相同的嵌套结构。
// defaultProfileId 在 settings 表里存的是模板 code,需要反查一次主键。
func fillSettings(ctx context.Context, tx *sql.Tx, values map[string]string, snap *Snapshot) error {
snap.Settings = dto.ParseEventSettings(values)
code := values["scoring.default_profile"]
if code == "" {
return nil
}
var id int64
err := tx.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
switch {
case errors.Is(err, sql.ErrNoRows):
// 配置引用的模板已被删除:保持 null,与 REST 一致,由管理端提示重选。
case err != nil:
return fmt.Errorf("解析默认积分模板: %w", err)
default:
snap.Settings.DefaultProfileID = &id
}
return nil
}
// loadTeams 读取全量单位表(含停用单位):榜单展示字段要与 REST 一致,
// 停用但仍有流水的单位也必须能补出编码/名称/颜色,否则大屏会出现空白行。
func loadTeams(ctx context.Context, tx *sql.Tx) ([]domain.Team, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
FROM teams
ORDER BY sort_order, code`)
if err != nil {
return nil, fmt.Errorf("读取单位: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.Team, 0)
for rows.Next() {
var (
t domain.Team
active int
)
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active); err != nil {
return nil, fmt.Errorf("解析单位: %w", err)
}
t.Active = active != 0
out = append(out, t)
}
return out, rows.Err()
}
func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
SELECT id, sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status,
COALESCE(scheduled_at, ''), COALESCE(venue, ''), sort_order, COALESCE(remark, '')
FROM events
ORDER BY sort_order, id`)
if err != nil {
return fmt.Errorf("读取小项: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
e domain.Event
eventType string
resultMode string
resultType string
compare string
status string
recordEligible int
scored int
)
if err := rows.Scan(
&e.ID, &e.SportID, &e.Code, &e.Name, &eventType, &resultMode, &resultType, &compare,
&recordEligible, &scored, &e.Multiplier, &status,
&e.ScheduledAt, &e.Venue, &e.SortOrder, &e.Remark,
); err != nil {
return fmt.Errorf("解析小项: %w", err)
}
e.EventType = domain.EventType(eventType)
e.ResultMode = domain.ResultMode(resultMode)
e.ResultType = domain.ResultType(resultType)
e.Compare = domain.CompareMode(compare)
e.Status = domain.EventStatus(status)
e.RecordEligible = recordEligible != 0
e.Scored = scored != 0
snap.Events = append(snap.Events, dto.EventFromDomain(e))
}
return rows.Err()
}
// loadStandings 复用 domain 的排名口径,且输入与 REST /api/standings 完全一致:
// 排名输入只含启用单位,展示字段用全量单位表补齐;个人流水按系数折算、团体奖牌开关生效、
// 只有配了计分通道的项目才产出名次,排序键取自规则包落库的 standings.tie_break。
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, teams []domain.Team, values map[string]string) error {
ledger, err := loadLedger(ctx, tx)
if err != nil {
return err
}
ranked, err := loadRanked(ctx, tx)
if err != nil {
return err
}
activeTeams := make([]domain.Team, 0, len(teams))
for _, t := range teams {
if t.Active {
activeTeams = append(activeTeams, t)
}
}
rows := domain.BuildTeamStandings(domain.StandingsInput{
Teams: activeTeams,
Ledger: ledger,
Ranked: ranked,
TieBreak: splitTieBreak(values["standings.tie_break"]),
IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true),
})
snap.Standings = dto.StandingRows(rows, teams)
return nil
}
// splitTieBreak 解析 standings.tie_break(逗号分隔的排序键);空串返回 nil,由 domain 落回默认口径。
func splitTieBreak(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
rows, err := tx.QueryContext(ctx, `
SELECT id, team_id, COALESCE(athlete_id, 0), COALESCE(event_id, 0),
COALESCE(entry_id, 0), COALESCE(result_id, 0), source_type, source_id, points
FROM score_ledger WHERE voided = 0 ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("读取积分流水: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.LedgerEntry
for rows.Next() {
var (
e domain.LedgerEntry
sourceType string
)
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID, &sourceType, &e.SourceID, &e.Points); err != nil {
return nil, fmt.Errorf("解析积分流水: %w", err)
}
e.SourceType = domain.SourceType(sourceType)
out = append(out, e)
}
return out, rows.Err()
}
// loadRanked 与 REST 的 buildRanked 同源:必须 join event_score_channels,
// 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌。
func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) {
rows, err := tx.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
JOIN entries e ON e.id = r.entry_id
JOIN event_score_channels c ON c.event_id = r.event_id
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)
ORDER BY r.id, c.sort_order, c.id`)
if err != nil {
return nil, fmt.Errorf("读取奖牌名次: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.RankedResult
for rows.Next() {
var (
rr domain.RankedResult
value sql.NullFloat64
athleteID sql.NullInt64
rank int
)
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
return nil, fmt.Errorf("解析奖牌名次: %w", err)
}
rr.Rank = rank
if value.Valid {
v := value.Float64
rr.ResultValue = &v
}
rr.AthleteID = athleteID.Int64
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
out = append(out, rr)
}
return out, rows.Err()
}
func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
SELECT id, event_id, level, result_value, COALESCE(result_text, ''),
COALESCE(holder_name, ''), COALESCE(holder_team_id, 0),
COALESCE(achieved_at, ''), is_current, source
FROM records WHERE is_current = 1
ORDER BY event_id, level, id`)
if err != nil {
return fmt.Errorf("读取纪录榜: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
r Record
value sql.NullFloat64
isCurrent int
)
if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &value, &r.ResultText,
&r.HolderName, &r.HolderTeamID, &r.AchievedAt, &isCurrent, &r.Source); err != nil {
return fmt.Errorf("解析纪录: %w", err)
}
if value.Valid {
v := value.Float64
r.ResultValue = &v
}
r.IsCurrent = isCurrent != 0
snap.Records = append(snap.Records, r)
}
return rows.Err()
}
// loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。
// 已作废(status=withdrawn)的成绩必须排除,否则大屏会继续展示被作废的行;
// 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。
func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
rows, err := tx.QueryContext(ctx, `
SELECT r.event_id, COALESCE(ev.code, ''), COALESCE(ev.name, ''),
e.category_id, COALESCE(cat.name, ''),
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, ''),
r.entry_id, r.rank, e.team_id,
COALESCE(t.code, ''), COALESCE(t.name, ''), COALESCE(t.short_name, ''),
e.athlete_id, COALESCE(a.name, ''), COALESCE(e.member_names, ''),
COALESCE(r.result_text, ''), r.result_value, COALESCE(r.unit, ''), r.is_tie, r.status
FROM results r
JOIN entries e ON e.id = r.entry_id
JOIN events ev ON ev.id = r.event_id
LEFT JOIN event_categories cat ON cat.id = e.category_id
LEFT JOIN teams t ON t.id = e.team_id
LEFT JOIN athletes a ON a.id = e.athlete_id
WHERE r.state = 'published' AND r.status <> 'withdrawn'
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at) DESC, r.id DESC
LIMIT ?`, latestResultsLimit)
if err != nil {
return fmt.Errorf("读取最新成绩: %w", err)
}
defer func() { _ = rows.Close() }()
type groupKey struct {
eventID int64
categoryID int64
}
index := make(map[groupKey]int)
for rows.Next() {
var (
eventID, categoryID int64
eventCode, eventName string
categoryName string
publishedAt string
row dto.EventResultRow
rank sql.NullInt64
value sql.NullFloat64
athleteID sql.NullInt64
athleteName string
shortName string
memberNames string
isTie int
)
if err := rows.Scan(&eventID, &eventCode, &eventName, &categoryID, &categoryName, &publishedAt,
&row.EntryID, &rank, &row.TeamID, &row.TeamCode, &row.TeamName, &shortName,
&athleteID, &athleteName, &memberNames,
&row.ResultText, &value, &row.Unit, &isTie, &row.Status); err != nil {
return fmt.Errorf("解析最新成绩: %w", err)
}
if rank.Valid {
v := int(rank.Int64)
row.Rank = &v
}
if value.Valid {
v := value.Float64
row.ResultValue = &v
}
if athleteID.Valid && athleteID.Int64 != 0 {
v := athleteID.Int64
row.AthleteID = &v
}
row.AthleteName = nilIfEmpty(athleteName)
row.ShortName = nilIfEmpty(shortName)
row.MemberNames = parseMemberNames(memberNames)
row.IsTie = isTie != 0
key := groupKey{eventID: eventID, categoryID: categoryID}
i, ok := index[key]
if !ok {
i = len(snap.LatestResults)
index[key] = i
snap.LatestResults = append(snap.LatestResults, dto.EventResultGroup{
EventID: eventID,
EventCode: eventCode,
EventName: eventName,
CategoryID: categoryID,
CategoryName: categoryName,
PublishedAt: nilIfEmpty(publishedAt),
Rows: []dto.EventResultRow{},
})
}
snap.LatestResults[i].Rows = append(snap.LatestResults[i].Rows, row)
}
if err := rows.Err(); err != nil {
return err
}
// 行按发布时间倒序取回,组内需改回名次顺序;无名次的排最后。
for i := range snap.LatestResults {
sort.SliceStable(snap.LatestResults[i].Rows, func(a, b int) bool {
ra, rb := snap.LatestResults[i].Rows[a], snap.LatestResults[i].Rows[b]
switch {
case ra.Rank == nil && rb.Rank == nil:
return ra.EntryID < rb.EntryID
case ra.Rank == nil:
return false
case rb.Rank == nil:
return true
default:
return *ra.Rank < *rb.Rank
}
})
}
return nil
}
// parseMemberNames 解析 entries.member_names;空值或坏 JSON 一律当作「无成员名单」。
func parseMemberNames(raw string) []string {
if raw == "" {
return nil
}
var out []string
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil
}
return out
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
func atofDefault(v string, def float64) float64 {
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
if err != nil {
return def
}
return n
}
func boolDefault(v string, def bool) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "true", "1", "yes":
return true
case "false", "0", "no":
return false
default:
return def
}
}