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:
AskaEth committed 2026-09-09 19:02:24 +08:00
1 parent d0e3b93e76
commit bc8b1ee6c0
145 files changed
+25159 -321

No files matched your search

+641
View File
@@ -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
}