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 时草稿成绩连值一起导出; // 为 false(默认)时草稿行照样占一行,但成绩值不外露,按未发布呈现(见 EventResultRow.Unpublished)。 // 总积分榜与奖牌榜始终只认已发布成绩——草稿不上屏也不计分。 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, ''), count_to_team 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 voided int countToTeam 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, &countToTeam); err != nil { return nil, fmt.Errorf("export: 扫描积分流水: %w", err) } e.Voided = voided != 0 e.CountToTeam = countToTeam != 0 out = append(out, e) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历积分流水: %w", err) } return out, nil } // loadRanked 读取发布产出的通道名次(channel_ranks),供 domain 统计奖牌。 // 与 REST 的 buildRanked、快照的 loadRanked 同口径:每个计分通道各自一行名次, // 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌(devdocs 17 §五)。 // 三个奖牌开关随行携带(events.award_medals + 通道的 count_to_medals / count_to_team_medals), // domain 的榜单函数不必回查库;积分以 score_ledger 为准,这里只取名次,不需要重算分值。 func loadRanked(db *sql.DB) ([]domain.RankedResult, error) { rows, err := db.Query(` SELECT COALESCE(cr.result_id, 0), cr.event_id, cr.category_id, cr.rank, cr.result_value, cr.status, COALESCE(cr.entry_id, 0), COALESCE(cr.team_id, 0), COALESCE(cr.athlete_id, 0), COALESCE(a.name, ''), cr.scope, cr.channel_id, cr.basis, cr.subject_key, ev.award_medals, c.count_to_medals, c.count_to_team_medals FROM channel_ranks cr JOIN events ev ON ev.id = cr.event_id JOIN event_score_channels c ON c.id = cr.channel_id LEFT JOIN athletes a ON a.id = cr.athlete_id WHERE ev.scored = 1 AND cr.rank > 0 ORDER BY cr.event_id, cr.category_id, c.sort_order, c.id, cr.rank, cr.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 status sql.NullString awardMedals int countToMedals int countToTeamMed int ) if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rr.Rank, &value, &status, &rr.EntryID, &rr.TeamID, &rr.AthleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID, &rr.Basis, &rr.SubjectKey, &awardMedals, &countToMedals, &countToTeamMed); err != nil { return nil, fmt.Errorf("export: 扫描名次: %w", err) } rr.Status = domain.ResultStatus(status.String) if value.Valid { v := value.Float64 rr.ResultValue = &v } // 归属份额沿用「主单位独占 100%」:team_id 为空(聚合 / 团体手工名次理论上不会)时不造份额, // 免得给 0 号单位凭空记牌,与 domain 的 wholeShare 一致。 if rr.TeamID != 0 { rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}} } rr.AwardMedals = awardMedals != 0 rr.CountToMedals = countToMedals != 0 rr.CountToTeamMedals = countToTeamMed != 0 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 表示不限)的报名与成绩。 // 未录入成绩的报名项也会列出,状态显示「未录入」——现场核对名单时用得上。 // includeDrafts 为 false 时草稿成绩的值不对外,但那一行仍占位(Unpublished); // 连成绩行都没有的报名项同样占位(NoResult)。 func loadEventRows(db *sql.DB, eventID, categoryID int64, includeDrafts bool) ([]EventResultRow, error) { // 名次列取「主通道」名次(devdocs 17 §11.3):双通道项目(个人通道 + 团体通道)一行只能出一个名次, // 口径 = scope=athlete 优先,其次通道 sort_order 小者。 // channel_ranks 是发布 / 重算才重建的派生数据,还没有它时(老库刚升级、只排名不计分项目的合成通道 // 落不了库)回落到 results.rank,保持名次列不空白。 query := ` SELECT COALESCE(cr.rank, 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 LEFT JOIN event_score_channels mc ON mc.id = ( SELECT c2.id FROM event_score_channels c2 WHERE c2.event_id = en.event_id ORDER BY CASE WHEN c2.scope = 'athlete' THEN 0 ELSE 1 END, c2.sort_order, c2.id LIMIT 1) LEFT JOIN channel_ranks cr ON cr.channel_id = mc.id AND cr.entry_id = en.id AND cr.category_id = en.category_id WHERE en.event_id = ?` args := []any{eventID} if categoryID != 0 { query += ` AND en.category_id = ?` args = append(args, categoryID) } // 只看已发布成绩时也不把草稿行滤掉:名单来自报名表,成绩没发布只是这一行的状态。 // 整行消失会被当成「这个人没报名」(现场实测就是这么报的),所以草稿行照样占一行, // 只是成绩值按未发布处理(见下面的 Unpublished 与 eventRows)。 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) // results.state 是 NOT NULL,所以 state 为空只可能是 LEFT JOIN 没命中所致:这条报名还没录成绩。 // 它与「有草稿没发布」要分开:前者三格都没值,后者只是成绩值不对外。 row.NoResult = !resultState.Valid row.Unpublished = !includeDrafts && resultState.Valid && domain.ResultState(resultState.String) == domain.ResultStateDraft 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 } // teamDetailRankExpr 取一条单位流水的名次(devdocs 17 §11.3):按流水对应的通道与主体关联 channel_ranks。 // // score_ledger 上没有通道列(域模型 LedgerEntry.ChannelID 没有落库),所以只能按流水自身能确定的主体来找: // - 有 entry_id 的流水(成绩流水):按报名项关联,通道用「主通道」口径(scope=athlete 优先,其次 sort_order); // - entry_id / result_id 都为空的流水(聚合名次与团体手工名次):写入侧把 source_id 退化为通道 id, // 就拿它 + 主体(单位 / 运动员)关联。 // // 组别用该行能唯一确定的那一个(c = 报名项的组别,ca = 回推出来的组别);回推不出来时不猜,名次留空。 // channel_ranks 是发布 / 重算才重建的派生数据,没有对应行时回落到 results.rank,不把明细的名次列变成空白。 const teamDetailRankExpr = `COALESCE(( SELECT cr.rank FROM channel_ranks cr JOIN event_score_channels ch ON ch.id = cr.channel_id WHERE cr.event_id = l.event_id AND cr.category_id = COALESCE(c.id, ca.id) AND ((l.entry_id IS NOT NULL AND cr.entry_id = l.entry_id) OR (l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result' AND cr.channel_id = l.source_id AND ((cr.scope = 'team' AND cr.team_id = l.team_id) OR (cr.scope = 'athlete' AND cr.athlete_id = l.athlete_id)))) ORDER BY CASE WHEN ch.scope = 'athlete' THEN 0 ELSE 1 END, ch.sort_order, ch.id, cr.id LIMIT 1), COALESCE(r.rank, 0))` func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) { // 聚合通道(basis=aggregate,即「按队内平均」)的流水 entry_id / result_id 都为空, // 从 entries 取不到组别。这类流水是「单位在本组别的成绩取平均」的产物,组别在算分时已经确定; // 库里没把它落到流水上,只能回推:单位在本小项只参加了一个组别时,那个组别就是答案(ac 子查询)。 // 参加了多个组别时回推不出来(同一 (小项, 单位) 有多条无法区分的聚合流水),宁可留空也不猜。 // 回推只对聚合流水生效(与 isAggregateLedger 同判据),人工调整等其它无 entry_id 的流水不受影响。 rows, err := db.Query(` SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''), COALESCE(a.name, ''), `+teamDetailRankExpr+`, 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 ( SELECT en2.event_id, MIN(en2.category_id) AS category_id FROM entries en2 WHERE en2.team_id = ? GROUP BY en2.event_id HAVING COUNT(DISTINCT en2.category_id) = 1 ) ac ON ac.event_id = l.event_id AND l.entry_id IS NULL AND l.result_id IS NULL AND l.source_type = 'result' LEFT JOIN event_categories ca ON ca.id = ac.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 AND l.count_to_team = 1 ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(NULLIF(`+teamDetailRankExpr+`, 0), 999999), l.id`, teamID, 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 }