fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件

This commit is contained in:
AskaEth committed 2026-09-09 20:57:58 +08:00
1 parent c121111f51
commit b80c64a115
39 files changed
+4217 -345

No files matched your search

+214 -84
View File
@@ -4,24 +4,31 @@ 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 控制快照里「最新成绩」的条数,避免大屏首包过大。
// latestResultsLimit 控制快照里「最新成绩」的行数上限,避免大屏首包过大。
// 行会按「小项 + 组别」聚合后再下发,因此组数只会比这个值更少。
const latestResultsLimit = 20
// Snapshot 是 snapshot 事件的 data,也是客户端冷启动的完整状态。
// 字段名与协议第 3 节一致。
// 字段名与协议第 3 节一致;settings/standings/latestResults 的形状必须与 REST 同构,
// 否则大屏收到首包就会按错误的键名渲染。
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"`
Version int64 `json:"version"`
Settings dto.EventSettings `json:"settings"`
Teams []domain.Team `json:"teams"`
Events []domain.Event `json:"events"`
Standings []dto.StandingRow `json:"standings"`
Records []Record `json:"records"`
LatestResults []dto.EventResultGroup `json:"latestResults"`
}
// Record 是纪录榜的一行。
@@ -38,23 +45,6 @@ type Record struct {
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 读取一份一致的完整状态。
//
// 全部查询放在同一个只读事务里,且版本号取自同一事务,
@@ -71,18 +61,21 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
defer func() { _ = tx.Rollback() }()
snap := &Snapshot{
Settings: map[string]json.RawMessage{},
Teams: []domain.Team{},
Events: []domain.Event{},
Standings: []domain.StandingsRow{},
Standings: []dto.StandingRow{},
Records: []Record{},
LatestResults: []LatestResult{},
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)
}
if err := loadSettings(ctx, tx, snap); err != nil {
values, err := loadSettings(ctx, tx)
if err != nil {
return nil, err
}
if err := fillSettings(ctx, tx, values, snap); err != nil {
return nil, err
}
if err := loadTeams(ctx, tx, snap); err != nil {
@@ -91,7 +84,7 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
if err := loadEvents(ctx, tx, snap); err != nil {
return nil, err
}
if err := loadStandings(ctx, tx, snap); err != nil {
if err := loadStandings(ctx, tx, snap, values); err != nil {
return nil, err
}
if err := loadRecords(ctx, tx, snap); err != nil {
@@ -107,21 +100,46 @@ func (h *Hub) Snapshot(ctx context.Context) (*Snapshot, error) {
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`)
// 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 fmt.Errorf("读取赛事配置: %w", err)
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 fmt.Errorf("解析赛事配置: %w", err)
return nil, fmt.Errorf("解析赛事配置: %w", err)
}
snap.Settings[key] = rawJSONOrString(value)
out[key] = value
}
return rows.Err()
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
}
func loadTeams(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
@@ -191,9 +209,9 @@ func loadEvents(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
return rows.Err()
}
// loadStandings 复用 domain 的排名口径:积分来自流水,奖牌来自已发布名次,
// 保证大屏看到的榜单与 REST /api/standings 一致。
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
// loadStandings 复用 domain 的排名口径,且输入与 REST /api/standings 完全一致:
// 个人流水按系数折算、团体奖牌开关生效、只有配了计分通道的项目才产出名次。
func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, values map[string]string) error {
ledger, err := loadLedger(ctx, tx)
if err != nil {
return err
@@ -202,11 +220,14 @@ func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
if err != nil {
return err
}
snap.Standings = domain.BuildTeamStandings(domain.StandingsInput{
Teams: snap.Teams,
Ledger: ledger,
Ranked: ranked,
rows := domain.BuildTeamStandings(domain.StandingsInput{
Teams: snap.Teams,
Ledger: ledger,
Ranked: ranked,
IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true),
})
snap.Standings = dto.StandingRows(rows, snap.Teams)
return nil
}
@@ -235,13 +256,19 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) {
return out, rows.Err()
}
// loadRanked 只取已发布的前三名,用于统计奖牌;名次明细的完整计算仍由成绩模块负责。
// loadRanked 与 REST 的 buildRanked 同源:必须 join event_score_channels,
// 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌。
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`)
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)
}
@@ -250,19 +277,23 @@ func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error)
var out []domain.RankedResult
for rows.Next() {
var (
teamID, athleteID int64
rank int
rr domain.RankedResult
value sql.NullFloat64
athleteID sql.NullInt64
rank int
)
if err := rows.Scan(&teamID, &athleteID, &rank); err != nil {
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)
}
out = append(out, domain.RankedResult{
TeamID: teamID,
AthleteID: athleteID,
Rank: rank,
Scope: domain.ScopeTeam,
Shares: []domain.TeamShare{{TeamID: teamID, Weight: 1}},
})
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()
}
@@ -299,50 +330,149 @@ func loadRecords(ctx context.Context, tx *sql.Tx, snap *Snapshot) error {
return rows.Err()
}
// loadLatestResults 取最近发布的成绩,按「小项 + 组别」聚合成组后下发。
// 前端按组渲染(TickerBar 取最新一组、EventResultsPage 取前 N 组),平铺行会让它拿到 undefined。
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)
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'
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 (
lr LatestResult
value sql.NullFloat64
isTie int
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(&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 {
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
lr.ResultValue = &v
row.ResultValue = &v
}
lr.IsTie = isTie != 0
snap.LatestResults = append(snap.LatestResults, lr)
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)
}
return rows.Err()
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
}
// rawJSONOrString 让 settings 的值保持原样:本来就是 JSON 的直接透传,
// 纯文本则编码成 JSON 字符串,客户端统一按 JSON 解析。
func rawJSONOrString(value string) json.RawMessage {
if json.Valid([]byte(value)) {
return json.RawMessage(value)
// parseMemberNames 解析 entries.member_names;空值或坏 JSON 一律当作「无成员名单」。
func parseMemberNames(raw string) []string {
if raw == "" {
return nil
}
quoted, err := json.Marshal(value)
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 json.RawMessage(`null`)
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
}
return quoted
}