先把「实机复测」能在这台 Linux 机器上验的全部自己验掉(真服务端 + 真 Chromium + 真样本),
六批全部通过,另挖出 16 条缺陷,逐条修完并复验:
导入
- 坏字节被静默替换成 U+FFFD 却报「组别不存在」→ 检测 RuneError 回 400 + 可操作中文文案
- UTF-16 无 BOM 误报「缺少列 event_code」→ 保守判据识别并正确解码
- CSV 语法错只有英文 → 中文提示,英文原文保留在 detail
- 表头缺列补「第 1 行应是表头」的指向
导出
- 名次列宽 8 → 11(放得下「3(并列)」,Excel 里不再硬裁)
- 运动员/队列宽 18 → 24(含奖牌明细的同一列)
- 聚合流水行补组别(仅单位在该小项只参加一个组别时回推;多组别宁可留空也不猜)
- docProps 作者从 excelize 默认的 xuri 改成中性名 + 真实导出时间
- Content-Disposition 补 ASCII 退化 filename(9 个下载端点,含此前没有测试的 project.zip)
错误分级与限流
- 未知大屏指令 500 → 400 VALIDATION_FAILED(传输层真失败仍走 5xx)
- 5xx 的 ERROR 日志补 role / ip(4xx 的 WARN 一直有)
- 写限流 30 → 120 次/分钟/用户(登录 5 次/分/IP 不动,性质不同)
- 赛事名含非法字符时导出给 400 + 可操作提示(原来永远 500「服务端异常」)
大屏与基础设施
- 实时赛况 2 栏成绩末位被裁(1:09.00 显示成 1:09.0)→ 成绩列按内容定宽 + nowrap;
字号不缩,让位给可省略号截断的运动员列。真 Chromium 实测 slack −45px → +6px、0 裁切、
scrollWidth 恒 1920(无横向滚动条);顺带修掉 1 栏长成绩同样被裁 8px 的问题
- logging 启动即预建两个日志目录(原来 interceptions/ 懒创建,现场会以为日志坏了)
- C→S 裸帧由静默忽略改为 WARN,并把「要套 {event,data} 外壳」写进日志
- 建单位/大项漏传 active 不再静默建成「停用」(新增 decodeJSONPresence 按键是否存在判意图)
文档
- README 单位 logo 措辞收窄到「大屏端」(管理端只负责上传配置)
- 导入规范补三类编码报错说明 + 现场另存结论 + 限流提示
- 部署指南补「导出前先看赛事名称」与「切换后怎么确认真的切过去了」
- devdocs 05/08/13 同步(含第三十五轮的完整记录与教训)
自检:服务端 13 包连跑两遍全绿;前端 257 例;vue-tsc + build 通过;Console 18 文件。
版本号 dev_0.1.4 → dev_0.1.5。
360 lines
14 KiB
Go
360 lines
14 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"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))
|
||
mux.HandleFunc("GET /api/export/all-flat.xlsx", s.read(auth.RoleGuest, s.exportAllFlatXLSX))
|
||
// Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。
|
||
mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX))
|
||
// 团体奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。
|
||
mux.HandleFunc("GET /api/export/medals.xlsx", s.read(auth.RoleGuest, s.exportMedalsXLSX))
|
||
mux.HandleFunc("GET /api/export/athletes.xlsx", s.read(auth.RoleGuest, s.exportAthletesXLSX))
|
||
// teams.xlsx 与 teams/{id} 段数不同,ServeMux 不会冲突。
|
||
mux.HandleFunc("GET /api/export/teams.xlsx", s.read(auth.RoleGuest, s.exportAllTeamsXLSX))
|
||
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))
|
||
}
|
||
|
||
// contentDisposition 组装下载头:filename* 给支持 RFC 5987 的浏览器(保留中文名),
|
||
// filename 给不认 filename* 的老客户端兜底——只有 filename* 时它们会退化成按 URL 末段
|
||
// 命名,teams/1 这种连扩展名都没有。asciiName 是兜底段的全部内容,必须能原样放进引号,
|
||
// 所以这里再过滤一遍非 ASCII、引号、反斜杠与控制字符;过滤后为空就只留 filename*。
|
||
func contentDisposition(asciiName, filename string) string {
|
||
var ascii strings.Builder
|
||
ascii.Grow(len(asciiName))
|
||
for _, r := range asciiName {
|
||
if r < 0x20 || r > 0x7e || r == '"' || r == '\\' {
|
||
continue
|
||
}
|
||
ascii.WriteRune(r)
|
||
}
|
||
disposition := "attachment"
|
||
if ascii.Len() > 0 {
|
||
disposition += `; filename="` + ascii.String() + `"`
|
||
}
|
||
return disposition + "; filename*=UTF-8''" + url.PathEscape(filename)
|
||
}
|
||
|
||
// streamDownload 直接写响应体;返回 errHandled 让统一响应层不再追加 JSON。
|
||
func (s *server) streamDownload(w http.ResponseWriter, asciiName, filename, contentType string, write func(io.Writer) error) (any, error) {
|
||
w.Header().Set("Content-Type", contentType)
|
||
w.Header().Set("Content-Disposition", contentDisposition(asciiName, 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, "standings.xlsx", "总积分榜.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, "event.xlsx", "分项成绩表.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, "all.xlsx", "赛事成绩总表.xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteAllXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
// exportAllFlatXLSX 导出全部成绩的单页平铺版:一个工作簿只含一张表,
|
||
// 每行 = 某个单位某个人的某个项目。与「全部」(all.xlsx,多 sheet)内容同源。
|
||
func (s *server) exportAllFlatXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
wb, err := export.LoadFlatWorkbook(s.ev, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "all-flat.xlsx", "赛事成绩总表(单页).xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteFlatXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
// exportMedalsXLSX 导出团体奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。
|
||
func (s *server) exportMedalsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
wb, err := export.LoadMedalDetailWorkbook(s.ev, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "medals.xlsx", "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteMedalDetailXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
// exportAthletesXLSX 导出个人得分明细:记到个人的流水逐条列出,总表 + 每个有得分的单位一张分表。
|
||
func (s *server) exportAthletesXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
wb, err := export.LoadAthleteDetailWorkbook(s.ev, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "athletes.xlsx", "个人积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteAthleteDetailXLSX(out, wb)
|
||
})
|
||
}
|
||
|
||
// exportAllTeamsXLSX 导出所有单位的积分明细:第一张总表 + 每个单位一张分表。
|
||
func (s *server) exportAllTeamsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
wb, err := export.LoadAllTeamDetailsWorkbook(s.ev, export.Options{})
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
return s.streamDownload(w, "teams.xlsx", "单位积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||
return export.WriteAllTeamDetailsXLSX(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, "team.xlsx", "单位积分明细.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 {
|
||
// 赛事名(设置里的 event.name)非法会让整包永远导不出来;它是入参问题,
|
||
// 必须按 400 回一句「怎么改」,不能笼统地报「服务端异常」把现场卡死。
|
||
// 其他 export 错误(磁盘、SQLite 等)仍走 5xx。
|
||
if errors.Is(err, export.ErrUnsafeName) {
|
||
return nil, badRequest(exportProjectNameMessage(name), err.Error())
|
||
}
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = os.Remove(dst) }()
|
||
|
||
w.Header().Set("Content-Type", "application/zip")
|
||
w.Header().Set("Content-Disposition", contentDisposition("project.zip", "赛事项目包.zip"))
|
||
http.ServeFile(w, r, dst)
|
||
return nil, errHandled
|
||
}
|
||
|
||
// exportProjectNameMessage 把 export.SafeProjectName 的拒绝原因翻成可操作的中文提示。
|
||
// 现场只会看到这一句:必须点出是哪些字符、或名字过长,否则只看到「服务端异常」无从下手。
|
||
func exportProjectNameMessage(name string) string {
|
||
trimmed := strings.TrimSpace(name)
|
||
if n := len([]rune(trimmed)); n > 64 {
|
||
return fmt.Sprintf("赛事名称超过 64 个字符(当前 %d 个),请先改短赛事名称再导出", n)
|
||
}
|
||
// 去重后列出非法字符,避免同一个字符按出现次数刷屏。
|
||
bad := make([]string, 0, 4)
|
||
seen := make(map[rune]bool, 4)
|
||
for _, r := range name {
|
||
if r < 0x20 || strings.ContainsRune(`/\:*?"<>|`, r) {
|
||
if !seen[r] {
|
||
seen[r] = true
|
||
bad = append(bad, fmt.Sprintf("%q", r))
|
||
}
|
||
}
|
||
}
|
||
if len(bad) > 0 {
|
||
return fmt.Sprintf("赛事名称 %q 含有不能用于文件名的字符:%s;请先改赛事名称再导出",
|
||
trimmed, strings.Join(bad, "、"))
|
||
}
|
||
return fmt.Sprintf("赛事名称 %q 不能用作项目目录名(不能以点或空格结尾,也不能是系统保留名),请先改赛事名称再导出", trimmed)
|
||
}
|
||
|
||
// 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
|
||
}
|