Files
Scoreboard/server/internal/export/load.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

676 lines
20 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 export
import (
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// Meta 是导出件抬头里的赛事信息;留空的字段自动回落到赛事库的 settings。
type Meta struct {
EventName string
Subtitle string
Organizer string
GeneratedAt time.Time
GeneratedBy string
}
func (m Meta) normalized() Meta {
if m.GeneratedAt.IsZero() {
m.GeneratedAt = time.Now()
}
return m
}
// Options 是导出选项;零值即「正式成绩」口径。
type Options struct {
Meta Meta
// IncludeDrafts 仅影响分项成绩表:为 true 时草稿成绩也导出。
// 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。
IncludeDrafts bool
}
// resolveMeta 读一次 settings,把留空的抬头字段补齐;顺带把设置表返回给调用方复用。
func resolveMeta(db *sql.DB, meta Meta) (Meta, map[string]string, error) {
settings, err := loadSettings(db)
if err != nil {
return Meta{}, nil, err
}
if meta.EventName == "" {
meta.EventName = settings["event.name"]
}
if meta.Subtitle == "" {
meta.Subtitle = settings["event.subtitle"]
}
if meta.Organizer == "" {
meta.Organizer = settings["event.organizer"]
}
return meta.normalized(), settings, nil
}
func loadSettings(db *sql.DB) (map[string]string, error) {
rows, err := db.Query(`SELECT key, value FROM settings`)
if err != nil {
return nil, fmt.Errorf("export: 读取赛事设置: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]string)
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("export: 扫描赛事设置: %w", err)
}
out[k] = v
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历赛事设置: %w", err)
}
return out, nil
}
// LoadStandingsWorkbook 读取单位、运动员、积分流水与名次,交给 domain 算出三个榜。
// 排名规则只在 domain 里有一份,这里不做任何二次计算。
func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error) {
if db == nil {
return StandingsWorkbook{}, ErrNoDatabase
}
meta, settings, err := resolveMeta(db, opts.Meta)
if err != nil {
return StandingsWorkbook{}, err
}
// 排名输入只含启用单位,与 REST 一致。
teams, err := loadTeams(db, true)
if err != nil {
return StandingsWorkbook{}, err
}
// 展示字段用全量单位表:停用但有流水的单位由 domain 兜底出行,名称不能是空的。
allTeams, err := loadTeams(db, false)
if err != nil {
return StandingsWorkbook{}, err
}
athletes, err := loadAthletes(db)
if err != nil {
return StandingsWorkbook{}, err
}
ledger, err := loadLedger(db)
if err != nil {
return StandingsWorkbook{}, err
}
ranked, err := loadRanked(db)
if err != nil {
return StandingsWorkbook{}, err
}
in := domain.StandingsInput{
Teams: teams,
Athletes: athletes,
Ledger: ledger,
Ranked: ranked,
TieBreak: splitSettingList(settings["standings.tie_break"]),
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"),
}
team := domain.BuildTeamStandings(in)
medal := domain.BuildMedalStandings(in)
athlete := domain.BuildAthleteStandings(in)
fillTeamDisplay(team, medal, athlete, allTeams)
return StandingsWorkbook{Meta: meta, Team: team, Medal: medal, Athlete: athlete}, nil
}
// loadTeams 读取单位表;activeOnly 为 true 时只取启用中的单位(排名输入口径)。
func loadTeams(db *sql.DB, activeOnly bool) ([]domain.Team, error) {
query := `
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
FROM teams`
if activeOnly {
query += ` WHERE active = 1`
}
query += ` ORDER BY sort_order, code`
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("export: 读取单位: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.Team
for rows.Next() {
var t domain.Team
var 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("export: 扫描单位: %w", err)
}
t.Active = active != 0
out = append(out, t)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历单位: %w", err)
}
return out, nil
}
// fillTeamDisplay 用全量单位表补齐榜单行的展示字段。
// 排名输入只含启用单位,停用但有流水的单位由 domain 兜底出行,编码/名称原本是空的。
func fillTeamDisplay(team []domain.StandingsRow, medal []domain.MedalRow, athlete []domain.AthleteStandingsRow, teams []domain.Team) {
byID := make(map[int64]domain.Team, len(teams))
for _, t := range teams {
byID[t.ID] = t
}
for i := range team {
if t, ok := byID[team[i].TeamID]; ok {
if team[i].Code == "" {
team[i].Code = t.Code
}
if team[i].Name == "" {
team[i].Name = t.Name
}
if team[i].ShortName == "" {
team[i].ShortName = t.ShortName
}
}
}
for i := range medal {
if t, ok := byID[medal[i].TeamID]; ok {
if medal[i].Code == "" {
medal[i].Code = t.Code
}
if medal[i].Name == "" {
medal[i].Name = t.Name
}
if medal[i].ShortName == "" {
medal[i].ShortName = t.ShortName
}
}
}
for i := range athlete {
if t, ok := byID[athlete[i].TeamID]; ok {
if athlete[i].TeamCode == "" {
athlete[i].TeamCode = t.Code
}
if athlete[i].TeamName == "" {
athlete[i].TeamName = t.Name
}
}
}
}
// splitSettingList 解析逗号分隔的设置值(如 standings.tie_break),空值返回 nil。
func splitSettingList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
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 loadAthletes(db *sql.DB) ([]domain.Athlete, error) {
// 停用的运动员也可能有历史积分,名字要能显示,所以这里不过滤 active。
rows, err := db.Query(`
SELECT id, name, COALESCE(gender, ''), COALESCE(team_id, 0), COALESCE(employee_no, ''), active
FROM athletes ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("export: 读取运动员: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.Athlete
for rows.Next() {
var a domain.Athlete
var active int
if err := rows.Scan(&a.ID, &a.Name, &a.Gender, &a.TeamID, &a.EmployeeNo, &active); err != nil {
return nil, fmt.Errorf("export: 扫描运动员: %w", err)
}
a.Active = active != 0
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历运动员: %w", err)
}
return out, nil
}
func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
rows, err := db.Query(`
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, voided, COALESCE(void_reason, '')
FROM score_ledger ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("export: 读取积分流水: %w", err)
}
defer func() { _ = rows.Close() }()
var out []domain.LedgerEntry
for rows.Next() {
var e domain.LedgerEntry
var voided int
if err := rows.Scan(&e.ID, &e.TeamID, &e.AthleteID, &e.EventID, &e.EntryID, &e.ResultID,
&e.SourceType, &e.SourceID, &e.Points, &voided, &e.VoidReason); err != nil {
return nil, fmt.Errorf("export: 扫描积分流水: %w", err)
}
e.Voided = voided != 0
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历积分流水: %w", err)
}
return out, nil
}
// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。
// 与 REST 的 buildRanked、快照的 loadRanked 同源:一条成绩在每个计分通道下各产出一条明细,
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌。
// 积分以 score_ledger 为准,这里只取名次,不需要重算分值。
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
rows, err := db.Query(`
SELECT r.id, r.event_id, en.category_id, r.rank, r.result_value, r.status,
en.id, en.team_id, en.athlete_id, COALESCE(a.name, ''), c.scope, c.id
FROM results r
JOIN entries en ON en.id = r.entry_id
JOIN events ev ON ev.id = r.event_id
JOIN event_score_channels c ON c.event_id = r.event_id
LEFT JOIN athletes a ON a.id = en.athlete_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
AND ev.scored = 1
AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)
ORDER BY r.id, c.sort_order, c.id`)
if err != nil {
return nil, fmt.Errorf("export: 读取名次: %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("export: 扫描名次: %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)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历名次: %w", err)
}
return out, nil
}
// eventInfo 是导出分项成绩表需要的项目信息。
type eventInfo struct {
id int64
sportName string
name string
}
// LoadEventWorkbook 读取一个小项(eventID=0 表示全部)的成绩,按组别拆成多个 sheet。
func LoadEventWorkbook(db *sql.DB, eventID int64, opts Options) (EventsWorkbook, error) {
if db == nil {
return EventsWorkbook{}, ErrNoDatabase
}
meta, _, err := resolveMeta(db, opts.Meta)
if err != nil {
return EventsWorkbook{}, err
}
events, err := loadEvents(db, eventID)
if err != nil {
return EventsWorkbook{}, err
}
if eventID != 0 && len(events) == 0 {
return EventsWorkbook{}, fmt.Errorf("export: 小项 %d 不存在", eventID)
}
sheets := make([]EventSheet, 0, len(events))
for _, ev := range events {
cats, err := loadCategories(db, ev.id)
if err != nil {
return EventsWorkbook{}, err
}
if len(cats) == 0 {
rows, err := loadEventRows(db, ev.id, 0, opts.IncludeDrafts)
if err != nil {
return EventsWorkbook{}, err
}
sheets = append(sheets, EventSheet{SportName: ev.sportName, EventName: ev.name, Rows: rows})
continue
}
for _, cat := range cats {
rows, err := loadEventRows(db, ev.id, cat.id, opts.IncludeDrafts)
if err != nil {
return EventsWorkbook{}, err
}
sheets = append(sheets, EventSheet{
SportName: ev.sportName,
EventName: ev.name,
Category: cat.name,
Rows: rows,
})
}
}
return EventsWorkbook{Meta: meta, Sheets: sheets}, nil
}
func loadEvents(db *sql.DB, eventID int64) ([]eventInfo, error) {
query := `
SELECT e.id, COALESCE(s.name, ''), e.name
FROM events e
LEFT JOIN sports s ON s.id = e.sport_id`
args := []any{}
if eventID != 0 {
query += ` WHERE e.id = ?`
args = append(args, eventID)
}
query += ` ORDER BY s.sort_order, e.sort_order, e.id`
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("export: 读取小项: %w", err)
}
defer func() { _ = rows.Close() }()
var out []eventInfo
for rows.Next() {
var ev eventInfo
if err := rows.Scan(&ev.id, &ev.sportName, &ev.name); err != nil {
return nil, fmt.Errorf("export: 扫描小项: %w", err)
}
out = append(out, ev)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历小项: %w", err)
}
return out, nil
}
type categoryInfo struct {
id int64
name string
}
func loadCategories(db *sql.DB, eventID int64) ([]categoryInfo, error) {
rows, err := db.Query(`
SELECT id, name FROM event_categories
WHERE event_id = ? ORDER BY sort_order, id`, eventID)
if err != nil {
return nil, fmt.Errorf("export: 读取组别: %w", err)
}
defer func() { _ = rows.Close() }()
var out []categoryInfo
for rows.Next() {
var c categoryInfo
if err := rows.Scan(&c.id, &c.name); err != nil {
return nil, fmt.Errorf("export: 扫描组别: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历组别: %w", err)
}
return out, nil
}
// loadEventRows 取一个组别(categoryID=0 表示不限)的报名与成绩。
// 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。
func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) {
query := `
SELECT COALESCE(r.rank, 0), COALESCE(r.is_tie, 0), COALESCE(en.bib, ''),
COALESCE(en.member_names, ''), COALESCE(t.name, ''), COALESCE(a.name, ''),
COALESCE(r.result_text, ''), COALESCE(r.unit, ''),
COALESCE((SELECT SUM(l.points) FROM score_ledger l
WHERE l.entry_id = en.id AND l.voided = 0), 0),
r.status, r.state, en.status, COALESCE(en.remark, ''), COALESCE(r.note, '')
FROM entries en
JOIN teams t ON t.id = en.team_id
LEFT JOIN athletes a ON a.id = en.athlete_id
LEFT JOIN results r ON r.entry_id = en.id
WHERE en.event_id = ?`
args := []any{eventID}
if categoryID != 0 {
query += ` AND en.category_id = ?`
args = append(args, categoryID)
}
if !includeDrafts {
query += ` AND (r.state IS NULL OR r.state = 'published')`
}
query += ` ORDER BY CASE WHEN r.rank IS NULL THEN 1 ELSE 0 END, r.rank, t.sort_order, t.code, en.id`
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("export: 读取成绩: %w", err)
}
defer func() { _ = rows.Close() }()
var out []EventResultRow
for rows.Next() {
var (
row EventResultRow
rank, isTie int
memberJSON, athleteName string
resultStat, resultState sql.NullString
entryStat, entryRemark string
resultNote string
)
if err := rows.Scan(&rank, &isTie, &row.Bib, &memberJSON, &row.TeamName, &athleteName,
&row.ResultText, &row.Unit, &row.Points, &resultStat, &resultState, &entryStat,
&entryRemark, &resultNote); err != nil {
return nil, fmt.Errorf("export: 扫描成绩: %w", err)
}
row.Participant = participantName(athleteName, memberJSON)
row.Rank = rankLabel(rank, isTie != 0)
row.Status = statusLabel(resultStat, resultState, entryStat)
row.Remark = joinRemark(entryRemark, resultNote)
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历成绩: %w", err)
}
return out, nil
}
// LoadTeamDetailWorkbook 读取某单位未被作废的积分流水,逐条列出得分来源。
func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailWorkbook, error) {
if db == nil {
return TeamDetailWorkbook{}, ErrNoDatabase
}
meta, settings, err := resolveMeta(db, opts.Meta)
if err != nil {
return TeamDetailWorkbook{}, err
}
team, err := loadTeam(db, teamID)
if err != nil {
return TeamDetailWorkbook{}, err
}
rows, err := loadTeamDetailRows(db, teamID)
if err != nil {
return TeamDetailWorkbook{}, err
}
return TeamDetailWorkbook{
Meta: meta,
Team: team,
Rows: rows,
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
}, nil
}
func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) {
var t domain.Team
var active int
err := db.QueryRow(`
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
COALESCE(color, ''), sort_order, active
FROM teams WHERE id = ?`, teamID).
Scan(&t.ID, &t.Code, &t.Name, &t.ShortName, &t.LogoPath, &t.Color, &t.SortOrder, &active)
if err == sql.ErrNoRows {
return domain.Team{}, fmt.Errorf("%w: %d", ErrTeamNotFound, teamID)
}
if err != nil {
return domain.Team{}, fmt.Errorf("export: 读取单位 %d: %w", teamID, err)
}
t.Active = active != 0
return t, nil
}
func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
rows, err := db.Query(`
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''),
COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''),
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0)
FROM score_ledger l
LEFT JOIN events e ON e.id = l.event_id
LEFT JOIN sports s ON s.id = e.sport_id
LEFT JOIN entries en ON en.id = l.entry_id
LEFT JOIN event_categories c ON c.id = en.category_id
LEFT JOIN results r ON r.id = l.result_id
LEFT JOIN athletes a ON a.id = l.athlete_id
WHERE l.team_id = ? AND l.voided = 0
ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(r.rank, 999999), l.id`, teamID)
if err != nil {
return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err)
}
defer func() { _ = rows.Close() }()
var out []TeamDetailRow
for rows.Next() {
var r TeamDetailRow
var source string
var athleteID int64
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
&r.ResultText, &source, &r.Points, &r.Remark, &athleteID); err != nil {
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
}
r.Source = sourceLabel(source)
r.individual = athleteID != 0
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("export: 遍历积分明细: %w", err)
}
return out, nil
}
func rankLabel(rank int, isTie bool) string {
if rank <= 0 {
return ""
}
if isTie {
return strconv.Itoa(rank) + "(并列)"
}
return strconv.Itoa(rank)
}
// statusLabel 把成绩状态翻成中文;没有成绩行时退回报名状态。
func statusLabel(resultStatus, resultState sql.NullString, entryStatus string) string {
if !resultStatus.Valid {
return entryStatusLabel(entryStatus)
}
if resultState.Valid && domain.ResultState(resultState.String) == domain.ResultStateDraft {
return "草稿"
}
switch domain.ResultStatus(resultStatus.String) {
case domain.ResultStatusOK, "":
return "有效"
case domain.ResultStatusDQ:
return "犯规"
case domain.ResultStatusDNS:
return "弃权"
case domain.ResultStatusDNF:
return "未完成"
case domain.ResultStatusWithdrawn:
return "退赛"
default:
return resultStatus.String
}
}
// joinRemark 合并报名备注与成绩备注,两条都有时用分号隔开。
func joinRemark(entryRemark, resultNote string) string {
entryRemark = strings.TrimSpace(entryRemark)
resultNote = strings.TrimSpace(resultNote)
switch {
case entryRemark == "":
return resultNote
case resultNote == "":
return entryRemark
default:
return entryRemark + ";" + resultNote
}
}
func entryStatusLabel(status string) string {
switch domain.EntryStatus(status) {
case domain.EntryStatusRegistered, "":
return "未录入"
case domain.EntryStatusScratched:
return "弃权"
case domain.EntryStatusDQ:
return "犯规"
case domain.EntryStatusDNS:
return "弃权"
default:
return status
}
}
func sourceLabel(source string) string {
switch domain.SourceType(source) {
case domain.SourceResult:
return "成绩"
case domain.SourceRecordBonus:
return "破纪录加分"
case domain.SourceAdjustment:
return "人工调整"
default:
return source
}
}
func parseFloatDefault(raw string, def float64) float64 {
v, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil {
return def
}
return v
}
// participantName 取个人姓名;团体项目则把成员 JSON 数组拼成顿号列表。
func participantName(athleteName, memberJSON string) string {
if strings.TrimSpace(athleteName) != "" {
return athleteName
}
memberJSON = strings.TrimSpace(memberJSON)
if memberJSON == "" {
return ""
}
var names []string
if err := json.Unmarshal([]byte(memberJSON), &names); err == nil && len(names) > 0 {
return strings.Join(names, "、")
}
return memberJSON
}