Files
Scoreboard/server/internal/export/basedata_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

341 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 (
"bytes"
"database/sql"
"strings"
"testing"
"github.com/xuri/excelize/v2"
)
// seedBaseData 造一份字段尽量填满的基础数据:两个单位、两个大项、三个小项、四个组别。
// 含简称 / 颜色 / 权重 / 性别 / 年龄区间 / 场地 / 备注与一个停用的大项,
// 让导出→导入的往返能覆盖到「启用 = 否」这类容易被静默改掉的列。
func seedBaseData(t *testing.T, db *sql.DB) {
t.Helper()
stmts := []string{
`INSERT INTO teams(code, name, short_name, color, sort_order, active)
VALUES ('T1', '第一分公司', '一公司', '#3482FF', 1, 1)`,
`INSERT INTO teams(code, name, sort_order, active) VALUES ('T2', '第二分公司', 2, 0)`,
`INSERT INTO sports(code, name, short_name, sort_order, active) VALUES ('SP1', '田径', '田', 1, 1)`,
`INSERT INTO sports(code, name, sort_order, active) VALUES ('SP2', '游泳', 2, 0)`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, sort_order, venue, remark)
VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 2, 'ongoing', 1, '主体育场', '决赛')`,
`INSERT INTO events(sport_id, code, name) VALUES (1, 'E2', '4×100米接力')`,
`INSERT INTO events(sport_id, code, name, event_type) VALUES (2, 'E3', '100米自由泳', 'team')`,
`INSERT INTO event_categories(event_id, name, multiplier, gender, age_min, age_max, sort_order)
VALUES (1, '男子青年组', 1.5, '男', 18, 35, 1)`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (1, '女子组', 2)`,
`INSERT INTO event_categories(event_id, name, gender, sort_order) VALUES (2, '团体组', '男', 1)`,
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (3, '男子', 1)`,
`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)
}
}
}
// findRecord 按某字段的取值找一行;顺序不是本测试要验证的东西,按内容找更稳。
func findRecord(t *testing.T, rows []BaseDataRow, field, value string) BaseDataRow {
t.Helper()
for _, row := range rows {
if row.Cells[field] == value {
return row
}
}
t.Fatalf("找不到 %s = %q 的行", field, value)
return BaseDataRow{}
}
// TestBaseDataPackageRoundTrip 是往返硬要求:导出的包原样读回来,四张表的表名、表头、
// 行数与每行的关键取值都要对得上——表头对不上,导回来就是「找不到表头行」。
func TestBaseDataPackageRoundTrip(t *testing.T) {
db := openEventDB(t)
seedBaseData(t, db)
wb, err := LoadBaseDataWorkbook(db, Options{})
if err != nil {
t.Fatalf("读取基础数据: %v", err)
}
var buf bytes.Buffer
if err := WriteBaseDataXLSX(&buf, wb); err != nil {
t.Fatalf("写出基础数据包: %v", err)
}
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("读回基础数据包: %v", err)
}
if len(tables) != 4 {
t.Fatalf("工作表数 = %d,期望 4", len(tables))
}
wantSheets := []string{BaseDataSheetTeams, BaseDataSheetSports, BaseDataSheetEvents, BaseDataSheetCategories}
wantRows := []int{2, 2, 3, 4}
for i, table := range tables {
if table.Sheet != wantSheets[i] {
t.Fatalf("第 %d 张表 = %q,期望 %q", i+1, table.Sheet, wantSheets[i])
}
if table.HeaderLine != rowHeader {
t.Errorf("%s 表头行 = %d,期望 %d", table.Sheet, table.HeaderLine, rowHeader)
}
if len(table.Rows) != wantRows[i] {
t.Errorf("%s 行数 = %d,期望 %d", table.Sheet, len(table.Rows), wantRows[i])
}
// 表头必须与导出契约逐字一致,否则导入端认不出列。
spec := BaseDataSpecs()[i]
for _, h := range spec.Headers {
found := false
for _, row := range table.Rows {
if row.Has[h.Field] {
found = true
break
}
}
if !found && len(table.Rows) > 0 {
t.Errorf("%s 缺少列 %q(字段 %s)", table.Sheet, h.Header, h.Field)
}
}
}
teams := tables[0]
if got := findRecord(t, teams.Rows, "code", "T1").Cells["code"]; got != "T1" {
t.Errorf("单位编码 = %q,期望 T1", got)
}
t1 := findRecord(t, teams.Rows, "code", "T1")
if got := t1.Cells["active"]; got != "是" {
t.Errorf("T1 启用 = %q,期望 是", got)
}
if got := t1.Cells["color"]; got != "#3482FF" {
t.Errorf("T1 颜色 = %q", got)
}
if got := findRecord(t, teams.Rows, "code", "T2").Cells["active"]; got != "否" {
t.Errorf("停用的 T2 启用 = %q,期望 否(往返不能把停用改回启用)", got)
}
events := tables[2]
e1 := findRecord(t, events.Rows, "code", "E1")
if got := e1.Cells["sportCode"]; got != "SP1" {
t.Errorf("小项大项编码 = %q,期望 SP1", got)
}
if got := e1.Cells["multiplier"]; got != "2" {
t.Errorf("E1 权重 = %q,期望 2", got)
}
if got := e1.Cells["venue"]; got != "主体育场" {
t.Errorf("E1 场地 = %q", got)
}
if got := e1.Cells["resultType"]; got != "time" {
t.Errorf("E1 结果类型 = %q,期望 time", got)
}
if got := findRecord(t, events.Rows, "code", "E3").Cells["sportCode"]; got != "SP2" {
t.Errorf("E3 大项编码 = %q,期望 SP2", got)
}
cats := tables[3]
findRecord(t, cats.Rows, "eventCode", "E1")
c1 := findRecord(t, cats.Rows, "name", "男子青年组")
if got := c1.Cells["eventCode"]; got != "E1" {
t.Errorf("组别所属小项编码 = %q,期望 E1(组别必须带小项编码)", got)
}
if got := c1.Cells["ageMin"]; got != "18" {
t.Errorf("年龄下限 = %q,期望 18", got)
}
if got := c1.Cells["gender"]; got != "男" {
t.Errorf("性别 = %q,期望 男", got)
}
if got := c1.Cells["multiplier"]; got != "1.5" {
t.Errorf("组别权重 = %q,期望 1.5", got)
}
// 再读一遍原始表头,确认表头文案与契约逐字一致(含「组别 sheet 带小项编码」这条)。
raw, err := excelize.OpenReader(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("打开基础数据包: %v", err)
}
defer func() { _ = raw.Close() }()
headerRow, err := raw.GetRows(BaseDataSheetCategories)
if err != nil {
t.Fatalf("读取组别表: %v", err)
}
for i, h := range []string{"小项编码", "组别名", "权重", "性别", "年龄下限", "年龄上限", "排序"} {
if got := cell(headerRow, rowHeader-1, i); got != h {
t.Errorf("组别表头第 %d 列 = %q,期望 %q", i+1, got, h)
}
}
}
// 手工整理的表通常把表头放在第 1 行,也必须认得出来,且行号要报真实行号。
func TestReadBaseDataPackageHeaderAtFirstRow(t *testing.T) {
f := excelize.NewFile()
defer func() { _ = f.Close() }()
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
t.Fatalf("重命名工作表: %v", err)
}
rows := [][]string{
{"单位编码", "单位名称", "启用"},
{"T9", "第九分公司", "是"},
{"T8", "第八分公司", "否"},
}
for r, row := range rows {
for c, v := range row {
name, _ := excelize.CoordinatesToCellName(c+1, r+1)
if err := f.SetCellValue("单位", name, v); err != nil {
t.Fatalf("写单元格: %v", err)
}
}
}
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
t.Fatalf("写出测试工作簿: %v", err)
}
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("读回: %v", err)
}
if len(tables) != 1 || tables[0].Sheet != "单位" {
t.Fatalf("只应认出单位表,得到 %+v", tables)
}
if tables[0].HeaderLine != 1 {
t.Errorf("表头行 = %d,期望 1", tables[0].HeaderLine)
}
if len(tables[0].Rows) != 2 {
t.Fatalf("行数 = %d,期望 2", len(tables[0].Rows))
}
if got := tables[0].Rows[0].Line; got != 2 {
t.Errorf("首行行号 = %d,期望 2", got)
}
if got := tables[0].Rows[1].Line; got != 3 {
t.Errorf("次行行号 = %d,期望 3", got)
}
// 文件里没有「简称」列:Has 必须是 false,导入端才知道「不改」而不是「写空」。
if tables[0].Rows[0].Has["shortName"] {
t.Errorf("文件里没有简称列,Has[shortName] 不该为 true")
}
if !tables[0].Rows[0].Has["active"] {
t.Errorf("文件里有启用列,Has[active] 应为 true")
}
if tables[0].Rows[0].Cells["name"] != "第九分公司" {
t.Errorf("单位名 = %q", tables[0].Rows[0].Cells["name"])
}
}
func TestReadBaseDataPackageErrors(t *testing.T) {
// 表名认得出来但表头找不到:必须点名缺哪一列,而不是笼统地说文件坏了。
f := excelize.NewFile()
defer func() { _ = f.Close() }()
if err := f.SetSheetName("Sheet1", "单位"); err != nil {
t.Fatalf("重命名工作表: %v", err)
}
if err := f.SetCellValue("单位", "A1", "随便写点什么"); err != nil {
t.Fatalf("写单元格: %v", err)
}
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
t.Fatalf("写出测试工作簿: %v", err)
}
if _, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes())); err == nil {
t.Fatal("找不到表头应报错")
} else if !strings.Contains(err.Error(), "单位编码") {
t.Errorf("错误信息应点名缺的表头,得到 %q", err.Error())
}
// 一张可识别的表都没有。
f2 := excelize.NewFile()
defer func() { _ = f2.Close() }()
if err := f2.SetCellValue("Sheet1", "A1", "无关内容"); err != nil {
t.Fatalf("写单元格: %v", err)
}
var buf2 bytes.Buffer
if err := f2.Write(&buf2); err != nil {
t.Fatalf("写出测试工作簿: %v", err)
}
if _, err := ReadBaseDataPackage(bytes.NewReader(buf2.Bytes())); err == nil {
t.Fatal("没有任何可识别工作表应报错")
}
}
// B03:工作表名带尾随空格时也必须认得出来,且取表要用文件里的原名。
// 现场把导出件另存 / 手工改过表名后很容易多出个空格;旧代码「认表用 TrimSpace 后的名字、
// 取表却用契约里的名字」,于是报出一句与事实相反的错:表就在文件里,却说它不存在。
func TestReadBaseDataPackageTrimsSheetName(t *testing.T) {
const spaced = BaseDataSheetTeams + " " // "单位 ":末尾一个空格
f := excelize.NewFile()
defer func() { _ = f.Close() }()
if err := f.SetSheetName("Sheet1", spaced); err != nil {
t.Fatalf("重命名工作表为带尾随空格的名字: %v", err)
}
rows := [][]string{
{"单位编码", "单位名称", "启用"},
{"T9", "第九分公司", "是"},
}
for r, row := range rows {
for c, v := range row {
name, err := excelize.CoordinatesToCellName(c+1, r+1)
if err != nil {
t.Fatalf("算单元格坐标: %v", err)
}
if err := f.SetCellValue(spaced, name, v); err != nil {
t.Fatalf("写单元格: %v", err)
}
}
}
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
t.Fatalf("写出测试工作簿: %v", err)
}
tables, err := ReadBaseDataPackage(bytes.NewReader(buf.Bytes()))
if err != nil {
t.Fatalf("表名带尾随空格时应照样读得出来,得到错误:%v", err)
}
if len(tables) != 1 || tables[0].Sheet != BaseDataSheetTeams {
t.Fatalf("应认出「单位」表,且 Sheet 报契约里的名字,得到 %+v", tables)
}
if len(tables[0].Rows) != 1 {
t.Fatalf("数据行数 = %d,期望 1:%+v", len(tables[0].Rows), tables[0].Rows)
}
if got := tables[0].Rows[0].Cells["code"]; got != "T9" {
t.Errorf("单位编码 = %q,期望 T9", got)
}
if got := tables[0].Rows[0].Cells["name"]; got != "第九分公司" {
t.Errorf("单位名称 = %q,期望 第九分公司", got)
}
}
func TestParseBaseDataHelpers(t *testing.T) {
for _, raw := range []string{"是", "1", "true", "启用", "YES", "on"} {
if v, ok := ParseBaseDataBool(raw, false); !ok || !v {
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 true,true", raw, v, ok)
}
}
for _, raw := range []string{"否", "0", "false", "停用", "NO", "off"} {
if v, ok := ParseBaseDataBool(raw, true); !ok || v {
t.Errorf("ParseBaseDataBool(%q) = %v,%v,期望 false,true", raw, v, ok)
}
}
if v, ok := ParseBaseDataBool("", true); !ok || !v {
t.Errorf("空值应取 fallback,得到 %v,%v", v, ok)
}
if _, ok := ParseBaseDataBool("也许", true); ok {
t.Error("认不出的写法应报不合法")
}
if v, ok := ParseBaseDataFloat("", 1); !ok || v != 1 {
t.Errorf("空权重应取 fallback 1,得到 %v,%v", v, ok)
}
if _, ok := ParseBaseDataFloat("两倍", 1); ok {
t.Error("非数字权重应报不合法")
}
if v, ok := ParseBaseDataInt(""); !ok || v != nil {
t.Errorf("空整数应为 nil,true,得到 %v,%v", v, ok)
}
if _, ok := ParseBaseDataInt("三"); ok {
t.Error("非整数应报不合法")
}
}