人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员
大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)
图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮
关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
(用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)
安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认
自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
248 lines
8.5 KiB
Go
248 lines
8.5 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))
|
||
// 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}
|
||
// uploads 与图片上传共用同一路径约定;目录可能还没建过,只在存在时打包,否则 copyTree 会报错。
|
||
if uploads, err := s.uploadsDir(); err == nil {
|
||
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
|
||
}
|