fix: 实机复测六批发现的 16 条缺陷(导入编码静默错误 / 导出观感 / 错误分级 / 大屏裁切)
先把「实机复测」能在这台 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。
This commit is contained in:
1 parent
a52e6a4a3b
commit
8dd599bd97
39 files changed
+1308
-106
No files matched your search
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -41,10 +43,30 @@ func (s *server) exportRoutes(mux *http.ServeMux) {
|
||||
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, filename, contentType string, write func(io.Writer) error) (any, error) {
|
||||
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", "attachment; filename*=UTF-8''"+url.PathEscape(filename))
|
||||
w.Header().Set("Content-Disposition", contentDisposition(asciiName, filename))
|
||||
if err := write(w); err != nil {
|
||||
s.logger.Error("导出写出失败", "file", filename, "err", err)
|
||||
}
|
||||
@@ -56,7 +78,7 @@ func (s *server) exportStandingsXLSX(w http.ResponseWriter, r *http.Request) (an
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "总积分榜.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "standings.xlsx", "总积分榜.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteStandingsXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -70,7 +92,7 @@ func (s *server) exportEventXLSX(w http.ResponseWriter, r *http.Request) (any, e
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "分项成绩表.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "event.xlsx", "分项成绩表.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteEventXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -80,7 +102,7 @@ func (s *server) exportAllXLSX(w http.ResponseWriter, r *http.Request) (any, err
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "赛事成绩总表.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "all.xlsx", "赛事成绩总表.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteAllXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -92,7 +114,7 @@ func (s *server) exportAllFlatXLSX(w http.ResponseWriter, r *http.Request) (any,
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "赛事成绩总表(单页).xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "all-flat.xlsx", "赛事成绩总表(单页).xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteFlatXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -103,7 +125,7 @@ func (s *server) exportMedalsXLSX(w http.ResponseWriter, r *http.Request) (any,
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "medals.xlsx", "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteMedalDetailXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -114,7 +136,7 @@ func (s *server) exportAthletesXLSX(w http.ResponseWriter, r *http.Request) (any
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "个人积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "athletes.xlsx", "个人积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteAthleteDetailXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -125,7 +147,7 @@ func (s *server) exportAllTeamsXLSX(w http.ResponseWriter, r *http.Request) (any
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "单位积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "teams.xlsx", "单位积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteAllTeamDetailsXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -139,7 +161,7 @@ func (s *server) exportTeamXLSX(w http.ResponseWriter, r *http.Request) (any, er
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "单位积分明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "team.xlsx", "单位积分明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteTeamDetailXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -184,16 +206,47 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
|
||||
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", "attachment; filename*=UTF-8''"+url.PathEscape("赛事项目包.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) {
|
||||
|
||||
Reference in new issue
Block a user