Files
Scoreboard/server/internal/export/event_test.go
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

315 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package export
import (
"database/sql"
"path/filepath"
"testing"
"github.com/xuri/excelize/v2"
)
// seedUnsettledEvent 造一屏「延迟结算」现场:3 个人报名同一个组别,只有 1 个人出了成绩。
//
// 已发布的那条**没有名次、没有流水**——延迟结算(standings.live_on_publish=false)下名次与流水
// 要等整组录完才写(httpapi 的 scopeSettled),这正是 A17 说的「已发布未结算」。
// 另外两条分别是「有草稿没发布」与「连成绩行都没有」,三种占位态因此能并排比。
func seedUnsettledEvent(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO teams(code, name, short_name, sort_order) VALUES ('T1', '第一分公司', '一公司', 1)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('张三', '男', 1)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('李四', '男', 1)`,
`INSERT INTO athletes(name, gender, team_id) VALUES ('王五', '男', 1)`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '男子', 1)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
SELECT 1, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 1, '101')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 2, '102')`,
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (1, 1, 1, 3, '103')`,
`INSERT INTO results(event_id, entry_id, result_text, result_value, unit, state)
VALUES (1, 1, '12.30', 12.30, 's', 'published')`,
`INSERT INTO results(event_id, entry_id, result_text, result_value, unit, state)
VALUES (1, 2, '12.40', 12.40, 's', 'draft')`,
`UPDATE settings SET value = '测试运动会' WHERE key = 'event.name'`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
}
// A17:延迟结算下「已发布但整组未结算」的行必须写成「名次 —、积分 —、状态 未结算」,
// 不能是「名次空 + 积分 0 + 状态 有效」——打印件上后者会被读成「算出来就是 0 分」,
// 正是 R18 立项要避免的误读。分项成绩表与单页表逐格一致(两者都走 eventRows)。
func TestUnsettledRowsPrintPlaceholders(t *testing.T) {
db := openEventDB(t)
seedUnsettledEvent(t, db)
dir := t.TempDir()
eventDst := filepath.Join(dir, "分项.xlsx")
if err := ExportEventXLSX(db, 1, eventDst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
flatDst := filepath.Join(dir, "单页.xlsx")
if err := ExportFlatXLSX(db, flatDst, Options{}); err != nil {
t.Fatalf("导出单页成绩表: %v", err)
}
// 三种占位态并排:未结算 / 草稿未发布 / 完全没录。列序见 eventColumns。
want := [][]string{
{"—", "101", "第一分公司", "张三", "12.30 秒", "—", "未结算", ""},
{"—", "102", "第一分公司", "李四", "分数未录入", "—", "草稿", ""},
{"—", "103", "第一分公司", "王五", "—", "—", "未录入", ""},
}
f := openXLSX(t, eventDst)
defer func() { _ = f.Close() }()
rows, err := f.GetRows("田径·100米跑·男子")
if err != nil {
t.Fatalf("读取分项成绩: %v", err)
}
for i, wantRow := range want {
for c, v := range wantRow {
if got := cell(rows, rowHeader+i, c); got != v {
t.Errorf("分项成绩表第 %d 行第 %d 列 = %q,期望 %q", i+1, c, got, v)
}
}
}
// 单页表比多 sheet 版多三列(大项 / 小项 / 组别),后 8 列同序。
ff := openXLSX(t, flatDst)
defer func() { _ = ff.Close() }()
flatRows, err := ff.GetRows(flatSheet)
if err != nil {
t.Fatalf("读取单页成绩: %v", err)
}
for i, wantRow := range want {
for c, v := range wantRow {
if got := cell(flatRows, rowHeader+i, c+3); got != v {
t.Errorf("单页表第 %d 行第 %d 列 = %q,期望 %q(与分项成绩表逐格一致)", i+1, c+3, got, v)
}
}
}
}
// A17 的反向钉子:实时结算(默认)下分批发布同样会让「已发布 < 应出成绩」,
// 但那一批当场结算过、名次与积分都在——不能被标成未结算。
// 同组的犯规行也不许被改写成「未结算」:判罚结果是现场明确给出的,不能弄丢。
func TestSettledAndPenalisedRowsAreNotMarkedUnsettled(t *testing.T) {
db := openEventDB(t)
seedUnsettledEvent(t, db)
stmts := []string{
// 已结算:名次与流水都写好了(发布即结算的形态)。
`UPDATE results SET rank = 1 WHERE entry_id = 1`,
`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, source_type, source_id, points)
VALUES (1, 1, 1, 1, 1, 'result', 1, 10)`,
// 第二个人改为「已发布 + 犯规」:犯规不占名次,状态列要保住「犯规」。
`UPDATE results SET state = 'published', status = 'dq' WHERE entry_id = 2`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造数据 %q: %v", s, err)
}
}
dst := filepath.Join(t.TempDir(), "分项.xlsx")
if err := ExportEventXLSX(db, 1, dst, Options{}); err != nil {
t.Fatalf("导出分项成绩表: %v", err)
}
f := openXLSX(t, dst)
defer func() { _ = f.Close() }()
rows, err := f.GetRows("田径·100米跑·男子")
if err != nil {
t.Fatalf("读取分项成绩: %v", err)
}
// 已结算的那条:名次 1、积分 10、状态 有效。
if got := cell(rows, rowHeader, 0); got != "1" {
t.Errorf("已结算行名次 = %q,期望 1(不该被当成未结算)", got)
}
if got := cell(rows, rowHeader, 5); got != "10" {
t.Errorf("已结算行积分 = %q,期望 10", got)
}
if got := cell(rows, rowHeader, 6); got != "有效" {
t.Errorf("已结算行状态 = %q,期望 有效", got)
}
// 犯规的那条:状态仍是「犯规」,不能被改写成「未结算」(判罚是现场明确给出的结论)。
dq := findRowByCell(t, rows, 1, "102")
if got := cell(rows, dq, 6); got != "犯规" {
t.Errorf("犯规行状态 = %q,期望 犯规(判罚结果不能被改写成未结算)", got)
}
}
// findRowByCell 在表里找「第 col 列等于 value」的数据行下标;找不到直接失败。
func findRowByCell(t *testing.T, rows [][]string, col int, value string) int {
t.Helper()
for r := rowHeader; r < len(rows); r++ {
if cell(rows, r, col) == value {
return r
}
}
t.Fatalf("表里找不到第 %d 列 = %q 的行", col, value)
return -1
}
// 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)
seedChannelRanks(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
}