feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -0,0 +1,641 @@
|
||||
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
|
||||
}
|
||||
|
||||
teams, err := loadTeams(db)
|
||||
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,
|
||||
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
|
||||
ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"),
|
||||
}
|
||||
return StandingsWorkbook{
|
||||
Meta: meta,
|
||||
Team: domain.BuildTeamStandings(in),
|
||||
Medal: domain.BuildMedalStandings(in),
|
||||
Athlete: domain.BuildAthleteStandings(in),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadTeams(db *sql.DB) ([]domain.Team, error) {
|
||||
// 只取启用中的单位;已软删除但仍有流水的单位由 domain 兜底补一行,分数不会被吞掉。
|
||||
rows, err := db.Query(`
|
||||
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 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
|
||||
}
|
||||
|
||||
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 统计奖牌。
|
||||
// 奖牌只看「名次 + 通道 scope」,积分以 score_ledger 为准,所以这里不需要
|
||||
// 重算分值,也就能保证与正式计分结果完全一致。
|
||||
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
scopes, err := loadChannelScopes(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT r.id, r.entry_id, r.event_id, en.category_id, en.team_id,
|
||||
COALESCE(en.athlete_id, 0), COALESCE(a.name, ''), r.rank, r.result_value
|
||||
FROM results r
|
||||
JOIN entries en ON en.id = r.entry_id
|
||||
JOIN events e ON e.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 e.scored = 1 AND en.status = 'registered' AND r.status = 'ok'
|
||||
ORDER BY r.event_id, r.rank, r.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
|
||||
resultValue sql.NullFloat64
|
||||
)
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EntryID, &rr.EventID, &rr.CategoryID, &rr.TeamID,
|
||||
&rr.AthleteID, &rr.AthleteName, &rr.Rank, &resultValue); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描名次: %w", err)
|
||||
}
|
||||
if resultValue.Valid {
|
||||
v := resultValue.Float64
|
||||
rr.ResultValue = &v
|
||||
}
|
||||
if rr.TeamID == 0 {
|
||||
continue
|
||||
}
|
||||
// 一个项目可能同时配个人通道与团体通道,同一成绩要在两个榜各记一枚,
|
||||
// 因此按 scope 展开成多条明细。
|
||||
for _, sc := range scopes[rr.EventID] {
|
||||
if sc == domain.ScopeAthlete && rr.AthleteID == 0 {
|
||||
continue
|
||||
}
|
||||
item := rr
|
||||
item.Scope = sc
|
||||
item.Basis = domain.BasisResult
|
||||
item.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("export: 遍历名次: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadChannelScopes(db *sql.DB) (map[int64][]domain.Scope, error) {
|
||||
rows, err := db.Query(`SELECT event_id, scope FROM event_score_channels ORDER BY event_id, sort_order, id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取计分通道: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64][]domain.Scope)
|
||||
for rows.Next() {
|
||||
var eventID int64
|
||||
var scope domain.Scope
|
||||
if err := rows.Scan(&eventID, &scope); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描计分通道: %w", err)
|
||||
}
|
||||
if !containsScope(out[eventID], scope) {
|
||||
out[eventID] = append(out[eventID], scope)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("export: 遍历计分通道: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func containsScope(list []domain.Scope, scope domain.Scope) bool {
|
||||
for _, s := range list {
|
||||
if s == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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, _, 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}, 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, '')
|
||||
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
|
||||
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
|
||||
&r.ResultText, &source, &r.Points, &r.Remark); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
|
||||
}
|
||||
r.Source = sourceLabel(source)
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user