fix: 补 .gitattributes(CRLF for .bat) + README 端口 19540 + 提交 console 锁文件
This commit is contained in:
1 parent
c121111f51
commit
b80c64a115
39 files changed
+4217
-345
No files matched your search
@@ -24,24 +24,30 @@ type client struct {
|
||||
closeCode websocket.StatusCode
|
||||
closeReason string
|
||||
|
||||
mu sync.Mutex
|
||||
greeted bool
|
||||
role string
|
||||
userID int64
|
||||
instanceID string
|
||||
mu sync.Mutex
|
||||
greeted bool
|
||||
// role/userID 是 hello 之后实际生效的身份;sessionRole/sessionUserID 是握手时从会话取到的上限,
|
||||
// 客户端自报的 role/userId 不能越过它。
|
||||
role string
|
||||
userID int64
|
||||
sessionRole string
|
||||
sessionUserID int64
|
||||
instanceID string
|
||||
|
||||
ackVersion atomic.Int64 // 客户端已确认应用到的版本
|
||||
lastSent atomic.Int64 // 服务端已发出的最大版本
|
||||
slow atomic.Bool
|
||||
}
|
||||
|
||||
func newClient(h *Hub, conn *websocket.Conn, remoteAddr string) *client {
|
||||
func newClient(h *Hub, conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) *client {
|
||||
return &client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
remoteAddr: remoteAddr,
|
||||
send: make(chan []byte, defaultSendBuffer),
|
||||
done: make(chan struct{}),
|
||||
hub: h,
|
||||
conn: conn,
|
||||
remoteAddr: remoteAddr,
|
||||
send: make(chan []byte, defaultSendBuffer),
|
||||
done: make(chan struct{}),
|
||||
sessionRole: sessionRole,
|
||||
sessionUserID: sessionUserID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
)
|
||||
|
||||
// 默认参数;现场可通过 Set* 调整。
|
||||
@@ -210,14 +213,26 @@ func (h *Hub) Handler() http.Handler {
|
||||
h.mu.Unlock()
|
||||
defer h.connWG.Done()
|
||||
|
||||
h.serve(conn, r.RemoteAddr)
|
||||
// 身份只认会话:/ws 必须被 Authenticate 中间件包住,否则一律按 guest 处理。
|
||||
role, userID := wsIdentity(r.Context())
|
||||
h.serve(conn, r.RemoteAddr, role, userID)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Hub) serve(conn *websocket.Conn, remoteAddr string) {
|
||||
// wsIdentity 从请求上下文取会话身份并折算成实时层角色。
|
||||
// 未登录(guest)只能进大屏房间;recorder/admin 才有管理端房间的订阅权。
|
||||
func wsIdentity(ctx context.Context) (string, int64) {
|
||||
u := middleware.User(ctx)
|
||||
if u == nil || !u.Role.Allows(auth.RoleRecorder) {
|
||||
return RoleDisplay, 0
|
||||
}
|
||||
return RoleAdmin, u.ID
|
||||
}
|
||||
|
||||
func (h *Hub) serve(conn *websocket.Conn, remoteAddr, sessionRole string, sessionUserID int64) {
|
||||
conn.SetReadLimit(1 << 20)
|
||||
|
||||
c := newClient(h, conn, remoteAddr)
|
||||
c := newClient(h, conn, remoteAddr, sessionRole, sessionUserID)
|
||||
h.addClient(c)
|
||||
|
||||
ctx, cancel := context.WithCancel(h.ctx)
|
||||
@@ -293,22 +308,42 @@ func (h *Hub) roomClients(room string) []*client {
|
||||
return out
|
||||
}
|
||||
|
||||
// handleHello 只信任会话身份:客户端自报的 role/userId 会被忽略,
|
||||
// 管理端房间必须由登录会话授予,未登录连接请求管理端直接断开。
|
||||
func (h *Hub) handleHello(ctx context.Context, c *client, p helloPayload) {
|
||||
if p.Role != RoleDisplay && p.Role != RoleAdmin {
|
||||
c.mu.Lock()
|
||||
sessionRole, sessionUserID := c.sessionRole, c.sessionUserID
|
||||
c.mu.Unlock()
|
||||
|
||||
if p.Role != "" && p.Role != RoleDisplay && p.Role != RoleAdmin {
|
||||
h.log.Debug("hello 角色非法", "role", p.Role, "remote", c.remoteAddr)
|
||||
c.stop(websocket.StatusPolicyViolation, "role 非法")
|
||||
return
|
||||
}
|
||||
if p.Role == RoleAdmin && sessionRole != RoleAdmin {
|
||||
h.log.Warn("未登录连接请求管理端房间,已断开", "remote", c.remoteAddr)
|
||||
c.stop(websocket.StatusPolicyViolation, "无权订阅管理端房间")
|
||||
return
|
||||
}
|
||||
|
||||
// 不写 role 时按会话上限走;显式 role=display 允许管理端用户降级成大屏。
|
||||
role := sessionRole
|
||||
if p.Role == RoleDisplay {
|
||||
role = RoleDisplay
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.role = p.Role
|
||||
c.userID = p.UserID
|
||||
c.role = role
|
||||
c.userID = 0
|
||||
if role == RoleAdmin {
|
||||
c.userID = sessionUserID
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if p.Role == RoleAdmin {
|
||||
if role == RoleAdmin {
|
||||
rooms := []string{RoomAdmin}
|
||||
if p.UserID > 0 {
|
||||
rooms = append(rooms, AdminRoom(p.UserID))
|
||||
if c.userID > 0 {
|
||||
rooms = append(rooms, AdminRoom(c.userID))
|
||||
}
|
||||
h.join(c, rooms...)
|
||||
} else {
|
||||
|
||||
@@ -84,6 +84,9 @@ func encode(event string, data any) ([]byte, error) {
|
||||
}
|
||||
|
||||
// helloPayload 是 C→S 的 hello 载荷。
|
||||
//
|
||||
// role 只是「申请」:服务端以会话身份为准,未登录连接申请 admin 会被拒绝,
|
||||
// 因此这里没有 userId —— 定向房间的 userId 只能来自会话。
|
||||
// instanceId / page 是协议字段之外的可选扩展:注册表要记「谁在线、在哪一页」,
|
||||
// 老客户端不带这两个字段也能正常工作。
|
||||
type helloPayload struct {
|
||||
@@ -91,7 +94,6 @@ type helloPayload struct {
|
||||
LastVersion *int64 `json:"lastVersion,omitempty"`
|
||||
DisplayKey string `json:"displayKey,omitempty"`
|
||||
InstanceID string `json:"instanceId,omitempty"`
|
||||
UserID int64 `json:"userId,omitempty"`
|
||||
Page string `json:"page,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user