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
@@ -192,15 +192,26 @@ func TestDisplayEndpointsRequireRecorder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 未知 action 仍要被实时层拒收(走 strictDisplaySender 的同口径校验)。
|
||||
// 未知 action 仍要被实时层拒收(走 strictDisplaySender 的同口径校验),
|
||||
// 但它是请求体入参不合法:按 400 报,现场才看得懂要改 action 而不是「服务端异常」。
|
||||
func TestDisplayCommandRejectsUnknownAction(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/display/command",
|
||||
map[string]any{"action": "nope"}, true)
|
||||
if status != http.StatusInternalServerError || env2.Error == nil {
|
||||
t.Fatalf("未知 action 应被拒收,得到 %d %+v", status, env2.Error)
|
||||
if status != http.StatusBadRequest || env2.Error == nil {
|
||||
t.Fatalf("未知 action 应被拒收为 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if env2.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("未知 action 的错误码应为 VALIDATION_FAILED,得到 %+v", env2.Error)
|
||||
}
|
||||
// message 保持实时层那句中文原文(现场要看懂),detail 带上原始 action(便于检索)。
|
||||
if !strings.Contains(env2.Error.Message, "未知的大屏指令") {
|
||||
t.Fatalf("message 应保持可读的中文原文,得到 %q", env2.Error.Message)
|
||||
}
|
||||
if got := string(env2.Error.Detail); got != `"nope"` {
|
||||
t.Fatalf("detail 应带上原始 action,得到 %s", got)
|
||||
}
|
||||
if len(sender.sent) != 0 {
|
||||
t.Fatalf("未知 action 不该下发,得到 %v", sender.sent)
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi_test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -16,23 +17,26 @@ import (
|
||||
// - 响应体 detail —— 管理端 `describeError()` 把它拼在提示上,录人员常在局域网平板上
|
||||
// 操作,够不到服务端控制台的日志面板;
|
||||
// - ERROR 日志 —— 此前只有响应里有原文、日志里只有笼统 message,服务端自己没留痕。
|
||||
//
|
||||
// 触发源是真正的下发失败(写 socket 失败):未知 action 现在是 400,不能再当 5xx 用。
|
||||
func TestServerErrorCarriesRawReasonToClientAndLog(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
// 这句原始错误串必须一字不落地出现在响应 detail 与 ERROR 日志里。
|
||||
const rawReason = "写 socket 失败: broken pipe"
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) {
|
||||
o.Logger = logger
|
||||
// 未知 action 会让投递层直接报错,是最省事的 500 触发点。
|
||||
o.Display = &strictDisplaySender{}
|
||||
o.Display = &strictDisplaySender{failWith: errors.New(rawReason)}
|
||||
})
|
||||
client := recorderClient(t, env)
|
||||
|
||||
raw, err := json.Marshal(map[string]any{"action": "nope"})
|
||||
raw, err := json.Marshal(map[string]any{"action": "goto", "payload": map[string]any{"page": "medals"}})
|
||||
if err != nil {
|
||||
t.Fatalf("序列化请求体: %v", err)
|
||||
}
|
||||
status, body := env.doRaw(client, http.MethodPost, "/api/display/command", raw, "application/json", true)
|
||||
if status != http.StatusInternalServerError {
|
||||
t.Fatalf("未知大屏指令应 500,得到 %d %s", status, body)
|
||||
t.Fatalf("下发失败应 500,得到 %d %s", status, body)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
@@ -49,15 +53,22 @@ func TestServerErrorCarriesRawReasonToClientAndLog(t *testing.T) {
|
||||
if payload.Error.Code != "INTERNAL" || payload.Error.Message != "服务端异常" {
|
||||
t.Fatalf("5xx 的 code 与笼统文案要保持,得到 %+v", payload.Error)
|
||||
}
|
||||
if !strings.Contains(string(payload.Error.Detail), "未知的大屏指令") {
|
||||
if !strings.Contains(string(payload.Error.Detail), rawReason) {
|
||||
t.Fatalf("响应体 detail 应带上原始错误串(现场要能直接看懂为什么失败),得到 %s", payload.Error.Detail)
|
||||
}
|
||||
|
||||
// 原文必须能在日志里找到,否则现场就真的查不下去了。
|
||||
logs := waitLog(t, buf, `"level":"ERROR"`)
|
||||
if !strings.Contains(logs, "未知的大屏指令") {
|
||||
if !strings.Contains(logs, rawReason) {
|
||||
t.Fatalf("ERROR 行应带上原始错误串,得到:\n%s", logs)
|
||||
}
|
||||
// 「谁干的」不能缺一半:5xx 的 ERROR 行与 4xx 的 WARN 同口径,要有 role 与 ip。
|
||||
if !strings.Contains(logs, `"role":"recorder"`) {
|
||||
t.Fatalf("ERROR 行应带 role(现场按日志查是谁),得到:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, `"ip":"127.0.0.1"`) {
|
||||
t.Fatalf("ERROR 行应带 ip(现场按日志查来源),得到:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// 改密码时原密码填错返回 400,不是 401:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// assertContentDisposition 逐字校验两段文件名,并单独确认 ASCII 段是纯可打印 ASCII。
|
||||
// 这一段是不认 filename* 的老客户端唯一的文件名来源,混进中文、引号、反斜杠或控制字符
|
||||
// 会把响应头拼坏,所以它不能只靠「与期望字符串相等」来兜底。
|
||||
func assertContentDisposition(t *testing.T, got, asciiName, filename string) {
|
||||
t.Helper()
|
||||
want := `attachment; filename="` + asciiName + `"; filename*=UTF-8''` + url.PathEscape(filename)
|
||||
if got != want {
|
||||
t.Errorf("Content-Disposition = %q,期望 %q", got, want)
|
||||
}
|
||||
const prefix = `attachment; filename="`
|
||||
end := strings.Index(got, `"; filename*=UTF-8''`)
|
||||
if !strings.HasPrefix(got, prefix) || end < len(prefix) {
|
||||
t.Fatalf("Content-Disposition 缺少 ASCII 兜底段: %q", got)
|
||||
}
|
||||
ascii := got[len(prefix):end]
|
||||
if ascii == "" {
|
||||
t.Fatalf("ASCII 兜底名为空: %q", got)
|
||||
}
|
||||
for i := 0; i < len(ascii); i++ {
|
||||
if c := ascii[i]; c < 0x20 || c > 0x7e || c == '"' || c == '\\' {
|
||||
t.Errorf("ASCII 兜底名 %q 第 %d 字节 %q 不是可打印 ASCII,或含引号/反斜杠", ascii, i, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有下载接口都必须同时给出 filename* 与 filename:老客户端不认 filename*,
|
||||
// 只给 filename* 时它们会退化成按 URL 末段命名(teams/1 甚至没有扩展名)。
|
||||
// 期望的 ASCII 名与路由末段一致,避免出现「保存成 1」这种没有扩展名的文件。
|
||||
func TestExportContentDispositionHasASCIIFallback(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
ascii string
|
||||
file string
|
||||
ext string
|
||||
}{
|
||||
{"/api/export/standings.xlsx", "standings.xlsx", "总积分榜.xlsx", ".xlsx"},
|
||||
{"/api/export/events/1", "event.xlsx", "分项成绩表.xlsx", ".xlsx"},
|
||||
{"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", ".xlsx"},
|
||||
{"/api/export/all-flat.xlsx", "all-flat.xlsx", "赛事成绩总表(单页).xlsx", ".xlsx"},
|
||||
{"/api/export/medals.xlsx", "medals.xlsx", "团体奖牌明细.xlsx", ".xlsx"},
|
||||
{"/api/export/athletes.xlsx", "athletes.xlsx", "个人积分总明细.xlsx", ".xlsx"},
|
||||
{"/api/export/teams.xlsx", "teams.xlsx", "单位积分总明细.xlsx", ".xlsx"},
|
||||
{"/api/export/teams/1", "team.xlsx", "单位积分明细.xlsx", ".xlsx"},
|
||||
{"/api/export/project.zip", "project.zip", "赛事项目包.zip", ".zip"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
status, header, body := env.getRaw(t, client, tc.path)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("%s 应 200,得到 %d %s", tc.path, status, body)
|
||||
continue
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), tc.ascii, tc.file)
|
||||
if !strings.HasSuffix(tc.ascii, tc.ext) {
|
||||
t.Errorf("%s 的 ASCII 兜底名 %q 必须带 %s 扩展名", tc.path, tc.ascii, tc.ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -19,10 +18,7 @@ func TestExportAllFlatXLSXGuest(t *testing.T) {
|
||||
if got := header.Get("Content-Type"); got != xlsxContentType {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("赛事成绩总表(单页).xlsx")
|
||||
if got := header.Get("Content-Disposition"); got != wantCD {
|
||||
t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "all-flat.xlsx", "赛事成绩总表(单页).xlsx")
|
||||
|
||||
f := openWorkbook(t, body)
|
||||
defer func() { _ = f.Close() }()
|
||||
@@ -95,14 +91,15 @@ func TestExistingExportsUnchanged(t *testing.T) {
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
ascii string
|
||||
file string
|
||||
sheets []string
|
||||
}{
|
||||
{"/api/export/standings.xlsx", "总积分榜.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}},
|
||||
{"/api/export/all.xlsx", "赛事成绩总表.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}},
|
||||
{"/api/export/events/1", "分项成绩表.xlsx", []string{"田径·100米跑·男子"}},
|
||||
{"/api/export/teams.xlsx", "单位积分总明细.xlsx", []string{"积分总明细"}},
|
||||
{"/api/export/teams/1", "单位积分明细.xlsx", []string{"第一分公司积分明细"}},
|
||||
{"/api/export/standings.xlsx", "standings.xlsx", "总积分榜.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}},
|
||||
{"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}},
|
||||
{"/api/export/events/1", "event.xlsx", "分项成绩表.xlsx", []string{"田径·100米跑·男子"}},
|
||||
{"/api/export/teams.xlsx", "teams.xlsx", "单位积分总明细.xlsx", []string{"积分总明细"}},
|
||||
{"/api/export/teams/1", "team.xlsx", "单位积分明细.xlsx", []string{"第一分公司积分明细"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
status, header, body := env.getRaw(t, client, tc.path)
|
||||
@@ -110,10 +107,7 @@ func TestExistingExportsUnchanged(t *testing.T) {
|
||||
t.Errorf("%s 应 200,得到 %d %s", tc.path, status, body)
|
||||
continue
|
||||
}
|
||||
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape(tc.file)
|
||||
if got := header.Get("Content-Disposition"); got != wantCD {
|
||||
t.Errorf("%s 文件名 = %q,期望 %q", tc.path, got, wantCD)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), tc.ascii, tc.file)
|
||||
f := openWorkbook(t, body)
|
||||
if got := f.GetSheetList(); !equalSheetList(got, tc.sheets) {
|
||||
t.Errorf("%s sheet 列表 = %v,期望 %v", tc.path, got, tc.sheets)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 同一秒内的两次导出必须落到不同文件,否则并发导出会互相截断(P2-6)。
|
||||
// 在同一秒内的两次导出必须落到不同文件,否则并发导出会互相截断(P2-6)。
|
||||
func TestProjectExportPathUniqueWithinSecond(t *testing.T) {
|
||||
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
|
||||
first, err := projectExportPath("/tmp/exports", now)
|
||||
@@ -20,3 +22,48 @@ func TestProjectExportPathUniqueWithinSecond(t *testing.T) {
|
||||
t.Fatalf("同一秒内两次导出不应撞名,均为 %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
// 下载头必须同时给出中文 filename* 与 ASCII filename;兜底段是给不认 filename* 的
|
||||
// 老客户端用的,混进引号、反斜杠、控制字符会拼坏响应头,必须被过滤掉。
|
||||
func TestContentDispositionKeepsASCIIFallbackClean(t *testing.T) {
|
||||
escaped := "filename*=UTF-8''" + url.PathEscape("单位积分明细.xlsx")
|
||||
cases := []struct {
|
||||
name string
|
||||
ascii string
|
||||
want string
|
||||
}{
|
||||
{"正常兜底名", "team.xlsx", `attachment; filename="team.xlsx"; ` + escaped},
|
||||
{"引号反斜杠与控制字符被过滤", "te\"am\x01.xlsx", `attachment; filename="team.xlsx"; ` + escaped},
|
||||
{"中文兜底名被过滤后不残留", "单位积分明细.xlsx", `attachment; filename=".xlsx"; ` + escaped},
|
||||
{"过滤后为空则只留 filename*", "", "attachment; " + escaped},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := contentDisposition(c.ascii, "单位积分明细.xlsx"); got != c.want {
|
||||
t.Errorf("%s: contentDisposition(%q) = %q,期望 %q", c.name, c.ascii, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 赛事名被 SafeProjectName 拒绝时,提示必须可操作:点出非法字符或长度,并说清下一步。
|
||||
func TestExportProjectNameMessageIsActionable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
want []string
|
||||
}{
|
||||
{"田径/游泳邀请赛", []string{"赛事名称", "'/'", "请先改赛事名称再导出"}},
|
||||
{"田径:游泳", []string{"':'"}},
|
||||
{"赛事. ", []string{"不能用作项目目录名", "请先改赛事名称再导出"}},
|
||||
{"CON", []string{"不能用作项目目录名"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := exportProjectNameMessage(c.name)
|
||||
for _, want := range c.want {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("exportProjectNameMessage(%q) 应含 %q,得到 %q", c.name, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := exportProjectNameMessage(strings.Repeat("长", 65)); !strings.Contains(got, "超过 64 个字符") {
|
||||
t.Errorf("超长名称应提示长度上限,得到 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,36 @@ func TestImportProjectZipRejectsRunningProject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 赛事名里的 `/` 会让 SafeProjectName 拒绝建目录:此前整包永远导不出来,界面只有一句「服务端异常」。
|
||||
// 名字来自设置、属入参问题,必须回 400 并说清怎么改(而不是 500)。
|
||||
func TestExportProjectZipUnsafeEventNameIsBadRequest(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
status, body := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||
"event": map[string]any{"name": "田径/游泳邀请赛"},
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("设置赛事名应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
|
||||
status, body = env.do(client, http.MethodGet, "/api/export/project.zip", nil, false)
|
||||
if status != http.StatusBadRequest || body.Error == nil {
|
||||
t.Fatalf("赛事名含 / 时导出应 400,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if body.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("错误码应为 VALIDATION_FAILED,得到 %+v", body.Error)
|
||||
}
|
||||
for _, want := range []string{"赛事名称", "'/'", "请先改赛事名称再导出"} {
|
||||
if !strings.Contains(body.Error.Message, want) {
|
||||
t.Fatalf("message 应可操作(含 %q),得到 %q", want, body.Error.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 并发导出不能撞同名文件,否则先返回的响应会被后写的截断(P2-6)。
|
||||
func TestExportProjectZipConcurrentUnique(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
@@ -3,7 +3,6 @@ package httpapi_test
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -53,10 +52,7 @@ func TestExportMedalsXLSXGuest(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("guest 下载奖牌明细应 200,得到 %d %s", status, body)
|
||||
}
|
||||
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("团体奖牌明细.xlsx")
|
||||
if got := header.Get("Content-Disposition"); got != wantCD {
|
||||
t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "medals.xlsx", "团体奖牌明细.xlsx")
|
||||
|
||||
f := openWorkbook(t, body)
|
||||
defer func() { _ = f.Close() }()
|
||||
@@ -110,10 +106,7 @@ func TestExportAthletesXLSXGuest(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("guest 下载个人得分明细应 200,得到 %d %s", status, body)
|
||||
}
|
||||
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("个人积分总明细.xlsx")
|
||||
if got := header.Get("Content-Disposition"); got != wantCD {
|
||||
t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "athletes.xlsx", "个人积分总明细.xlsx")
|
||||
|
||||
f := openWorkbook(t, body)
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -58,10 +57,7 @@ func TestExportAllTeamDetailsXLSXGuest(t *testing.T) {
|
||||
if got := header.Get("Content-Type"); got != xlsxContentType {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("单位积分总明细.xlsx")
|
||||
if got := header.Get("Content-Disposition"); got != wantCD {
|
||||
t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "teams.xlsx", "单位积分总明细.xlsx")
|
||||
|
||||
f := openWorkbook(t, body)
|
||||
defer func() { _ = f.Close() }()
|
||||
@@ -94,10 +90,7 @@ func TestExportAllTeamDetailsXLSXGuest(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("guest 下载单位明细应 200,得到 %d %s", status, singleBody)
|
||||
}
|
||||
wantSingle := "attachment; filename*=UTF-8''" + url.PathEscape("单位积分明细.xlsx")
|
||||
if got := header.Get("Content-Disposition"); got != wantSingle {
|
||||
t.Errorf("单位明细文件名 = %q,期望 %q", got, wantSingle)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "team.xlsx", "单位积分明细.xlsx")
|
||||
sf := openWorkbook(t, singleBody)
|
||||
defer func() { _ = sf.Close() }()
|
||||
if got := sf.GetSheetList(); !equalSheetList(got, []string{"第一分公司积分明细"}) {
|
||||
|
||||
@@ -193,9 +193,15 @@ func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, er
|
||||
|
||||
func (s *server) createTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in teamDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 缺省 = 启用。Go 的 bool 零值是 false,漏传 active 会被静默建成「停用」,
|
||||
// 而停用的单位在界面上等于不存在 —— 脚本与导入侧踩过一次就很难查。
|
||||
if !keys.has("active") {
|
||||
in.Active = true
|
||||
}
|
||||
created, err := s.createTeam(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -210,13 +216,19 @@ func (s *server) updateTeamHandler(w http.ResponseWriter, r *http.Request) (any,
|
||||
return nil, err
|
||||
}
|
||||
var in teamDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getTeam(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 更新仍是「全量覆盖」语义,但漏传 active 不等于「把它停用」:沿用库里的现值。
|
||||
// 想停用请显式传 false,或走 DELETE(那才是界面上那个「停用」)。
|
||||
if !keys.has("active") {
|
||||
in.Active = before.Active
|
||||
}
|
||||
updated, err := s.updateTeam(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -269,9 +281,14 @@ func (s *server) getSportHandler(w http.ResponseWriter, r *http.Request) (any, e
|
||||
|
||||
func (s *server) createSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in sportDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 与建单位同口径:缺省 = 启用(漏传 active 不该建成一个在界面上不存在的大项)。
|
||||
if !keys.has("active") {
|
||||
in.Active = true
|
||||
}
|
||||
created, err := s.createSport(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -286,13 +303,18 @@ func (s *server) updateSportHandler(w http.ResponseWriter, r *http.Request) (any
|
||||
return nil, err
|
||||
}
|
||||
var in sportDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getSport(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 同单位:漏传 active 沿用现值,不静默停用。
|
||||
if !keys.has("active") {
|
||||
in.Active = before.Active
|
||||
}
|
||||
updated, err := s.updateSport(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -734,7 +756,9 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
reader.TrimLeadingSpace = true
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, badRequest("无法解析 CSV", err.Error())
|
||||
// CSV 语法错误几乎都是格子里有没配对的双引号。message 给现场能照着做的中文提示,
|
||||
// Go 的英文原文(parse error on line ...)留在 detail 里,排查时还要用。
|
||||
return nil, badRequest("无法解析 CSV:表格里多半有没配对的双引号。请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入", err.Error())
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -747,6 +771,11 @@ var (
|
||||
tableUTF16BEBOM = []byte{0xFE, 0xFF}
|
||||
)
|
||||
|
||||
// errTableReplacementRune 表示解码结果里出现了替换字符 U+FFFD。
|
||||
// x/text 的解码器对非法字节、孤立代理对这类坏数据不返回 error,只把它们换成 U+FFFD,
|
||||
// 靠这个哨兵错误把「解出一串 �」和「真的解开了」区分开。
|
||||
var errTableReplacementRune = errors.New("decoded text contains U+FFFD replacement character")
|
||||
|
||||
// decodeTableText 把报名表的字节流解码成 UTF-8 文本。
|
||||
//
|
||||
// 现场拿到的 CSV 基本都是直接从 Excel 存的,而 Excel 默认不写 UTF-8:中文 Windows 上
|
||||
@@ -765,9 +794,16 @@ func decodeTableText(file io.Reader) (string, error) {
|
||||
case bytes.HasPrefix(data, tableUTF8BOM):
|
||||
data = data[len(tableUTF8BOM):]
|
||||
case bytes.HasPrefix(data, tableUTF16LEBOM):
|
||||
return decodeUTF16Table(data[len(tableUTF16LEBOM):], textunicode.LittleEndian)
|
||||
return decodeUTF16Table(data[len(tableUTF16LEBOM):], textunicode.LittleEndian, false)
|
||||
case bytes.HasPrefix(data, tableUTF16BEBOM):
|
||||
return decodeUTF16Table(data[len(tableUTF16BEBOM):], textunicode.BigEndian)
|
||||
return decodeUTF16Table(data[len(tableUTF16BEBOM):], textunicode.BigEndian, false)
|
||||
}
|
||||
|
||||
// 丢了 BOM 的 UTF-16 必须排在 utf8.Valid 之前判断:UTF-16 里 0x00 是合法的 UTF-8 码点,
|
||||
// utf8.Valid 会放它过去,表头随即被当成 "e\0v\0e\0n\0t...",最后报「缺少列 event_code」——
|
||||
// 把现场引去查表头,而真正的问题是编码。
|
||||
if order, ok := guessUTF16Order(data); ok {
|
||||
return decodeUTF16Table(data, order, true)
|
||||
}
|
||||
|
||||
if utf8.Valid(data) {
|
||||
@@ -778,14 +814,63 @@ func decodeTableText(file io.Reader) (string, error) {
|
||||
if err != nil {
|
||||
return "", badRequest("报名表编码无法识别,请另存为 UTF-8 或 GB18030 编码的 CSV 后重试", err.Error())
|
||||
}
|
||||
// 非法字节会被静默换成 U+FFFD:不拦住的话它一路往下走,现场看到的是
|
||||
// 「第 2 行:组别 ��� 不存在」,跑去查组别配置——真正坏的是文件。
|
||||
if strings.ContainsRune(text, utf8.RuneError) {
|
||||
return "", badRequest("报名表编码无法识别:文件里混有无法解码的字节(多半是复制粘贴或转换工具弄坏的)。请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入", nil)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// decodeUTF16Table 解 UTF-16(BOM 已由调用方摘掉)。
|
||||
func decodeUTF16Table(data []byte, order textunicode.Endianness) (string, error) {
|
||||
// guessUTF16Order 判断无 BOM 的字节流是不是 UTF-16,是的话顺带给出字节序。
|
||||
//
|
||||
// 判据刻意保守,因为猜错的代价是把好文件解成乱码:UTF-16 的每个 ASCII 字符
|
||||
// (列名、逗号、数字、换行)都会贡献一个固定奇偶位的 0x00,而正常的 UTF-8/GB18030
|
||||
// 报名表里一个 0x00 都没有。所以只认「NUL 足够多、且几乎只落在同一位」的字节流,
|
||||
// 只是混进一两个坏字节的文本文件不会被误判。
|
||||
func guessUTF16Order(data []byte) (textunicode.Endianness, bool) {
|
||||
var even, odd int
|
||||
for i, b := range data {
|
||||
if b != 0 {
|
||||
continue
|
||||
}
|
||||
if i%2 == 0 {
|
||||
even++
|
||||
} else {
|
||||
odd++
|
||||
}
|
||||
}
|
||||
zeros := even + odd
|
||||
if zeros < 8 || zeros*4 < len(data) {
|
||||
return false, false
|
||||
}
|
||||
switch {
|
||||
case odd*20 >= zeros*19: // 小端:ASCII 的高位 0x00 落在奇数位
|
||||
return textunicode.LittleEndian, true
|
||||
case even*20 >= zeros*19: // 大端:0x00 落在偶数位
|
||||
return textunicode.BigEndian, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// decodeUTF16Table 解 UTF-16(BOM 已由调用方摘掉)。missingBOM 决定失败时给现场哪句提示:
|
||||
// 缺 BOM 的要教他「另存为 CSV UTF-8」,带 BOM 还解不动的按编码损坏处理。
|
||||
func decodeUTF16Table(data []byte, order textunicode.Endianness, missingBOM bool) (string, error) {
|
||||
text, _, err := transform.String(textunicode.UTF16(order, textunicode.IgnoreBOM).NewDecoder(), string(data))
|
||||
if err == nil && strings.ContainsRune(text, utf8.RuneError) {
|
||||
err = errTableReplacementRune
|
||||
}
|
||||
// 字节序猜反时整份文本会翻成另一堆合法码点(不会出现 U+FFFD),再拿表头必然有的
|
||||
// 逗号兜一道底:认不出是 CSV 就宁可报编码问题,也不能把乱码当成功导进去。
|
||||
if err == nil && missingBOM && !strings.ContainsRune(text, ',') {
|
||||
err = errors.New("utf-16 decode produced no csv delimiter")
|
||||
}
|
||||
if err != nil {
|
||||
return "", badRequest("报名表编码无法识别,请另存为 UTF-8 编码的 CSV 后重试", err.Error())
|
||||
message := "报名表编码无法识别,请另存为 UTF-8 编码的 CSV 后重试"
|
||||
if missingBOM {
|
||||
message = "报名表看起来是 UTF-16 编码但缺少 BOM,请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入"
|
||||
}
|
||||
return "", badRequest(message, err.Error())
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
@@ -813,7 +898,9 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
}
|
||||
for _, col := range importColumns {
|
||||
if _, ok := index[col]; !ok {
|
||||
return result, badRequest("报名表缺少列 "+col, nil)
|
||||
// 表头类报错必须点到「表头行」本身:用户常在表头上方加一行标题,
|
||||
// 只说「缺少列 X」会让他去翻数据行,找不到问题在哪。
|
||||
return result, badRequest("报名表缺少列 "+col+":第 1 行应是表头(列名与导入模板一致),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
textunicode "golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// 本文件钉住报名导入链路里 4 个已复现的缺陷:
|
||||
// 1. 非法字节被 GB18030 / UTF-16 解码器静默换成 U+FFFD,导入看着"成功"、内容却全烂;
|
||||
// 2. 无 BOM 的 UTF-16 被当普通文本解析,误报「缺少列 event_code」;
|
||||
// 3. CSV 语法错误只回 Go 英文原文,现场看不懂该做什么;
|
||||
// 4. 表头缺列的报错不点到「表头行」,用户加了标题行时找不到问题在哪。
|
||||
|
||||
// encodeUTF16NoBOM 编码成不带 BOM 的 UTF-16,模拟手工拼接 / 被工具剥掉 BOM 的文件。
|
||||
func encodeUTF16NoBOM(t *testing.T, text string, order textunicode.Endianness) string {
|
||||
t.Helper()
|
||||
|
||||
out, _, err := transform.String(textunicode.UTF16(order, textunicode.IgnoreBOM).NewEncoder(), text)
|
||||
if err != nil {
|
||||
t.Fatalf("编码 UTF-16(无 BOM): %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 数据里混进非法 GB18030 字节(0xFF 0xFF 0xFF)。
|
||||
// 修复前:解码器把它们换成 � 且不返回 error,接口回 200「第 2 行:组别 ��� 不存在」,
|
||||
// 把现场引去查组别配置;真正坏的是文件本身。
|
||||
func TestImportRejectsInvalidGB18030Bytes(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\r\n" +
|
||||
"M100,\xff\xff\xff,A,X,110101199001019001,m,26,9001,,registered,\r\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("坏字节应回 400,得到 %d %+v", status, env2)
|
||||
}
|
||||
msg := env2.Error.Message
|
||||
if !strings.Contains(msg, "编码无法识别") || !strings.Contains(msg, "CSV UTF-8") {
|
||||
t.Fatalf("message 应说明编码坏了并教怎么重存,得到 %q", msg)
|
||||
}
|
||||
// 关键回归点:不能再把现场引向组别配置。
|
||||
if strings.Contains(msg, "组别") {
|
||||
t.Fatalf("message 不应指向组别配置,得到 %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 无 BOM 的 UTF-16(LE / BE 都要认)。修复前 utf8.Valid 放行(UTF-16 里的 0x00 是合法
|
||||
// UTF-8 码点),表头被当成 "e\0v\0e\0n\0t...",最后报「缺少列 event_code」。
|
||||
func TestImportDecodesUTF16WithoutBOM(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
order textunicode.Endianness
|
||||
}{
|
||||
{"LE", textunicode.LittleEndian},
|
||||
{"BE", textunicode.BigEndian},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := encodeUTF16NoBOM(t, importHeader+"\n"+importBody, tc.order)
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
// 不只是"没报缺列":表头与中文姓名都要真的解对。
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 无 BOM 且解不动的 UTF-16(截断后长度成奇数)要给明确中文提示,
|
||||
// 而不是退回去报「缺少列 event_code」。
|
||||
func TestImportTruncatedUTF16WithoutBOMGivesEncodingHint(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
full := encodeUTF16NoBOM(t, importHeader+"\n"+importBody, textunicode.LittleEndian)
|
||||
body := full[:len(full)-1]
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("截断的 UTF-16 应回 400,得到 %d %+v", status, env2)
|
||||
}
|
||||
msg := env2.Error.Message
|
||||
if !strings.Contains(msg, "UTF-16") || !strings.Contains(msg, "BOM") {
|
||||
t.Fatalf("message 应提示 UTF-16 缺 BOM,得到 %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "缺少列") {
|
||||
t.Fatalf("message 不应误报缺列,得到 %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 反向用例:正常编码必须仍然走原路径。
|
||||
func TestImportKeepsNormalEncodingsWorking(t *testing.T) {
|
||||
plain := importHeader + "\n" + importBody
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"utf8", plain},
|
||||
{"utf8_bom", "\uFEFF" + plain},
|
||||
{"gb18030", encodeGB18030(t, plain)},
|
||||
{"utf16le_bom", encodeUTF16LE(t, plain)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", tc.body, "")
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 反向用例:UTF-8 文件里混进零星 NUL(人为拼接 / 损坏)不能被猜成 UTF-16。
|
||||
// 猜错会把整份中文解成另一堆合法码点,比原样报错更糟。
|
||||
// NUL 交替落在奇偶两位,凑不出「单一位次占绝对多数」的特征。
|
||||
func TestImportDoesNotGuessUTF16FromScatteredNULs(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,,s\x00e\x00e\x00d\x00,registered," + strings.Repeat("\x00", 8) + "\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
}
|
||||
|
||||
// 裸双引号导致 CSV 语法错误:message 给中文可操作提示,英文原文留在 detail 里。
|
||||
func TestImportBareQuoteGivesActionableChineseMessage(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\r\n" +
|
||||
"M100,男子,A,张\"三,110101199001010951,男,26,951,,registered,\r\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("CSV 语法错误应回 400,得到 %d %+v", status, env2)
|
||||
}
|
||||
msg := env2.Error.Message
|
||||
if !strings.Contains(msg, "双引号") || !strings.Contains(msg, "CSV UTF-8") {
|
||||
t.Fatalf("message 应给中文可操作提示,得到 %q", msg)
|
||||
}
|
||||
detail := string(env2.Error.Detail)
|
||||
if !strings.Contains(detail, "non-quoted-field") {
|
||||
t.Fatalf("detail 应保留 Go 英文原文,得到 %q", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// 表头类报错必须点到「表头行」:最常见的成因是用户在表头上方加了一行标题,
|
||||
// 只说「缺少列 X」会让他去翻数据行。
|
||||
func TestImportMissingColumnPointsAtHeaderRow(t *testing.T) {
|
||||
t.Run("标题行在表头上方", func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := "比赛报名表\n" + importHeader + "\n" + importBody
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("缺列应回 400,得到 %d %+v", status, env2)
|
||||
}
|
||||
msg := env2.Error.Message
|
||||
if !strings.Contains(msg, "缺少列 event_code") {
|
||||
t.Fatalf("message 应保留缺列信息,得到 %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "第 1 行") || !strings.Contains(msg, "表头") {
|
||||
t.Fatalf("message 应点到表头行,得到 %q", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("真的少了一列", func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
noAthleteName := "event_code,category,team_code,id_card,gender,age,bib,seed,status,member_names"
|
||||
row := "M100,男子,A,110101199001011234,男,26,952,,registered,"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", noAthleteName+"\n"+row+"\n", "")
|
||||
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("缺列应回 400,得到 %d %+v", status, env2)
|
||||
}
|
||||
msg := env2.Error.Message
|
||||
if !strings.Contains(msg, "缺少列 athlete_name") || !strings.Contains(msg, "表头") {
|
||||
t.Fatalf("message 应点名列名并提及表头行,得到 %q", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -235,6 +237,13 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
|
||||
if s.display != nil && (in.Action != cmdAnnounceClear || realtime.ValidDisplayAction(in.Action)) {
|
||||
n, err := s.display.SendDisplayCommand(in.Action, in.Payload, in.Targets)
|
||||
if err != nil {
|
||||
// 未知 action 是请求体入参不合法,按 400 报,现场才看得懂「改 action」而不是「服务端异常」。
|
||||
// 生产路径上这个错由 realtime.NewDisplayCommand 返回(裸错误,没带哨兵),
|
||||
// 所以除了 errors.Is 还要按协议清单判一次;两类失败不能混成同一个 5xx:
|
||||
// 只有传输层真失败(写 socket 失败等)才走 internal。
|
||||
if errors.Is(err, realtime.ErrUnknownAction) || !realtime.ValidDisplayAction(in.Action) {
|
||||
return nil, badRequest(fmt.Sprintf("未知的大屏指令 %q", in.Action), in.Action)
|
||||
}
|
||||
return nil, internal(err)
|
||||
}
|
||||
matched = n
|
||||
|
||||
@@ -11,16 +11,22 @@ import (
|
||||
// strictDisplaySender 与 main.go 的 displaySender 同口径:经实时层校验 action,未知 action 直接报错。
|
||||
// matched 为 nil 时按「广播算命中一块屏、定向一条算命中一块」返回,便于断言正常路径;
|
||||
// 需要模拟「目标已离线」时把它设成 0。
|
||||
// failWith 非空时直接返回它,用来造一个**真正的传输层 5xx**(写 socket 失败之类):
|
||||
// 未知 action 现在是 4xx,5xx 的日志用例不能再拿它当触发源。
|
||||
type strictDisplaySender struct {
|
||||
sent []string
|
||||
targets [][]string
|
||||
matched *int
|
||||
sent []string
|
||||
targets [][]string
|
||||
matched *int
|
||||
failWith error
|
||||
}
|
||||
|
||||
func (d *strictDisplaySender) SendDisplayCommand(action string, payload any, targets []string) (int, error) {
|
||||
if _, err := realtime.NewDisplayCommand(action, payload); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if d.failWith != nil {
|
||||
return 0, d.failWith
|
||||
}
|
||||
d.sent = append(d.sent, action)
|
||||
d.targets = append(d.targets, targets)
|
||||
if d.matched != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -98,7 +99,8 @@ func TestInterceptLogLevels(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("5xx", func(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
// 未知 action 现在是 4xx,5xx 的日志行为改用真正的下发失败(写 socket 失败)来触发。
|
||||
sender := &strictDisplaySender{failWith: errors.New("写 socket 失败: broken pipe")}
|
||||
buf := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) {
|
||||
@@ -108,9 +110,9 @@ func TestInterceptLogLevels(t *testing.T) {
|
||||
client := recorderClient(t, env)
|
||||
|
||||
status, _ := env.do(client, http.MethodPost, "/api/display/command",
|
||||
map[string]any{"action": "nope"}, true)
|
||||
map[string]any{"action": "goto", "payload": map[string]any{"page": "medals"}}, true)
|
||||
if status != http.StatusInternalServerError {
|
||||
t.Fatalf("未知指令应 500,得到 %d", status)
|
||||
t.Fatalf("下发失败应 500,得到 %d", status)
|
||||
}
|
||||
logs := waitLog(t, buf, `"level":"ERROR"`)
|
||||
if !strings.Contains(logs, `"origin":"internal/httpapi/handlers_results.go:`) ||
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -137,7 +138,8 @@ func TestMiddlewareInterceptsLandInInterceptionFile(t *testing.T) {
|
||||
// 5xx 的归属:只进 interceptions/,不进 operations/。
|
||||
func TestServerErrorGoesToInterceptionFileOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sender := &strictDisplaySender{}
|
||||
// 未知 action 现在是 4xx;这里用真正的下发失败(写 socket 失败)造 5xx。
|
||||
sender := &strictDisplaySender{failWith: errors.New("写 socket 失败: broken pipe")}
|
||||
handler, closer := logging.New(logging.Options{Dir: dir, Level: slog.LevelInfo, Console: &bytes.Buffer{}})
|
||||
defer func() { _ = closer.Close() }()
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) {
|
||||
@@ -149,8 +151,8 @@ func TestServerErrorGoesToInterceptionFileOnly(t *testing.T) {
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
if status, _ := env.do(client, http.MethodPost, "/api/display/command",
|
||||
map[string]any{"action": "nope"}, true); status != http.StatusInternalServerError {
|
||||
t.Fatalf("未知指令应 500,得到 %d", status)
|
||||
map[string]any{"action": "goto", "payload": map[string]any{"page": "medals"}}, true); status != http.StatusInternalServerError {
|
||||
t.Fatalf("下发失败应 500,得到 %d", status)
|
||||
}
|
||||
|
||||
inter := waitCategory(t, filepath.Join(dir, logging.DirInterceptions), logging.DirInterceptions, "请求失败")
|
||||
|
||||
@@ -65,11 +65,13 @@ func logIntercepted(r *http.Request, logger *slog.Logger, api *apiError) {
|
||||
reqID := middleware.GetRequestID(r.Context())
|
||||
|
||||
if api.status >= http.StatusInternalServerError {
|
||||
// 5xx 的原始错误串(SQLite / 引擎)只落在这里,不进响应体:
|
||||
// 现场看日志能定位,前端拿不到内部表名列名。
|
||||
// 5xx 的原始错误串(SQLite / 引擎)在这里留痕,现场看日志能定位;
|
||||
// 响应体里也有一份(见 response.go 的 internalMsg),前端不需要翻服务端日志。
|
||||
// role / ip 与下面 4xx 的 WARN 同口径:按日志查「谁干的」时,5xx 不能缺一半线索。
|
||||
logger.Error("请求失败",
|
||||
"code", api.code, "err", api.message, "detail", api.logDetail, "origin", api.origin,
|
||||
"method", r.Method, "path", r.URL.Path, "actor", actor, "requestId", reqID)
|
||||
"method", r.Method, "path", r.URL.Path,
|
||||
"actor", actor, "role", role, "ip", middleware.ClientIP(r), "requestId", reqID)
|
||||
return
|
||||
}
|
||||
logger.Warn("请求被拦截",
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||||
)
|
||||
|
||||
// 写限流要真的拦得住(默认 30 次/分钟/用户):
|
||||
// 写限流要真的拦得住(默认 120 次/分钟/用户):
|
||||
// 之前没有任何 429 用例,限流键名写错、中间件漏挂都测不出来。
|
||||
// 这里把阈值压到 2,避免为了触发限流跑 31 次写请求。
|
||||
func TestWriteRateLimitReturns429(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -202,6 +203,43 @@ func decodeJSON(r *http.Request, dst any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxJSONBodyBytes 是普通 JSON 接口的请求体上限。
|
||||
// 此前只有上传 / 导入用 MaxBytesReader 限过大小,JSON 接口没有上限;
|
||||
// 配合下面的「整读一次」,必须补一个(8 MiB 对任何正当请求都够)。
|
||||
const maxJSONBodyBytes = 8 << 20
|
||||
|
||||
// keySet 是「请求体里真的出现过哪些键」。
|
||||
type keySet map[string]json.RawMessage
|
||||
|
||||
func (k keySet) has(key string) bool {
|
||||
_, ok := k[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// decodeJSONPresence 与 decodeJSON 一样解析请求体,另外回报「哪些键出现过」。
|
||||
//
|
||||
// 存在的理由:区分「字段没传」与「显式传了零值」。Go 的 bool 零值是 false,
|
||||
// 建单位 / 大项时漏传 active 会被静默写成「停用」,而停用的单位在界面上等于不存在 ——
|
||||
// 少写一个字段就变成另一种东西的接口,只能靠「键是否存在」判断调用方意图。
|
||||
func decodeJSONPresence(r *http.Request, dst any) (keySet, error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBodyBytes+1))
|
||||
if err != nil {
|
||||
return nil, badRequest("读取请求体失败", err.Error())
|
||||
}
|
||||
if len(raw) > maxJSONBodyBytes {
|
||||
return nil, tooLarge("请求体超过上限")
|
||||
}
|
||||
if err := json.Unmarshal(raw, dst); err != nil {
|
||||
return nil, badRequest("请求体不是合法 JSON", err.Error())
|
||||
}
|
||||
var keys keySet
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
// 顶层不是对象(例如是数组)时没有「键」的概念,交给 dst 的解析结果去报错。
|
||||
return keySet{}, nil
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// pageQuery 解析 ?page=&size=,默认第 1 页、每页 50 条,上限 500。
|
||||
func pageQuery(r *http.Request) (page, size int) {
|
||||
page = intQuery(r, "page", 1)
|
||||
|
||||
Reference in new issue
Block a user