- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
151 lines
5.0 KiB
Go
151 lines
5.0 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
|
)
|
|
|
|
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
|
|
// 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)
|
|
}
|
|
dst := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip")
|
|
if _, err := export.ExportProjectZip(s.ev, dst, export.ProjectExportOptions{}); 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
|
|
}
|
|
|
|
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
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
|
|
}
|