fix: 实机复测六批发现的 16 条缺陷(导入编码静默错误 / 导出观感 / 错误分级 / 大屏裁切)
先把「实机复测」能在这台 Linux 机器上验的全部自己验掉(真服务端 + 真 Chromium + 真样本),
六批全部通过,另挖出 16 条缺陷,逐条修完并复验:
导入
- 坏字节被静默替换成 U+FFFD 却报「组别不存在」→ 检测 RuneError 回 400 + 可操作中文文案
- UTF-16 无 BOM 误报「缺少列 event_code」→ 保守判据识别并正确解码
- CSV 语法错只有英文 → 中文提示,英文原文保留在 detail
- 表头缺列补「第 1 行应是表头」的指向
导出
- 名次列宽 8 → 11(放得下「3(并列)」,Excel 里不再硬裁)
- 运动员/队列宽 18 → 24(含奖牌明细的同一列)
- 聚合流水行补组别(仅单位在该小项只参加一个组别时回推;多组别宁可留空也不猜)
- docProps 作者从 excelize 默认的 xuri 改成中性名 + 真实导出时间
- Content-Disposition 补 ASCII 退化 filename(9 个下载端点,含此前没有测试的 project.zip)
错误分级与限流
- 未知大屏指令 500 → 400 VALIDATION_FAILED(传输层真失败仍走 5xx)
- 5xx 的 ERROR 日志补 role / ip(4xx 的 WARN 一直有)
- 写限流 30 → 120 次/分钟/用户(登录 5 次/分/IP 不动,性质不同)
- 赛事名含非法字符时导出给 400 + 可操作提示(原来永远 500「服务端异常」)
大屏与基础设施
- 实时赛况 2 栏成绩末位被裁(1:09.00 显示成 1:09.0)→ 成绩列按内容定宽 + nowrap;
字号不缩,让位给可省略号截断的运动员列。真 Chromium 实测 slack −45px → +6px、0 裁切、
scrollWidth 恒 1920(无横向滚动条);顺带修掉 1 栏长成绩同样被裁 8px 的问题
- logging 启动即预建两个日志目录(原来 interceptions/ 懒创建,现场会以为日志坏了)
- C→S 裸帧由静默忽略改为 WARN,并把「要套 {event,data} 外壳」写进日志
- 建单位/大项漏传 active 不再静默建成「停用」(新增 decodeJSONPresence 按键是否存在判意图)
文档
- README 单位 logo 措辞收窄到「大屏端」(管理端只负责上传配置)
- 导入规范补三类编码报错说明 + 现场另存结论 + 限流提示
- 部署指南补「导出前先看赛事名称」与「切换后怎么确认真的切过去了」
- devdocs 05/08/13 同步(含第三十五轮的完整记录与教训)
自检:服务端 13 包连跑两遍全绿;前端 257 例;vue-tsc + build 通过;Console 18 文件。
版本号 dev_0.1.4 → dev_0.1.5。
This commit is contained in:
1 parent
a52e6a4a3b
commit
8dd599bd97
39 files changed
+1308
-106
No files matched your search
@@ -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
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user