Files
Scoreboard/server/internal/httpapi/entry_heat_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

329 lines
13 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 httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// importHeaderHeat 是带批次列的 12 列表头(与 ImportSection 模板一致)。
const importHeaderHeat = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
// 批量设置批次:成功写库、GET 下发、再传 null 清除;重复提交幂等。
func TestSetEntriesHeatSetsAndClears(t *testing.T) {
env, ids, client := recorderWithSeed(t)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, ids.entryB},
"heat": 2,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("设置批次应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["updated"] != float64(2) || result["heat"] != float64(2) {
t.Fatalf("批量设置结果不对: %+v", result)
}
var heat int
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&heat); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if heat != 2 {
t.Fatalf("entryA 的批次应为 2,得到 %d", heat)
}
// 下发路径也要带上 heat:录入页靠 GET /api/events/{id}/entries 拿批次。
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", ids.event), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读报名列表应 200,得到 %d %+v", status, env2.Error)
}
items := getEntryBoard(t, env, client, ids.event).Entries
if len(items) != 2 {
t.Fatalf("报名列表应有 2 条,得到 %d", len(items))
}
for _, item := range items {
if item["heat"] != float64(2) {
t.Fatalf("报名列表应下发 heat=2,得到 %+v", item["heat"])
}
}
// 幂等:同一批再设一次批次号,结果一致。
status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, ids.entryB},
"heat": 2,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("重复设置同一批次应 200,得到 %d %+v", status, env2.Error)
}
if again := decodeData[map[string]any](t, env2); again["updated"] != float64(2) {
t.Fatalf("重复设置应同样更新 2 条,得到 %+v", again)
}
// 传 null 清除为「未分批」。
status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, ids.entryB},
"heat": nil,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("清除批次应 200,得到 %d %+v", status, env2.Error)
}
if cleared := decodeData[map[string]any](t, env2); cleared["heat"] != nil {
t.Fatalf("清除后 heat 应为 null,得到 %+v", cleared["heat"])
}
var nullHeat any
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&nullHeat); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if nullHeat != nil {
t.Fatalf("清除后 entries.heat 应为 NULL,得到 %v", nullHeat)
}
}
// 跨小项批量设置一定是选错行:必须拒绝,且一条都不能改。
func TestSetEntriesHeatRejectsCrossEvent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
otherEvent := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
otherCategory := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, otherEvent)
otherEntry := env.insert(t,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`,
otherEvent, otherCategory, ids.teamB, ids.athB)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, otherEntry},
"heat": 1,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("跨小项设置批次应 400,得到 %d %+v", status, env2.Error)
}
assertNoHeat(t, env, ids.entryA)
assertNoHeat(t, env, otherEntry)
}
// entryIds 为空 / 全部非法、以及 heat 越界都必须被拒。
func TestSetEntriesHeatValidatesInput(t *testing.T) {
env, ids, client := recorderWithSeed(t)
cases := []struct {
name string
body map[string]any
}{
{"entryIds 为空", map[string]any{"entryIds": []int64{}, "heat": 1}},
{"entryIds 缺失", map[string]any{"heat": 1}},
{"entryIds 全是非法 id", map[string]any{"entryIds": []int64{0, -3}, "heat": 1}},
{"heat 为 0", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 0}},
{"heat 为负", map[string]any{"entryIds": []int64{ids.entryA}, "heat": -1}},
{"heat 超上限", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 100000}},
{"报名项不存在", map[string]any{"entryIds": []int64{ids.entryA, 999999}, "heat": 1}},
}
for _, tc := range cases {
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", tc.body, true)
if status != http.StatusBadRequest {
t.Errorf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
continue
}
if env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
t.Errorf("%s 应返回 VALIDATION_FAILED,得到 %+v", tc.name, env2.Error)
}
}
assertNoHeat(t, env, ids.entryA)
}
// entryHeatBatchLimit 与 POST /api/entries/batch 的 `maxEntryBatch` 刻意同值 500:
// 两个接口处理的都是「一批报名项」,上限与文案就该是同一句(服务端直接复用同一个常量)。
// 用例在 httpapi 包外拿不到常量,写字面量;上限改动时这里会红。
const entryHeatBatchLimit = 500
// 超限(上限 + 1):400,响应体与既有 400 同构,且一条报名项的批次都没改。
//
// 501 条全是同一个小项里的**真实**报名项(复用 partial_publish_test.go 的 seedBatch:
// 同样是在 seed 基础上补齐到 N 条):删掉上限检查后这一批会被真的写进库,
// 用例只能靠状态码与文案变红,不会靠「部分报名项不存在」之类的旁证蒙对。
func TestSetEntriesHeatRejectsOverLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := seedBatch(t, env, ids, entryHeatBatchLimit+1)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": targets, "heat": 1}, true)
if status != http.StatusBadRequest {
t.Fatalf("超限应 400,得到 %d %+v", status, env2.Error)
}
if env2.OK || env2.Error == nil {
t.Fatalf("超限应回 {ok:false,error},得到 %+v", env2)
}
want := fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", entryHeatBatchLimit)
if env2.Error.Message != want {
t.Fatalf("超限文案应为 %q,得到 %q", want, env2.Error.Message)
}
// 「与既有 400 同构」:拿同一个接口上既有的入参错误逐字段比,只允许 message 不同。
refStatus, ref := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": []int64{}, "heat": 1}, true)
if refStatus != http.StatusBadRequest || ref.Error == nil {
t.Fatalf("对照组(entryIds 为空)应 400,得到 %d %+v", refStatus, ref)
}
if env2.OK != ref.OK || env2.Error.Code != ref.Error.Code ||
(env2.Error.Detail == nil) != (ref.Error.Detail == nil) {
t.Fatalf("超限的 400 应与既有 400 同构,得到 %+v / %+v", env2.Error, ref.Error)
}
// 先校验、后写库:被拒的请求一条批次都不能落。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE heat IS NOT NULL`); n != 0 {
t.Fatalf("超限被拒后不该写任何批次,得到 %d 条", n)
}
}
// 正好等于上限必须放行:边界是「超过」才拒,500 条同小项的报名项一次写成同一批次。
func TestSetEntriesHeatAllowsExactlyAtLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := seedBatch(t, env, ids, entryHeatBatchLimit)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": targets, "heat": 2}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("正好 %d 条应放行,得到 %d %+v", entryHeatBatchLimit, status, env2.Error)
}
if result := decodeData[map[string]any](t, env2); result["updated"] != float64(entryHeatBatchLimit) {
t.Fatalf("应更新 %d 条,得到 %+v", entryHeatBatchLimit, result)
}
assertCount(t, env, `SELECT COUNT(*) FROM entries WHERE heat = 2`, entryHeatBatchLimit)
}
// 权限:guest 未登录 401;recorder 放行;批次写操作进审计。
func TestSetEntriesHeatPermissionAndAudit(t *testing.T) {
env, ids, _ := recorderWithSeed(t)
guest, _ := env.client()
status, env2 := env.do(guest, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA}, "heat": 1,
}, false)
if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("guest 设置批次应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
}
assertNoHeat(t, env, ids.entryA)
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
status, env2 = env.do(recorder, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA}, "heat": 3,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 设置批次应 200,得到 %d %+v", status, env2.Error)
}
var (
action, after string
)
if err := env.ev.QueryRow(
`SELECT action, after_json FROM audit_logs WHERE action = 'entry.heat' ORDER BY id DESC LIMIT 1`).
Scan(&action, &after); err != nil {
t.Fatalf("查批次审计: %v", err)
}
if !strings.Contains(after, `"heat":3`) {
t.Fatalf("审计里应记下批次号 3,得到 %s", after)
}
}
func assertNoHeat(t *testing.T, env *testEnv, entryID int64) {
t.Helper()
var heat any
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, entryID).Scan(&heat); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if heat != nil {
t.Fatalf("entry %d 的批次应保持未分批,得到 %v", entryID, heat)
}
}
// 导入带 heat 列:正常值入库、留空 = 未分批、非法值走行级拦截且其余行照常导入。
func TestImportEntriesWithHeatColumn(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeaderHeat + "\n" +
"M100,男子,A,张三,110101199001011234,男,,,,1,registered,\n" +
"M100,男子,A,李强,110101199001011235,男,,,,2,registered,\n" +
"M100,男子,B,王芳,110101199001011236,女,,,,3,registered,\n" +
"M100,男子,A,赵六,110101199001011237,男,,,,abc,registered,\n" +
"M100,男子,B,孙七,110101199001011238,男,,,,0,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(3) || result["skipped"] != float64(2) {
t.Fatalf("应新建 3 条、跳过 2 条,得到 %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 2 {
t.Fatalf("应有 2 条行级错误,得到 %+v", errs)
}
for _, raw := range errs {
text, _ := raw.(string)
if !strings.Contains(text, "heat") {
t.Errorf("行级错误应说明 heat 非法,得到 %q", text)
}
}
// 正常值入库;heat 非法的两行被拦,不影响同一份表里的其余行。
for idCard, want := range map[string]int64{
"110101199001011234": 1,
"110101199001011235": 2,
"110101199001011236": 3,
} {
var heat int64
if err := env.ev.QueryRow(
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = ?`, idCard).
Scan(&heat); err != nil {
t.Fatalf("读 %s 的报名批次: %v", idCard, err)
}
if heat != want {
t.Errorf("%s 的批次应为 %d,得到 %d", idCard, want, heat)
}
}
// 非法值的两行没有落库。
assertCount(t, env, `SELECT COUNT(*) FROM athletes WHERE id_card IN ('110101199001011237','110101199001011238')`, 0)
// 表头没有 heat 列时(老模板)照常导入,批次为未分批。
status, env2 = env.postImport(t, client, "/api/entries/import", importHeader+
"\nM100,男子,A,周八,110101199001011239,男,,,,registered,\n", "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("无 heat 列的老模板应照常导入,得到 %d %+v", status, env2.Error)
}
var heat any
if err := env.ev.QueryRow(
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011239'`).
Scan(&heat); err != nil {
t.Fatalf("读老模板导入的报名批次: %v", err)
}
if heat != nil {
t.Fatalf("无 heat 列时应为未分批,得到 %v", heat)
}
}
// 导入留空 = 未分批:显式带 heat 列但留空,写入 NULL。
func TestImportEntriesHeatBlankMeansUnbatched(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeaderHeat + "\nM100,男子,A,张三,110101199001011234,男,,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
if result := decodeData[map[string]any](t, env2); result["created"] != float64(1) {
t.Fatalf("应新建 1 条,得到 %+v", result)
}
var heat any
if err := env.ev.QueryRow(
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011234'`).
Scan(&heat); err != nil {
t.Fatalf("读报名批次: %v", err)
}
if heat != nil {
t.Fatalf("留空应为未分批(NULL),得到 %v", heat)
}
}