用户答复「允许细化」——给另外两个榜单各做一份逐项来源导出(此前它们的「明细」共用 单位积分明细、个人榜连明细列都没有)。 - `GET /api/export/medals.xlsx` → `团体奖牌明细.xlsx`:总表 单位/大项/小项/组别/名次/奖牌/运动员-队 + 每个有奖牌的单位一张分表(末尾小计 金X/银Y/铜Z);无奖牌的单位不出分表。 去重与牌色判定**完全交给 domain**(每组调 domain.BuildMedalStandings),导出侧只做 「同一报名项归一组」,不复制任何计奖规则——双计通道只记一枚奖牌因此自动成立。 - `GET /api/export/athletes.xlsx` → `个人积分总明细.xlsx`:总表 单位/运动员/大项/小项/组别/ 名次/成绩/得分/来源 + 每个有得分的单位一张分表(按单位分,不按人分)。 得分取**记到个人的原始分**(与个人积分榜同一口径,不看 count_to_team、不乘折算系数)。 - 管理端三个榜单的卡片头各一个导出入口(导出总明细 / 导出奖牌明细 / 导出个人明细), 展开单位时明细卡头是「导出该单位明细」;导出页仍是 5 项,两个新导出只在积分榜页触发。 护栏(跨端点一致性,都有实测断言): - 奖牌明细逐单位与「团体奖牌榜」对齐,双计项目只出一行; - 个人明细合计与「个人积分榜」一致,且把折算系数改成 0.5 后个人明细一分不变、单位总分变 5。 测试:服务端 12 包全绿(358 个测试函数);前端 vue-tsc 通过、190 例全过;Console 18 文件。 docs/ 与 devdocs/ 同步(FAQ 到 Q72,含上一轮"个人积分榜不能导出"的修正)。
307 lines
11 KiB
Go
307 lines
11 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
|
||
)
|
||
|
||
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||
|
||
// maxProjectImportBytes 是赛事包上传的硬上限,避免超大请求把内存和磁盘撑爆。
|
||
const maxProjectImportBytes = 512 << 20
|
||
|
||
// exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。
|
||
func (s *server) exportRoutes(mux *http.ServeMux) {
|
||
mux.HandleFunc("GET /api/export/standings.xlsx", s.read(auth.RoleGuest, s.exportStandingsXLSX))
|
||
mux.HandleFunc("GET /api/export/all.xlsx", s.read(auth.RoleGuest, s.exportAllXLSX))
|
||
mux.HandleFunc("GET /api/export/all-flat.xlsx", s.read(auth.RoleGuest, s.exportAllFlatXLSX))
|
||
// Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。
|
||
mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX))
|
||
// 团体奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。
|
||
mux.HandleFunc("GET /api/export/medals.xlsx", s.read(auth.RoleGuest, s.exportMedalsXLSX))
|
||
mux.HandleFunc("GET /api/export/athletes.xlsx", s.read(auth.RoleGuest, s.exportAthletesXLSX))
|
||
// teams.xlsx 与 teams/{id} 段数不同,ServeMux 不会冲突。
|
||
mux.HandleFunc("GET /api/export/teams.xlsx", s.read(auth.RoleGuest, s.exportAllTeamsXLSX))
|
||
mux.HandleFunc("GET /api/export/teams/{id}", s.read(auth.RoleGuest, s.exportTeamXLSX))
|
||
|
||
mux.HandleFunc("GET /api/export/project.zip", s.read(auth.RoleAdmin, s.exportProjectZip))
|
||
mux.HandleFunc("POST /api/export/project/import", s.write(auth.RoleAdmin, s.importProjectZip))
|
||
mux.HandleFunc("GET /api/export/backups", s.read(auth.RoleAdmin, s.listBackups))
|
||
}
|
||
|
||
// streamDownload 直接写响应体;返回 errHandled 让统一响应层不再追加 JSON。
|
||
func (s *server) streamDownload(w http.ResponseWriter, filename, contentType string, write func(io.Writer) error) (any, error) {
|
||
w.Header().Set("Content-Type", contentType)
|
||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(filename))
|
||
if err := write(w); err != nil {
|
||
s.logger.Error("导出写出失败", "file", filename, "err", err)
|
||
}
|
||
return nil, errHandled
|
||
}
|
||
|
||
func (s *server) exportStandingsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
wb, err := export.LoadStandingsWorkbook(s.ev, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "总积分榜.xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteStandingsXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
func (s *server) exportEventXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
wb, err := export.LoadEventWorkbook(s.ev, id, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "分项成绩表.xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteEventXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
func (s *server) exportAllXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
wb, err := export.LoadAllWorkbook(s.ev, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "赛事成绩总表.xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteAllXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
// exportAllFlatXLSX 导出全部成绩的单页平铺版:一个工作簿只含一张表,
|
||
// 每行 = 某个单位某个人的某个项目。与「全部」(all.xlsx,多 sheet)内容同源。
|
||
func (s *server) exportAllFlatXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
wb, err := export.LoadFlatWorkbook(s.ev, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "赛事成绩总表(单页).xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteFlatXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
// exportMedalsXLSX 导出团体奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。
|
||
func (s *server) exportMedalsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
wb, err := export.LoadMedalDetailWorkbook(s.ev, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteMedalDetailXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
// exportAthletesXLSX 导出个人得分明细:记到个人的流水逐条列出,总表 + 每个有得分的单位一张分表。
|
||
func (s *server) exportAthletesXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
wb, err := export.LoadAthleteDetailWorkbook(s.ev, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "个人积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteAthleteDetailXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
// exportAllTeamsXLSX 导出所有单位的积分明细:第一张总表 + 每个单位一张分表。
|
||
func (s *server) exportAllTeamsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
wb, err := export.LoadAllTeamDetailsWorkbook(s.ev, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "单位积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteAllTeamDetailsXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
func (s *server) exportTeamXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
wb, err := export.LoadTeamDetailWorkbook(s.ev, id, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "单位积分明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteTeamDetailXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
dir := filepath.Join(s.dataDir, "exports")
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
// 项目名取赛事设置里的 event.name;为空会触发 SafeProjectName 校验失败,回退到固定名
|
||
name := "scoreboard-project"
|
||
var raw string
|
||
if err := s.ev.QueryRowContext(r.Context(),
|
||
`SELECT value FROM settings WHERE key = 'event.name'`).Scan(&raw); err == nil && raw != "" {
|
||
name = raw
|
||
}
|
||
|
||
opts := export.ProjectExportOptions{Name: name, AppVersion: s.version}
|
||
// uploads 与图片上传共用同一路径约定;目录可能还没建过,只在存在时打包,否则 copyTree 会报错。
|
||
if uploads, err := s.uploadsDir(); err == nil {
|
||
if info, err := os.Stat(uploads); err == nil && info.IsDir() {
|
||
opts.UploadsDir = uploads
|
||
}
|
||
}
|
||
// 上传字体单独再接一次:它就在 uploads 目录里,整目录打包本来就会带上,
|
||
// 但字体是大屏渲染的硬依赖,这里显式再兜一层,避免以后 uploads 打包条件变动时静默漏掉。
|
||
if fonts, err := s.fontsDir(); err == nil {
|
||
if info, err := os.Stat(fonts); err == nil && info.IsDir() {
|
||
opts.FontsDir = fonts
|
||
opts.IncludeFonts = true
|
||
}
|
||
}
|
||
rulesPath, cleanup, err := s.writeRulesTempFile(r.Context())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer cleanup()
|
||
opts.RulesPath = rulesPath
|
||
|
||
dst, err := projectExportPath(dir, time.Now())
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
if _, err := export.ExportProjectZip(s.ev, dst, opts); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = os.Remove(dst) }()
|
||
|
||
w.Header().Set("Content-Type", "application/zip")
|
||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape("赛事项目包.zip"))
|
||
http.ServeFile(w, r, dst)
|
||
return nil, errHandled
|
||
}
|
||
|
||
// projectExportPath 生成带随机后缀的导出文件名:精度到秒的时间戳在并发导出时会撞名,
|
||
// 后写的会截断先写的文件,导致先返回的响应内容损坏。
|
||
func projectExportPath(dir string, now time.Time) (string, error) {
|
||
suffix := make([]byte, 4)
|
||
if _, err := rand.Read(suffix); err != nil {
|
||
return "", err
|
||
}
|
||
return filepath.Join(dir, "project-"+now.Format("20060102-150405")+"-"+hex.EncodeToString(suffix)+".zip"), nil
|
||
}
|
||
|
||
// writeRulesTempFile 把当前规则包导出成临时 YAML,供赛事项目包内嵌 rules.yaml。
|
||
// 返回的清理函数由调用方 defer,避免临时文件泄漏。
|
||
func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error) {
|
||
pack, err := rules.Export(s.ev, rules.Meta{})
|
||
if err != nil {
|
||
return "", func() {}, internal(err)
|
||
}
|
||
body, err := rules.Marshal(pack)
|
||
if err != nil {
|
||
return "", func() {}, internal(err)
|
||
}
|
||
f, err := os.CreateTemp("", "scoreboard-rules-*.yaml")
|
||
if err != nil {
|
||
return "", func() {}, internal(err)
|
||
}
|
||
if _, err := f.Write(body); err != nil {
|
||
_ = f.Close()
|
||
_ = os.Remove(f.Name())
|
||
return "", func() {}, internal(err)
|
||
}
|
||
if err := f.Close(); err != nil {
|
||
_ = os.Remove(f.Name())
|
||
return "", func() {}, internal(err)
|
||
}
|
||
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
|
||
}
|
||
|
||
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
r.Body = http.MaxBytesReader(w, r.Body, maxProjectImportBytes)
|
||
if err := r.ParseMultipartForm(512 << 20); err != nil {
|
||
return nil, badRequest("解析上传失败", nil)
|
||
}
|
||
file, _, err := r.FormFile("file")
|
||
if err != nil {
|
||
return nil, badRequest("缺少上传字段 file", nil)
|
||
}
|
||
defer func() { _ = file.Close() }()
|
||
|
||
tmpDir, err := os.MkdirTemp("", "scoreboard-import-")
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||
|
||
tmp := filepath.Join(tmpDir, "project.zip")
|
||
out, err := os.Create(tmp)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
if _, err := io.Copy(out, file); err != nil {
|
||
_ = out.Close()
|
||
return nil, internal(err)
|
||
}
|
||
if err := out.Close(); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
|
||
// 覆盖正在运行的赛事项目会让运行中的 event.db 被 unlink(Linux)或 rename 失败(Windows),
|
||
// 现场继续写进已删除的 inode,重启即丢数据,必须在落盘前直接拒绝。
|
||
if cur := currentProjectName(s.projectDir); cur != "" {
|
||
meta, err := export.InspectProjectZip(tmp)
|
||
if err != nil {
|
||
return nil, badRequest(err.Error(), nil)
|
||
}
|
||
name := meta.Name
|
||
if v := strings.TrimSpace(r.FormValue("rename")); v != "" {
|
||
name = v
|
||
}
|
||
if safe, err := export.SafeProjectName(name); err == nil && strings.EqualFold(safe, cur) {
|
||
return nil, badRequest("不能覆盖正在运行的赛事项目 "+cur, nil)
|
||
}
|
||
}
|
||
|
||
res, err := export.ImportProjectZip(tmp, filepath.Join(s.dataDir, "event_projs"), export.ImportOptions{
|
||
Overwrite: r.FormValue("overwrite") == "1",
|
||
Rename: r.FormValue("rename"),
|
||
})
|
||
if err != nil {
|
||
return nil, badRequest(err.Error(), nil)
|
||
}
|
||
s.audit(r.Context(), "project.import", "project", 0, nil, res)
|
||
return res, nil
|
||
}
|
||
|
||
// currentProjectName 从项目目录反推当前运行的赛事项目名;未配置目录时返回空串。
|
||
func currentProjectName(projectDir string) string {
|
||
cleaned := filepath.Clean(strings.TrimSpace(projectDir))
|
||
if cleaned == "" || cleaned == "." || cleaned == string(filepath.Separator) {
|
||
return ""
|
||
}
|
||
return filepath.Base(cleaned)
|
||
}
|
||
|
||
func (s *server) listBackups(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
items, err := export.ListBackups(filepath.Join(s.dataDir, "backups"), "")
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return items, nil
|
||
}
|