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,198 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultBackupKeep 是滚动保留的备份份数(见 08-安全与可靠性 第 6 节)。
|
||||
const DefaultBackupKeep = 20
|
||||
|
||||
// BackupOptions 描述一次备份。
|
||||
type BackupOptions struct {
|
||||
// Dir 是备份目录,通常为 <dataDir>/backups。
|
||||
Dir string
|
||||
// Prefix 是文件名前缀,用于区分不同库(如 event / system)。
|
||||
Prefix string
|
||||
// Reason 记录触发原因:manual / publish / auto。
|
||||
Reason string
|
||||
// Keep 是滚动保留份数,<=0 时用 DefaultBackupKeep。
|
||||
Keep int
|
||||
// SkipPrune 为 true 时只备份不清理,供「手动留档」场景使用。
|
||||
SkipPrune bool
|
||||
// Now 便于测试注入固定时间;零值取当前时间。
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// BackupInfo 是一份备份的描述。
|
||||
type BackupInfo struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Backup 用 VACUUM INTO 生成一致性快照,并按 Keep 滚动清理旧备份。
|
||||
// VACUUM INTO 由 SQLite 自己保证快照一致,服务运行中也能安全备份。
|
||||
func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
|
||||
if db == nil {
|
||||
return BackupInfo{}, ErrNoDatabase
|
||||
}
|
||||
dir := strings.TrimSpace(opts.Dir)
|
||||
if dir == "" {
|
||||
return BackupInfo{}, errors.New("export: 备份目录为空")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return BackupInfo{}, fmt.Errorf("export: 创建备份目录 %s: %w", dir, err)
|
||||
}
|
||||
|
||||
prefix := strings.TrimSpace(opts.Prefix)
|
||||
if prefix == "" {
|
||||
prefix = "db"
|
||||
}
|
||||
reason := strings.TrimSpace(opts.Reason)
|
||||
if reason == "" {
|
||||
reason = "manual"
|
||||
}
|
||||
now := nowOr(opts.Now)
|
||||
|
||||
path := uniqueBackupPath(dir, prefix, reason, now)
|
||||
if err := vacuumInto(db, path); err != nil {
|
||||
return BackupInfo{}, err
|
||||
}
|
||||
sum, size, err := hashFile(path)
|
||||
if err != nil {
|
||||
return BackupInfo{}, err
|
||||
}
|
||||
// 文件名里的时间戳就是备份时间,文件时间与之一致,排序不会错乱。
|
||||
_ = os.Chtimes(path, now, now)
|
||||
|
||||
info := BackupInfo{
|
||||
Path: path,
|
||||
Name: filepath.Base(path),
|
||||
Size: size,
|
||||
SHA256: sum,
|
||||
Reason: reason,
|
||||
CreatedAt: now,
|
||||
}
|
||||
if !opts.SkipPrune {
|
||||
if _, err := PruneBackups(dir, prefix, opts.Keep); err != nil {
|
||||
return info, err
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// BackupBeforePublish 是「发布前备份」入口:同一套逻辑,只是把原因记为 publish,
|
||||
// 便于在备份列表里一眼看出哪些是发布节点留的。
|
||||
func BackupBeforePublish(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
|
||||
if strings.TrimSpace(opts.Reason) == "" {
|
||||
opts.Reason = "publish"
|
||||
}
|
||||
return Backup(db, opts)
|
||||
}
|
||||
|
||||
// ListBackups 按时间倒序列出备份;prefix 为空时列出全部 .db 备份。
|
||||
// 不计算校验和(避免每次列表都全量读盘),需要校验时用 Backup 的返回值。
|
||||
func ListBackups(dir, prefix string) ([]BackupInfo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取备份目录 %s: %w", dir, err)
|
||||
}
|
||||
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
out := make([]BackupInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".db") {
|
||||
continue
|
||||
}
|
||||
if prefix != "" && !strings.HasPrefix(e.Name(), prefix+"-") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取备份信息 %s: %w", e.Name(), err)
|
||||
}
|
||||
out = append(out, BackupInfo{
|
||||
Path: filepath.Join(dir, e.Name()),
|
||||
Name: e.Name(),
|
||||
Size: info.Size(),
|
||||
Reason: reasonFromName(e.Name(), prefix),
|
||||
CreatedAt: info.ModTime(),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||
return out[i].CreatedAt.After(out[j].CreatedAt)
|
||||
}
|
||||
return out[i].Name > out[j].Name
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LatestBackup 返回最近一份备份,用于健康检查展示「最近备份时间」。
|
||||
func LatestBackup(dir, prefix string) (BackupInfo, bool, error) {
|
||||
list, err := ListBackups(dir, prefix)
|
||||
if err != nil || len(list) == 0 {
|
||||
return BackupInfo{}, false, err
|
||||
}
|
||||
return list[0], true, nil
|
||||
}
|
||||
|
||||
// PruneBackups 只保留最近 keep 份,返回被删除的文件路径。
|
||||
func PruneBackups(dir, prefix string, keep int) ([]string, error) {
|
||||
if keep <= 0 {
|
||||
keep = DefaultBackupKeep
|
||||
}
|
||||
list, err := ListBackups(dir, prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) <= keep {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
removed := make([]string, 0, len(list)-keep)
|
||||
for _, b := range list[keep:] {
|
||||
if err := os.Remove(b.Path); err != nil && !os.IsNotExist(err) {
|
||||
return removed, fmt.Errorf("export: 删除旧备份 %s: %w", b.Path, err)
|
||||
}
|
||||
removed = append(removed, b.Path)
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
func uniqueBackupPath(dir, prefix, reason string, now time.Time) string {
|
||||
base := fmt.Sprintf("%s-%s-%s", prefix, reason, now.Format("20060102-150405"))
|
||||
p := filepath.Join(dir, base+".db")
|
||||
for i := 2; ; i++ {
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
return p
|
||||
}
|
||||
p = filepath.Join(dir, fmt.Sprintf("%s-%d.db", base, i))
|
||||
}
|
||||
}
|
||||
|
||||
// reasonFromName 从 <prefix>-<reason>-<yyyyMMdd>-<HHmmss>.db 里取回原因。
|
||||
func reasonFromName(name, prefix string) string {
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if prefix != "" {
|
||||
base = strings.TrimPrefix(base, prefix+"-")
|
||||
}
|
||||
parts := strings.Split(base, "-")
|
||||
if len(parts) >= 3 {
|
||||
return strings.Join(parts[:len(parts)-2], "-")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackupAndRetention(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
dir := t.TempDir()
|
||||
base := time.Date(2026, 9, 9, 10, 0, 0, 0, time.Local)
|
||||
|
||||
info, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "publish", Keep: 3, Now: base})
|
||||
if err != nil {
|
||||
t.Fatalf("备份: %v", err)
|
||||
}
|
||||
if info.Size == 0 || info.SHA256 == "" {
|
||||
t.Errorf("备份信息不完整: %+v", info)
|
||||
}
|
||||
if info.Reason != "publish" {
|
||||
t.Errorf("备份原因 = %q,期望 publish", info.Reason)
|
||||
}
|
||||
if _, err := os.Stat(info.Path); err != nil {
|
||||
t.Fatalf("备份文件不存在: %v", err)
|
||||
}
|
||||
|
||||
// 快照必须可打开,且数据与源库一致。
|
||||
bdb := openSQLite(t, info.Path)
|
||||
if got := countRows(t, bdb, "teams"); got != 2 {
|
||||
t.Errorf("备份内 teams = %d,期望 2", got)
|
||||
}
|
||||
if got := countRows(t, bdb, "score_ledger"); got != 2 {
|
||||
t.Errorf("备份内 score_ledger = %d,期望 2", got)
|
||||
}
|
||||
_ = bdb.Close()
|
||||
|
||||
// 再生成 5 份,Keep=3 应只留最近 3 份。
|
||||
for i := 1; i <= 5; i++ {
|
||||
if _, err := Backup(db, BackupOptions{
|
||||
Dir: dir, Prefix: "event", Reason: "auto", Keep: 3,
|
||||
Now: base.Add(time.Duration(i) * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("第 %d 次备份: %v", i, err)
|
||||
}
|
||||
}
|
||||
list, err := ListBackups(dir, "event")
|
||||
if err != nil {
|
||||
t.Fatalf("列出备份: %v", err)
|
||||
}
|
||||
if len(list) != 3 {
|
||||
t.Fatalf("保留份数 = %d,期望 3", len(list))
|
||||
}
|
||||
if list[0].Reason != "auto" {
|
||||
t.Errorf("最新备份原因 = %q,期望 auto", list[0].Reason)
|
||||
}
|
||||
|
||||
latest, ok, err := LatestBackup(dir, "event")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("最近备份: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !latest.CreatedAt.Equal(base.Add(5 * time.Minute)) {
|
||||
t.Errorf("最近备份时间 = %s,期望 %s", latest.CreatedAt, base.Add(5*time.Minute))
|
||||
}
|
||||
|
||||
if _, err := PruneBackups(dir, "event", 1); err != nil {
|
||||
t.Fatalf("手动清理: %v", err)
|
||||
}
|
||||
list, err = ListBackups(dir, "event")
|
||||
if err != nil {
|
||||
t.Fatalf("清理后列出备份: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Errorf("清理后份数 = %d,期望 1", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupBeforePublishAndUniqueNames(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
dir := t.TempDir()
|
||||
base := time.Date(2026, 9, 9, 11, 0, 0, 0, time.Local)
|
||||
|
||||
a, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Now: base, SkipPrune: true})
|
||||
if err != nil {
|
||||
t.Fatalf("备份 A: %v", err)
|
||||
}
|
||||
b, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Now: base, SkipPrune: true})
|
||||
if err != nil {
|
||||
t.Fatalf("备份 B: %v", err)
|
||||
}
|
||||
if a.Path == b.Path {
|
||||
t.Errorf("同一秒的两份备份重名: %s", a.Path)
|
||||
}
|
||||
|
||||
info, err := BackupBeforePublish(db, BackupOptions{Dir: dir, Prefix: "event", Keep: 20, Now: base.Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatalf("发布前备份: %v", err)
|
||||
}
|
||||
if info.Reason != "publish" {
|
||||
t.Errorf("发布前备份原因 = %q,期望 publish", info.Reason)
|
||||
}
|
||||
if filepath.Dir(info.Path) != dir {
|
||||
t.Errorf("备份目录 = %q,期望 %q", filepath.Dir(info.Path), dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBackupsMissingDir(t *testing.T) {
|
||||
list, err := ListBackups(filepath.Join(t.TempDir(), "不存在"), "event")
|
||||
if err != nil {
|
||||
t.Fatalf("目录不存在不应报错: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Errorf("备份数 = %d,期望 0", len(list))
|
||||
}
|
||||
if _, ok, err := LatestBackup(filepath.Join(t.TempDir(), "不存在"), "event"); err != nil || ok {
|
||||
t.Errorf("空目录的最近备份: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package export 负责成绩导出(xlsx)、赛事项目包(zip)与数据库备份。
|
||||
//
|
||||
// 本包只做三件事:把数据整理成可直接打印的表格、把整场赛事打包/解包、把库
|
||||
// 备份成一致性快照。所有业务数值(积分、名次、奖牌)都来自调用方传入的
|
||||
// domain 结构体或赛事库本身,这里不重新实现任何计分规则——规则只有一份,
|
||||
// 在 internal/domain。
|
||||
//
|
||||
// 依赖方向:export → domain(只读类型)与 database/sql;domain 不认识 export。
|
||||
package export
|
||||
|
||||
import "errors"
|
||||
|
||||
// 导出的哨兵错误,调用方据此把失败原因映射成 HTTP 状态码或界面提示。
|
||||
var (
|
||||
// ErrNoDatabase 表示调用方传入了空的数据库连接。
|
||||
ErrNoDatabase = errors.New("export: 数据库连接为空")
|
||||
// ErrInvalidPackage 表示赛事项目包结构非法(缺 meta.yaml、路径穿越、校验信息缺失等)。
|
||||
ErrInvalidPackage = errors.New("export: 赛事项目包非法")
|
||||
// ErrUnsupportedVersion 表示包内 meta.yaml 的格式版本高于当前程序能读的版本。
|
||||
ErrUnsupportedVersion = errors.New("export: 赛事项目包格式版本不受支持")
|
||||
// ErrChecksumMismatch 表示包内文件与 meta.yaml 记录的校验和不一致。
|
||||
ErrChecksumMismatch = errors.New("export: 赛事项目包校验和不匹配")
|
||||
// ErrProjectExists 表示目标目录已有同名赛事项目,且调用方未选择覆盖。
|
||||
ErrProjectExists = errors.New("export: 目标赛事项目已存在")
|
||||
// ErrUnsafeName 表示赛事项目名含有路径分隔符、保留名等不安全的字符。
|
||||
ErrUnsafeName = errors.New("export: 赛事项目名非法")
|
||||
// ErrTeamNotFound 表示导出单位积分明细时找不到该单位。
|
||||
ErrTeamNotFound = errors.New("export: 单位不存在")
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// EventResultRow 是分项成绩表的一行。
|
||||
// Rank 用字符串是为了承载「1(并列)」这种展示值。
|
||||
type EventResultRow struct {
|
||||
Rank string
|
||||
Bib string
|
||||
TeamName string
|
||||
Participant string
|
||||
ResultText string
|
||||
Unit string
|
||||
Points float64
|
||||
Status string
|
||||
Remark string
|
||||
}
|
||||
|
||||
// EventSheet 是一个小项的一个组别(无组别时 Category 为空)对应的一张表。
|
||||
type EventSheet struct {
|
||||
SportName string
|
||||
EventName string
|
||||
Category string
|
||||
Rows []EventResultRow
|
||||
}
|
||||
|
||||
// EventsWorkbook 是「分项成绩表」的内容:一个小项可含多个组别 sheet,
|
||||
// 也可承载全部小项(ExportAllXLSX 用)。
|
||||
type EventsWorkbook struct {
|
||||
Meta Meta
|
||||
Sheets []EventSheet
|
||||
}
|
||||
|
||||
var eventColumns = []column{
|
||||
{"名次", 8, kindCenter},
|
||||
{"号码", 9, kindCenter},
|
||||
{"单位", 22, kindText},
|
||||
{"运动员/队", 18, kindText},
|
||||
{"成绩", 12, kindCenter},
|
||||
{"积分", 8, kindNumber},
|
||||
{"状态", 8, kindCenter},
|
||||
{"备注", 20, kindText},
|
||||
}
|
||||
|
||||
// ExportEventXLSX 导出单个小项的成绩表;eventID 为 0 时导出全部小项。
|
||||
func ExportEventXLSX(db *sql.DB, eventID int64, dst string, opts Options) error {
|
||||
wb, err := LoadEventWorkbook(db, eventID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := eventFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveWorkbook(f, dst)
|
||||
}
|
||||
|
||||
// WriteEventXLSX 把分项成绩表写成 xlsx 流。
|
||||
func WriteEventXLSX(w io.Writer, wb EventsWorkbook) error {
|
||||
f, err := eventFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Write(w)
|
||||
}
|
||||
|
||||
func eventFile(wb EventsWorkbook) (*excelize.File, error) {
|
||||
return buildWorkbook("成绩表", func(f *excelize.File, st *styleSet) error {
|
||||
return writeEventSheets(f, st, wb.Meta, wb.Sheets, &sheetNamer{})
|
||||
})
|
||||
}
|
||||
|
||||
func writeEventSheets(f *excelize.File, st *styleSet, meta Meta, sheets []EventSheet, namer *sheetNamer) error {
|
||||
meta = meta.normalized()
|
||||
for _, sh := range sheets {
|
||||
name := namer.add(eventSheetBase(sh))
|
||||
if err := addSheet(f, name); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeSheet(f, st, name, meta, eventSheetTitle(meta, sh), eventColumns, eventRows(sh.Rows)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventSheetBase 是工作表名:大项·小项·组别,够短且能区分。
|
||||
func eventSheetBase(sh EventSheet) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if sh.SportName != "" {
|
||||
parts = append(parts, sh.SportName)
|
||||
}
|
||||
parts = append(parts, sh.EventName)
|
||||
if sh.Category != "" {
|
||||
parts = append(parts, sh.Category)
|
||||
}
|
||||
return strings.Join(parts, "·")
|
||||
}
|
||||
|
||||
// eventSheetTitle 是表内大标题,带赛事名。
|
||||
func eventSheetTitle(meta Meta, sh EventSheet) string {
|
||||
base := eventSheetBase(sh)
|
||||
if base == "" {
|
||||
base = "成绩表"
|
||||
}
|
||||
return sheetTitle(meta, base)
|
||||
}
|
||||
|
||||
func eventRows(rows []EventResultRow) [][]any {
|
||||
out := make([][]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, []any{
|
||||
r.Rank, r.Bib, r.TeamName, r.Participant,
|
||||
displayResult(r.ResultText, r.Unit), r.Points, r.Status, r.Remark,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// displayResult 把成绩与单位拼成「11.58 秒」;单位已含在文本里时不重复拼接。
|
||||
func displayResult(text, unit string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
unit = strings.TrimSpace(unit)
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
if unit == "" || strings.HasSuffix(text, unit) {
|
||||
return text
|
||||
}
|
||||
return text + " " + unitLabel(unit)
|
||||
}
|
||||
|
||||
func unitLabel(unit string) string {
|
||||
switch unit {
|
||||
case "s":
|
||||
return "秒"
|
||||
case "m":
|
||||
return "米"
|
||||
case "pts":
|
||||
return "分"
|
||||
case "次":
|
||||
return "次"
|
||||
default:
|
||||
return unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||||
)
|
||||
|
||||
// openEventDB 建一个临时赛事库并跑完迁移;DSN 与 store.Open 保持一致。
|
||||
func openEventDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "event.db")
|
||||
dsn := "file:" + path +
|
||||
"?_pragma=journal_mode(WAL)" +
|
||||
"&_pragma=foreign_keys(1)" +
|
||||
"&_pragma=busy_timeout(5000)"
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("打开赛事库: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("迁移赛事库: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// seedEvent 造一场最小可用赛事:2 个单位、2 名运动员、1 个小项 1 个组别、
|
||||
// 2 条已发布成绩与对应积分流水。
|
||||
func seedEvent(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
|
||||
stmts := []string{
|
||||
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`,
|
||||
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T2', '第二分公司', '二公司', 2)`,
|
||||
`INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`,
|
||||
`INSERT INTO athletes(name, gender, team_id) VALUES ('李四', '男', 2)`,
|
||||
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
|
||||
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
|
||||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 2, 2, '102')`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||
VALUES (1, 1, 1, '11.58', 11.58, 's', 'published')`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state)
|
||||
VALUES (1, 2, 2, '11.90', 11.90, 's', 'published')`,
|
||||
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
|
||||
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
|
||||
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
|
||||
VALUES (2, 2, 1, 2, 2, 'result', 2, 8)`,
|
||||
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
|
||||
`UPDATE settings SET value = '测试主办方' WHERE key = 'event.organizer'`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func countRows(t *testing.T, db *sql.DB, table string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
|
||||
t.Fatalf("统计 %s: %v", table, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// openSQLite 以只读方式打开一个已存在的库,用于校验备份 / 导入产物可读。
|
||||
func openSQLite(t *testing.T, path string) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
|
||||
if err != nil {
|
||||
t.Fatalf("打开 %s: %v", path, err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
// projectFormat 是赛事项目包的标识,导入时用来挡住「拿错文件」。
|
||||
projectFormat = "scoreboard-event-project"
|
||||
// projectFormatVersion 是当前程序能读写的包格式版本。
|
||||
projectFormatVersion = 1
|
||||
// eventDBName 是包内赛事库的固定文件名。
|
||||
eventDBName = "event.db"
|
||||
// maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。
|
||||
maxExtractBytes = 4 << 30
|
||||
)
|
||||
|
||||
// ProjectFile 描述包内一个文件的校验信息。
|
||||
type ProjectFile struct {
|
||||
Path string `yaml:"path"`
|
||||
Size int64 `yaml:"size"`
|
||||
SHA256 string `yaml:"sha256"`
|
||||
}
|
||||
|
||||
// ProjectMeta 是包内 meta.yaml 的映射;导入时先校验 format/formatVersion 与校验和。
|
||||
type ProjectMeta struct {
|
||||
Format string `yaml:"format"`
|
||||
FormatVersion int `yaml:"formatVersion"`
|
||||
Name string `yaml:"name"`
|
||||
EventName string `yaml:"eventName,omitempty"`
|
||||
Subtitle string `yaml:"subtitle,omitempty"`
|
||||
Organizer string `yaml:"organizer,omitempty"`
|
||||
CreatedAt time.Time `yaml:"createdAt"`
|
||||
CreatedBy string `yaml:"createdBy,omitempty"`
|
||||
AppVersion string `yaml:"appVersion,omitempty"`
|
||||
Database ProjectFile `yaml:"database"`
|
||||
Files []ProjectFile `yaml:"files"`
|
||||
}
|
||||
|
||||
// ProjectExportOptions 描述一次赛事包导出。
|
||||
type ProjectExportOptions struct {
|
||||
Name string // 项目名,导入后落成 data/event_projs/<Name>/
|
||||
EventName string // 留空则读赛事库 settings
|
||||
Subtitle string
|
||||
Organizer string
|
||||
CreatedBy string
|
||||
AppVersion string
|
||||
UploadsDir string // 可选:背景图 / 会标目录
|
||||
FontsDir string // 可选:字体目录
|
||||
IncludeFonts bool // 是否把字体一并打包
|
||||
RulesPath string // 可选:规则包 YAML
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// ImportOptions 描述一次赛事包导入。
|
||||
type ImportOptions struct {
|
||||
// Overwrite 为 true 时覆盖同名项目;覆盖前旧目录会先改名,避免删到一半失败。
|
||||
Overwrite bool
|
||||
// Rename 非空时用该名字作为目录名(解决「两台机器都有同名赛事」)。
|
||||
Rename string
|
||||
}
|
||||
|
||||
// ImportResult 是导入结果。
|
||||
type ImportResult struct {
|
||||
Name string `json:"name"`
|
||||
Dir string `json:"dir"`
|
||||
EventDB string `json:"eventDB"`
|
||||
Meta ProjectMeta `json:"meta"`
|
||||
}
|
||||
|
||||
// ExportProjectZip 把当前赛事库、uploads、可选字体与规则包打成一个 zip。
|
||||
// 包内 event.db 用 VACUUM INTO 生成,即使服务正在写入也是一致快照。
|
||||
func ExportProjectZip(db *sql.DB, dst string, opts ProjectExportOptions) (ProjectMeta, error) {
|
||||
if db == nil {
|
||||
return ProjectMeta{}, ErrNoDatabase
|
||||
}
|
||||
name, err := SafeProjectName(opts.Name)
|
||||
if err != nil {
|
||||
return ProjectMeta{}, err
|
||||
}
|
||||
|
||||
meta, _, err := resolveMeta(db, Meta{EventName: opts.EventName, Subtitle: opts.Subtitle, Organizer: opts.Organizer})
|
||||
if err != nil {
|
||||
return ProjectMeta{}, err
|
||||
}
|
||||
|
||||
tmp, err := os.MkdirTemp("", "scoreboard-project-*")
|
||||
if err != nil {
|
||||
return ProjectMeta{}, fmt.Errorf("export: 创建临时目录: %w", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tmp) }()
|
||||
|
||||
if err := vacuumInto(db, filepath.Join(tmp, eventDBName)); err != nil {
|
||||
return ProjectMeta{}, err
|
||||
}
|
||||
// uploads 即使为空也要保留目录,导入方才知道该往哪放资源。
|
||||
uploads := filepath.Join(tmp, "uploads")
|
||||
if err := os.MkdirAll(uploads, 0o755); err != nil {
|
||||
return ProjectMeta{}, fmt.Errorf("export: 创建 uploads 目录: %w", err)
|
||||
}
|
||||
if opts.UploadsDir != "" {
|
||||
if err := copyTree(opts.UploadsDir, uploads); err != nil {
|
||||
return ProjectMeta{}, err
|
||||
}
|
||||
}
|
||||
if opts.IncludeFonts && opts.FontsDir != "" {
|
||||
if err := copyTree(opts.FontsDir, filepath.Join(tmp, "fonts")); err != nil {
|
||||
return ProjectMeta{}, err
|
||||
}
|
||||
}
|
||||
if opts.RulesPath != "" {
|
||||
if err := copyFile(opts.RulesPath, filepath.Join(tmp, "rules.yaml")); err != nil {
|
||||
return ProjectMeta{}, err
|
||||
}
|
||||
}
|
||||
|
||||
files, err := buildManifest(tmp)
|
||||
if err != nil {
|
||||
return ProjectMeta{}, err
|
||||
}
|
||||
dbFile, ok := findFile(files, eventDBName)
|
||||
if !ok {
|
||||
return ProjectMeta{}, fmt.Errorf("%w: 缺少 %s", ErrInvalidPackage, eventDBName)
|
||||
}
|
||||
|
||||
pkg := ProjectMeta{
|
||||
Format: projectFormat,
|
||||
FormatVersion: projectFormatVersion,
|
||||
Name: name,
|
||||
EventName: meta.EventName,
|
||||
Subtitle: meta.Subtitle,
|
||||
Organizer: meta.Organizer,
|
||||
CreatedAt: nowOr(opts.Now),
|
||||
CreatedBy: opts.CreatedBy,
|
||||
AppVersion: opts.AppVersion,
|
||||
Database: dbFile,
|
||||
Files: files,
|
||||
}
|
||||
raw, err := yaml.Marshal(&pkg)
|
||||
if err != nil {
|
||||
return ProjectMeta{}, fmt.Errorf("export: 生成 meta.yaml: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmp, "meta.yaml"), raw, 0o644); err != nil {
|
||||
return ProjectMeta{}, fmt.Errorf("export: 写入 meta.yaml: %w", err)
|
||||
}
|
||||
if err := zipDir(tmp, dst); err != nil {
|
||||
return ProjectMeta{}, err
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
// InspectProjectZip 只读取包内 meta.yaml 并校验格式,不解压——导入前预览用。
|
||||
func InspectProjectZip(src string) (ProjectMeta, error) {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return ProjectMeta{}, fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
for _, f := range r.File {
|
||||
if path.Clean(strings.ReplaceAll(f.Name, "\\", "/")) != "meta.yaml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err)
|
||||
}
|
||||
defer func() { _ = rc.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(rc, 1<<20))
|
||||
if err != nil {
|
||||
return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err)
|
||||
}
|
||||
var meta ProjectMeta
|
||||
if err := yaml.Unmarshal(raw, &meta); err != nil {
|
||||
return ProjectMeta{}, fmt.Errorf("%w: 解析 meta.yaml: %v", ErrInvalidPackage, err)
|
||||
}
|
||||
if err := validateMeta(meta); err != nil {
|
||||
return ProjectMeta{}, err
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
return ProjectMeta{}, fmt.Errorf("%w: 缺少 meta.yaml", ErrInvalidPackage)
|
||||
}
|
||||
|
||||
func validateMeta(meta ProjectMeta) error {
|
||||
if meta.Format != projectFormat {
|
||||
return fmt.Errorf("%w: format=%q", ErrInvalidPackage, meta.Format)
|
||||
}
|
||||
if meta.FormatVersion < 1 || meta.FormatVersion > projectFormatVersion {
|
||||
return fmt.Errorf("%w: formatVersion=%d(本程序支持 1..%d)", ErrUnsupportedVersion, meta.FormatVersion, projectFormatVersion)
|
||||
}
|
||||
if _, err := SafeProjectName(meta.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if path.Clean(meta.Database.Path) != eventDBName {
|
||||
return fmt.Errorf("%w: database.path=%q", ErrInvalidPackage, meta.Database.Path)
|
||||
}
|
||||
if meta.Database.SHA256 == "" {
|
||||
return fmt.Errorf("%w: meta.yaml 缺少 event.db 校验和", ErrInvalidPackage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportProjectZip 校验并解压赛事包到 projRoot/<项目名>/,返回落盘位置。
|
||||
func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, error) {
|
||||
meta, err := InspectProjectZip(src)
|
||||
if err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
name := meta.Name
|
||||
if strings.TrimSpace(opts.Rename) != "" {
|
||||
name = opts.Rename
|
||||
}
|
||||
name, err = SafeProjectName(name)
|
||||
if err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(projRoot, 0o755); err != nil {
|
||||
return ImportResult{}, fmt.Errorf("export: 创建赛事项目根目录: %w", err)
|
||||
}
|
||||
tmp, err := os.MkdirTemp(projRoot, ".import-*")
|
||||
if err != nil {
|
||||
return ImportResult{}, fmt.Errorf("export: 创建导入临时目录: %w", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tmp) }()
|
||||
|
||||
if err := extractZip(src, tmp); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
if err := verifyManifest(tmp, meta); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
eventDB := filepath.Join(tmp, eventDBName)
|
||||
if err := verifySQLite(eventDB); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
|
||||
target := filepath.Join(projRoot, name)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
if !opts.Overwrite {
|
||||
return ImportResult{}, fmt.Errorf("%w: %s", ErrProjectExists, name)
|
||||
}
|
||||
stale := target + ".replaced-" + time.Now().Format("20060102-150405")
|
||||
if err := os.Rename(target, stale); err != nil {
|
||||
return ImportResult{}, fmt.Errorf("export: 暂存旧项目 %s: %w", name, err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(stale) }()
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
return ImportResult{}, fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err)
|
||||
}
|
||||
return ImportResult{
|
||||
Name: name,
|
||||
Dir: target,
|
||||
EventDB: filepath.Join(target, eventDBName),
|
||||
Meta: meta,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SafeProjectName 校验项目名:它会被当作目录名,必须挡住路径穿越与 Windows 保留名。
|
||||
func SafeProjectName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("%w: 名称为空", ErrUnsafeName)
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
|
||||
}
|
||||
if len([]rune(name)) > 64 {
|
||||
return "", fmt.Errorf("%w: 名称超过 64 个字符", ErrUnsafeName)
|
||||
}
|
||||
if strings.ContainsAny(name, `/\:*?"<>|`) || strings.ContainsRune(name, 0) {
|
||||
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
|
||||
}
|
||||
for _, r := range name {
|
||||
if r < 0x20 {
|
||||
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
|
||||
}
|
||||
}
|
||||
if filepath.IsAbs(name) {
|
||||
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
|
||||
}
|
||||
base := strings.ToUpper(strings.TrimSuffix(name, filepath.Ext(name)))
|
||||
switch base {
|
||||
case "CON", "PRN", "AUX", "NUL",
|
||||
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
|
||||
return "", fmt.Errorf("%w: %q 是系统保留名", ErrUnsafeName, name)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func vacuumInto(db *sql.DB, dst string) error {
|
||||
// VACUUM INTO 的目标是语句里的字面量,路径由本包自己生成,转义单引号即可。
|
||||
stmt := "VACUUM INTO '" + strings.ReplaceAll(dst, "'", "''") + "'"
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
return fmt.Errorf("export: VACUUM INTO %s: %w", dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildManifest(root string) ([]ProjectFile, error) {
|
||||
var out []ProjectFile
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == "meta.yaml" {
|
||||
return nil
|
||||
}
|
||||
sum, size, err := hashFile(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, ProjectFile{Path: rel, Size: size, SHA256: sum})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 生成文件清单: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func findFile(files []ProjectFile, name string) (ProjectFile, bool) {
|
||||
for _, f := range files {
|
||||
if f.Path == name {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return ProjectFile{}, false
|
||||
}
|
||||
|
||||
func hashFile(p string) (string, int64, error) {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("export: 读取 %s: %w", p, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, f)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("export: 计算 %s 校验和: %w", p, err)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), n, nil
|
||||
}
|
||||
|
||||
func copyTree(src, dst string) error {
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: 读取目录 %s: %w", src, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("export: %s 不是目录", src)
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return fmt.Errorf("export: 创建目录 %s: %w", dst, err)
|
||||
}
|
||||
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
return copyFile(p, target)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: 读取 %s: %w", src, err)
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(dst), err)
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: 创建 %s: %w", dst, err)
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return fmt.Errorf("export: 复制 %s → %s: %w", src, dst, err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return fmt.Errorf("export: 关闭 %s: %w", dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// zipDir 先写临时文件再改名,避免导出中途失败留下半截 zip。
|
||||
func zipDir(root, dst string) error {
|
||||
if dir := filepath.Dir(dst); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("export: 创建导出目录 %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
tmp := dst + ".tmp"
|
||||
out, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: 创建 %s: %w", tmp, err)
|
||||
}
|
||||
zw := zip.NewWriter(out)
|
||||
if err := writeZipTree(zw, root); err != nil {
|
||||
_ = zw.Close()
|
||||
_ = out.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
_ = out.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("export: 关闭 zip: %w", err)
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("export: 关闭 %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("export: 重命名 %s: %w", dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeZipTree(zw *zip.Writer, root string) error {
|
||||
return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
name := filepath.ToSlash(rel)
|
||||
if d.IsDir() {
|
||||
// 保留空目录(如空的 uploads/),导入方才能得到完整结构。
|
||||
_, err := zw.Create(name + "/")
|
||||
return err
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr.Name = name
|
||||
hdr.Method = zip.Deflate
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := os.Open(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("export: 读取 %s: %w", p, err)
|
||||
}
|
||||
_, copyErr := io.Copy(w, src)
|
||||
closeErr := src.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("export: 写入 zip 条目 %s: %w", name, copyErr)
|
||||
}
|
||||
return closeErr
|
||||
})
|
||||
}
|
||||
|
||||
func extractZip(src, dst string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
var total uint64
|
||||
for _, f := range r.File {
|
||||
target, err := secureJoin(dst, f.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return fmt.Errorf("export: 创建目录 %s: %w", target, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
total += f.UncompressedSize64
|
||||
if total > maxExtractBytes {
|
||||
return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(target), err)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: 读取条目 %s: %v", ErrInvalidPackage, f.Name, err)
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
_ = rc.Close()
|
||||
return fmt.Errorf("export: 写入 %s: %w", target, err)
|
||||
}
|
||||
_, copyErr := io.Copy(out, rc)
|
||||
closeOutErr := out.Close()
|
||||
closeInErr := rc.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("export: 解压 %s: %w", f.Name, copyErr)
|
||||
}
|
||||
if closeOutErr != nil {
|
||||
return fmt.Errorf("export: 关闭 %s: %w", target, closeOutErr)
|
||||
}
|
||||
if closeInErr != nil {
|
||||
return fmt.Errorf("export: 关闭条目 %s: %w", f.Name, closeInErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// secureJoin 把 zip 条目名限制在 root 之内,挡住 ../ 与绝对路径(zip slip)。
|
||||
func secureJoin(root, name string) (string, error) {
|
||||
clean := path.Clean(strings.ReplaceAll(name, "\\", "/"))
|
||||
if clean == "." {
|
||||
return root, nil
|
||||
}
|
||||
if path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name)
|
||||
}
|
||||
target := filepath.Join(root, filepath.FromSlash(clean))
|
||||
rel, err := filepath.Rel(root, target)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func verifyManifest(root string, meta ProjectMeta) error {
|
||||
if len(meta.Files) == 0 {
|
||||
return fmt.Errorf("%w: meta.yaml 缺少文件清单", ErrInvalidPackage)
|
||||
}
|
||||
for _, f := range meta.Files {
|
||||
target, err := secureJoin(root, f.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum, size, err := hashFile(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidPackage, err)
|
||||
}
|
||||
if f.Size != 0 && f.Size != size {
|
||||
return fmt.Errorf("%w: %s 大小 %d ≠ %d", ErrChecksumMismatch, f.Path, size, f.Size)
|
||||
}
|
||||
if !strings.EqualFold(sum, f.SHA256) {
|
||||
return fmt.Errorf("%w: %s", ErrChecksumMismatch, f.Path)
|
||||
}
|
||||
}
|
||||
dbFile, ok := findFile(meta.Files, eventDBName)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: 文件清单缺少 %s", ErrInvalidPackage, eventDBName)
|
||||
}
|
||||
if !strings.EqualFold(dbFile.SHA256, meta.Database.SHA256) {
|
||||
return fmt.Errorf("%w: database.sha256 与清单不一致", ErrChecksumMismatch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifySQLite 确认包内 event.db 确实是完整可读的赛事库,而不是改了后缀的其它文件。
|
||||
func verifySQLite(p string) error {
|
||||
db, err := sql.Open("sqlite", "file:"+p+"?mode=ro")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: 打开 %s: %v", ErrInvalidPackage, p, err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
var integrity string
|
||||
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&integrity); err != nil {
|
||||
return fmt.Errorf("%w: 完整性检查: %v", ErrInvalidPackage, err)
|
||||
}
|
||||
if !strings.EqualFold(integrity, "ok") {
|
||||
return fmt.Errorf("%w: 完整性检查结果 %q", ErrInvalidPackage, integrity)
|
||||
}
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&n); err != nil {
|
||||
return fmt.Errorf("%w: 读取表结构: %v", ErrInvalidPackage, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: %s 不是赛事库(缺少 schema_migrations)", ErrInvalidPackage, eventDBName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestExportImportProjectZip(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
uploads := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(uploads, "logo.txt"), []byte("logo"), 0o644); err != nil {
|
||||
t.Fatalf("写 uploads: %v", err)
|
||||
}
|
||||
rules := filepath.Join(t.TempDir(), "rules.yaml")
|
||||
if err := os.WriteFile(rules, []byte("format: scoreboard-rules\nformatVersion: 1\n"), 0o644); err != nil {
|
||||
t.Fatalf("写 rules.yaml: %v", err)
|
||||
}
|
||||
|
||||
zipPath := filepath.Join(t.TempDir(), "赛事包.zip")
|
||||
meta, err := ExportProjectZip(db, zipPath, ProjectExportOptions{
|
||||
Name: "测试赛事",
|
||||
UploadsDir: uploads,
|
||||
RulesPath: rules,
|
||||
CreatedBy: "tester",
|
||||
Now: time.Date(2026, 9, 9, 12, 0, 0, 0, time.Local),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("导出赛事包: %v", err)
|
||||
}
|
||||
if meta.Format != projectFormat || meta.FormatVersion != projectFormatVersion {
|
||||
t.Fatalf("meta 格式 = %s/%d", meta.Format, meta.FormatVersion)
|
||||
}
|
||||
if meta.EventName != "测试运动会" {
|
||||
t.Errorf("meta.eventName = %q,期望 测试运动会", meta.EventName)
|
||||
}
|
||||
for _, want := range []string{"event.db", "uploads/logo.txt", "rules.yaml", "meta.yaml"} {
|
||||
if !zipHas(zipPath, t, want) {
|
||||
t.Errorf("包内缺少 %s", want)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := InspectProjectZip(zipPath)
|
||||
if err != nil {
|
||||
t.Fatalf("预览赛事包: %v", err)
|
||||
}
|
||||
if got.Name != "测试赛事" || got.Database.Path != "event.db" || got.Database.SHA256 == "" {
|
||||
t.Fatalf("预览结果异常: %+v", got)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
res, err := ImportProjectZip(zipPath, root, ImportOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("导入赛事包: %v", err)
|
||||
}
|
||||
if res.Dir != filepath.Join(root, "测试赛事") {
|
||||
t.Errorf("导入目录 = %q", res.Dir)
|
||||
}
|
||||
if _, err := os.Stat(res.EventDB); err != nil {
|
||||
t.Fatalf("导入后 event.db 不存在: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(res.Dir, "uploads", "logo.txt")); err != nil {
|
||||
t.Errorf("导入后 uploads 资源缺失: %v", err)
|
||||
}
|
||||
assertTeams(t, res.EventDB, 2)
|
||||
|
||||
if _, err := ImportProjectZip(zipPath, root, ImportOptions{}); !errors.Is(err, ErrProjectExists) {
|
||||
t.Fatalf("同名导入应报 ErrProjectExists,实际 %v", err)
|
||||
}
|
||||
if _, err := ImportProjectZip(zipPath, root, ImportOptions{Overwrite: true}); err != nil {
|
||||
t.Fatalf("覆盖导入: %v", err)
|
||||
}
|
||||
assertTeams(t, filepath.Join(root, "测试赛事", "event.db"), 2)
|
||||
|
||||
renamed, err := ImportProjectZip(zipPath, root, ImportOptions{Rename: "另一场赛事"})
|
||||
if err != nil {
|
||||
t.Fatalf("改名导入: %v", err)
|
||||
}
|
||||
if renamed.Name != "另一场赛事" {
|
||||
t.Errorf("改名导入结果 = %q", renamed.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsBadPackages(t *testing.T) {
|
||||
base := ProjectMeta{
|
||||
Format: projectFormat,
|
||||
FormatVersion: projectFormatVersion,
|
||||
Name: "坏包",
|
||||
Database: ProjectFile{Path: "event.db", SHA256: strings.Repeat("0", 64)},
|
||||
Files: []ProjectFile{{Path: "event.db", SHA256: strings.Repeat("0", 64)}},
|
||||
}
|
||||
|
||||
t.Run("校验和不匹配", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "bad.zip")
|
||||
writeZip(t, p, map[string]string{
|
||||
"meta.yaml": marshalMeta(t, base),
|
||||
"event.db": "not a database",
|
||||
})
|
||||
_, err := ImportProjectZip(p, t.TempDir(), ImportOptions{})
|
||||
if !errors.Is(err, ErrChecksumMismatch) {
|
||||
t.Fatalf("期望 ErrChecksumMismatch,实际 %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("版本过新", func(t *testing.T) {
|
||||
meta := base
|
||||
meta.FormatVersion = 99
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "bad.zip")
|
||||
writeZip(t, p, map[string]string{"meta.yaml": marshalMeta(t, meta), "event.db": "x"})
|
||||
_, err := ImportProjectZip(p, t.TempDir(), ImportOptions{})
|
||||
if !errors.Is(err, ErrUnsupportedVersion) {
|
||||
t.Fatalf("期望 ErrUnsupportedVersion,实际 %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("路径穿越", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "bad.zip")
|
||||
writeZip(t, p, map[string]string{
|
||||
"meta.yaml": marshalMeta(t, base),
|
||||
"../evil.txt": "oops",
|
||||
"event.db": "x",
|
||||
})
|
||||
_, err := ImportProjectZip(p, t.TempDir(), ImportOptions{})
|
||||
if !errors.Is(err, ErrInvalidPackage) {
|
||||
t.Fatalf("期望 ErrInvalidPackage,实际 %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("缺 meta.yaml", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "bad.zip")
|
||||
writeZip(t, p, map[string]string{"event.db": "x"})
|
||||
if _, err := InspectProjectZip(p); !errors.Is(err, ErrInvalidPackage) {
|
||||
t.Fatalf("期望 ErrInvalidPackage,实际 %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSafeProjectName(t *testing.T) {
|
||||
bad := []string{"", "..", "../evil", "a/b", `a\b`, "CON", "lpt1.db", "a:b", "x?y"}
|
||||
for _, name := range bad {
|
||||
if _, err := SafeProjectName(name); err == nil {
|
||||
t.Errorf("SafeProjectName(%q) 应报错", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"2026 运动会", "test-event_1", "测试赛事"} {
|
||||
if _, err := SafeProjectName(name); err != nil {
|
||||
t.Errorf("SafeProjectName(%q) 不应报错: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTeams(t *testing.T, path string, want int) {
|
||||
t.Helper()
|
||||
db := openSQLite(t, path)
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
var n int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM teams`).Scan(&n); err != nil {
|
||||
t.Fatalf("查询 %s 的 teams: %v", path, err)
|
||||
}
|
||||
if n != want {
|
||||
t.Errorf("%s 的 teams 数 = %d,期望 %d", path, n, want)
|
||||
}
|
||||
}
|
||||
|
||||
func zipHas(path string, t *testing.T, name string) bool {
|
||||
t.Helper()
|
||||
r, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
t.Fatalf("打开 zip: %v", err)
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
for _, f := range r.File {
|
||||
if f.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeZip(t *testing.T, path string, files map[string]string) {
|
||||
t.Helper()
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 zip: %v", err)
|
||||
}
|
||||
zw := zip.NewWriter(f)
|
||||
for name, body := range files {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("创建条目 %s: %v", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Fatalf("写入条目 %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("关闭 zip: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("关闭文件: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func marshalMeta(t *testing.T, meta ProjectMeta) string {
|
||||
t.Helper()
|
||||
raw, err := yaml.Marshal(&meta)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化 meta: %v", err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
)
|
||||
|
||||
// StandingsWorkbook 是「总表」的内容:总积分榜 + 团体奖牌榜 + 个人积分奖牌榜。
|
||||
// 三个榜由 domain 的排名函数算出,本包只负责排版。
|
||||
type StandingsWorkbook struct {
|
||||
Meta Meta
|
||||
Team []domain.StandingsRow
|
||||
Medal []domain.MedalRow
|
||||
Athlete []domain.AthleteStandingsRow
|
||||
}
|
||||
|
||||
var (
|
||||
teamColumns = []column{
|
||||
{"名次", 6, kindInt},
|
||||
{"单位代码", 10, kindCenter},
|
||||
{"单位名称", 26, kindText},
|
||||
{"总分", 9, kindNumber},
|
||||
{"团体项目分", 12, kindNumber},
|
||||
{"个人项目分", 12, kindNumber},
|
||||
{"金牌", 7, kindInt},
|
||||
{"银牌", 7, kindInt},
|
||||
{"铜牌", 7, kindInt},
|
||||
{"奖牌合计", 10, kindInt},
|
||||
}
|
||||
medalColumns = []column{
|
||||
{"名次", 6, kindInt},
|
||||
{"单位代码", 10, kindCenter},
|
||||
{"单位名称", 26, kindText},
|
||||
{"金牌", 7, kindInt},
|
||||
{"银牌", 7, kindInt},
|
||||
{"铜牌", 7, kindInt},
|
||||
{"奖牌合计", 10, kindInt},
|
||||
{"总分", 9, kindNumber},
|
||||
}
|
||||
athleteColumns = []column{
|
||||
{"名次", 6, kindInt},
|
||||
{"运动员", 16, kindText},
|
||||
{"单位代码", 10, kindCenter},
|
||||
{"单位名称", 24, kindText},
|
||||
{"总分", 9, kindNumber},
|
||||
{"金牌", 7, kindInt},
|
||||
{"银牌", 7, kindInt},
|
||||
{"铜牌", 7, kindInt},
|
||||
{"奖牌合计", 10, kindInt},
|
||||
}
|
||||
)
|
||||
|
||||
// ExportStandingsXLSX 从赛事库读取并导出总表(三个 sheet),落盘到 dst。
|
||||
func ExportStandingsXLSX(db *sql.DB, dst string, opts Options) error {
|
||||
wb, err := LoadStandingsWorkbook(db, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := standingsFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveWorkbook(f, dst)
|
||||
}
|
||||
|
||||
// WriteStandingsXLSX 把已算好的总表写成 xlsx 流,供 HTTP 直接响应下载。
|
||||
func WriteStandingsXLSX(w io.Writer, wb StandingsWorkbook) error {
|
||||
f, err := standingsFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Write(w)
|
||||
}
|
||||
|
||||
func standingsFile(wb StandingsWorkbook) (*excelize.File, error) {
|
||||
return buildWorkbook("总积分榜", func(f *excelize.File, st *styleSet) error {
|
||||
return writeStandingsSheets(f, st, wb)
|
||||
})
|
||||
}
|
||||
|
||||
func writeStandingsSheets(f *excelize.File, st *styleSet, wb StandingsWorkbook) error {
|
||||
meta := wb.Meta.normalized()
|
||||
specs := []struct {
|
||||
sheet string
|
||||
title string
|
||||
cols []column
|
||||
rows [][]any
|
||||
}{
|
||||
{"总积分榜", sheetTitle(meta, "总积分榜"), teamColumns, teamRows(wb.Team)},
|
||||
{"团体奖牌榜", sheetTitle(meta, "团体奖牌榜"), medalColumns, medalRows(wb.Medal)},
|
||||
{"个人积分奖牌榜", sheetTitle(meta, "个人积分奖牌榜"), athleteColumns, athleteRows(wb.Athlete)},
|
||||
}
|
||||
for _, s := range specs {
|
||||
if err := addSheet(f, s.sheet); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeSheet(f, st, s.sheet, meta, s.title, s.cols, s.rows); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func teamRows(rows []domain.StandingsRow) [][]any {
|
||||
out := make([][]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, []any{
|
||||
r.Rank, r.Code, displayName(r.Name, r.ShortName), r.Points,
|
||||
r.TeamOnlyPoints, r.IndividualPoints,
|
||||
r.Gold, r.Silver, r.Bronze, r.Medals,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func medalRows(rows []domain.MedalRow) [][]any {
|
||||
out := make([][]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, []any{
|
||||
r.Rank, r.Code, displayName(r.Name, r.ShortName),
|
||||
r.Gold, r.Silver, r.Bronze, r.Total, r.Points,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func athleteRows(rows []domain.AthleteStandingsRow) [][]any {
|
||||
out := make([][]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, []any{
|
||||
r.Rank, r.Name, r.TeamCode, displayName(r.TeamName, ""),
|
||||
r.Points, r.Gold, r.Silver, r.Bronze, r.Medals,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// displayName 优先用全称,其次简称;都为空时留空,不显示 "0" 之类的噪声。
|
||||
func displayName(name, short string) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return short
|
||||
}
|
||||
|
||||
// AllWorkbook 是「全部」导出:总表 + 全部分项成绩。
|
||||
type AllWorkbook struct {
|
||||
Standings StandingsWorkbook
|
||||
Events EventsWorkbook
|
||||
}
|
||||
|
||||
// ExportAllXLSX 导出总表 + 全部分项成绩到一个多 sheet 工作簿。
|
||||
func ExportAllXLSX(db *sql.DB, dst string, opts Options) error {
|
||||
wb, err := LoadAllWorkbook(db, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := allFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveWorkbook(f, dst)
|
||||
}
|
||||
|
||||
// WriteAllXLSX 把「全部」写成 xlsx 流。
|
||||
func WriteAllXLSX(w io.Writer, wb AllWorkbook) error {
|
||||
f, err := allFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Write(w)
|
||||
}
|
||||
|
||||
// LoadAllWorkbook 一次性读取总表与全部分项成绩。
|
||||
func LoadAllWorkbook(db *sql.DB, opts Options) (AllWorkbook, error) {
|
||||
standings, err := LoadStandingsWorkbook(db, opts)
|
||||
if err != nil {
|
||||
return AllWorkbook{}, err
|
||||
}
|
||||
events, err := LoadEventWorkbook(db, 0, opts)
|
||||
if err != nil {
|
||||
return AllWorkbook{}, err
|
||||
}
|
||||
return AllWorkbook{Standings: standings, Events: events}, nil
|
||||
}
|
||||
|
||||
func allFile(wb AllWorkbook) (*excelize.File, error) {
|
||||
return buildWorkbook("总积分榜", func(f *excelize.File, st *styleSet) error {
|
||||
if err := writeStandingsSheets(f, st, wb.Standings); err != nil {
|
||||
return err
|
||||
}
|
||||
namer := &sheetNamer{}
|
||||
// 三个榜先登记名称,分项表才不会撞名。
|
||||
for _, n := range []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"} {
|
||||
namer.add(n)
|
||||
}
|
||||
return writeEventSheets(f, st, wb.Events.Meta, wb.Events.Sheets, namer)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// 配色跟随管理端 MIUIx 令牌,导出件与界面观感一致;字体用 Windows 自带的微软雅黑,
|
||||
// 现场机器不需要额外装字体就能正确显示。
|
||||
const (
|
||||
colorPrimary = "3482FF"
|
||||
colorHeaderText = "FFFFFF"
|
||||
colorBorder = "D0D7E5"
|
||||
colorBand = "F5F8FF"
|
||||
colorMuted = "6B7280"
|
||||
fontFamily = "微软雅黑"
|
||||
)
|
||||
|
||||
// 每张表的固定版式:1 标题 / 2 副标题 / 3 空行 / 4 表头 / 5 起为数据。
|
||||
const (
|
||||
rowTitle = 1
|
||||
rowSubtitle = 2
|
||||
rowHeader = 4
|
||||
)
|
||||
|
||||
// maxSheetName 是 Excel 对工作表名的硬限制。
|
||||
const maxSheetName = 31
|
||||
|
||||
// cellKind 决定单元格的对齐方式与数字格式。
|
||||
type cellKind int
|
||||
|
||||
const (
|
||||
kindText cellKind = iota
|
||||
kindCenter
|
||||
kindNumber
|
||||
kindInt
|
||||
)
|
||||
|
||||
// column 是一列的展示定义;宽度按内容预留,导出即可打印,不用再手工调列宽。
|
||||
type column struct {
|
||||
title string
|
||||
width float64
|
||||
kind cellKind
|
||||
}
|
||||
|
||||
type dataKey struct {
|
||||
kind cellKind
|
||||
band bool
|
||||
}
|
||||
|
||||
// styleSet 是一份工作簿内复用的样式集合;excelize 的样式 id 与工作簿绑定,
|
||||
// 因此每打开一份新工作簿都要重建一次。
|
||||
type styleSet struct {
|
||||
title int
|
||||
subtitle int
|
||||
header int
|
||||
data map[dataKey]int
|
||||
}
|
||||
|
||||
func newStyleSet(f *excelize.File) (*styleSet, error) {
|
||||
border := []excelize.Border{
|
||||
{Type: "left", Color: colorBorder, Style: 1},
|
||||
{Type: "right", Color: colorBorder, Style: 1},
|
||||
{Type: "top", Color: colorBorder, Style: 1},
|
||||
{Type: "bottom", Color: colorBorder, Style: 1},
|
||||
}
|
||||
|
||||
st := &styleSet{data: make(map[dataKey]int, 8)}
|
||||
var err error
|
||||
if st.title, err = f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Size: 16, Family: fontFamily},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("export: 创建标题样式: %w", err)
|
||||
}
|
||||
if st.subtitle, err = f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Size: 10, Family: fontFamily, Color: colorMuted},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("export: 创建副标题样式: %w", err)
|
||||
}
|
||||
if st.header, err = f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Size: 11, Family: fontFamily, Color: colorHeaderText},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{colorPrimary}},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
|
||||
Border: border,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("export: 创建表头样式: %w", err)
|
||||
}
|
||||
|
||||
for _, kind := range []cellKind{kindText, kindCenter, kindNumber, kindInt} {
|
||||
for _, band := range []bool{false, true} {
|
||||
id, err := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Size: 11, Family: fontFamily},
|
||||
Alignment: &excelize.Alignment{Horizontal: horizontal(kind), Vertical: "center", WrapText: kind == kindText},
|
||||
Border: border,
|
||||
Fill: fill(band),
|
||||
CustomNumFmt: numFmt(kind),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 创建数据样式: %w", err)
|
||||
}
|
||||
st.data[dataKey{kind: kind, band: band}] = id
|
||||
}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func horizontal(kind cellKind) string {
|
||||
if kind == kindText {
|
||||
return "left"
|
||||
}
|
||||
return "center"
|
||||
}
|
||||
|
||||
func fill(band bool) excelize.Fill {
|
||||
if !band {
|
||||
return excelize.Fill{}
|
||||
}
|
||||
return excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{colorBand}}
|
||||
}
|
||||
|
||||
// numFmt 让积分最多保留两位小数、不显示多余的 0;整数列固定不带小数。
|
||||
func numFmt(kind cellKind) *string {
|
||||
switch kind {
|
||||
case kindNumber:
|
||||
return strPtr("0.##")
|
||||
case kindInt:
|
||||
return strPtr("0")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *styleSet) cell(kind cellKind, band bool) int {
|
||||
return s.data[dataKey{kind: kind, band: band}]
|
||||
}
|
||||
|
||||
// buildWorkbook 建好工作簿与样式后交给 build 填充。
|
||||
// build 若不新建任何工作表,就把默认工作表改名为 defaultSheet,避免出现空白的 "Sheet1"。
|
||||
func buildWorkbook(defaultSheet string, build func(f *excelize.File, st *styleSet) error) (*excelize.File, error) {
|
||||
f := excelize.NewFile()
|
||||
st, err := newStyleSet(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := build(f, st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sheets := f.GetSheetList()
|
||||
if len(sheets) == 1 && sheets[0] == "Sheet1" {
|
||||
if err := f.SetSheetName("Sheet1", sanitizeSheetName(defaultSheet)); err != nil {
|
||||
return nil, fmt.Errorf("export: 重命名工作表: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
if idx, err := f.GetSheetIndex("Sheet1"); err == nil && idx >= 0 {
|
||||
_ = f.DeleteSheet("Sheet1")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func addSheet(f *excelize.File, name string) error {
|
||||
if _, err := f.NewSheet(name); err != nil {
|
||||
return fmt.Errorf("export: 新建工作表 %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeSheet 按统一版式写一张表:合并标题、副标题、带底色的表头、斑马纹数据行、
|
||||
// 冻结表头,并预设 A4 横向、按宽度缩放、每页重复表头,导出后可直接打印。
|
||||
func writeSheet(f *excelize.File, st *styleSet, sheet string, meta Meta, title string, cols []column, rows [][]any) error {
|
||||
if len(cols) == 0 {
|
||||
return fmt.Errorf("export: 工作表 %s 没有列定义", sheet)
|
||||
}
|
||||
lastCol := colName(len(cols))
|
||||
|
||||
if err := f.SetCellValue(sheet, "A1", title); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.MergeCell(sheet, "A1", lastCol+"1"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetCellStyle(sheet, "A1", lastCol+"1", st.title); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetRowHeight(sheet, rowTitle, 32); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sub := metaSubtitle(meta); sub != "" {
|
||||
if err := f.SetCellValue(sheet, "A2", sub); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.MergeCell(sheet, "A2", lastCol+"2"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetCellStyle(sheet, "A2", lastCol+"2", st.subtitle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for i, c := range cols {
|
||||
name := colName(i + 1)
|
||||
if err := f.SetCellValue(sheet, name+strconv.Itoa(rowHeader), c.title); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetColWidth(sheet, name, name, c.width); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := f.SetCellStyle(sheet, "A"+strconv.Itoa(rowHeader), lastCol+strconv.Itoa(rowHeader), st.header); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetRowHeight(sheet, rowHeader, 26); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for r, row := range rows {
|
||||
excelRow := rowHeader + 1 + r
|
||||
for i, c := range cols {
|
||||
var v any = ""
|
||||
if i < len(row) && row[i] != nil {
|
||||
v = row[i]
|
||||
}
|
||||
cell := colName(i+1) + strconv.Itoa(excelRow)
|
||||
if err := f.SetCellValue(sheet, cell, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetCellStyle(sheet, cell, cell, st.cell(c.kind, r%2 == 1)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.SetPanes(sheet, &excelize.Panes{
|
||||
Freeze: true,
|
||||
YSplit: rowHeader,
|
||||
TopLeftCell: "A" + strconv.Itoa(rowHeader+1),
|
||||
ActivePane: "bottomLeft",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetPageLayout(sheet, &excelize.PageLayoutOptions{
|
||||
Size: intPtr(9), // 9 = A4
|
||||
Orientation: strPtr("landscape"),
|
||||
FitToWidth: intPtr(1),
|
||||
FitToHeight: intPtr(0),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// 打印标题与页码是「锦上添花」,失败不影响文件可用性,故忽略错误。
|
||||
_ = f.SetDefinedName(&excelize.DefinedName{
|
||||
Name: "_xlnm.Print_Titles",
|
||||
RefersTo: fmt.Sprintf("'%s'!$1:$%d", sheet, rowHeader),
|
||||
Scope: sheet,
|
||||
})
|
||||
_ = f.SetHeaderFooter(sheet, &excelize.HeaderFooterOptions{
|
||||
OddHeader: "&C" + title,
|
||||
OddFooter: "&C第 &P 页 / 共 &N 页",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveWorkbook 落盘;目录不存在时自动创建,避免调用方先手工 mkdir。
|
||||
func saveWorkbook(f *excelize.File, dst string) error {
|
||||
if strings.TrimSpace(dst) == "" {
|
||||
return errors.New("export: 导出路径为空")
|
||||
}
|
||||
if dir := filepath.Dir(dst); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("export: 创建导出目录 %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
if err := f.SaveAs(dst); err != nil {
|
||||
return fmt.Errorf("export: 保存 xlsx %s: %w", dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sheetTitle 把赛事名拼在表名前面,打印出来一眼能看出是哪场赛事。
|
||||
func sheetTitle(meta Meta, name string) string {
|
||||
if meta.EventName == "" {
|
||||
return name
|
||||
}
|
||||
return meta.EventName + " · " + name
|
||||
}
|
||||
|
||||
func metaSubtitle(m Meta) string {
|
||||
parts := make([]string, 0, 4)
|
||||
if m.Organizer != "" {
|
||||
parts = append(parts, m.Organizer)
|
||||
}
|
||||
if m.Subtitle != "" {
|
||||
parts = append(parts, m.Subtitle)
|
||||
}
|
||||
if !m.GeneratedAt.IsZero() {
|
||||
parts = append(parts, "导出时间 "+m.GeneratedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
if m.GeneratedBy != "" {
|
||||
parts = append(parts, "导出人 "+m.GeneratedBy)
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// sanitizeSheetName 替换 Excel 不允许的字符并截断到 31 字符。
|
||||
func sanitizeSheetName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
name = "成绩"
|
||||
}
|
||||
name = strings.NewReplacer(
|
||||
"[", "(", "]", ")", ":", ":", "*", "*",
|
||||
"?", "?", "/", "/", "\\", "\",
|
||||
).Replace(name)
|
||||
if rs := []rune(name); len(rs) > maxSheetName {
|
||||
name = string(rs[:maxSheetName])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// sheetNamer 保证同一工作簿内的工作表名唯一(Excel 不允许重名)。
|
||||
type sheetNamer struct {
|
||||
used map[string]bool
|
||||
}
|
||||
|
||||
func (n *sheetNamer) add(base string) string {
|
||||
base = sanitizeSheetName(base)
|
||||
if n.used == nil {
|
||||
n.used = make(map[string]bool)
|
||||
}
|
||||
name := base
|
||||
for i := 2; n.used[name]; i++ {
|
||||
suffix := fmt.Sprintf("(%d)", i)
|
||||
rs := []rune(base)
|
||||
if keep := maxSheetName - len([]rune(suffix)); len(rs) > keep {
|
||||
rs = rs[:keep]
|
||||
}
|
||||
name = string(rs) + suffix
|
||||
}
|
||||
n.used[name] = true
|
||||
return name
|
||||
}
|
||||
|
||||
func colName(n int) string {
|
||||
name, _ := excelize.ColumnNumberToName(n)
|
||||
return name
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
func strPtr(v string) *string { return &v }
|
||||
|
||||
// nowOr 让测试可以注入固定时间;零值取当前时间。
|
||||
func nowOr(t time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
return time.Now()
|
||||
}
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
)
|
||||
|
||||
// TeamDetailRow 是单位积分明细的一行:一分一行的得分来源。
|
||||
type TeamDetailRow struct {
|
||||
SportName string
|
||||
EventName string
|
||||
Category string
|
||||
AthleteName string
|
||||
Rank int
|
||||
ResultText string
|
||||
Source string
|
||||
Points float64
|
||||
Remark string
|
||||
}
|
||||
|
||||
// TeamDetailWorkbook 是「单位积分明细」的内容。
|
||||
type TeamDetailWorkbook struct {
|
||||
Meta Meta
|
||||
Team domain.Team
|
||||
Rows []TeamDetailRow
|
||||
}
|
||||
|
||||
var teamDetailColumns = []column{
|
||||
{"序号", 6, kindInt},
|
||||
{"大项", 14, kindText},
|
||||
{"小项", 18, kindText},
|
||||
{"组别", 14, kindText},
|
||||
{"运动员", 14, kindText},
|
||||
{"名次", 7, kindCenter},
|
||||
{"成绩", 12, kindCenter},
|
||||
{"来源", 12, kindCenter},
|
||||
{"积分", 8, kindNumber},
|
||||
{"备注", 20, kindText},
|
||||
}
|
||||
|
||||
// ExportTeamDetailXLSX 导出某单位的积分明细(逐项列出得分来源)。
|
||||
func ExportTeamDetailXLSX(db *sql.DB, teamID int64, dst string, opts Options) error {
|
||||
wb, err := LoadTeamDetailWorkbook(db, teamID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := teamDetailFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveWorkbook(f, dst)
|
||||
}
|
||||
|
||||
// WriteTeamDetailXLSX 把单位积分明细写成 xlsx 流。
|
||||
func WriteTeamDetailXLSX(w io.Writer, wb TeamDetailWorkbook) error {
|
||||
f, err := teamDetailFile(wb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Write(w)
|
||||
}
|
||||
|
||||
func teamDetailFile(wb TeamDetailWorkbook) (*excelize.File, error) {
|
||||
return buildWorkbook("积分明细", func(f *excelize.File, st *styleSet) error {
|
||||
meta := wb.Meta.normalized()
|
||||
name := sanitizeSheetName(displayName(wb.Team.Name, wb.Team.ShortName) + "积分明细")
|
||||
if err := addSheet(f, name); err != nil {
|
||||
return err
|
||||
}
|
||||
title := sheetTitle(meta, displayName(wb.Team.Name, wb.Team.ShortName)+" · 积分明细")
|
||||
return writeSheet(f, st, name, meta, title, teamDetailColumns, teamDetailRows(wb.Rows))
|
||||
})
|
||||
}
|
||||
|
||||
func teamDetailRows(rows []TeamDetailRow) [][]any {
|
||||
out := make([][]any, 0, len(rows)+1)
|
||||
var total float64
|
||||
for i, r := range rows {
|
||||
total += r.Points
|
||||
rank := ""
|
||||
if r.Rank > 0 {
|
||||
rank = strconv.Itoa(r.Rank)
|
||||
}
|
||||
out = append(out, []any{
|
||||
i + 1, r.SportName, r.EventName, r.Category, r.AthleteName,
|
||||
rank, r.ResultText, r.Source, r.Points, r.Remark,
|
||||
})
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
out = append(out, []any{"合计", "", "", "", "", "", "", "", total, ""})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestExportStandingsXLSX(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "总表.xlsx")
|
||||
if err := ExportStandingsXLSX(db, dst, Options{}); err != nil {
|
||||
t.Fatalf("导出总表: %v", err)
|
||||
}
|
||||
|
||||
f, err := excelize.OpenFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("重新打开 xlsx: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}
|
||||
if got := f.GetSheetList(); !equalStrings(got, want) {
|
||||
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
|
||||
}
|
||||
|
||||
// 版式固定:1 标题 / 2 副标题 / 3 空行 / 4 表头 / 5 起数据;2 个单位 → 6 行。
|
||||
team, err := f.GetRows("总积分榜")
|
||||
if err != nil {
|
||||
t.Fatalf("读取总积分榜: %v", err)
|
||||
}
|
||||
if len(team) != 6 {
|
||||
t.Fatalf("总积分榜行数 = %d,期望 6", len(team))
|
||||
}
|
||||
if got := cell(team, 0, 0); got != "测试运动会 · 总积分榜" {
|
||||
t.Errorf("标题 = %q", got)
|
||||
}
|
||||
if got := cell(team, 3, 0); got != "名次" {
|
||||
t.Errorf("表头首列 = %q,期望 名次", got)
|
||||
}
|
||||
if got := cell(team, 4, 1); got != "T1" {
|
||||
t.Errorf("第一名单位代码 = %q,期望 T1", got)
|
||||
}
|
||||
if got := cell(team, 4, 3); got != "10" {
|
||||
t.Errorf("T1 总分 = %q,期望 10", got)
|
||||
}
|
||||
if got := cell(team, 4, 6); got != "1" {
|
||||
t.Errorf("T1 金牌 = %q,期望 1", got)
|
||||
}
|
||||
if got := cell(team, 5, 1); got != "T2" {
|
||||
t.Errorf("第二名单位代码 = %q,期望 T2", got)
|
||||
}
|
||||
|
||||
medal, err := f.GetRows("团体奖牌榜")
|
||||
if err != nil {
|
||||
t.Fatalf("读取团体奖牌榜: %v", err)
|
||||
}
|
||||
if len(medal) != 6 {
|
||||
t.Fatalf("团体奖牌榜行数 = %d,期望 6", len(medal))
|
||||
}
|
||||
if got := cell(medal, 4, 3); got != "1" { // T1 金牌
|
||||
t.Errorf("T1 奖牌榜金牌 = %q,期望 1", got)
|
||||
}
|
||||
|
||||
athlete, err := f.GetRows("个人积分奖牌榜")
|
||||
if err != nil {
|
||||
t.Fatalf("读取个人积分奖牌榜: %v", err)
|
||||
}
|
||||
if len(athlete) != 6 {
|
||||
t.Fatalf("个人积分奖牌榜行数 = %d,期望 6", len(athlete))
|
||||
}
|
||||
if got := cell(athlete, 4, 1); got != "张三" {
|
||||
t.Errorf("个人榜第一行 = %q,期望 张三", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteStandingsXLSXStream(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取总表数据: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteStandingsXLSX(&buf, wb); err != nil {
|
||||
t.Fatalf("流式导出: %v", err)
|
||||
}
|
||||
f, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("打开流式导出的 xlsx: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if got := len(f.GetSheetList()); got != 3 {
|
||||
t.Errorf("sheet 数 = %d,期望 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportEventXLSX(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "分项.xlsx")
|
||||
if err := ExportEventXLSX(db, 1, dst, Options{}); err != nil {
|
||||
t.Fatalf("导出分项成绩表: %v", err)
|
||||
}
|
||||
f, err := excelize.OpenFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("重新打开 xlsx: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
want := []string{"田径·100米跑·男子"}
|
||||
if got := f.GetSheetList(); !equalStrings(got, want) {
|
||||
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
|
||||
}
|
||||
rows, err := f.GetRows(want[0])
|
||||
if err != nil {
|
||||
t.Fatalf("读取分项成绩: %v", err)
|
||||
}
|
||||
if len(rows) != 6 {
|
||||
t.Fatalf("分项成绩行数 = %d,期望 6", len(rows))
|
||||
}
|
||||
if got := cell(rows, 4, 0); got != "1" {
|
||||
t.Errorf("第一名名次 = %q,期望 1", got)
|
||||
}
|
||||
if got := cell(rows, 4, 1); got != "101" {
|
||||
t.Errorf("第一名号码 = %q,期望 101", got)
|
||||
}
|
||||
if got := cell(rows, 4, 3); got != "张三" {
|
||||
t.Errorf("第一名运动员 = %q,期望 张三", got)
|
||||
}
|
||||
if got := cell(rows, 4, 4); got != "11.58 秒" {
|
||||
t.Errorf("第一名成绩 = %q,期望 11.58 秒", got)
|
||||
}
|
||||
if got := cell(rows, 4, 5); got != "10" {
|
||||
t.Errorf("第一名积分 = %q,期望 10", got)
|
||||
}
|
||||
if got := cell(rows, 4, 6); got != "有效" {
|
||||
t.Errorf("第一名状态 = %q,期望 有效", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportAllXLSX(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "全部.xlsx")
|
||||
if err := ExportAllXLSX(db, dst, Options{}); err != nil {
|
||||
t.Fatalf("导出全部: %v", err)
|
||||
}
|
||||
f, err := excelize.OpenFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("重新打开 xlsx: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
want := []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}
|
||||
if got := f.GetSheetList(); !equalStrings(got, want) {
|
||||
t.Fatalf("sheet 列表 = %v,期望 %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportTeamDetailXLSX(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
dst := filepath.Join(t.TempDir(), "明细.xlsx")
|
||||
if err := ExportTeamDetailXLSX(db, 1, dst, Options{}); err != nil {
|
||||
t.Fatalf("导出单位积分明细: %v", err)
|
||||
}
|
||||
f, err := excelize.OpenFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("重新打开 xlsx: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
sheet := "第一分公司积分明细"
|
||||
rows, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s: %v", sheet, err)
|
||||
}
|
||||
// 4 行版式 + 1 条明细 + 1 行合计
|
||||
if len(rows) != 6 {
|
||||
t.Fatalf("%s 行数 = %d,期望 6", sheet, len(rows))
|
||||
}
|
||||
if got := cell(rows, 4, 1); got != "田径" {
|
||||
t.Errorf("明细大项 = %q,期望 田径", got)
|
||||
}
|
||||
if got := cell(rows, 4, 5); got != "1" {
|
||||
t.Errorf("明细名次 = %q,期望 1", got)
|
||||
}
|
||||
if got := cell(rows, 4, 7); got != "成绩" {
|
||||
t.Errorf("明细来源 = %q,期望 成绩", got)
|
||||
}
|
||||
if got := cell(rows, 4, 8); got != "10" {
|
||||
t.Errorf("明细积分 = %q,期望 10", got)
|
||||
}
|
||||
if got := cell(rows, 5, 0); got != "合计" {
|
||||
t.Errorf("合计行首列 = %q,期望 合计", got)
|
||||
}
|
||||
if got := cell(rows, 5, 8); got != "10" {
|
||||
t.Errorf("合计积分 = %q,期望 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadStandingsHonoursSettings 验证个人分折算系数确实来自赛事库设置。
|
||||
func TestLoadStandingsHonoursSettings(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
if _, err := db.Exec(`UPDATE settings SET value = '2' WHERE key = 'standings.individual_to_team_weight'`); err != nil {
|
||||
t.Fatalf("修改折算系数: %v", err)
|
||||
}
|
||||
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取总表数据: %v", err)
|
||||
}
|
||||
if len(wb.Team) != 2 {
|
||||
t.Fatalf("单位数 = %d,期望 2", len(wb.Team))
|
||||
}
|
||||
if wb.Team[0].Points != 20 {
|
||||
t.Errorf("系数 2 时 T1 总分 = %v,期望 20", wb.Team[0].Points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEventWorkbookMissingEvent(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
if _, err := LoadEventWorkbook(db, 999, Options{}); err == nil {
|
||||
t.Fatal("导出不存在的小项应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func cell(rows [][]string, r, c int) string {
|
||||
if r < 0 || r >= len(rows) {
|
||||
return ""
|
||||
}
|
||||
if c < 0 || c >= len(rows[r]) {
|
||||
return ""
|
||||
}
|
||||
return rows[r][c]
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user