From 8dd599bd97350c0a5f62ace4f67bc5e0c803ced0 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Fri, 11 Sep 2026 15:35:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=9E=E6=9C=BA=E5=A4=8D=E6=B5=8B?= =?UTF-8?q?=E5=85=AD=E6=89=B9=E5=8F=91=E7=8E=B0=E7=9A=84=2016=20=E6=9D=A1?= =?UTF-8?q?=E7=BC=BA=E9=99=B7=EF=BC=88=E5=AF=BC=E5=85=A5=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E9=94=99=E8=AF=AF=20/=20=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E8=A7=82=E6=84=9F=20/=20=E9=94=99=E8=AF=AF=E5=88=86=E7=BA=A7?= =?UTF-8?q?=20/=20=E5=A4=A7=E5=B1=8F=E8=A3=81=E5=88=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先把「实机复测」能在这台 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。 --- README.md | 2 +- VERSION | 2 +- docs/报名名单导入规范.md | 11 +- docs/部署指南.md | 2 + server/internal/auth/auth.go | 10 +- server/internal/auth/auth_test.go | 11 ++ server/internal/export/event.go | 9 +- server/internal/export/event_test.go | 166 ++++++++++++++++ server/internal/export/load.go | 19 +- server/internal/export/load_test.go | 70 +++++++ server/internal/export/medals.go | 3 +- server/internal/export/style.go | 29 +++ server/internal/export/style_test.go | 91 +++++++++ .../internal/httpapi/display_command_test.go | 17 +- server/internal/httpapi/error_surface_test.go | 23 ++- .../export_content_disposition_test.go | 72 +++++++ server/internal/httpapi/export_flat_test.go | 22 +-- server/internal/httpapi/export_handlers.go | 75 +++++-- .../httpapi/export_handlers_internal_test.go | 49 ++++- .../internal/httpapi/export_handlers_test.go | 30 +++ .../internal/httpapi/export_records_test.go | 11 +- server/internal/httpapi/export_teams_test.go | 11 +- server/internal/httpapi/handlers_config.go | 109 +++++++++-- .../handlers_import_encoding_fix_test.go | 185 ++++++++++++++++++ server/internal/httpapi/handlers_results.go | 9 + .../internal/httpapi/handlers_results_test.go | 12 +- server/internal/httpapi/intercept_log_test.go | 8 +- server/internal/httpapi/log_files_test.go | 8 +- server/internal/httpapi/origin.go | 8 +- server/internal/httpapi/ratelimit_api_test.go | 2 +- server/internal/httpapi/response.go | 38 ++++ server/internal/logging/logging.go | 12 ++ server/internal/middleware/middleware.go | 4 +- server/internal/realtime/client.go | 12 +- server/internal/realtime/hub.go | 9 +- web/src/display/components/pages/LivePage.vue | 36 +++- web/src/display/fitFont.ts | 117 ++++++++++- web/src/display/tests/fitFont.test.ts | 93 ++++++++- web/src/display/tests/live-page.test.ts | 17 ++ 39 files changed, 1308 insertions(+), 106 deletions(-) create mode 100644 server/internal/export/event_test.go create mode 100644 server/internal/export/style_test.go create mode 100644 server/internal/httpapi/export_content_disposition_test.go create mode 100644 server/internal/httpapi/handlers_import_encoding_fix_test.go diff --git a/README.md b/README.md index 9df4eca..e1a7fad 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ - **结果导出**:Excel 总表、全部(多 sheet)、**全部(单页)**、分项成绩表、单位积分明细可分别导出;「积分榜」页三个榜单的卡片头各有自己的明细导出——单位总分榜**导出总明细**(总表 + 每个有数据的单位一张分表)、团体奖牌榜**导出奖牌明细**(逐枚奖牌列出来源)、个人积分榜**导出个人明细**(逐条得分来源,与个人榜一致且不受折算系数影响),展开单位还能**导出该单位明细** - **日志可复盘**:服务端在数据目录 `logs\` 下按「放行 / 拦截」两类分目录落盘——`operations\` 记放行的写操作与常规运行日志,`interceptions\` 记我们主动拦下的报错与服务端异常;拦截日志带中文原因与**拦截点 `文件:行 函数名`**、操作人与 requestId,两类都按天 + 按大小轮转并自动清理旧文件(见部署指南第 10 节) - **成绩写法容错**:时间类接受 `66`、`66.33`、`1:06`、`1:06.33`、`1分06秒`、`1小时2分3秒` 等写法(全角冒号 / 全角数字也认),秒位 ≥ 60 会被拒但报错写明原因;成绩格里的示例是带「例」字的淡色斜体灰字,不会和已填内容混淆 -- **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo +- **单位 logo**:**大屏端**的积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo(个人榜显示在姓名前;管理端页面只负责上传与配置,不显示 logo) - **大屏字体可配**:标题字体与正文字体可在「系统设置 → 赛事信息 → 字体」里从四款内置字体中选,或上传自己的字体(ttf / otf / woff / woff2,单个不超过 20 MiB);只作用于大屏,加载失败自动回落默认字体,字体文件随赛事项目包一起导出/导入 ## 快速开始 diff --git a/VERSION b/VERSION index 1064c23..6f2059d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.1.4 +dev_0.1.5 diff --git a/docs/报名名单导入规范.md b/docs/报名名单导入规范.md index eb0361d..43e2946 100644 --- a/docs/报名名单导入规范.md +++ b/docs/报名名单导入规范.md @@ -170,7 +170,10 @@ |---|---|---| | 第 N 行:event_code / team_code 必填 | 这两格至少有一格是空的 | 每行都必须有小项编码和单位编码 | | 报名表缺少列 xxx | 表头少了这一列,或者列名被写成中文 / 有错别字 / 上面多了标题行 | 对照第二节的表头逐字核对;最稳的是重新下载模板,把数据粘进去 | -| 中文变成乱码 | 以前会这样(Excel 另存的 CSV 编码问题),**现在已修好** | 直接传原文件即可,不用转码 | +| 中文变成乱码 | 编码问题,**现在已修好**:Excel 另存的 GB18030 / GBK / UTF-8(带 BOM) / UTF-16(带 BOM) 都认 | **直接传原文件即可,不用转码**。唯一例外见下面两条 | +| 报名表编码无法识别:文件里混有无法解码的字节 | 这个文件**本身坏了**(复制粘贴、转换工具、半路截断都会这样):坏字节会被静默解成 `�`,导进去就是一串乱码人名,比报错更危险,所以系统直接拦下 | 用 Excel 打开**原文件**,另存为「CSV UTF-8」后再上传;如果另存后仍报同一句,说明原文件已经损坏,请找原始数据重新导出一份 | +| 文件像 UTF-16 但缺 BOM | 用记事本「另存为 Unicode」会产出**不带 BOM** 的 UTF-16;这种文件以前会被误报成「缺少列 event_code」,指向完全错误的方向 | 多数情况系统能自动认出字节序并正确导入;仍报此提示时,用 Excel 另存为「CSV UTF-8」 | +| 无法解析 CSV:表格里多半有没配对的双引号 | 格子里**手工**写了双引号,或只有一个引号没配对 | 让 Excel 重新另存一次(引号由 Excel 按规则加,别手工写);确实需要引号的,写成 `""` 转义 | | 第 N 行:小项 XXX 不存在 | `event_code` 写错,或该小项还没在系统里配 | 到「基础数据 → 大项 / 小项」核对编码;没配的先配 | | 第 N 行:单位 XX 不存在 | `team_code` 写错,或该单位还没配 | 到「基础数据 → 参赛单位」核对 | | 第 N 行:组别 XXX 不存在 | 组别名和系统里不一致,或该小项还没有这个组别 | 到「基础数据 → 组别」核对名称(注意别多字少字) | @@ -196,6 +199,7 @@ 3. 名单导入后,到「成绩录入」里录成绩、发布,积分榜就会自动更新。 4. **被「彻底删除」的人,导入时会以新人员建档**:管理员彻底删除某个人时,他的档案连同他名下的报名与成绩会一起从库里删掉;之后表里再出现同一个证件号或号码布,系统匹配不到旧档案,会把他当作**新人员**重新建档——名单里多出一条「以前删过的人」通常就是这么来的。新档案是全新的,他以前的报名、成绩与积分不会回来,需要重新录。 5. **被「彻底删除」的单位则相反,导入时会「复活」**:管理员彻底删除某个单位,只是把它从单位列表里移除,它的报名与成绩一条都没删;表里再出现这个 `team_code`,系统按编码复用原来那条单位记录,清掉删除标记并让它重新出现在列表里,**原有的报名、成绩与积分都还在**,也不会新建第二个同名单位。 +6. **别一口气连传几十次**:写操作限 **120 次/分钟/用户**,超了会返回「操作过于频繁,请稍后再试」。正常一次导入只算一次;真被限了就等一分钟再传,不要连点。 --- @@ -209,4 +213,7 @@ > 6. 拿不准的列**留空**,不要写「无」「/」「—」这类符号; > 7. 表里**不要合并单元格**、不要加标题行、不要插图; > 8. 填完先对着第五节的清单自查一遍,再发给收表人; -> 9. **批次(`heat`)**可以不填;要分批比赛就填 1–999 的整数,它只影响录入时怎么分组找人,**不影响成绩与名次**。 +> 9. **批次(`heat`)**可以不填;要分批比赛就填 1–999 的整数,它只影响录入时怎么分组找人,**不影响成绩与名次**; +> 10. **存成什么格式**:Excel →「另存为」→「**CSV UTF-8 (逗号分隔) (*.csv)**」最稳;中文 Excel 默认的「CSV (逗号分隔)」也能用。**别用「Unicode 文本」(UTF-16)**,**别手工改名或把两个文件拼起来**,也别在格子里手写双引号(让 Excel 自己加)。 +> +> 一句话:**下载模板 → 只往下面粘数据 → 另存为 CSV UTF-8 → 上传**。中间任何一个"顺手改一下"都可能让整份表被拦下。 diff --git a/docs/部署指南.md b/docs/部署指南.md index b1ffd8a..585b76f 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -478,6 +478,8 @@ http://<主机IP>:19540/display 3. 切换 = 服务端把项目名写进 `<数据目录>\active_project.txt` 后优雅退出,由 Console 自动拉起并打开新赛事(页面会显示「正在重启服务端…」,恢复后自动刷新,最多等 60 秒)。**所以切换需要 Console 在运行**;如果是不经 Console、直接双击 `scoreboard.exe` 跑的,切换后要自己把服务端重新启动一次; 4. 之后即使不带任何启动参数,服务端也仍然打开这个赛事(记忆文件生效);换了别的赛事再切一次即可。 +- **导出前先看一眼赛事名称**:名称里含 `/ \ : * ? " < > |`、超过 64 个字、或以点 / 空格结尾时**导不出来**——导出包的目录名取自赛事名称。现在这种非法名会返回一句明确的提示(不是「服务端异常」),按提示把「赛事信息 → 赛事名称」改掉再导即可; +- **切换后怎么确认真的切过去了**:看服务端日志里有没有 `msg=当前赛事项目 project=<项目名> source="记忆文件 active_project.txt"` 这一行。看到它才算切过去,再去核对榜单、成绩、单位 logo、大屏背景图与字体(图片有长缓存,必要时在大屏窗口按一次强制刷新); - 不能覆盖**正在运行**的赛事项目:对当前赛事的同名包,导入会被直接拒绝(先切到别的赛事再导); - 勾选「覆盖同名项目」时,旧目录会先改名保留、新项目落盘成功后才删除它; - 参赛单位 logo、会标、背景图与上传的字体都在 `uploads\` 里,所以**备份 / 迁移时要连 `event_projs\<项目名>\uploads\` 一起拷**(见 8.6); diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go index 888fedf..81f87ba 100644 --- a/server/internal/auth/auth.go +++ b/server/internal/auth/auth.go @@ -27,10 +27,14 @@ const ( DefaultSessionTTL = 12 * time.Hour // DefaultBcryptCost 满足文档「bcrypt cost ≥ 12」。 DefaultBcryptCost = 12 - // DefaultLoginPerMinute 登录限流:5 次/分钟/IP。 + // DefaultLoginPerMinute 登录限流:5 次/分钟/IP。防爆破用,别跟着写限流一起放大。 DefaultLoginPerMinute = 5 - // DefaultWritePerMinute 写操作限流:30 次/分钟/用户。 - DefaultWritePerMinute = 30 + // DefaultWritePerMinute 写操作限流:120 次/分钟/用户。 + // + // 原来定 30,现场一次批量导入 / 批量删除跑完就会撞上(第 7 次起全是 429), + // 表现为「操作到一半被限流」。单人现场录入的突发量本来就大,而且失败的请求也计数, + // 留 4 倍余量;登录限流另算,不受影响。 + DefaultWritePerMinute = 120 // MaxPasswordBytes 是 bcrypt 的输入上限;超过会被静默截断,必须显式拒绝。 MaxPasswordBytes = 72 ) diff --git a/server/internal/auth/auth_test.go b/server/internal/auth/auth_test.go index f3b4af2..26d922f 100644 --- a/server/internal/auth/auth_test.go +++ b/server/internal/auth/auth_test.go @@ -30,3 +30,14 @@ func TestMaxPasswordBytesMatchesBcrypt(t *testing.T) { t.Fatalf("MaxPasswordBytes 应为 bcrypt 的 72 字节上限,得到 %d", MaxPasswordBytes) } } + +// 写限流默认值:30 太低,现场一次批量导入跑完就整批 429,因此提到 120。 +// 登录限流是防爆破的,性质不同,保持 5 不变。 +func TestDefaultRateLimits(t *testing.T) { + if DefaultWritePerMinute != 120 { + t.Fatalf("写限流默认值应为 120 次/分钟/用户,得到 %d", DefaultWritePerMinute) + } + if DefaultLoginPerMinute != 5 { + t.Fatalf("登录限流默认值应保持 5 次/分钟/IP,得到 %d", DefaultLoginPerMinute) + } +} diff --git a/server/internal/export/event.go b/server/internal/export/event.go index 436209c..bab1902 100644 --- a/server/internal/export/event.go +++ b/server/internal/export/event.go @@ -37,11 +37,16 @@ type EventsWorkbook struct { Sheets []EventSheet } +// eventColumns 的列宽按「最长实际内容」预留:名次并列时写成「3(并列)」, +// 列宽 8 且这一类列不折行,右边又紧挨非空的「号码」,在 Excel 里会被硬裁;取 11 才能连 +// 「10(并列)」这种两位数并列一起放下。运动员/队 是接力队名单(「王磊、赵雷、陈刚2、郑凯」 +// 约 23 个显示宽度),18 会折成两行,24 够放 4~6 人名单。加宽只影响 fitToWidth=1 的打印 +// 缩放比例(整表总宽 105→114,A4 横向仍是一页),不会把表推到第二页;再宽就得重新权衡。 var eventColumns = []column{ - {"名次", 8, kindCenter}, + {"名次", 11, kindCenter}, {"号码", 9, kindCenter}, {"单位", 22, kindText}, - {"运动员/队", 18, kindText}, + {"运动员/队", 24, kindText}, {"成绩", 12, kindCenter}, {"积分", 8, kindNumber}, {"状态", 8, kindCenter}, diff --git a/server/internal/export/event_test.go b/server/internal/export/event_test.go new file mode 100644 index 0000000..8f3f2ac --- /dev/null +++ b/server/internal/export/event_test.go @@ -0,0 +1,166 @@ +package export + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/xuri/excelize/v2" +) + +// seedTieAndRelay 在 seedEvent 之上补两类现场常见、却最容易把列宽顶爆的数据: +// 并列名次(名次列会写成「3(并列)」)与接力队名单(运动员/队 列写成顿号列表)。 +func seedTieAndRelay(t *testing.T, db *sql.DB) { + t.Helper() + stmts := []string{ + `UPDATE results SET rank = 3, is_tie = 1`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (1, 'E2', '4×100米接力', 'team', 'auto', 'time', 'asc', 'published')`, + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`, + `INSERT INTO event_score_channels(event_id, scope, basis, profile_id) + SELECT 2, 'team', 'result', id FROM scoring_profiles WHERE code = 'team_top6'`, + `INSERT INTO entries(event_id, category_id, team_id, member_names, bib) + VALUES (2, 2, 1, '["王磊","赵雷","陈刚2","郑凯"]', '701')`, + `INSERT INTO results(event_id, entry_id, rank, result_text, result_value, unit, state) + VALUES (2, 3, 1, '0:44.12', 44.12, 's', 'published')`, + `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points) + VALUES (1, NULL, 2, 3, 3, 'result', 3, 15)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } +} + +// TestColumnsFitContent 守住「不折行的列必须放得下实际内容」: +// 名次列写「3(并列)」、运动员/队 列写接力队名单,列宽不够时 Excel 会把右侧的字硬裁掉 +// (这一类列没有折行),领导拿到的表就是缺字的。kindText 列有折行,超宽只会换行,不在此列。 +func TestColumnsFitContent(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + seedTieAndRelay(t, db) + + cases := []struct { + name string + file string + export func(dst string) error + sheets []sheetLayout + }{ + { + name: "分项成绩表", + file: "分项.xlsx", + export: func(dst string) error { return ExportEventXLSX(db, 0, dst, Options{}) }, + sheets: []sheetLayout{ + {"田径·100米跑·男子", eventColumns}, + {"田径·4×100米接力·男子", eventColumns}, + }, + }, + { + name: "全部成绩(单页)", + file: "单页.xlsx", + export: func(dst string) error { return ExportFlatXLSX(db, dst, Options{}) }, + sheets: []sheetLayout{ + {flatSheet, flatColumns}, + }, + }, + { + name: "团体奖牌明细", + file: "奖牌.xlsx", + export: func(dst string) error { return ExportMedalDetailXLSX(db, dst, Options{}) }, + sheets: []sheetLayout{ + {medalDetailSheet, medalDetailColumns}, + {"第一分公司奖牌明细", medalTeamColumns}, + }, + }, + } + + dir := t.TempDir() + for _, tc := range cases { + dst := filepath.Join(dir, tc.file) + if err := tc.export(dst); err != nil { + t.Fatalf("导出%s: %v", tc.name, err) + } + f := openXLSX(t, dst) + for _, sl := range tc.sheets { + assertColumnsFit(t, f, sl.sheet, sl.columns) + } + if err := f.Close(); err != nil { + t.Fatalf("关闭 %s: %v", dst, err) + } + } +} + +// sheetLayout 是一张表的列定义,用来知道哪些列不折行。 +type sheetLayout struct { + sheet string + columns []column +} + +// participantTitle 是接力队名单所在列的表头,分项成绩表与奖牌明细都印这一列。 +const participantTitle = "运动员/队" + +// assertColumnsFit 逐列核对:不折行的列,列宽必须放得下该列最长的一条数据。 +func assertColumnsFit(t *testing.T, f *excelize.File, sheet string, cols []column) { + t.Helper() + rows, err := f.GetRows(sheet) + if err != nil { + t.Fatalf("读取 %s: %v", sheet, err) + } + for i, c := range cols { + // 折行列(kindText)超宽只会换行,不丢字;但「运动员/队」是接力队名单, + // 一个名单折成两三行会把每页能印的行数压掉一半,所以它也按「一行放得下」要求。 + if c.kind == kindText && c.title != participantTitle { + continue + } + name, err := excelize.ColumnNumberToName(i + 1) + if err != nil { + t.Fatalf("第 %d 列的列名: %v", i+1, err) + } + width, err := f.GetColWidth(sheet, name) + if err != nil { + t.Fatalf("读取 %s!%s 的列宽: %v", sheet, name, err) + } + if width != c.width { + t.Errorf("%s「%s」列宽 = %v,期望 %v(版式与列定义应同源)", sheet, c.title, width, c.width) + } + // 前 4 行是标题 / 副标题 / 空行 / 表头,数据从第 5 行(下标 4)起。 + for r := 4; r < len(rows); r++ { + v := cell(rows, r, i) + w := displayWidth(v) + if w <= width { + continue + } + avail := width + if c.kind != kindText { + // 不折行的列:右侧连着空格时 Excel 会让文字溢出显示,真正被裁的是 + // 「溢出到有内容的那一列」——名次列右边紧挨着非空的号码,正是缺陷现场。 + for j := i + 1; j < len(cols) && cell(rows, r, j) == ""; j++ { + avail += cols[j].width + } + } + if w > avail { + t.Errorf("%s 第 %d 行「%s」列 = %q 需要 %v 个显示宽度,右侧可用的只有 %v,会被裁掉或折行", + sheet, r+1, c.title, v, w, avail) + } + } + } +} + +// displayWidth 按 Excel 的粗略口径估算显示宽度:东亚全角字符(汉字、全角括号、顿号)算 2, +// 其余算 1。列宽就是按这个口径定的,用它判断一格会不会被裁。 +func displayWidth(s string) float64 { + var w float64 + for _, r := range s { + switch { + case r >= 0x1100 && (r <= 0x115F || r == 0x2329 || r == 0x232A || + (r >= 0x2E80 && r <= 0xA4CF) || (r >= 0xAC00 && r <= 0xD7A3) || + (r >= 0xF900 && r <= 0xFAFF) || (r >= 0xFE30 && r <= 0xFE6F) || + (r >= 0xFF00 && r <= 0xFF60) || (r >= 0xFFE0 && r <= 0xFFE6)): + w += 2 + default: + w++ + } + } + return w +} diff --git a/server/internal/export/load.go b/server/internal/export/load.go index 6eac064..f221994 100644 --- a/server/internal/export/load.go +++ b/server/internal/export/load.go @@ -541,8 +541,13 @@ func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) { } func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) { + // 聚合通道(basis=aggregate,即「按队内平均」)的流水 entry_id / result_id 都为空, + // 从 entries 取不到组别。这类流水是「单位在本组别的成绩取平均」的产物,组别在算分时已经确定; + // 库里没把它落到流水上,只能回推:单位在本小项只参加了一个组别时,那个组别就是答案(ac 子查询)。 + // 参加了多个组别时回推不出来(同一 (小项, 单位) 有多条无法区分的聚合流水),宁可留空也不猜。 + // 回推只对聚合流水生效(与 isAggregateLedger 同判据),人工调整等其它无 entry_id 的流水不受影响。 rows, err := db.Query(` - SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''), + SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''), COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''), l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0) FROM score_ledger l @@ -550,10 +555,20 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) { LEFT JOIN sports s ON s.id = e.sport_id LEFT JOIN entries en ON en.id = l.entry_id LEFT JOIN event_categories c ON c.id = en.category_id + LEFT JOIN ( + SELECT en2.event_id, MIN(en2.category_id) AS category_id + FROM entries en2 + WHERE en2.team_id = ? + GROUP BY en2.event_id + HAVING COUNT(DISTINCT en2.category_id) = 1 + ) ac ON ac.event_id = l.event_id AND l.entry_id IS NULL AND l.result_id IS NULL + AND l.source_type = 'result' + LEFT JOIN event_categories ca ON ca.id = ac.category_id LEFT JOIN results r ON r.id = l.result_id LEFT JOIN athletes a ON a.id = l.athlete_id WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1 - ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(r.rank, 999999), l.id`, teamID) + ORDER BY s.sort_order, e.sort_order, e.id, c.sort_order, COALESCE(r.rank, 999999), l.id`, + teamID, teamID) if err != nil { return nil, fmt.Errorf("export: 读取单位 %d 积分明细: %w", teamID, err) } diff --git a/server/internal/export/load_test.go b/server/internal/export/load_test.go index 71566fe..5c884f7 100644 --- a/server/internal/export/load_test.go +++ b/server/internal/export/load_test.go @@ -192,6 +192,76 @@ func TestTeamDetailTotalAppliesWeight(t *testing.T) { } } +// 「按队内平均」(basis=aggregate)产生的流水 entry_id 为空,从 entries 取不到组别; +// 单位在本小项只参加一个组别时那个组别就是唯一答案,导出必须把它补上,不能留空。 +// 运动员 / 名次 / 成绩留空是合理的:聚合分不对应单个运动员。 +func TestTeamDetailAggregateRowKeepsCategory(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + // 模拟聚合通道产出的流水:entry_id 与 result_id 都为空(见 isAggregateLedger)。 + if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, + source_type, source_id, points, count_to_team) + VALUES (1, NULL, 1, NULL, NULL, 'result', 99, 15, 1)`); err != nil { + t.Fatalf("写入聚合流水: %v", err) + } + + wb, err := LoadTeamDetailWorkbook(db, 1, Options{}) + if err != nil { + t.Fatalf("读取积分明细: %v", err) + } + agg, ok := findAggregateRow(wb.Rows) + if !ok { + t.Fatalf("聚合流水没出现在明细里: %+v", wb.Rows) + } + if agg.Category != "男子" { + t.Errorf("聚合流水的组别 = %q,期望 男子", agg.Category) + } + if agg.AthleteName != "" || agg.Rank != 0 || agg.ResultText != "" { + t.Errorf("聚合流水不该带单个运动员的信息: %+v", agg) + } +} + +// 单位在同一小项参加了多个组别时,库里同一 (小项, 单位) 有多条内容相同、无法区分的聚合流水, +// 回推不出组别;导出宁可留空也不猜(要修得先往 score_ledger 上落组别,属于模型改动)。 +func TestTeamDetailAggregateRowAmbiguousCategoryStaysEmpty(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + stmts := []string{ + `INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子', 2)`, + `INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '女', 1)`, + `INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 2, 1, 3, '103')`, + `INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points, count_to_team) + VALUES (1, NULL, 1, NULL, NULL, 'result', 99, 15, 1)`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } + + wb, err := LoadTeamDetailWorkbook(db, 1, Options{}) + if err != nil { + t.Fatalf("读取积分明细: %v", err) + } + agg, ok := findAggregateRow(wb.Rows) + if !ok { + t.Fatalf("聚合流水没出现在明细里: %+v", wb.Rows) + } + if agg.Category != "" { + t.Errorf("无法回推的聚合流水组别 = %q,期望留空", agg.Category) + } +} + +// findAggregateRow 找出那条聚合流水(测试里用 15 分做标记,种子数据只有 10 与 8 分)。 +func findAggregateRow(rows []TeamDetailRow) (TeamDetailRow, bool) { + for _, r := range rows { + if r.Points == 15 { + return r, true + } + } + return TeamDetailRow{}, false +} + // 「个人+团体」项目的个人通道流水(count_to_team = 0)不进单位总分, // 导出侧与 REST / WS 快照必须同口径。 func TestLoadStandingsSkipsPersonalOnlyLedger(t *testing.T) { diff --git a/server/internal/export/medals.go b/server/internal/export/medals.go index 0870668..d37af33 100644 --- a/server/internal/export/medals.go +++ b/server/internal/export/medals.go @@ -60,7 +60,8 @@ var medalDetailColumns = []column{ {"组别", 14, kindText}, {"名次", 8, kindCenter}, {"奖牌", 8, kindCenter}, - {"运动员/队", 18, kindText}, + // 与分项成绩表的「运动员/队」同宽:奖牌明细同样会印接力队名单。 + {"运动员/队", 24, kindText}, } // medalTeamColumns 是单位分表的列:与总表一致,去掉「单位」列。 diff --git a/server/internal/export/style.go b/server/internal/export/style.go index a88cf86..2df01d2 100644 --- a/server/internal/export/style.go +++ b/server/internal/export/style.go @@ -147,6 +147,9 @@ func (s *styleSet) cell(kind cellKind, band bool) int { // build 若不新建任何工作表,就把默认工作表改名为 defaultSheet,避免出现空白的 "Sheet1"。 func buildWorkbook(defaultSheet string, build func(f *excelize.File, st *styleSet) error) (*excelize.File, error) { f := excelize.NewFile() + if err := setDocProps(f); err != nil { + return nil, err + } st, err := newStyleSet(f) if err != nil { return nil, err @@ -168,6 +171,32 @@ func buildWorkbook(defaultSheet string, build func(f *excelize.File, st *styleSe return f, nil } +// 文档属性里的作者与应用名。这属于对外可见的元数据(Excel「文件 → 信息」), +// 用系统名即可;绝不能写内部署名——那是构建期注入、只应出现在管理端「关于」页的东西。 +const ( + docAuthor = "赛事积分榜系统" + docApplication = "赛事积分榜系统" +) + +// setDocProps 覆盖 excelize 的默认文档属性(作者 xuri、创建/修改时间 2006-09-16): +// 领导在「文件 → 信息」里看到的是这份导出件的元数据,不该带第三方库作者名和一个假日期。 +// 时间取导出当下——它表示这份文件的生成时间,与二进制何时构建无关。 +func setDocProps(f *excelize.File) error { + now := time.Now().Format(time.RFC3339) + if err := f.SetDocProps(&excelize.DocProperties{ + Creator: docAuthor, + LastModifiedBy: docAuthor, + Created: now, + Modified: now, + }); err != nil { + return fmt.Errorf("export: 设置文档属性: %w", err) + } + if err := f.SetAppProps(&excelize.AppProperties{Application: docApplication}); err != nil { + return fmt.Errorf("export: 设置应用属性: %w", err) + } + return nil +} + func addSheet(f *excelize.File, name string) error { if _, err := f.NewSheet(name); err != nil { return fmt.Errorf("export: 新建工作表 %s: %w", name, err) diff --git a/server/internal/export/style_test.go b/server/internal/export/style_test.go new file mode 100644 index 0000000..4392834 --- /dev/null +++ b/server/internal/export/style_test.go @@ -0,0 +1,91 @@ +package export + +import ( + "path/filepath" + "strings" + "testing" + "time" +) + +// 导出件的文档属性不能用 excelize 的默认值(作者 xuri、创建/修改时间 2006-09-16): +// 这几个字段会直接出现在 Excel 的「文件 → 信息」里。作者用系统名,时间用导出当下。 +func TestExportDocPropsAreNeutral(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + dst := filepath.Join(t.TempDir(), "总表.xlsx") + before := time.Now() + if err := ExportStandingsXLSX(db, dst, Options{}); err != nil { + t.Fatalf("导出总表: %v", err) + } + after := time.Now() + + f := openXLSX(t, dst) + defer func() { _ = f.Close() }() + + props, err := f.GetDocProps() + if err != nil { + t.Fatalf("读取文档属性: %v", err) + } + if props.Creator != docAuthor { + t.Errorf("作者 = %q,期望 %q", props.Creator, docAuthor) + } + if props.LastModifiedBy != docAuthor { + t.Errorf("最后修改者 = %q,期望 %q", props.LastModifiedBy, docAuthor) + } + if strings.Contains(props.Creator+props.LastModifiedBy, "xuri") { + t.Errorf("文档属性里不该出现 excelize 的作者名:%+v", props) + } + created, err := time.Parse(time.RFC3339, props.Created) + if err != nil { + t.Fatalf("创建时间 %q 不是 RFC3339: %v", props.Created, err) + } + if created.Before(before.Add(-time.Minute)) || created.After(after.Add(time.Minute)) { + t.Errorf("创建时间 = %v,不在本次导出的时间窗内", created) + } + if props.Modified != props.Created { + t.Errorf("修改时间 = %q,期望与创建时间同为 %q", props.Modified, props.Created) + } + + app, err := f.GetAppProps() + if err != nil { + t.Fatalf("读取应用属性: %v", err) + } + if app.Application != docApplication { + t.Errorf("Application = %q,期望 %q", app.Application, docApplication) + } +} + +// 所有 xlsx 导出都走 buildWorkbook,文档属性必须一处设好、处处生效。 +func TestEveryWorkbookCarriesDocProps(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + + dir := t.TempDir() + exports := map[string]func(string) error{ + "总表.xlsx": func(dst string) error { return ExportStandingsXLSX(db, dst, Options{}) }, + "分项.xlsx": func(dst string) error { return ExportEventXLSX(db, 1, dst, Options{}) }, + "单页.xlsx": func(dst string) error { return ExportFlatXLSX(db, dst, Options{}) }, + "奖牌.xlsx": func(dst string) error { return ExportMedalDetailXLSX(db, dst, Options{}) }, + "个人明细.xlsx": func(dst string) error { return ExportAthleteDetailXLSX(db, dst, Options{}) }, + "单位明细.xlsx": func(dst string) error { return ExportTeamDetailXLSX(db, 1, dst, Options{}) }, + "单位总明细.xlsx": func(dst string) error { return ExportAllTeamDetailsXLSX(db, dst, Options{}) }, + } + for name, run := range exports { + dst := filepath.Join(dir, name) + if err := run(dst); err != nil { + t.Fatalf("导出 %s: %v", name, err) + } + f := openXLSX(t, dst) + props, err := f.GetDocProps() + if err != nil { + t.Fatalf("读取 %s 的文档属性: %v", name, err) + } + if props.Creator != docAuthor { + t.Errorf("%s 作者 = %q,期望 %q", name, props.Creator, docAuthor) + } + if err := f.Close(); err != nil { + t.Fatalf("关闭 %s: %v", name, err) + } + } +} diff --git a/server/internal/httpapi/display_command_test.go b/server/internal/httpapi/display_command_test.go index 0c8ebc8..4b02f9e 100644 --- a/server/internal/httpapi/display_command_test.go +++ b/server/internal/httpapi/display_command_test.go @@ -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) diff --git a/server/internal/httpapi/error_surface_test.go b/server/internal/httpapi/error_surface_test.go index d4c5037..788ba7f 100644 --- a/server/internal/httpapi/error_surface_test.go +++ b/server/internal/httpapi/error_surface_test.go @@ -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: diff --git a/server/internal/httpapi/export_content_disposition_test.go b/server/internal/httpapi/export_content_disposition_test.go new file mode 100644 index 0000000..9da4d25 --- /dev/null +++ b/server/internal/httpapi/export_content_disposition_test.go @@ -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) + } + } +} diff --git a/server/internal/httpapi/export_flat_test.go b/server/internal/httpapi/export_flat_test.go index 6b690aa..b1023ea 100644 --- a/server/internal/httpapi/export_flat_test.go +++ b/server/internal/httpapi/export_flat_test.go @@ -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) diff --git a/server/internal/httpapi/export_handlers.go b/server/internal/httpapi/export_handlers.go index e8fde32..20e5b8e 100644 --- a/server/internal/httpapi/export_handlers.go +++ b/server/internal/httpapi/export_handlers.go @@ -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) { diff --git a/server/internal/httpapi/export_handlers_internal_test.go b/server/internal/httpapi/export_handlers_internal_test.go index 92c19cd..4413815 100644 --- a/server/internal/httpapi/export_handlers_internal_test.go +++ b/server/internal/httpapi/export_handlers_internal_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/export_handlers_test.go b/server/internal/httpapi/export_handlers_test.go index 4d70b77..d79566c 100644 --- a/server/internal/httpapi/export_handlers_test.go +++ b/server/internal/httpapi/export_handlers_test.go @@ -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) diff --git a/server/internal/httpapi/export_records_test.go b/server/internal/httpapi/export_records_test.go index 7c98125..6941a4f 100644 --- a/server/internal/httpapi/export_records_test.go +++ b/server/internal/httpapi/export_records_test.go @@ -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() }() diff --git a/server/internal/httpapi/export_teams_test.go b/server/internal/httpapi/export_teams_test.go index 3202ba7..4e0bb78 100644 --- a/server/internal/httpapi/export_teams_test.go +++ b/server/internal/httpapi/export_teams_test.go @@ -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{"第一分公司积分明细"}) { diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index 8e1663a..f3e66ff 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -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) } } diff --git a/server/internal/httpapi/handlers_import_encoding_fix_test.go b/server/internal/httpapi/handlers_import_encoding_fix_test.go new file mode 100644 index 0000000..d4c8515 --- /dev/null +++ b/server/internal/httpapi/handlers_import_encoding_fix_test.go @@ -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) + } + }) +} diff --git a/server/internal/httpapi/handlers_results.go b/server/internal/httpapi/handlers_results.go index 4af7142..eae0e86 100644 --- a/server/internal/httpapi/handlers_results.go +++ b/server/internal/httpapi/handlers_results.go @@ -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 diff --git a/server/internal/httpapi/handlers_results_test.go b/server/internal/httpapi/handlers_results_test.go index efe4f0d..8042978 100644 --- a/server/internal/httpapi/handlers_results_test.go +++ b/server/internal/httpapi/handlers_results_test.go @@ -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 { diff --git a/server/internal/httpapi/intercept_log_test.go b/server/internal/httpapi/intercept_log_test.go index 34119c9..f030e3f 100644 --- a/server/internal/httpapi/intercept_log_test.go +++ b/server/internal/httpapi/intercept_log_test.go @@ -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:`) || diff --git a/server/internal/httpapi/log_files_test.go b/server/internal/httpapi/log_files_test.go index 1e95060..ed6df94 100644 --- a/server/internal/httpapi/log_files_test.go +++ b/server/internal/httpapi/log_files_test.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, "请求失败") diff --git a/server/internal/httpapi/origin.go b/server/internal/httpapi/origin.go index c77bd0d..3589c04 100644 --- a/server/internal/httpapi/origin.go +++ b/server/internal/httpapi/origin.go @@ -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("请求被拦截", diff --git a/server/internal/httpapi/ratelimit_api_test.go b/server/internal/httpapi/ratelimit_api_test.go index 2146220..c1cc6fd 100644 --- a/server/internal/httpapi/ratelimit_api_test.go +++ b/server/internal/httpapi/ratelimit_api_test.go @@ -13,7 +13,7 @@ import ( "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" ) -// 写限流要真的拦得住(默认 30 次/分钟/用户): +// 写限流要真的拦得住(默认 120 次/分钟/用户): // 之前没有任何 429 用例,限流键名写错、中间件漏挂都测不出来。 // 这里把阈值压到 2,避免为了触发限流跑 31 次写请求。 func TestWriteRateLimitReturns429(t *testing.T) { diff --git a/server/internal/httpapi/response.go b/server/internal/httpapi/response.go index 587a78e..1e8a579 100644 --- a/server/internal/httpapi/response.go +++ b/server/internal/httpapi/response.go @@ -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) diff --git a/server/internal/logging/logging.go b/server/internal/logging/logging.go index aba6871..3edc437 100644 --- a/server/internal/logging/logging.go +++ b/server/internal/logging/logging.go @@ -2,6 +2,7 @@ package logging import ( "context" + "fmt" "io" "log/slog" "os" @@ -62,6 +63,17 @@ func New(opts Options) (slog.Handler, io.Closer) { }) } + // 两个目录在启动时就建好。拦截类目录原先只在「第一次拦截发生」时才被创建, + // 现场验收时会以为「目录不存在 = 日志坏了」——排查工具本身不能让人起疑。 + // 建不出来不影响启动,交给 warn 走降级告警。 + if dir != "" { + for _, sub := range []string{DirOperations, DirInterceptions} { + if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { + warn(fmt.Errorf("创建日志目录 %s: %w", sub, err)) + } + } + } + ops := newRotator(filepath.Join(dir, DirOperations), operationsPrefix, maxBytes, keep, warn) inter := newRotator(filepath.Join(dir, DirInterceptions), interceptionPrefix, maxBytes, keep, warn) diff --git a/server/internal/middleware/middleware.go b/server/internal/middleware/middleware.go index 506b00c..ff76d83 100644 --- a/server/internal/middleware/middleware.go +++ b/server/internal/middleware/middleware.go @@ -228,7 +228,9 @@ func sameOrigin(r *http.Request) bool { return strings.EqualFold(parsed.Host, r.Host) } -// RateLimitWrite 对已登录用户的写操作限流(默认 30 次/分钟/用户)。 +// RateLimitWrite 对已登录用户的写操作限流。 +// 阈值只写在 auth.DefaultWritePerMinute 一处,这里不复述具体数字—— +// 写死一个副本,改常量时必然漏改(本轮就从 30 提到 120 而漏了这句注释)。 func RateLimitWrite(svc *auth.Service, logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/server/internal/realtime/client.go b/server/internal/realtime/client.go index 9ea3a39..6f25192 100644 --- a/server/internal/realtime/client.go +++ b/server/internal/realtime/client.go @@ -3,6 +3,7 @@ package realtime import ( "context" "encoding/json" + "strings" "sync" "sync/atomic" "time" @@ -165,7 +166,16 @@ func (c *client) handleMessage(ctx context.Context, data []byte) { c.hub.sendInitial(ctx, c, nil) default: - c.hub.log.Debug("忽略未知实时事件", "event", env.Event, "remote", c.remoteAddr) + // 客户端最常见的错法:只发了 payload,没套 `{event,data}` 外壳。 + // 那种帧解出来 Event == "",以前只在 DEBUG 里留一行 —— 现场表现是 + // 「连上了但一直没数据,15 秒后被断开」,而且客户端拿不到任何提示。 + // 这里升到 WARN 并把「该怎么发」写进日志,省掉一次翻源码。 + if strings.TrimSpace(env.Event) == "" { + c.hub.log.Warn("实时帧缺少 event 字段,已忽略(C→S 帧同样要套 {event,data} 外壳)", + "remote", c.remoteAddr) + return + } + c.hub.log.Warn("忽略未知实时事件", "event", env.Event, "remote", c.remoteAddr) } } diff --git a/server/internal/realtime/hub.go b/server/internal/realtime/hub.go index f952f6c..71f909c 100644 --- a/server/internal/realtime/hub.go +++ b/server/internal/realtime/hub.go @@ -3,6 +3,7 @@ package realtime import ( "context" "database/sql" + "errors" "fmt" "log/slog" "net/http" @@ -546,6 +547,11 @@ func (h *Hub) SendCommandTo(room string, cmd DisplayCommand) { } } +// ErrUnknownAction 表示指令的 action 不在协议清单内。 +// 它不是服务端异常:action 是请求体入参,HTTP 层据此用 errors.Is 把它映射成 400, +// 只有真正的下发失败(写 socket 失败等)才该按 5xx 处理。 +var ErrUnknownAction = errors.New("realtime: 未知的大屏指令") + // SendCommandToInstances 按实例定向下发 display:command,返回命中的实例数。 // // targets 是要控制的 instanceID(GET /api/display/instances 里的 id);为空时退化成广播, @@ -554,7 +560,8 @@ func (h *Hub) SendCommandTo(room string, cmd DisplayCommand) { // 定向指令需要一个明确的失败信号。 func (h *Hub) SendCommandToInstances(targets []string, cmd DisplayCommand) (int, error) { if !ValidDisplayAction(cmd.Action) { - return 0, fmt.Errorf("未知的大屏指令 %q", cmd.Action) + // 包上哨兵,调用方才能把「入参非法」与「下发失败」分开映射成 4xx / 5xx。 + return 0, fmt.Errorf("未知的大屏指令 %q: %w", cmd.Action, ErrUnknownAction) } if len(targets) == 0 { h.SendCommand(cmd) diff --git a/web/src/display/components/pages/LivePage.vue b/web/src/display/components/pages/LivePage.vue index 9bab00c..9e7e4ce 100644 --- a/web/src/display/components/pages/LivePage.vue +++ b/web/src/display/components/pages/LivePage.vue @@ -1,6 +1,6 @@