- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
349 lines
11 KiB
Go
349 lines
11 KiB
Go
package realtime
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
|
)
|
|
|
|
// latestResultsLimit 控制快照里「最新成绩」的条数,避免大屏首包过大。
|
|
const latestResultsLimit = 20
|
|
|
|
// Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。
|
|
// 字段名与协议第 3 节一致。
|
|
type Snapshot struct {
|
|
Version int64 `json:"version"`
|
|
Settings map[string]json.RawMessage `json:"settings"`
|
|
Teams []domain.Team `json:"teams"`
|
|
Events []domain.Event `json:"events"`
|
|
Standings []domain.StandingsRow `json:"standings"`
|
|
Records []Record `json:"records"`
|
|
LatestResults []LatestResult `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"`
|
|
}
|
|
|
|
// LatestResult 是已发布成绩的展示行,供大屏滚动与详情页使用。
|
|
type LatestResult struct {
|
|
ID int64 `json:"id"`
|
|
EventID int64 `json:"eventId"`
|
|
EntryID int64 `json:"entryId"`
|
|
TeamID int64 `json:"teamId"`
|
|
AthleteID int64 `json:"athleteId,omitempty"`
|
|
Rank int `json:"rank,omitempty"`
|
|
ResultText string `json:"resultText,omitempty"`
|
|
ResultValue *float64 `json:"resultValue,omitempty"`
|
|
Unit string `json:"unit,omitempty"`
|
|
IsTie bool `json:"isTie,omitempty"`
|
|
Status string `json:"status"`
|
|
State string `json:"state"`
|
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
|
}
|
|
|
|
// 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{
|
|
Settings: map[string]json.RawMessage{},
|
|
Teams: []domain.Team{},
|
|
Events: []domain.Event{},
|
|
Standings: []domain.StandingsRow{},
|
|
Records: []Record{},
|
|
LatestResults: []LatestResult{},
|
|
}
|
|
|
|
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(id), 0) FROM change_log`).Scan(&snap.Version); err != nil {
|
|
return nil, fmt.Errorf("读取快照版本号: %w", err)
|
|
}
|
|
if err := loadSettings(ctx, tx, snap); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := loadTeams(ctx, tx, snap); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := loadEvents(ctx, tx, snap); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := loadStandings(ctx, tx, snap); 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
|
|
}
|
|
|
|
func loadSettings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
|
rows, err := tx.QueryContext(ctx, `SELECT key, value FROM settings ORDER BY key`)
|
|
if err != nil {
|
|
return fmt.Errorf("读取赛事配置: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
for rows.Next() {
|
|
var key, value string
|
|
if err := rows.Scan(&key, &value); err != nil {
|
|
return fmt.Errorf("解析赛事配置: %w", err)
|
|
}
|
|
snap.Settings[key] = rawJSONOrString(value)
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
|
|
COALESCE(color, ''), sort_order, active
|
|
FROM teams WHERE active = 1
|
|
ORDER BY sort_order, code`)
|
|
if err != nil {
|
|
return fmt.Errorf("读取单位: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
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 fmt.Errorf("解析单位: %w", err)
|
|
}
|
|
t.Active = active != 0
|
|
snap.Teams = append(snap.Teams, t)
|
|
}
|
|
return 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, e)
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
// loadStandings 复用 domain 的排名口径:积分来自流水,奖牌来自已发布名次,
|
|
// 保证大屏看到的榜单与 REST /api/standings 一致。
|
|
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
|
ledger, err := loadLedger(ctx, tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ranked, err := loadRanked(ctx, tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
snap.Standings = domain.BuildTeamStandings(domain.StandingsInput{
|
|
Teams: snap.Teams,
|
|
Ledger: ledger,
|
|
Ranked: ranked,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
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 只取已发布的前三名,用于统计奖牌;名次明细的完整计算仍由成绩模块负责。
|
|
func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) {
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT e.team_id, COALESCE(e.athlete_id, 0), r.rank
|
|
FROM results r JOIN entries e ON e.id = r.entry_id
|
|
WHERE r.state = 'published' AND r.status = 'ok' AND r.rank BETWEEN 1 AND 3
|
|
ORDER BY r.id`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取奖牌名次: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
var out []domain.RankedResult
|
|
for rows.Next() {
|
|
var (
|
|
teamID, athleteID int64
|
|
rank int
|
|
)
|
|
if err := rows.Scan(&teamID, &athleteID, &rank); err != nil {
|
|
return nil, fmt.Errorf("解析奖牌名次: %w", err)
|
|
}
|
|
out = append(out, domain.RankedResult{
|
|
TeamID: teamID,
|
|
AthleteID: athleteID,
|
|
Rank: rank,
|
|
Scope: domain.ScopeTeam,
|
|
Shares: []domain.TeamShare{{TeamID: teamID, Weight: 1}},
|
|
})
|
|
}
|
|
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()
|
|
}
|
|
|
|
func loadLatestResults(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT r.id, r.event_id, r.entry_id, e.team_id, COALESCE(e.athlete_id, 0),
|
|
COALESCE(r.rank, 0), COALESCE(r.result_text, ''), r.result_value,
|
|
COALESCE(r.unit, ''), r.is_tie, r.status, r.state,
|
|
COALESCE(r.verified_at, r.recorded_at, r.updated_at, r.created_at, '')
|
|
FROM results r JOIN entries e ON e.id = r.entry_id
|
|
WHERE r.state = 'published'
|
|
ORDER BY COALESCE(r.verified_at, r.recorded_at, r.updated_at) DESC, r.id DESC
|
|
LIMIT ?`, latestResultsLimit)
|
|
if err != nil {
|
|
return fmt.Errorf("读取最新成绩: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
for rows.Next() {
|
|
var (
|
|
lr LatestResult
|
|
value sql.NullFloat64
|
|
isTie int
|
|
)
|
|
if err := rows.Scan(&lr.ID, &lr.EventID, &lr.EntryID, &lr.TeamID, &lr.AthleteID,
|
|
&lr.Rank, &lr.ResultText, &value, &lr.Unit, &isTie, &lr.Status, &lr.State, &lr.UpdatedAt); err != nil {
|
|
return fmt.Errorf("解析最新成绩: %w", err)
|
|
}
|
|
if value.Valid {
|
|
v := value.Float64
|
|
lr.ResultValue = &v
|
|
}
|
|
lr.IsTie = isTie != 0
|
|
snap.LatestResults = append(snap.LatestResults, lr)
|
|
}
|
|
return rows.Err()
|
|
}
|
|
|
|
// rawJSONOrString 让 settings 的值保持原样:本来就是 JSON 的直接透传,
|
|
// 纯文本则编码成 JSON 字符串,客户端统一按 JSON 解析。
|
|
func rawJSONOrString(value string) json.RawMessage {
|
|
if json.Valid([]byte(value)) {
|
|
return json.RawMessage(value)
|
|
}
|
|
quoted, err := json.Marshal(value)
|
|
if err != nil {
|
|
return json.RawMessage(`null`)
|
|
}
|
|
return quoted
|
|
}
|