Files
Scoreboard/server/internal/export/load.go
T
AskaEth bc8b1ee6c0 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
2026-09-09 19:02:24 +08:00

642 lines
19 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
}
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
}