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)) // Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。 mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX)) 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) }) } 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} if s.projectDir != "" { // uploads 可能还没建过;只在目录存在时打包,否则 copyTree 会报错。 uploads := filepath.Join(s.projectDir, "uploads") if info, err := os.Stat(uploads); err == nil && info.IsDir() { opts.UploadsDir = uploads } } 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 }