Files
Scoreboard/server/internal/httpapi/export_handlers.go
T
AskaEth fc96d17660 feat: 字体可配并随赛事包打包 + 大屏调试标识收敛 + 报名表 CSV 编码兼容
三块改动,均为现场反馈:

一、字体配置与随包打包(此前「没有入口可以配置字体」,导出包也不带字体)
- 服务端新增 httpapi/fonts.go 与 4 条路由:GET /api/fonts(recorder)、
  POST /api/fonts(admin,multipart file)、DELETE /api/fonts/{name}(admin)、
  GET /uploads/fonts/{name}(匿名——大屏 @font-face 不带会话)
- 上传限 ttf/otf/woff/woff2、单个 20 MiB;文件名经 safeFontName 收敛且需与
  fontContentType 往返一致,挡住路径穿越;重名 409、删除被引用字体 409
- 新增设置键 display.font_title / display.font_body(DTO fontTitle/fontBody,
  随快照下发给大屏),写入时校验取值合法
- 导出:接上一直没被赋值的 IncludeFonts/FontsDir;包内落点用 uploads/fonts/
  (运行时只读这里,落顶层 fonts/ 等于没人读)
- 大屏按配置动态注册字体并挂 --display-font-title/body,加载失败静默回落默认字体
- 管理端「赛事信息」新增字体区块:标题/正文字体下拉、上传、字体库、实时预览

二、大屏显示收敛
- 数据版本号角标 vN 彻底移除(原先只有窗口化窗口会显示,两处显隐口径不一致)
- 窗口化窗口按 F11 全屏后自绘标题栏不让位:根因是 Console 用 win.setFullScreen()
  做窗口级全屏,而页面用 document.fullscreenElement 判断,后者恒为 null;
  新增 display-preload.js 下发窗口级全屏状态,页面侧 shell.ts 把两级全屏取或,
  无 preload 时(浏览器直连 /display)回落到文档级监听

三、报名表 CSV 编码兼容(用户报「解析中文字符遇到编码问题」)
- 服务端不再把原始字节直接喂给 csv.Reader:先摘 UTF-8 BOM,再认 UTF-16 LE/BE
  BOM,其余非合法 UTF-8 按 GB18030 解(GBK 是其子集)
- 修复前症状:GBK 文件(Excel 另存为 CSV 的默认编码)中文变乱码;带 BOM 的文件
  (Excel「CSV UTF-8」,也正是我们自己模板的格式)把 BOM 拼进第一列表头,
  报「报名表缺少列 event_code」——连「下载模板→填→传回来」都走不通
- golang.org/x/text 由 indirect 提升为直接依赖,未新增外部依赖

测试:服务端 12 包全绿(新增 fonts_test.go / fonts_internal_test.go /
handlers_import_encoding_test.go);前端 vue-tsc 通过、pnpm build 通过、157 例全过;
Console 语法检查 17 个文件通过。docs/ 与 devdocs/ 同步更新。
2026-09-10 17:45:11 +08:00

256 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
// 上传字体单独再接一次:它就在 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
}