- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力 - realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份 - httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表 - web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换 - console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复 - docs: 用户文档三份 + README - 主代理: 破纪录加分接线(levels 未加载导致恒 0)
204 lines
6.7 KiB
Go
204 lines
6.7 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
|
|
)
|
|
|
|
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)
|
|
}
|
|
// 项目名取赛事设置里的 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 := filepath.Join(dir, "project-"+time.Now().Format("20060102-150405")+".zip")
|
|
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
|
|
}
|
|
|
|
// 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) {
|
|
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)
|
|
}
|
|
s.audit(r.Context(), "project.import", "project", 0, nil, res)
|
|
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
|
|
}
|