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:
AskaEth committed 2026-09-11 15:35:42 +08:00
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)
+17 -6
View File
@@ -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)
}
}
}
+8 -14
View File
@@ -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)
+64 -11
View File
@@ -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() }()
+2 -9
View File
@@ -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{"第一分公司积分明细"}) {
+98 -11
View File
@@ -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:`) ||
+5 -3
View File
@@ -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, "请求失败")
+5 -3
View File
@@ -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) {
+38
View File
@@ -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)