fix: 实机复测六批发现的 16 条缺陷(导入编码静默错误 / 导出观感 / 错误分级 / 大屏裁切)
先把「实机复测」能在这台 Linux 机器上验的全部自己验掉(真服务端 + 真 Chromium + 真样本),
六批全部通过,另挖出 16 条缺陷,逐条修完并复验:
导入
- 坏字节被静默替换成 U+FFFD 却报「组别不存在」→ 检测 RuneError 回 400 + 可操作中文文案
- UTF-16 无 BOM 误报「缺少列 event_code」→ 保守判据识别并正确解码
- CSV 语法错只有英文 → 中文提示,英文原文保留在 detail
- 表头缺列补「第 1 行应是表头」的指向
导出
- 名次列宽 8 → 11(放得下「3(并列)」,Excel 里不再硬裁)
- 运动员/队列宽 18 → 24(含奖牌明细的同一列)
- 聚合流水行补组别(仅单位在该小项只参加一个组别时回推;多组别宁可留空也不猜)
- docProps 作者从 excelize 默认的 xuri 改成中性名 + 真实导出时间
- Content-Disposition 补 ASCII 退化 filename(9 个下载端点,含此前没有测试的 project.zip)
错误分级与限流
- 未知大屏指令 500 → 400 VALIDATION_FAILED(传输层真失败仍走 5xx)
- 5xx 的 ERROR 日志补 role / ip(4xx 的 WARN 一直有)
- 写限流 30 → 120 次/分钟/用户(登录 5 次/分/IP 不动,性质不同)
- 赛事名含非法字符时导出给 400 + 可操作提示(原来永远 500「服务端异常」)
大屏与基础设施
- 实时赛况 2 栏成绩末位被裁(1:09.00 显示成 1:09.0)→ 成绩列按内容定宽 + nowrap;
字号不缩,让位给可省略号截断的运动员列。真 Chromium 实测 slack −45px → +6px、0 裁切、
scrollWidth 恒 1920(无横向滚动条);顺带修掉 1 栏长成绩同样被裁 8px 的问题
- logging 启动即预建两个日志目录(原来 interceptions/ 懒创建,现场会以为日志坏了)
- C→S 裸帧由静默忽略改为 WARN,并把「要套 {event,data} 外壳」写进日志
- 建单位/大项漏传 active 不再静默建成「停用」(新增 decodeJSONPresence 按键是否存在判意图)
文档
- README 单位 logo 措辞收窄到「大屏端」(管理端只负责上传配置)
- 导入规范补三类编码报错说明 + 现场另存结论 + 限流提示
- 部署指南补「导出前先看赛事名称」与「切换后怎么确认真的切过去了」
- devdocs 05/08/13 同步(含第三十五轮的完整记录与教训)
自检:服务端 13 包连跑两遍全绿;前端 257 例;vue-tsc + build 通过;Console 18 文件。
版本号 dev_0.1.4 → dev_0.1.5。
This commit is contained in:
1 parent
a52e6a4a3b
commit
8dd599bd97
39 files changed
+1305
-103
No files matched your search
@@ -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);只作用于大屏,加载失败自动回落默认字体,字体文件随赛事项目包一起导出/导入
|
||||
|
||||
## 快速开始
|
||||
|
||||
+9
-2
@@ -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 → 上传**。中间任何一个"顺手改一下"都可能让整份表被拦下。
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -60,7 +60,8 @@ var medalDetailColumns = []column{
|
||||
{"组别", 14, kindText},
|
||||
{"名次", 8, kindCenter},
|
||||
{"奖牌", 8, kindCenter},
|
||||
{"运动员/队", 18, kindText},
|
||||
// 与分项成绩表的「运动员/队」同宽:奖牌明细同样会印接力队名单。
|
||||
{"运动员/队", 24, kindText},
|
||||
}
|
||||
|
||||
// medalTeamColumns 是单位分表的列:与总表一致,去掉「单位」列。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,15 +192,26 @@ func TestDisplayEndpointsRequireRecorder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 未知 action 仍要被实时层拒收(走 strictDisplaySender 的同口径校验)。
|
||||
// 未知 action 仍要被实时层拒收(走 strictDisplaySender 的同口径校验),
|
||||
// 但它是请求体入参不合法:按 400 报,现场才看得懂要改 action 而不是「服务端异常」。
|
||||
func TestDisplayCommandRejectsUnknownAction(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
env, client := newDisplayEnv(t, sender)
|
||||
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/display/command",
|
||||
map[string]any{"action": "nope"}, true)
|
||||
if status != http.StatusInternalServerError || env2.Error == nil {
|
||||
t.Fatalf("未知 action 应被拒收,得到 %d %+v", status, env2.Error)
|
||||
if status != http.StatusBadRequest || env2.Error == nil {
|
||||
t.Fatalf("未知 action 应被拒收为 400,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if env2.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("未知 action 的错误码应为 VALIDATION_FAILED,得到 %+v", env2.Error)
|
||||
}
|
||||
// message 保持实时层那句中文原文(现场要看懂),detail 带上原始 action(便于检索)。
|
||||
if !strings.Contains(env2.Error.Message, "未知的大屏指令") {
|
||||
t.Fatalf("message 应保持可读的中文原文,得到 %q", env2.Error.Message)
|
||||
}
|
||||
if got := string(env2.Error.Detail); got != `"nope"` {
|
||||
t.Fatalf("detail 应带上原始 action,得到 %s", got)
|
||||
}
|
||||
if len(sender.sent) != 0 {
|
||||
t.Fatalf("未知 action 不该下发,得到 %v", sender.sent)
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi_test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -16,23 +17,26 @@ import (
|
||||
// - 响应体 detail —— 管理端 `describeError()` 把它拼在提示上,录人员常在局域网平板上
|
||||
// 操作,够不到服务端控制台的日志面板;
|
||||
// - ERROR 日志 —— 此前只有响应里有原文、日志里只有笼统 message,服务端自己没留痕。
|
||||
//
|
||||
// 触发源是真正的下发失败(写 socket 失败):未知 action 现在是 400,不能再当 5xx 用。
|
||||
func TestServerErrorCarriesRawReasonToClientAndLog(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
// 这句原始错误串必须一字不落地出现在响应 detail 与 ERROR 日志里。
|
||||
const rawReason = "写 socket 失败: broken pipe"
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) {
|
||||
o.Logger = logger
|
||||
// 未知 action 会让投递层直接报错,是最省事的 500 触发点。
|
||||
o.Display = &strictDisplaySender{}
|
||||
o.Display = &strictDisplaySender{failWith: errors.New(rawReason)}
|
||||
})
|
||||
client := recorderClient(t, env)
|
||||
|
||||
raw, err := json.Marshal(map[string]any{"action": "nope"})
|
||||
raw, err := json.Marshal(map[string]any{"action": "goto", "payload": map[string]any{"page": "medals"}})
|
||||
if err != nil {
|
||||
t.Fatalf("序列化请求体: %v", err)
|
||||
}
|
||||
status, body := env.doRaw(client, http.MethodPost, "/api/display/command", raw, "application/json", true)
|
||||
if status != http.StatusInternalServerError {
|
||||
t.Fatalf("未知大屏指令应 500,得到 %d %s", status, body)
|
||||
t.Fatalf("下发失败应 500,得到 %d %s", status, body)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
@@ -49,15 +53,22 @@ func TestServerErrorCarriesRawReasonToClientAndLog(t *testing.T) {
|
||||
if payload.Error.Code != "INTERNAL" || payload.Error.Message != "服务端异常" {
|
||||
t.Fatalf("5xx 的 code 与笼统文案要保持,得到 %+v", payload.Error)
|
||||
}
|
||||
if !strings.Contains(string(payload.Error.Detail), "未知的大屏指令") {
|
||||
if !strings.Contains(string(payload.Error.Detail), rawReason) {
|
||||
t.Fatalf("响应体 detail 应带上原始错误串(现场要能直接看懂为什么失败),得到 %s", payload.Error.Detail)
|
||||
}
|
||||
|
||||
// 原文必须能在日志里找到,否则现场就真的查不下去了。
|
||||
logs := waitLog(t, buf, `"level":"ERROR"`)
|
||||
if !strings.Contains(logs, "未知的大屏指令") {
|
||||
if !strings.Contains(logs, rawReason) {
|
||||
t.Fatalf("ERROR 行应带上原始错误串,得到:\n%s", logs)
|
||||
}
|
||||
// 「谁干的」不能缺一半:5xx 的 ERROR 行与 4xx 的 WARN 同口径,要有 role 与 ip。
|
||||
if !strings.Contains(logs, `"role":"recorder"`) {
|
||||
t.Fatalf("ERROR 行应带 role(现场按日志查是谁),得到:\n%s", logs)
|
||||
}
|
||||
if !strings.Contains(logs, `"ip":"127.0.0.1"`) {
|
||||
t.Fatalf("ERROR 行应带 ip(现场按日志查来源),得到:\n%s", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// 改密码时原密码填错返回 400,不是 401:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// assertContentDisposition 逐字校验两段文件名,并单独确认 ASCII 段是纯可打印 ASCII。
|
||||
// 这一段是不认 filename* 的老客户端唯一的文件名来源,混进中文、引号、反斜杠或控制字符
|
||||
// 会把响应头拼坏,所以它不能只靠「与期望字符串相等」来兜底。
|
||||
func assertContentDisposition(t *testing.T, got, asciiName, filename string) {
|
||||
t.Helper()
|
||||
want := `attachment; filename="` + asciiName + `"; filename*=UTF-8''` + url.PathEscape(filename)
|
||||
if got != want {
|
||||
t.Errorf("Content-Disposition = %q,期望 %q", got, want)
|
||||
}
|
||||
const prefix = `attachment; filename="`
|
||||
end := strings.Index(got, `"; filename*=UTF-8''`)
|
||||
if !strings.HasPrefix(got, prefix) || end < len(prefix) {
|
||||
t.Fatalf("Content-Disposition 缺少 ASCII 兜底段: %q", got)
|
||||
}
|
||||
ascii := got[len(prefix):end]
|
||||
if ascii == "" {
|
||||
t.Fatalf("ASCII 兜底名为空: %q", got)
|
||||
}
|
||||
for i := 0; i < len(ascii); i++ {
|
||||
if c := ascii[i]; c < 0x20 || c > 0x7e || c == '"' || c == '\\' {
|
||||
t.Errorf("ASCII 兜底名 %q 第 %d 字节 %q 不是可打印 ASCII,或含引号/反斜杠", ascii, i, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有下载接口都必须同时给出 filename* 与 filename:老客户端不认 filename*,
|
||||
// 只给 filename* 时它们会退化成按 URL 末段命名(teams/1 甚至没有扩展名)。
|
||||
// 期望的 ASCII 名与路由末段一致,避免出现「保存成 1」这种没有扩展名的文件。
|
||||
func TestExportContentDispositionHasASCIIFallback(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
ascii string
|
||||
file string
|
||||
ext string
|
||||
}{
|
||||
{"/api/export/standings.xlsx", "standings.xlsx", "总积分榜.xlsx", ".xlsx"},
|
||||
{"/api/export/events/1", "event.xlsx", "分项成绩表.xlsx", ".xlsx"},
|
||||
{"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", ".xlsx"},
|
||||
{"/api/export/all-flat.xlsx", "all-flat.xlsx", "赛事成绩总表(单页).xlsx", ".xlsx"},
|
||||
{"/api/export/medals.xlsx", "medals.xlsx", "团体奖牌明细.xlsx", ".xlsx"},
|
||||
{"/api/export/athletes.xlsx", "athletes.xlsx", "个人积分总明细.xlsx", ".xlsx"},
|
||||
{"/api/export/teams.xlsx", "teams.xlsx", "单位积分总明细.xlsx", ".xlsx"},
|
||||
{"/api/export/teams/1", "team.xlsx", "单位积分明细.xlsx", ".xlsx"},
|
||||
{"/api/export/project.zip", "project.zip", "赛事项目包.zip", ".zip"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
status, header, body := env.getRaw(t, client, tc.path)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("%s 应 200,得到 %d %s", tc.path, status, body)
|
||||
continue
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), tc.ascii, tc.file)
|
||||
if !strings.HasSuffix(tc.ascii, tc.ext) {
|
||||
t.Errorf("%s 的 ASCII 兜底名 %q 必须带 %s 扩展名", tc.path, tc.ascii, tc.ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -19,10 +18,7 @@ func TestExportAllFlatXLSXGuest(t *testing.T) {
|
||||
if got := header.Get("Content-Type"); got != xlsxContentType {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("赛事成绩总表(单页).xlsx")
|
||||
if got := header.Get("Content-Disposition"); got != wantCD {
|
||||
t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "all-flat.xlsx", "赛事成绩总表(单页).xlsx")
|
||||
|
||||
f := openWorkbook(t, body)
|
||||
defer func() { _ = f.Close() }()
|
||||
@@ -95,14 +91,15 @@ func TestExistingExportsUnchanged(t *testing.T) {
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
ascii string
|
||||
file string
|
||||
sheets []string
|
||||
}{
|
||||
{"/api/export/standings.xlsx", "总积分榜.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}},
|
||||
{"/api/export/all.xlsx", "赛事成绩总表.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}},
|
||||
{"/api/export/events/1", "分项成绩表.xlsx", []string{"田径·100米跑·男子"}},
|
||||
{"/api/export/teams.xlsx", "单位积分总明细.xlsx", []string{"积分总明细"}},
|
||||
{"/api/export/teams/1", "单位积分明细.xlsx", []string{"第一分公司积分明细"}},
|
||||
{"/api/export/standings.xlsx", "standings.xlsx", "总积分榜.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜"}},
|
||||
{"/api/export/all.xlsx", "all.xlsx", "赛事成绩总表.xlsx", []string{"总积分榜", "团体奖牌榜", "个人积分奖牌榜", "田径·100米跑·男子"}},
|
||||
{"/api/export/events/1", "event.xlsx", "分项成绩表.xlsx", []string{"田径·100米跑·男子"}},
|
||||
{"/api/export/teams.xlsx", "teams.xlsx", "单位积分总明细.xlsx", []string{"积分总明细"}},
|
||||
{"/api/export/teams/1", "team.xlsx", "单位积分明细.xlsx", []string{"第一分公司积分明细"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
status, header, body := env.getRaw(t, client, tc.path)
|
||||
@@ -110,10 +107,7 @@ func TestExistingExportsUnchanged(t *testing.T) {
|
||||
t.Errorf("%s 应 200,得到 %d %s", tc.path, status, body)
|
||||
continue
|
||||
}
|
||||
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape(tc.file)
|
||||
if got := header.Get("Content-Disposition"); got != wantCD {
|
||||
t.Errorf("%s 文件名 = %q,期望 %q", tc.path, got, wantCD)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), tc.ascii, tc.file)
|
||||
f := openWorkbook(t, body)
|
||||
if got := f.GetSheetList(); !equalSheetList(got, tc.sheets) {
|
||||
t.Errorf("%s sheet 列表 = %v,期望 %v", tc.path, got, tc.sheets)
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -41,10 +43,30 @@ func (s *server) exportRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /api/export/backups", s.read(auth.RoleAdmin, s.listBackups))
|
||||
}
|
||||
|
||||
// contentDisposition 组装下载头:filename* 给支持 RFC 5987 的浏览器(保留中文名),
|
||||
// filename 给不认 filename* 的老客户端兜底——只有 filename* 时它们会退化成按 URL 末段
|
||||
// 命名,teams/1 这种连扩展名都没有。asciiName 是兜底段的全部内容,必须能原样放进引号,
|
||||
// 所以这里再过滤一遍非 ASCII、引号、反斜杠与控制字符;过滤后为空就只留 filename*。
|
||||
func contentDisposition(asciiName, filename string) string {
|
||||
var ascii strings.Builder
|
||||
ascii.Grow(len(asciiName))
|
||||
for _, r := range asciiName {
|
||||
if r < 0x20 || r > 0x7e || r == '"' || r == '\\' {
|
||||
continue
|
||||
}
|
||||
ascii.WriteRune(r)
|
||||
}
|
||||
disposition := "attachment"
|
||||
if ascii.Len() > 0 {
|
||||
disposition += `; filename="` + ascii.String() + `"`
|
||||
}
|
||||
return disposition + "; filename*=UTF-8''" + url.PathEscape(filename)
|
||||
}
|
||||
|
||||
// streamDownload 直接写响应体;返回 errHandled 让统一响应层不再追加 JSON。
|
||||
func (s *server) streamDownload(w http.ResponseWriter, filename, contentType string, write func(io.Writer) error) (any, error) {
|
||||
func (s *server) streamDownload(w http.ResponseWriter, asciiName, filename, contentType string, write func(io.Writer) error) (any, error) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(filename))
|
||||
w.Header().Set("Content-Disposition", contentDisposition(asciiName, filename))
|
||||
if err := write(w); err != nil {
|
||||
s.logger.Error("导出写出失败", "file", filename, "err", err)
|
||||
}
|
||||
@@ -56,7 +78,7 @@ func (s *server) exportStandingsXLSX(w http.ResponseWriter, r *http.Request) (an
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "总积分榜.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "standings.xlsx", "总积分榜.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteStandingsXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -70,7 +92,7 @@ func (s *server) exportEventXLSX(w http.ResponseWriter, r *http.Request) (any, e
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "分项成绩表.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "event.xlsx", "分项成绩表.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteEventXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -80,7 +102,7 @@ func (s *server) exportAllXLSX(w http.ResponseWriter, r *http.Request) (any, err
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "赛事成绩总表.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "all.xlsx", "赛事成绩总表.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteAllXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -92,7 +114,7 @@ func (s *server) exportAllFlatXLSX(w http.ResponseWriter, r *http.Request) (any,
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "赛事成绩总表(单页).xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "all-flat.xlsx", "赛事成绩总表(单页).xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteFlatXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -103,7 +125,7 @@ func (s *server) exportMedalsXLSX(w http.ResponseWriter, r *http.Request) (any,
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "medals.xlsx", "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteMedalDetailXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -114,7 +136,7 @@ func (s *server) exportAthletesXLSX(w http.ResponseWriter, r *http.Request) (any
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "个人积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "athletes.xlsx", "个人积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteAthleteDetailXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -125,7 +147,7 @@ func (s *server) exportAllTeamsXLSX(w http.ResponseWriter, r *http.Request) (any
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "单位积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "teams.xlsx", "单位积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteAllTeamDetailsXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -139,7 +161,7 @@ func (s *server) exportTeamXLSX(w http.ResponseWriter, r *http.Request) (any, er
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return s.streamDownload(w, "单位积分明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return s.streamDownload(w, "team.xlsx", "单位积分明细.xlsx", xlsxMIME, func(out io.Writer) error {
|
||||
return export.WriteTeamDetailXLSX(out, wb)
|
||||
})
|
||||
}
|
||||
@@ -184,16 +206,47 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
|
||||
return nil, internal(err)
|
||||
}
|
||||
if _, err := export.ExportProjectZip(s.ev, dst, opts); err != nil {
|
||||
// 赛事名(设置里的 event.name)非法会让整包永远导不出来;它是入参问题,
|
||||
// 必须按 400 回一句「怎么改」,不能笼统地报「服务端异常」把现场卡死。
|
||||
// 其他 export 错误(磁盘、SQLite 等)仍走 5xx。
|
||||
if errors.Is(err, export.ErrUnsafeName) {
|
||||
return nil, badRequest(exportProjectNameMessage(name), err.Error())
|
||||
}
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = os.Remove(dst) }()
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape("赛事项目包.zip"))
|
||||
w.Header().Set("Content-Disposition", contentDisposition("project.zip", "赛事项目包.zip"))
|
||||
http.ServeFile(w, r, dst)
|
||||
return nil, errHandled
|
||||
}
|
||||
|
||||
// exportProjectNameMessage 把 export.SafeProjectName 的拒绝原因翻成可操作的中文提示。
|
||||
// 现场只会看到这一句:必须点出是哪些字符、或名字过长,否则只看到「服务端异常」无从下手。
|
||||
func exportProjectNameMessage(name string) string {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if n := len([]rune(trimmed)); n > 64 {
|
||||
return fmt.Sprintf("赛事名称超过 64 个字符(当前 %d 个),请先改短赛事名称再导出", n)
|
||||
}
|
||||
// 去重后列出非法字符,避免同一个字符按出现次数刷屏。
|
||||
bad := make([]string, 0, 4)
|
||||
seen := make(map[rune]bool, 4)
|
||||
for _, r := range name {
|
||||
if r < 0x20 || strings.ContainsRune(`/\:*?"<>|`, r) {
|
||||
if !seen[r] {
|
||||
seen[r] = true
|
||||
bad = append(bad, fmt.Sprintf("%q", r))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
return fmt.Sprintf("赛事名称 %q 含有不能用于文件名的字符:%s;请先改赛事名称再导出",
|
||||
trimmed, strings.Join(bad, "、"))
|
||||
}
|
||||
return fmt.Sprintf("赛事名称 %q 不能用作项目目录名(不能以点或空格结尾,也不能是系统保留名),请先改赛事名称再导出", trimmed)
|
||||
}
|
||||
|
||||
// projectExportPath 生成带随机后缀的导出文件名:精度到秒的时间戳在并发导出时会撞名,
|
||||
// 后写的会截断先写的文件,导致先返回的响应内容损坏。
|
||||
func projectExportPath(dir string, now time.Time) (string, error) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 同一秒内的两次导出必须落到不同文件,否则并发导出会互相截断(P2-6)。
|
||||
// 在同一秒内的两次导出必须落到不同文件,否则并发导出会互相截断(P2-6)。
|
||||
func TestProjectExportPathUniqueWithinSecond(t *testing.T) {
|
||||
now := time.Date(2026, 9, 9, 12, 0, 0, 0, time.UTC)
|
||||
first, err := projectExportPath("/tmp/exports", now)
|
||||
@@ -20,3 +22,48 @@ func TestProjectExportPathUniqueWithinSecond(t *testing.T) {
|
||||
t.Fatalf("同一秒内两次导出不应撞名,均为 %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
// 下载头必须同时给出中文 filename* 与 ASCII filename;兜底段是给不认 filename* 的
|
||||
// 老客户端用的,混进引号、反斜杠、控制字符会拼坏响应头,必须被过滤掉。
|
||||
func TestContentDispositionKeepsASCIIFallbackClean(t *testing.T) {
|
||||
escaped := "filename*=UTF-8''" + url.PathEscape("单位积分明细.xlsx")
|
||||
cases := []struct {
|
||||
name string
|
||||
ascii string
|
||||
want string
|
||||
}{
|
||||
{"正常兜底名", "team.xlsx", `attachment; filename="team.xlsx"; ` + escaped},
|
||||
{"引号反斜杠与控制字符被过滤", "te\"am\x01.xlsx", `attachment; filename="team.xlsx"; ` + escaped},
|
||||
{"中文兜底名被过滤后不残留", "单位积分明细.xlsx", `attachment; filename=".xlsx"; ` + escaped},
|
||||
{"过滤后为空则只留 filename*", "", "attachment; " + escaped},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := contentDisposition(c.ascii, "单位积分明细.xlsx"); got != c.want {
|
||||
t.Errorf("%s: contentDisposition(%q) = %q,期望 %q", c.name, c.ascii, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 赛事名被 SafeProjectName 拒绝时,提示必须可操作:点出非法字符或长度,并说清下一步。
|
||||
func TestExportProjectNameMessageIsActionable(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
want []string
|
||||
}{
|
||||
{"田径/游泳邀请赛", []string{"赛事名称", "'/'", "请先改赛事名称再导出"}},
|
||||
{"田径:游泳", []string{"':'"}},
|
||||
{"赛事. ", []string{"不能用作项目目录名", "请先改赛事名称再导出"}},
|
||||
{"CON", []string{"不能用作项目目录名"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := exportProjectNameMessage(c.name)
|
||||
for _, want := range c.want {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("exportProjectNameMessage(%q) 应含 %q,得到 %q", c.name, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := exportProjectNameMessage(strings.Repeat("长", 65)); !strings.Contains(got, "超过 64 个字符") {
|
||||
t.Errorf("超长名称应提示长度上限,得到 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,36 @@ func TestImportProjectZipRejectsRunningProject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 赛事名里的 `/` 会让 SafeProjectName 拒绝建目录:此前整包永远导不出来,界面只有一句「服务端异常」。
|
||||
// 名字来自设置、属入参问题,必须回 400 并说清怎么改(而不是 500)。
|
||||
func TestExportProjectZipUnsafeEventNameIsBadRequest(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
env.seed(t)
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
status, body := env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||||
"event": map[string]any{"name": "田径/游泳邀请赛"},
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("设置赛事名应 200,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
|
||||
status, body = env.do(client, http.MethodGet, "/api/export/project.zip", nil, false)
|
||||
if status != http.StatusBadRequest || body.Error == nil {
|
||||
t.Fatalf("赛事名含 / 时导出应 400,得到 %d %+v", status, body.Error)
|
||||
}
|
||||
if body.Error.Code != "VALIDATION_FAILED" {
|
||||
t.Fatalf("错误码应为 VALIDATION_FAILED,得到 %+v", body.Error)
|
||||
}
|
||||
for _, want := range []string{"赛事名称", "'/'", "请先改赛事名称再导出"} {
|
||||
if !strings.Contains(body.Error.Message, want) {
|
||||
t.Fatalf("message 应可操作(含 %q),得到 %q", want, body.Error.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 并发导出不能撞同名文件,否则先返回的响应会被后写的截断(P2-6)。
|
||||
func TestExportProjectZipConcurrentUnique(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
|
||||
@@ -3,7 +3,6 @@ package httpapi_test
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -53,10 +52,7 @@ func TestExportMedalsXLSXGuest(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("guest 下载奖牌明细应 200,得到 %d %s", status, body)
|
||||
}
|
||||
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("团体奖牌明细.xlsx")
|
||||
if got := header.Get("Content-Disposition"); got != wantCD {
|
||||
t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "medals.xlsx", "团体奖牌明细.xlsx")
|
||||
|
||||
f := openWorkbook(t, body)
|
||||
defer func() { _ = f.Close() }()
|
||||
@@ -110,10 +106,7 @@ func TestExportAthletesXLSXGuest(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("guest 下载个人得分明细应 200,得到 %d %s", status, body)
|
||||
}
|
||||
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("个人积分总明细.xlsx")
|
||||
if got := header.Get("Content-Disposition"); got != wantCD {
|
||||
t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "athletes.xlsx", "个人积分总明细.xlsx")
|
||||
|
||||
f := openWorkbook(t, body)
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -58,10 +57,7 @@ func TestExportAllTeamDetailsXLSXGuest(t *testing.T) {
|
||||
if got := header.Get("Content-Type"); got != xlsxContentType {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
wantCD := "attachment; filename*=UTF-8''" + url.PathEscape("单位积分总明细.xlsx")
|
||||
if got := header.Get("Content-Disposition"); got != wantCD {
|
||||
t.Errorf("Content-Disposition = %q,期望 %q", got, wantCD)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "teams.xlsx", "单位积分总明细.xlsx")
|
||||
|
||||
f := openWorkbook(t, body)
|
||||
defer func() { _ = f.Close() }()
|
||||
@@ -94,10 +90,7 @@ func TestExportAllTeamDetailsXLSXGuest(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("guest 下载单位明细应 200,得到 %d %s", status, singleBody)
|
||||
}
|
||||
wantSingle := "attachment; filename*=UTF-8''" + url.PathEscape("单位积分明细.xlsx")
|
||||
if got := header.Get("Content-Disposition"); got != wantSingle {
|
||||
t.Errorf("单位明细文件名 = %q,期望 %q", got, wantSingle)
|
||||
}
|
||||
assertContentDisposition(t, header.Get("Content-Disposition"), "team.xlsx", "单位积分明细.xlsx")
|
||||
sf := openWorkbook(t, singleBody)
|
||||
defer func() { _ = sf.Close() }()
|
||||
if got := sf.GetSheetList(); !equalSheetList(got, []string{"第一分公司积分明细"}) {
|
||||
|
||||
@@ -193,9 +193,15 @@ func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, er
|
||||
|
||||
func (s *server) createTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in teamDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 缺省 = 启用。Go 的 bool 零值是 false,漏传 active 会被静默建成「停用」,
|
||||
// 而停用的单位在界面上等于不存在 —— 脚本与导入侧踩过一次就很难查。
|
||||
if !keys.has("active") {
|
||||
in.Active = true
|
||||
}
|
||||
created, err := s.createTeam(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -210,13 +216,19 @@ func (s *server) updateTeamHandler(w http.ResponseWriter, r *http.Request) (any,
|
||||
return nil, err
|
||||
}
|
||||
var in teamDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getTeam(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 更新仍是「全量覆盖」语义,但漏传 active 不等于「把它停用」:沿用库里的现值。
|
||||
// 想停用请显式传 false,或走 DELETE(那才是界面上那个「停用」)。
|
||||
if !keys.has("active") {
|
||||
in.Active = before.Active
|
||||
}
|
||||
updated, err := s.updateTeam(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -269,9 +281,14 @@ func (s *server) getSportHandler(w http.ResponseWriter, r *http.Request) (any, e
|
||||
|
||||
func (s *server) createSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in sportDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 与建单位同口径:缺省 = 启用(漏传 active 不该建成一个在界面上不存在的大项)。
|
||||
if !keys.has("active") {
|
||||
in.Active = true
|
||||
}
|
||||
created, err := s.createSport(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -286,13 +303,18 @@ func (s *server) updateSportHandler(w http.ResponseWriter, r *http.Request) (any
|
||||
return nil, err
|
||||
}
|
||||
var in sportDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
keys, err := decodeJSONPresence(r, &in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getSport(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 同单位:漏传 active 沿用现值,不静默停用。
|
||||
if !keys.has("active") {
|
||||
in.Active = before.Active
|
||||
}
|
||||
updated, err := s.updateSport(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -734,7 +756,9 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
reader.TrimLeadingSpace = true
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, badRequest("无法解析 CSV", err.Error())
|
||||
// CSV 语法错误几乎都是格子里有没配对的双引号。message 给现场能照着做的中文提示,
|
||||
// Go 的英文原文(parse error on line ...)留在 detail 里,排查时还要用。
|
||||
return nil, badRequest("无法解析 CSV:表格里多半有没配对的双引号。请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入", err.Error())
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
@@ -747,6 +771,11 @@ var (
|
||||
tableUTF16BEBOM = []byte{0xFE, 0xFF}
|
||||
)
|
||||
|
||||
// errTableReplacementRune 表示解码结果里出现了替换字符 U+FFFD。
|
||||
// x/text 的解码器对非法字节、孤立代理对这类坏数据不返回 error,只把它们换成 U+FFFD,
|
||||
// 靠这个哨兵错误把「解出一串 �」和「真的解开了」区分开。
|
||||
var errTableReplacementRune = errors.New("decoded text contains U+FFFD replacement character")
|
||||
|
||||
// decodeTableText 把报名表的字节流解码成 UTF-8 文本。
|
||||
//
|
||||
// 现场拿到的 CSV 基本都是直接从 Excel 存的,而 Excel 默认不写 UTF-8:中文 Windows 上
|
||||
@@ -765,9 +794,16 @@ func decodeTableText(file io.Reader) (string, error) {
|
||||
case bytes.HasPrefix(data, tableUTF8BOM):
|
||||
data = data[len(tableUTF8BOM):]
|
||||
case bytes.HasPrefix(data, tableUTF16LEBOM):
|
||||
return decodeUTF16Table(data[len(tableUTF16LEBOM):], textunicode.LittleEndian)
|
||||
return decodeUTF16Table(data[len(tableUTF16LEBOM):], textunicode.LittleEndian, false)
|
||||
case bytes.HasPrefix(data, tableUTF16BEBOM):
|
||||
return decodeUTF16Table(data[len(tableUTF16BEBOM):], textunicode.BigEndian)
|
||||
return decodeUTF16Table(data[len(tableUTF16BEBOM):], textunicode.BigEndian, false)
|
||||
}
|
||||
|
||||
// 丢了 BOM 的 UTF-16 必须排在 utf8.Valid 之前判断:UTF-16 里 0x00 是合法的 UTF-8 码点,
|
||||
// utf8.Valid 会放它过去,表头随即被当成 "e\0v\0e\0n\0t...",最后报「缺少列 event_code」——
|
||||
// 把现场引去查表头,而真正的问题是编码。
|
||||
if order, ok := guessUTF16Order(data); ok {
|
||||
return decodeUTF16Table(data, order, true)
|
||||
}
|
||||
|
||||
if utf8.Valid(data) {
|
||||
@@ -778,14 +814,63 @@ func decodeTableText(file io.Reader) (string, error) {
|
||||
if err != nil {
|
||||
return "", badRequest("报名表编码无法识别,请另存为 UTF-8 或 GB18030 编码的 CSV 后重试", err.Error())
|
||||
}
|
||||
// 非法字节会被静默换成 U+FFFD:不拦住的话它一路往下走,现场看到的是
|
||||
// 「第 2 行:组别 ��� 不存在」,跑去查组别配置——真正坏的是文件。
|
||||
if strings.ContainsRune(text, utf8.RuneError) {
|
||||
return "", badRequest("报名表编码无法识别:文件里混有无法解码的字节(多半是复制粘贴或转换工具弄坏的)。请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入", nil)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// decodeUTF16Table 解 UTF-16(BOM 已由调用方摘掉)。
|
||||
func decodeUTF16Table(data []byte, order textunicode.Endianness) (string, error) {
|
||||
// guessUTF16Order 判断无 BOM 的字节流是不是 UTF-16,是的话顺带给出字节序。
|
||||
//
|
||||
// 判据刻意保守,因为猜错的代价是把好文件解成乱码:UTF-16 的每个 ASCII 字符
|
||||
// (列名、逗号、数字、换行)都会贡献一个固定奇偶位的 0x00,而正常的 UTF-8/GB18030
|
||||
// 报名表里一个 0x00 都没有。所以只认「NUL 足够多、且几乎只落在同一位」的字节流,
|
||||
// 只是混进一两个坏字节的文本文件不会被误判。
|
||||
func guessUTF16Order(data []byte) (textunicode.Endianness, bool) {
|
||||
var even, odd int
|
||||
for i, b := range data {
|
||||
if b != 0 {
|
||||
continue
|
||||
}
|
||||
if i%2 == 0 {
|
||||
even++
|
||||
} else {
|
||||
odd++
|
||||
}
|
||||
}
|
||||
zeros := even + odd
|
||||
if zeros < 8 || zeros*4 < len(data) {
|
||||
return false, false
|
||||
}
|
||||
switch {
|
||||
case odd*20 >= zeros*19: // 小端:ASCII 的高位 0x00 落在奇数位
|
||||
return textunicode.LittleEndian, true
|
||||
case even*20 >= zeros*19: // 大端:0x00 落在偶数位
|
||||
return textunicode.BigEndian, true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// decodeUTF16Table 解 UTF-16(BOM 已由调用方摘掉)。missingBOM 决定失败时给现场哪句提示:
|
||||
// 缺 BOM 的要教他「另存为 CSV UTF-8」,带 BOM 还解不动的按编码损坏处理。
|
||||
func decodeUTF16Table(data []byte, order textunicode.Endianness, missingBOM bool) (string, error) {
|
||||
text, _, err := transform.String(textunicode.UTF16(order, textunicode.IgnoreBOM).NewDecoder(), string(data))
|
||||
if err == nil && strings.ContainsRune(text, utf8.RuneError) {
|
||||
err = errTableReplacementRune
|
||||
}
|
||||
// 字节序猜反时整份文本会翻成另一堆合法码点(不会出现 U+FFFD),再拿表头必然有的
|
||||
// 逗号兜一道底:认不出是 CSV 就宁可报编码问题,也不能把乱码当成功导进去。
|
||||
if err == nil && missingBOM && !strings.ContainsRune(text, ',') {
|
||||
err = errors.New("utf-16 decode produced no csv delimiter")
|
||||
}
|
||||
if err != nil {
|
||||
return "", badRequest("报名表编码无法识别,请另存为 UTF-8 编码的 CSV 后重试", err.Error())
|
||||
message := "报名表编码无法识别,请另存为 UTF-8 编码的 CSV 后重试"
|
||||
if missingBOM {
|
||||
message = "报名表看起来是 UTF-16 编码但缺少 BOM,请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入"
|
||||
}
|
||||
return "", badRequest(message, err.Error())
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
@@ -813,7 +898,9 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
}
|
||||
for _, col := range importColumns {
|
||||
if _, ok := index[col]; !ok {
|
||||
return result, badRequest("报名表缺少列 "+col, nil)
|
||||
// 表头类报错必须点到「表头行」本身:用户常在表头上方加一行标题,
|
||||
// 只说「缺少列 X」会让他去翻数据行,找不到问题在哪。
|
||||
return result, badRequest("报名表缺少列 "+col+":第 1 行应是表头(列名与导入模板一致),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
textunicode "golang.org/x/text/encoding/unicode"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// 本文件钉住报名导入链路里 4 个已复现的缺陷:
|
||||
// 1. 非法字节被 GB18030 / UTF-16 解码器静默换成 U+FFFD,导入看着"成功"、内容却全烂;
|
||||
// 2. 无 BOM 的 UTF-16 被当普通文本解析,误报「缺少列 event_code」;
|
||||
// 3. CSV 语法错误只回 Go 英文原文,现场看不懂该做什么;
|
||||
// 4. 表头缺列的报错不点到「表头行」,用户加了标题行时找不到问题在哪。
|
||||
|
||||
// encodeUTF16NoBOM 编码成不带 BOM 的 UTF-16,模拟手工拼接 / 被工具剥掉 BOM 的文件。
|
||||
func encodeUTF16NoBOM(t *testing.T, text string, order textunicode.Endianness) string {
|
||||
t.Helper()
|
||||
|
||||
out, _, err := transform.String(textunicode.UTF16(order, textunicode.IgnoreBOM).NewEncoder(), text)
|
||||
if err != nil {
|
||||
t.Fatalf("编码 UTF-16(无 BOM): %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 数据里混进非法 GB18030 字节(0xFF 0xFF 0xFF)。
|
||||
// 修复前:解码器把它们换成 � 且不返回 error,接口回 200「第 2 行:组别 ��� 不存在」,
|
||||
// 把现场引去查组别配置;真正坏的是文件本身。
|
||||
func TestImportRejectsInvalidGB18030Bytes(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\r\n" +
|
||||
"M100,\xff\xff\xff,A,X,110101199001019001,m,26,9001,,registered,\r\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("坏字节应回 400,得到 %d %+v", status, env2)
|
||||
}
|
||||
msg := env2.Error.Message
|
||||
if !strings.Contains(msg, "编码无法识别") || !strings.Contains(msg, "CSV UTF-8") {
|
||||
t.Fatalf("message 应说明编码坏了并教怎么重存,得到 %q", msg)
|
||||
}
|
||||
// 关键回归点:不能再把现场引向组别配置。
|
||||
if strings.Contains(msg, "组别") {
|
||||
t.Fatalf("message 不应指向组别配置,得到 %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 无 BOM 的 UTF-16(LE / BE 都要认)。修复前 utf8.Valid 放行(UTF-16 里的 0x00 是合法
|
||||
// UTF-8 码点),表头被当成 "e\0v\0e\0n\0t...",最后报「缺少列 event_code」。
|
||||
func TestImportDecodesUTF16WithoutBOM(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
order textunicode.Endianness
|
||||
}{
|
||||
{"LE", textunicode.LittleEndian},
|
||||
{"BE", textunicode.BigEndian},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := encodeUTF16NoBOM(t, importHeader+"\n"+importBody, tc.order)
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
// 不只是"没报缺列":表头与中文姓名都要真的解对。
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 无 BOM 且解不动的 UTF-16(截断后长度成奇数)要给明确中文提示,
|
||||
// 而不是退回去报「缺少列 event_code」。
|
||||
func TestImportTruncatedUTF16WithoutBOMGivesEncodingHint(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
full := encodeUTF16NoBOM(t, importHeader+"\n"+importBody, textunicode.LittleEndian)
|
||||
body := full[:len(full)-1]
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("截断的 UTF-16 应回 400,得到 %d %+v", status, env2)
|
||||
}
|
||||
msg := env2.Error.Message
|
||||
if !strings.Contains(msg, "UTF-16") || !strings.Contains(msg, "BOM") {
|
||||
t.Fatalf("message 应提示 UTF-16 缺 BOM,得到 %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "缺少列") {
|
||||
t.Fatalf("message 不应误报缺列,得到 %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 反向用例:正常编码必须仍然走原路径。
|
||||
func TestImportKeepsNormalEncodingsWorking(t *testing.T) {
|
||||
plain := importHeader + "\n" + importBody
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"utf8", plain},
|
||||
{"utf8_bom", "\uFEFF" + plain},
|
||||
{"gb18030", encodeGB18030(t, plain)},
|
||||
{"utf16le_bom", encodeUTF16LE(t, plain)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", tc.body, "")
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 反向用例:UTF-8 文件里混进零星 NUL(人为拼接 / 损坏)不能被猜成 UTF-16。
|
||||
// 猜错会把整份中文解成另一堆合法码点,比原样报错更糟。
|
||||
// NUL 交替落在奇偶两位,凑不出「单一位次占绝对多数」的特征。
|
||||
func TestImportDoesNotGuessUTF16FromScatteredNULs(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,,s\x00e\x00e\x00d\x00,registered," + strings.Repeat("\x00", 8) + "\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
assertImportedAsZhangSan(t, env, status, env2)
|
||||
}
|
||||
|
||||
// 裸双引号导致 CSV 语法错误:message 给中文可操作提示,英文原文留在 detail 里。
|
||||
func TestImportBareQuoteGivesActionableChineseMessage(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\r\n" +
|
||||
"M100,男子,A,张\"三,110101199001010951,男,26,951,,registered,\r\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("CSV 语法错误应回 400,得到 %d %+v", status, env2)
|
||||
}
|
||||
msg := env2.Error.Message
|
||||
if !strings.Contains(msg, "双引号") || !strings.Contains(msg, "CSV UTF-8") {
|
||||
t.Fatalf("message 应给中文可操作提示,得到 %q", msg)
|
||||
}
|
||||
detail := string(env2.Error.Detail)
|
||||
if !strings.Contains(detail, "non-quoted-field") {
|
||||
t.Fatalf("detail 应保留 Go 英文原文,得到 %q", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// 表头类报错必须点到「表头行」:最常见的成因是用户在表头上方加了一行标题,
|
||||
// 只说「缺少列 X」会让他去翻数据行。
|
||||
func TestImportMissingColumnPointsAtHeaderRow(t *testing.T) {
|
||||
t.Run("标题行在表头上方", func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := "比赛报名表\n" + importHeader + "\n" + importBody
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("缺列应回 400,得到 %d %+v", status, env2)
|
||||
}
|
||||
msg := env2.Error.Message
|
||||
if !strings.Contains(msg, "缺少列 event_code") {
|
||||
t.Fatalf("message 应保留缺列信息,得到 %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "第 1 行") || !strings.Contains(msg, "表头") {
|
||||
t.Fatalf("message 应点到表头行,得到 %q", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("真的少了一列", func(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
noAthleteName := "event_code,category,team_code,id_card,gender,age,bib,seed,status,member_names"
|
||||
row := "M100,男子,A,110101199001011234,男,26,952,,registered,"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", noAthleteName+"\n"+row+"\n", "")
|
||||
|
||||
if status != http.StatusBadRequest || env2.OK {
|
||||
t.Fatalf("缺列应回 400,得到 %d %+v", status, env2)
|
||||
}
|
||||
msg := env2.Error.Message
|
||||
if !strings.Contains(msg, "缺少列 athlete_name") || !strings.Contains(msg, "表头") {
|
||||
t.Fatalf("message 应点名列名并提及表头行,得到 %q", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -235,6 +237,13 @@ func (s *server) displayCommandHandler(w http.ResponseWriter, r *http.Request) (
|
||||
if s.display != nil && (in.Action != cmdAnnounceClear || realtime.ValidDisplayAction(in.Action)) {
|
||||
n, err := s.display.SendDisplayCommand(in.Action, in.Payload, in.Targets)
|
||||
if err != nil {
|
||||
// 未知 action 是请求体入参不合法,按 400 报,现场才看得懂「改 action」而不是「服务端异常」。
|
||||
// 生产路径上这个错由 realtime.NewDisplayCommand 返回(裸错误,没带哨兵),
|
||||
// 所以除了 errors.Is 还要按协议清单判一次;两类失败不能混成同一个 5xx:
|
||||
// 只有传输层真失败(写 socket 失败等)才走 internal。
|
||||
if errors.Is(err, realtime.ErrUnknownAction) || !realtime.ValidDisplayAction(in.Action) {
|
||||
return nil, badRequest(fmt.Sprintf("未知的大屏指令 %q", in.Action), in.Action)
|
||||
}
|
||||
return nil, internal(err)
|
||||
}
|
||||
matched = n
|
||||
|
||||
@@ -11,16 +11,22 @@ import (
|
||||
// strictDisplaySender 与 main.go 的 displaySender 同口径:经实时层校验 action,未知 action 直接报错。
|
||||
// matched 为 nil 时按「广播算命中一块屏、定向一条算命中一块」返回,便于断言正常路径;
|
||||
// 需要模拟「目标已离线」时把它设成 0。
|
||||
// failWith 非空时直接返回它,用来造一个**真正的传输层 5xx**(写 socket 失败之类):
|
||||
// 未知 action 现在是 4xx,5xx 的日志用例不能再拿它当触发源。
|
||||
type strictDisplaySender struct {
|
||||
sent []string
|
||||
targets [][]string
|
||||
matched *int
|
||||
failWith error
|
||||
}
|
||||
|
||||
func (d *strictDisplaySender) SendDisplayCommand(action string, payload any, targets []string) (int, error) {
|
||||
if _, err := realtime.NewDisplayCommand(action, payload); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if d.failWith != nil {
|
||||
return 0, d.failWith
|
||||
}
|
||||
d.sent = append(d.sent, action)
|
||||
d.targets = append(d.targets, targets)
|
||||
if d.matched != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -98,7 +99,8 @@ func TestInterceptLogLevels(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("5xx", func(t *testing.T) {
|
||||
sender := &strictDisplaySender{}
|
||||
// 未知 action 现在是 4xx,5xx 的日志行为改用真正的下发失败(写 socket 失败)来触发。
|
||||
sender := &strictDisplaySender{failWith: errors.New("写 socket 失败: broken pipe")}
|
||||
buf := &bytes.Buffer{}
|
||||
logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) {
|
||||
@@ -108,9 +110,9 @@ func TestInterceptLogLevels(t *testing.T) {
|
||||
client := recorderClient(t, env)
|
||||
|
||||
status, _ := env.do(client, http.MethodPost, "/api/display/command",
|
||||
map[string]any{"action": "nope"}, true)
|
||||
map[string]any{"action": "goto", "payload": map[string]any{"page": "medals"}}, true)
|
||||
if status != http.StatusInternalServerError {
|
||||
t.Fatalf("未知指令应 500,得到 %d", status)
|
||||
t.Fatalf("下发失败应 500,得到 %d", status)
|
||||
}
|
||||
logs := waitLog(t, buf, `"level":"ERROR"`)
|
||||
if !strings.Contains(logs, `"origin":"internal/httpapi/handlers_results.go:`) ||
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -137,7 +138,8 @@ func TestMiddlewareInterceptsLandInInterceptionFile(t *testing.T) {
|
||||
// 5xx 的归属:只进 interceptions/,不进 operations/。
|
||||
func TestServerErrorGoesToInterceptionFileOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sender := &strictDisplaySender{}
|
||||
// 未知 action 现在是 4xx;这里用真正的下发失败(写 socket 失败)造 5xx。
|
||||
sender := &strictDisplaySender{failWith: errors.New("写 socket 失败: broken pipe")}
|
||||
handler, closer := logging.New(logging.Options{Dir: dir, Level: slog.LevelInfo, Console: &bytes.Buffer{}})
|
||||
defer func() { _ = closer.Close() }()
|
||||
env := newTestEnvWith(t, func(o *httpapi.Options) {
|
||||
@@ -149,8 +151,8 @@ func TestServerErrorGoesToInterceptionFileOnly(t *testing.T) {
|
||||
env.login(client, "recorder", "recorder-pass")
|
||||
|
||||
if status, _ := env.do(client, http.MethodPost, "/api/display/command",
|
||||
map[string]any{"action": "nope"}, true); status != http.StatusInternalServerError {
|
||||
t.Fatalf("未知指令应 500,得到 %d", status)
|
||||
map[string]any{"action": "goto", "payload": map[string]any{"page": "medals"}}, true); status != http.StatusInternalServerError {
|
||||
t.Fatalf("下发失败应 500,得到 %d", status)
|
||||
}
|
||||
|
||||
inter := waitCategory(t, filepath.Join(dir, logging.DirInterceptions), logging.DirInterceptions, "请求失败")
|
||||
|
||||
@@ -65,11 +65,13 @@ func logIntercepted(r *http.Request, logger *slog.Logger, api *apiError) {
|
||||
reqID := middleware.GetRequestID(r.Context())
|
||||
|
||||
if api.status >= http.StatusInternalServerError {
|
||||
// 5xx 的原始错误串(SQLite / 引擎)只落在这里,不进响应体:
|
||||
// 现场看日志能定位,前端拿不到内部表名列名。
|
||||
// 5xx 的原始错误串(SQLite / 引擎)在这里留痕,现场看日志能定位;
|
||||
// 响应体里也有一份(见 response.go 的 internalMsg),前端不需要翻服务端日志。
|
||||
// role / ip 与下面 4xx 的 WARN 同口径:按日志查「谁干的」时,5xx 不能缺一半线索。
|
||||
logger.Error("请求失败",
|
||||
"code", api.code, "err", api.message, "detail", api.logDetail, "origin", api.origin,
|
||||
"method", r.Method, "path", r.URL.Path, "actor", actor, "requestId", reqID)
|
||||
"method", r.Method, "path", r.URL.Path,
|
||||
"actor", actor, "role", role, "ip", middleware.ClientIP(r), "requestId", reqID)
|
||||
return
|
||||
}
|
||||
logger.Warn("请求被拦截",
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||||
)
|
||||
|
||||
// 写限流要真的拦得住(默认 30 次/分钟/用户):
|
||||
// 写限流要真的拦得住(默认 120 次/分钟/用户):
|
||||
// 之前没有任何 429 用例,限流键名写错、中间件漏挂都测不出来。
|
||||
// 这里把阈值压到 2,避免为了触发限流跑 31 次写请求。
|
||||
func TestWriteRateLimitReturns429(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -202,6 +203,43 @@ func decodeJSON(r *http.Request, dst any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxJSONBodyBytes 是普通 JSON 接口的请求体上限。
|
||||
// 此前只有上传 / 导入用 MaxBytesReader 限过大小,JSON 接口没有上限;
|
||||
// 配合下面的「整读一次」,必须补一个(8 MiB 对任何正当请求都够)。
|
||||
const maxJSONBodyBytes = 8 << 20
|
||||
|
||||
// keySet 是「请求体里真的出现过哪些键」。
|
||||
type keySet map[string]json.RawMessage
|
||||
|
||||
func (k keySet) has(key string) bool {
|
||||
_, ok := k[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// decodeJSONPresence 与 decodeJSON 一样解析请求体,另外回报「哪些键出现过」。
|
||||
//
|
||||
// 存在的理由:区分「字段没传」与「显式传了零值」。Go 的 bool 零值是 false,
|
||||
// 建单位 / 大项时漏传 active 会被静默写成「停用」,而停用的单位在界面上等于不存在 ——
|
||||
// 少写一个字段就变成另一种东西的接口,只能靠「键是否存在」判断调用方意图。
|
||||
func decodeJSONPresence(r *http.Request, dst any) (keySet, error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBodyBytes+1))
|
||||
if err != nil {
|
||||
return nil, badRequest("读取请求体失败", err.Error())
|
||||
}
|
||||
if len(raw) > maxJSONBodyBytes {
|
||||
return nil, tooLarge("请求体超过上限")
|
||||
}
|
||||
if err := json.Unmarshal(raw, dst); err != nil {
|
||||
return nil, badRequest("请求体不是合法 JSON", err.Error())
|
||||
}
|
||||
var keys keySet
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
// 顶层不是对象(例如是数组)时没有「键」的概念,交给 dst 的解析结果去报错。
|
||||
return keySet{}, nil
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// pageQuery 解析 ?page=&size=,默认第 1 页、每页 50 条,上限 500。
|
||||
func pageQuery(r *http.Request) (page, size int) {
|
||||
page = intQuery(r, "page", 1)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { BOARD_AVAILABLE_PX, fitRowFont } from '../../fitFont'
|
||||
import { fitLiveRowFontPx, liveResultColumnPx } from '../../fitFont'
|
||||
import { LIVE_REFRESH_INTERVAL_MS, fallbackLiveSources, pickLiveColumns } from '../../live'
|
||||
import type { LiveEventSource } from '../../live'
|
||||
import { displayName, displayResult } from '../../results'
|
||||
@@ -42,11 +42,24 @@ const emptyText = computed(() =>
|
||||
liveFeed.value === null && !liveStale.value ? '正在获取当前项目…' : '暂无进行中的项目',
|
||||
)
|
||||
|
||||
// 字号取行数最多的那一栏,两栏行高才一致;两栏时每栏只有半屏宽,再收一档。
|
||||
const rowFont = computed(() => {
|
||||
const count = boards.value.reduce((max, board) => Math.max(max, board.rows.length), 0)
|
||||
return fitRowFont(count, BOARD_AVAILABLE_PX, boards.value.length > 1 ? 0.48 : 0.62)
|
||||
})
|
||||
// 字号取行数最多的那一栏,两栏行高才一致;成绩列宽再反过来给字号封顶(见 fitFont.ts)。
|
||||
const rowCount = computed(() =>
|
||||
boards.value.reduce((max, board) => Math.max(max, board.rows.length), 0),
|
||||
)
|
||||
|
||||
// 成绩列宽与字号互相牵制,所以由页面把一屏的成绩文本收齐了一次算完:行数 → 基准字号,
|
||||
// 文本宽度 → 字号上限,字号 + 文本宽度 → 列宽。两栏各算各的会让两栏列宽不一致。
|
||||
const resultTexts = computed(() =>
|
||||
boards.value.flatMap((board) => board.rows.map((row) => displayResult(row))),
|
||||
)
|
||||
const rowFontPx = computed(() =>
|
||||
fitLiveRowFontPx(rowCount.value, boards.value.length, resultTexts.value),
|
||||
)
|
||||
|
||||
const boardStyle = computed(() => ({
|
||||
'--row-font': `${rowFontPx.value}px`,
|
||||
'--rs-width': `${liveResultColumnPx(resultTexts.value, boards.value.length, rowFontPx.value)}px`,
|
||||
}))
|
||||
|
||||
// 定时兜底:项目切换靠 scheduled_at 自己过期,快照不会为时间流逝推送,所以必须有这条轮询。
|
||||
// 只在本页显示时跑(锁在别的页上时没必要打服务端);切回本页时挂载会立刻拉一次。
|
||||
@@ -70,7 +83,7 @@ onUnmounted(() => {
|
||||
v-if="boards.length > 0"
|
||||
class="boards"
|
||||
:class="`boards-${boards.length}`"
|
||||
:style="{ '--row-font': rowFont }"
|
||||
:style="boardStyle"
|
||||
>
|
||||
<article v-for="board in boards" :key="board.eventId" class="board">
|
||||
<header class="board-head">
|
||||
@@ -165,7 +178,9 @@ h3 {
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr 300px 220px;
|
||||
/* 成绩列宽由内容算(--rs-width,见 fitFont.ts):写死列宽时 56px 字号的 `1:09.09`
|
||||
墨迹有 201px,超出的部分会被 .rows 的 overflow:hidden 裁掉。名字列可省略号截断,先让位。 */
|
||||
grid-template-columns: 90px minmax(0, 1fr) 300px var(--rs-width, 220px);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1 1 0;
|
||||
@@ -177,7 +192,8 @@ h3 {
|
||||
animation: row-in 0.45s ease-out both;
|
||||
}
|
||||
.boards-2 .row {
|
||||
grid-template-columns: 60px 1fr 190px 150px;
|
||||
/* 两栏时名次 / 单位也窄一档,成绩列同样跟着内容走。 */
|
||||
grid-template-columns: 60px minmax(0, 1fr) 190px var(--rs-width, 150px);
|
||||
gap: 8px;
|
||||
}
|
||||
.row:last-child {
|
||||
@@ -230,6 +246,8 @@ h3 {
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* 成绩绝不折行:折成两行会被读成两个成绩,且第二行会被 .rows 的 overflow:hidden 竖着裁掉。 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
.board-empty,
|
||||
.empty {
|
||||
|
||||
+113
-4
@@ -4,15 +4,124 @@
|
||||
*/
|
||||
export const BOARD_AVAILABLE_PX = 560
|
||||
|
||||
/** 行字号上限:大屏一行数字的观感上限,与行数无关的硬顶。 */
|
||||
export const MAX_ROW_FONT_PX = 56
|
||||
|
||||
/** 行字号下限:再小就 10 米外看不清,宁可截断也不能比这更小。 */
|
||||
export const MIN_ROW_FONT_PX = 16
|
||||
|
||||
/** 没有行可排(还没数据)时的兜底字号。 */
|
||||
const EMPTY_ROW_FONT_PX = 48
|
||||
|
||||
/**
|
||||
* 行数不可控,字号必须跟着算,否则单位一多就会把最后一行顶到轮播条下面。
|
||||
*
|
||||
* 行高由 flex 均分,这里按「可用高度 ÷ 行数 × 比例」给出字号,
|
||||
* 比例留出 padding 与行距,保证文字始终装得下。
|
||||
*/
|
||||
export function fitRowFont(count: number, availablePx: number, ratio = 0.62): string {
|
||||
if (count <= 0) return '48px'
|
||||
export function fitRowFontPx(count: number, availablePx: number, ratio = 0.62): number {
|
||||
if (count <= 0) return EMPTY_ROW_FONT_PX
|
||||
const perRow = availablePx / count
|
||||
const size = Math.min(56, Math.max(16, perRow * ratio))
|
||||
return `${Math.floor(size)}px`
|
||||
return Math.floor(Math.min(MAX_ROW_FONT_PX, Math.max(MIN_ROW_FONT_PX, perRow * ratio)))
|
||||
}
|
||||
|
||||
export function fitRowFont(count: number, availablePx: number, ratio = 0.62): string {
|
||||
return `${fitRowFontPx(count, availablePx, ratio)}px`
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时赛况一屏分几栏 → 行高比例。
|
||||
*
|
||||
* 与 LivePage.vue 的 .boards-1 / .boards-2 网格同源:两栏时每栏只有半屏宽,
|
||||
* 行高(也就是字号)要再收一档。
|
||||
*/
|
||||
export function liveRowFontRatio(columns: number): number {
|
||||
return columns > 1 ? 0.48 : 0.62
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩列宽度下限(px),与 LivePage.vue 网格里原来的写死列宽一致。
|
||||
*
|
||||
* 保留下限是为了「短成绩时观感不变」:`59.99` 这类文本本来就装得下,列宽不该因为
|
||||
* 改成按文本算就抖一下。
|
||||
*/
|
||||
export function liveResultColumnFloorPx(columns: number): number {
|
||||
return columns > 1 ? 150 : 220
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩列宽度预算(px):成绩列最多占这么宽,再宽就得靠收字号解决。
|
||||
*
|
||||
* 两栏时一行可用宽约 815px,扣掉名次 60 / 单位 190 / 间距 24 后剩 541px 由「运动员 + 成绩」
|
||||
* 分。预算 240px 给成绩,运动员列还能留 300px(56px 字号下约 5 个汉字);名字本来就是
|
||||
* 可以省略号截断的次要信息,末位成绩被裁才是不能接受的(`1:09.0` 会被读成另一个成绩)。
|
||||
*/
|
||||
export function liveResultColumnBudgetPx(columns: number): number {
|
||||
return columns > 1 ? 240 : 340
|
||||
}
|
||||
|
||||
/** 估宽时额外留的像素:抹掉 ceil 与亚像素误差,避免差 1px 又把末位数字裁掉。 */
|
||||
const RESULT_COLUMN_PAD_PX = 4
|
||||
|
||||
/**
|
||||
* 成绩文本的估算墨迹宽(em = 墨迹宽 / 字号)。
|
||||
*
|
||||
* 纯函数里量不到真实字形,所以按字符类别取四种内置字体里最宽的那个再上浮一点:
|
||||
* 数字 0.70(MiSans 的 `0`/`8` 实测 0.6786,最宽)、小数点与冒号 0.34(实测 0.3393)、
|
||||
* 拉丁字母 0.85(实测最宽的 `D` 是 0.8036)、空白 0.30、其余(汉字 / 破折号等)1.00
|
||||
* (汉字实测 1.00)。取值只许偏大:偏大多留几像素,偏小就会重新裁掉末位数字。
|
||||
*/
|
||||
export function resultTextEm(text: string): number {
|
||||
let em = 0
|
||||
for (const ch of text) {
|
||||
if (ch >= '0' && ch <= '9') em += 0.7
|
||||
else if (ch === ':' || ch === '.' || ch === ',') em += 0.34
|
||||
else if (ch === ' ') em += 0.3
|
||||
else if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')) em += 0.85
|
||||
else em += 1
|
||||
}
|
||||
return em
|
||||
}
|
||||
|
||||
/** 一屏所有成绩里最宽的那个(em);页面一次算全页,各行列宽才不会左右错开。 */
|
||||
function widestResultEm(results: readonly string[]): number {
|
||||
let widest = 0
|
||||
for (const text of results) widest = Math.max(widest, resultTextEm(text))
|
||||
return widest
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时赛况的行字号(px):行数定基准,成绩列宽只做上限。
|
||||
*
|
||||
* 真机缺陷:两栏时成绩列写死 150px,而 `1:09.09` 在 56px 字号下墨迹 201px,末位数字被外层
|
||||
* overflow:hidden 裁掉;1–6 行时字号都大于 41px(150px 列的容限),于是全中招。这里把
|
||||
* 「装不下就收字号」当兜底:典型成绩(4.2em)在 240px 预算下仍能取满 56px,靠加宽成绩列解决;
|
||||
* 只有 `64:21.50` 这类特别长的成绩才真的收字号。
|
||||
*/
|
||||
export function fitLiveRowFontPx(
|
||||
rowCount: number,
|
||||
columns: number,
|
||||
results: readonly string[],
|
||||
): number {
|
||||
const base = fitRowFontPx(rowCount, BOARD_AVAILABLE_PX, liveRowFontRatio(columns))
|
||||
const widest = widestResultEm(results)
|
||||
if (widest <= 0) return base
|
||||
const byWidth = Math.floor((liveResultColumnBudgetPx(columns) - RESULT_COLUMN_PAD_PX) / widest)
|
||||
return Math.max(MIN_ROW_FONT_PX, Math.min(base, byWidth))
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时赛况的成绩列宽(px):当前字号下最宽成绩的估算墨迹 + 余量,下限是原来的写死列宽。
|
||||
*
|
||||
* 由内容定宽是必须的——成绩文本长短不可控(`59.99` 与 `64:21.50` 差一倍),写死就一定会裁。
|
||||
* 全页取一个宽度而不是逐行 max-content:每行是各自独立的网格,逐行按内容撑宽会让
|
||||
* 「单位」列的左边界一行一个位置(实测偏移可达 14px 以上),大屏上很显眼。
|
||||
*/
|
||||
export function liveResultColumnPx(
|
||||
results: readonly string[],
|
||||
columns: number,
|
||||
fontPx: number,
|
||||
): number {
|
||||
const need = Math.ceil(widestResultEm(results) * fontPx) + RESULT_COLUMN_PAD_PX
|
||||
return Math.max(liveResultColumnFloorPx(columns), need)
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { BOARD_AVAILABLE_PX, fitRowFont } from '../fitFont'
|
||||
import {
|
||||
BOARD_AVAILABLE_PX,
|
||||
MAX_ROW_FONT_PX,
|
||||
fitLiveRowFontPx,
|
||||
fitRowFont,
|
||||
liveResultColumnBudgetPx,
|
||||
liveResultColumnFloorPx,
|
||||
liveResultColumnPx,
|
||||
resultTextEm,
|
||||
} from '../fitFont'
|
||||
|
||||
test('fitRowFont 空列表用固定字号', () => {
|
||||
assert.equal(fitRowFont(0, BOARD_AVAILABLE_PX), '48px')
|
||||
@@ -13,3 +22,85 @@ test('fitRowFont 行数少时封顶 56px', () => {
|
||||
test('fitRowFont 行数多时守住 16px 下限', () => {
|
||||
assert.equal(fitRowFont(100, BOARD_AVAILABLE_PX), '16px')
|
||||
})
|
||||
|
||||
// 真机实测(Noto Sans SC、56px):`1:09.09` 墨迹 201px、`12.345 米` 252px、`64:21.50` 234px。
|
||||
// 估算一旦小于实测,末位数字就会重新被 .rows 的 overflow:hidden 裁掉(屏上是 `1:09.0`)。
|
||||
test('成绩估宽按实测最宽的字体上浮,宁可偏大', () => {
|
||||
assert.ok(resultTextEm('1:09.09') * 56 >= 201)
|
||||
assert.ok(resultTextEm('12.345 米') * 56 >= 252)
|
||||
assert.ok(resultTextEm('64:21.50') * 56 >= 234)
|
||||
// MiSans 的数字比 Noto 宽(`0` 实测 0.6786em),按最宽的那个估。
|
||||
assert.ok(resultTextEm('0:00.00') >= 4)
|
||||
assert.equal(resultTextEm(''), 0)
|
||||
})
|
||||
|
||||
test('两栏 1–10 行:字号封顶 56px、墨迹一律装得进算出来的列宽', () => {
|
||||
const results = ['1:06.33', '1:09.09', '1:10.55', '59.99']
|
||||
const widest = resultTextEm('1:06.33')
|
||||
const expectedFont = [56, 56, 56, 56, 53, 44, 38, 33, 29, 26]
|
||||
for (let rows = 1; rows <= 10; rows++) {
|
||||
const font = fitLiveRowFontPx(rows, 2, results)
|
||||
assert.equal(font, expectedFont[rows - 1], `${rows} 行`)
|
||||
assert.ok(font <= MAX_ROW_FONT_PX)
|
||||
const column = liveResultColumnPx(results, 2, font)
|
||||
assert.ok(column >= liveResultColumnFloorPx(2), `${rows} 行不该比原来的 150px 更窄`)
|
||||
assert.ok(widest * font <= column, `${rows} 行仍会裁`)
|
||||
}
|
||||
})
|
||||
|
||||
test('两栏 1–7 行才需要加宽成绩列,8 行以上仍用原来的 150px', () => {
|
||||
const results = ['1:06.33']
|
||||
const widest = resultTextEm('1:06.33')
|
||||
for (let rows = 1; rows <= 7; rows++) {
|
||||
const font = fitLiveRowFontPx(rows, 2, results)
|
||||
// 写死 150px 时这些行数真的会裁:这正是本次要修的真机缺陷。
|
||||
assert.ok(widest * font > liveResultColumnFloorPx(2), `${rows} 行`)
|
||||
assert.ok(liveResultColumnPx(results, 2, font) >= widest * font)
|
||||
}
|
||||
for (const rows of [8, 9, 10]) {
|
||||
const font = fitLiveRowFontPx(rows, 2, results)
|
||||
assert.ok(widest * font <= liveResultColumnFloorPx(2), `${rows} 行`)
|
||||
assert.equal(liveResultColumnPx(results, 2, font), 150)
|
||||
}
|
||||
})
|
||||
|
||||
test('长成绩:先加宽列,装不下才收字号,且不越预算', () => {
|
||||
const results = ['64:21.50']
|
||||
const font = fitLiveRowFontPx(1, 2, results)
|
||||
assert.ok(font < MAX_ROW_FONT_PX, '特别长的成绩要让出字号')
|
||||
assert.ok(font >= 40, '让出的幅度不该把大屏字变小')
|
||||
const column = liveResultColumnPx(results, 2, font)
|
||||
assert.ok(resultTextEm('64:21.50') * font <= column)
|
||||
assert.ok(column <= liveResultColumnBudgetPx(2))
|
||||
|
||||
// 典型成绩(7 字符)不该因为这条上限被收字号。
|
||||
assert.equal(fitLiveRowFontPx(1, 2, ['1:06.33', '1:09.09']), 56)
|
||||
})
|
||||
|
||||
test('单栏:56px 上限不因宽度被削,短成绩仍用原来的 220px 列', () => {
|
||||
assert.equal(fitLiveRowFontPx(1, 1, ['1:09.09']), MAX_ROW_FONT_PX)
|
||||
assert.equal(fitLiveRowFontPx(6, 1, ['1:09.09']), MAX_ROW_FONT_PX)
|
||||
assert.equal(liveResultColumnPx(['59.99'], 1, MAX_ROW_FONT_PX), liveResultColumnFloorPx(1))
|
||||
// 单栏也会裁长成绩(220px 装不下 56px 的 `64:21.50`),同样由内容定宽修掉。
|
||||
const font = fitLiveRowFontPx(1, 1, ['64:21.50'])
|
||||
assert.equal(font, MAX_ROW_FONT_PX)
|
||||
assert.ok(liveResultColumnPx(['64:21.50'], 1, font) >= resultTextEm('64:21.50') * font)
|
||||
})
|
||||
|
||||
test('没有行可排时退回固定字号与列宽下限', () => {
|
||||
assert.equal(fitLiveRowFontPx(0, 2, []), 48)
|
||||
assert.equal(liveResultColumnPx([], 2, 56), liveResultColumnFloorPx(2))
|
||||
assert.equal(liveResultColumnPx([], 1, 56), liveResultColumnFloorPx(1))
|
||||
})
|
||||
|
||||
test('行数越多字号只减不增', () => {
|
||||
const results = ['1:06.33', 'DNF', '12.35米']
|
||||
for (const columns of [1, 2]) {
|
||||
let previous = Infinity
|
||||
for (let rows = 1; rows <= 12; rows++) {
|
||||
const font = fitLiveRowFontPx(rows, columns, results)
|
||||
assert.ok(font <= previous, `${columns} 栏 ${rows} 行`)
|
||||
previous = font
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -254,3 +254,20 @@ test('页面按栏数切 class,三种文案都不留白', () => {
|
||||
assert.match(source, /liveFeed/)
|
||||
assert.match(source, /onMounted\(\(\) => \{\s*void refreshLiveEvents\(\)/)
|
||||
})
|
||||
|
||||
test('成绩列宽由内容算出来,网格里不能写死列宽', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../components/pages/LivePage.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
// 真机缺陷:两栏写死 150px,56px 字号的 `1:09.09` 墨迹 201px,末位数字被裁掉。
|
||||
// 列宽改成由 fitFont.ts 按成绩文本算(--rs-width),字号也要经过同一个口径。
|
||||
assert.doesNotMatch(source, /grid-template-columns: 60px 1fr 190px 150px/)
|
||||
assert.doesNotMatch(source, /grid-template-columns: 90px 1fr 300px 220px/)
|
||||
assert.match(source, /var\(--rs-width, 150px\)/)
|
||||
assert.match(source, /var\(--rs-width, 220px\)/)
|
||||
assert.match(source, /fitLiveRowFontPx/)
|
||||
assert.match(source, /liveResultColumnPx/)
|
||||
// 成绩不能折行:折成两行会被读成两个成绩,第二行还会被 .rows 竖着裁掉。
|
||||
assert.match(source, /\.rs \{[^}]*white-space: nowrap/s)
|
||||
})
|
||||
Reference in new issue
Block a user