Files
Scoreboard/server/internal/httpapi/handlers_config_test.go
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

1465 lines
57 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 (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// importHeader 与 ImportSection 模板 / 服务端 importColumns 保持一致。
const importHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,status,member_names"
// importHeaderNoAge 是升级前的 10 列表头,用来验证缺 age 列不报错也不误报冲突。
const importHeaderNoAge = "event_code,category,team_code,athlete_name,id_card,gender,bib,seed,status,member_names"
// postImport 以 multipart 上传报名表;decisions 非空时作为 resolve 接口的 decisions 字段。
func (e *testEnv) postImport(t *testing.T, client *http.Client, path, content, decisions string) (int, envelope) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", "entries.csv")
if err != nil {
t.Fatalf("构造 multipart: %v", err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatalf("写入报名表: %v", err)
}
if decisions != "" {
if err := mw.WriteField("decisions", decisions); err != nil {
t.Fatalf("写入 decisions: %v", err)
}
}
if err := mw.Close(); err != nil {
t.Fatalf("关闭 multipart: %v", err)
}
req, err := http.NewRequest(http.MethodPost, e.server.URL+path, &buf)
if err != nil {
t.Fatalf("构造请求: %v", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
resp, err := client.Do(req)
if err != nil {
t.Fatalf("上传报名表: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var env envelope
raw, _ := io.ReadAll(resp.Body)
if len(bytes.TrimSpace(raw)) > 0 {
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("解析响应: %v (%s)", err, raw)
}
}
return resp.StatusCode, env
}
// recorderWithSeed 建好种子数据并返回已登录的 recorder 客户端。
func recorderWithSeed(t *testing.T) (*testEnv, seedIDs, *http.Client) {
t.Helper()
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
return env, ids, client
}
// 登录审计必须落在真实用户上:请求 ctx 里仍是 guest,靠 AuditEntry.ActorID 补(P1-13)。
func TestLoginAuditRecordsUserID(t *testing.T) {
env := newTestEnv(t)
userID := env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
var auditUser sql.NullInt64
if err := env.ev.QueryRow(
`SELECT user_id FROM audit_logs WHERE action = 'auth.login' ORDER BY id DESC LIMIT 1`).Scan(&auditUser); err != nil {
t.Fatalf("查询登录审计: %v", err)
}
if !auditUser.Valid || auditUser.Int64 != userID {
t.Fatalf("登录审计 user_id 应为 %d,得到 %+v", userID, auditUser)
}
// 按 userId 过滤必须能查到这条登录记录。
env.createUser("admin", "admin-pass", "admin")
adminClient, _ := env.client()
env.login(adminClient, "admin", "admin-pass")
_, env2 := env.do(adminClient, http.MethodGet, fmt.Sprintf("/api/audit-logs?userId=%d", userID), nil, false)
page := decodeData[map[string]any](t, env2)
items, _ := page["items"].([]any)
found := false
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
continue
}
if item["action"] == "auth.login" && item["userId"] == float64(userID) {
found = true
}
}
if !found {
t.Fatalf("按 userId 过滤应能查到登录审计,得到 %+v", items)
}
}
// 跨站登录(login CSRF)必须被同源校验挡住(P2-6)。
func TestLoginRejectsCrossOrigin(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
body := map[string]any{"username": "recorder", "password": "recorder-pass"}
status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", body, false,
map[string]string{"Origin": "http://evil.example"})
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
t.Fatalf("跨站登录应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
}
// 同源(Origin 与 Host 一致)仍可登录。
status, env2 = env.doH(client, http.MethodPost, "/api/auth/login", body, false,
map[string]string{"Origin": env.server.URL})
if status != http.StatusOK || !env2.OK {
t.Fatalf("同源登录应 200,得到 %d %+v", status, env2.Error)
}
}
// 超过 bcrypt 72 字节上限的新密码必须 400,而不是被静默截断(P2-6)。
func TestChangePasswordRejectsTooLong(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
tooLong := strings.Repeat("a", auth.MaxPasswordBytes+1)
status, env2 := env.do(client, http.MethodPost, "/api/auth/password", map[string]any{
"currentPassword": "recorder-pass",
"newPassword": tooLong,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("超长新密码应 400,得到 %d %+v", status, env2.Error)
}
}
// 导入遇到新证件号 → 新建人员(name/gender/id_card/team)并建报名。
func TestImportCreatesAthleteWithIDCard(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeader + "\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)
}
result := decodeData[map[string]any](t, env2)
if result["created"] != float64(1) || result["skipped"] != float64(0) {
t.Fatalf("应新建 1 条报名且无跳过: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("不应有冲突: %+v", conflicts)
}
var (
name, gender string
teamID int64
)
if err := env.ev.QueryRow(
`SELECT name, gender, team_id FROM athletes WHERE id_card = ?`, "110101199001011234").
Scan(&name, &gender, &teamID); err != nil {
t.Fatalf("查询新建人员: %v", err)
}
if name != "张三" || gender != "男" || teamID != ids.teamA {
t.Fatalf("新建人员字段不对: name=%s gender=%s team=%d", name, gender, teamID)
}
var entries int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entries WHERE event_id = ? AND athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
ids.event, "110101199001011234").Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 1 {
t.Fatalf("应建 1 条报名,得到 %d", entries)
}
}
// 姓名 + 单位命中且库中证件号为空 → 回填证件号,不新建人员。
func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeader + "\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)
}
result := decodeData[map[string]any](t, env2)
if result["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("回填应更新已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("回填不算冲突: %+v", conflicts)
}
var idCard sql.NullString
if err := env.ev.QueryRow(`SELECT id_card FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard); err != nil {
t.Fatalf("查询人员证件号: %v", err)
}
if !idCard.Valid || idCard.String != "110101199001011234" {
t.Fatalf("应回填证件号,得到 %+v", idCard)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
}
}
// 号码布命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,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(0) || result["updated"] != float64(0) || result["skipped"] != float64(0) {
t.Fatalf("冲突行不应计入新增/更新/跳过: %+v", result)
}
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
if conflict["row"] != float64(2) || conflict["idCard"] != "110101199001011234" ||
conflict["athleteId"] != float64(ids.athleteA) {
t.Fatalf("冲突定位信息不对: %+v", conflict)
}
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "teamCode" {
t.Fatalf("应只有 teamCode 变化,得到 %+v", changed)
}
existing := conflict["existing"].(map[string]any)
if existing["name"] != "张伟" || existing["teamCode"] != "A" || existing["gender"] != "男" {
t.Fatalf("existing 快照不对: %+v", existing)
}
incoming := conflict["incoming"].(map[string]any)
if incoming["name"] != "张伟" || incoming["teamCode"] != "B" {
t.Fatalf("incoming 快照不对: %+v", incoming)
}
var teamID int64
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
t.Fatalf("查询人员单位: %v", err)
}
if teamID != ids.teamA {
t.Fatalf("冲突不应改人员单位,得到 %d", teamID)
}
var entries int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 0 {
t.Fatalf("冲突行的报名不应落库,得到 %d 条", entries)
}
}
// 同一文件里证件号重复出现不再报错:证件号是可选档案信息,不参与匹配也不拦截。
func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
"M100,男子,A,张三,110101199001011234,男,,,,registered,\n" +
"M100,男子,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)
}
result := decodeData[map[string]any](t, env2)
if result["skipped"] != float64(0) {
t.Fatalf("同表重复证件号不应再拦截: %+v", result)
}
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("不应有行级报错,得到 %+v", errs)
}
if result["created"] != float64(1) || result["updated"] != float64(1) {
t.Fatalf("首行新建、次行按姓名+单位命中同一人并更新报名: %+v", result)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 1 {
t.Fatalf("同一个人只应建 1 个人员,得到 %d", athletes)
}
}
// 新人带的证件号已被库里另一个人占用 → 明确的中文行级错误,不静默改库、不静默跳过。
func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
// 另一个人(李四 / 号码布 B200)拿着已被张伟占用的证件号来建档。
body := importHeader + "\nM100,男子,B,李四,110101199001011234,男,,B200,,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(0) || result["skipped"] != float64(1) {
t.Fatalf("撞号的证件号应跳过该行: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 {
t.Fatalf("应报 1 条行级错误,得到 %+v", errs)
}
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
if errs[0] != want {
t.Fatalf("错误文案不对:\n 得到 %q\n 期望 %q", errs[0], want)
}
// 库里的张伟一行没动,也没有多出一个人。
var (
name string
bib string
count int
)
if err := env.ev.QueryRow(`SELECT name, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name, &bib); err != nil {
t.Fatalf("查询人员: %v", err)
}
if name != "张伟" || bib != "A100" {
t.Fatalf("撞号不应改动库里已有人员,得到 name=%s bib=%s", name, bib)
}
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&count); err != nil {
t.Fatalf("统计人员: %v", err)
}
if count != 2 {
t.Fatalf("撞号不应新建人员,得到 %d 个", count)
}
}
// 号码布不同 → 按「同名的另一个人」新建档案,并在 sameNameNewPersons 里单独报告,不进冲突面板。
func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,,男,,2001,,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(1) || result["skipped"] != float64(0) {
t.Fatalf("同名不同号码布应新建报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("同名不同号码布不进冲突面板: %+v", conflicts)
}
reported, _ := result["sameNameNewPersons"].([]any)
if len(reported) != 1 {
t.Fatalf("应报告 1 条同名新建,得到 %+v", reported)
}
item := reported[0].(map[string]any)
if item["name"] != "张伟" || item["teamCode"] != "A" || item["newBib"] != "2001" || item["existingBib"] != "1001" {
t.Fatalf("同名新建报告字段不对: %+v", item)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE name = '张伟' AND team_id = ?`, ids.teamA).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("应新建第二个人,得到 %d 个", athletes)
}
// 原档案的号码布保持不动,新档案拿表里的号码布。
var oldBib string
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&oldBib); err != nil {
t.Fatalf("查询原人员: %v", err)
}
if oldBib != "1001" {
t.Fatalf("原档案号码布不应被改,得到 %s", oldBib)
}
}
// 姓名 + 单位命中多条候选:不能替用户挑人,进冲突面板等决策。
func TestImportAmbiguousNameTeamGoesToConflictPanel(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 同单位两个「张伟」,都没有号码布,表里来第三个候选信息 → 歧义。
env.exec(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA)
body := importHeader + "\nM100,男子,A,张伟,,男,,9001,,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(0) || result["skipped"] != float64(0) {
t.Fatalf("歧义行不落库也不跳过: %+v", result)
}
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
}
if conflicts[0].(map[string]any)["athleteId"] != float64(ids.athleteA) {
t.Fatalf("歧义冲突应以第一条候选为代表: %+v", conflicts[0])
}
}
// 有 athlete_name 的行必须给 id_card 或 bib,两个都缺才报错。
func TestImportRequiresIDCardOrBibWhenNameGiven(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张三,,男,,,,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(0) || result["skipped"] != float64(1) {
t.Fatalf("缺证件号与号码布应跳过: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "id_card") {
t.Fatalf("应报缺少 id_card 或 bib,得到 %+v", errs)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("该行不应建人员,得到 %d 个", athletes)
}
}
// id_card / gender / age 是可选列:表头缺失不报错(团体行不涉及人员)。
func TestImportToleratesMissingOptionalColumns(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := "event_code,category,team_code,athlete_name,bib,seed,status,member_names\n" +
"M100,男子,A,,,,,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(1) || result["skipped"] != float64(0) {
t.Fatalf("团体行应正常导入: %+v", result)
}
}
// keep-existing:保留库中人员字段,报名跟随库里已有的单位。
func TestImportResolveKeepExisting(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
// 未给决策时,冲突原样返回。
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]")
if status != http.StatusOK || !env2.OK {
t.Fatalf("决策接口应 200,得到 %d %+v", status, env2.Error)
}
if conflicts, _ := decodeData[map[string]any](t, env2)["conflicts"].([]any); len(conflicts) != 1 {
t.Fatalf("未决策的冲突应继续返回: %+v", conflicts)
}
decisions := `[{"idCard":"110101199001011234","action":"keep-existing"}]`
status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
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(1) || result["created"] != float64(0) {
t.Fatalf("keep-existing 应更新库里已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
var teamID int64
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
t.Fatalf("查询人员单位: %v", err)
}
if teamID != ids.teamA {
t.Fatalf("keep-existing 应保留库中单位,得到 %d", teamID)
}
var entries int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 0 {
t.Fatalf("keep-existing 不应在表里的单位下建报名,得到 %d 条", entries)
}
var audit int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM audit_logs WHERE action = 'entries.import.resolve'`).Scan(&audit); err != nil {
t.Fatalf("统计审计: %v", err)
}
if audit == 0 {
t.Fatalf("决策应写审计")
}
}
// keep-new:用表里的字段更新人员,再按表里的单位建报名。
func TestImportResolveKeepNew(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
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(1) || result["updated"] != float64(0) {
t.Fatalf("keep-new 应在表里的单位下新建报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
var teamID int64
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
t.Fatalf("查询人员单位: %v", err)
}
if teamID != ids.teamB {
t.Fatalf("keep-new 应用表里的单位更新人员,得到 %d", teamID)
}
var entries int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 1 {
t.Fatalf("keep-new 应在表里的单位下建 1 条报名,得到 %d 条", entries)
}
}
// decisions 非法时返回 400。
func TestImportResolveRejectsBadDecisions(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
for _, decisions := range []string{"不是 JSON", `[{"idCard":"1","action":"keep-both"}]`} {
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("非法 decisions 应 400,得到 %d %+v", status, env2.Error)
}
}
}
// 只给号码布(没有证件号)也能新建人员,人员与报名都写入号码布。
func TestImportCreatesAthleteWithBibOnly(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,赵六,,男,,B1001,,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(1) || result["skipped"] != float64(0) {
t.Fatalf("仅给号码布应新建 1 条报名: %+v", result)
}
var (
idCard sql.NullString
bib string
)
if err := env.ev.QueryRow(`SELECT id_card, bib FROM athletes WHERE name = '赵六'`).Scan(&idCard, &bib); err != nil {
t.Fatalf("查询新建人员: %v", err)
}
if idCard.Valid || bib != "B1001" {
t.Fatalf("人员应只有号码布: id_card=%+v bib=%s", idCard, bib)
}
var entryBib sql.NullString
if err := env.ev.QueryRow(
`SELECT bib FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B1001')`).Scan(&entryBib); err != nil {
t.Fatalf("查询报名号码布: %v", err)
}
if !entryBib.Valid || entryBib.String != "B1001" {
t.Fatalf("报名也应写号码布: %+v", entryBib)
}
}
// 号码布精确命中已有人员 → 视为同一人,只更新报名。
func TestImportMatchesAthleteByBib(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,,男,,A100,,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["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("号码布命中应更新已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("号码布命中不算冲突: %+v", conflicts)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("号码布命中不应新建人员,得到 %d 个", athletes)
}
}
// 姓名 + 单位命中且库中证件号为空 → 回填号码布,不新建人员。
func TestImportBackfillsBibForExistingAthlete(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张伟,,男,,A100,,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["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("回填应更新已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("回填不算冲突: %+v", conflicts)
}
var bib sql.NullString
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
t.Fatalf("查询人员号码布: %v", err)
}
if !bib.Valid || bib.String != "A100" {
t.Fatalf("应回填号码布,得到 %+v", bib)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
}
}
// 号码布命中时,表里多给的证件号也回填进库中为空的人员。
func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A100,,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["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("号码布命中应更新已有报名: %+v", result)
}
var idCard sql.NullString
if err := env.ev.QueryRow(`SELECT id_card FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard); err != nil {
t.Fatalf("查询人员证件号: %v", err)
}
if !idCard.Valid || idCard.String != "110101199001011234" {
t.Fatalf("应回填证件号,得到 %+v", idCard)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
}
}
// 同一文件里号码布重复出现 → 该行报错并跳过。
func TestImportRejectsDuplicateBibInFile(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
"M100,男子,A,张三,,男,,B2001,,registered,\n" +
"M100,男子,A,李四,,男,,B2001,,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(1) || result["skipped"] != float64(1) {
t.Fatalf("首行应新建、次行应跳过: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "号码布") {
t.Fatalf("应报号码布重复,得到 %+v", errs)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE bib = 'B2001'`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 1 {
t.Fatalf("重复号码布只应建 1 个人员,得到 %d", athletes)
}
}
// 同名 + 号码布不同本应按「同名的另一个人」新建,但表里的证件号已被库里那个人占用时,
// 唯一索引的底线更高:明确报出占用者,既不静默改库也不静默跳过。
func TestImportSameNameDifferentBibButTakenIDCard(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A200,,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["skipped"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("撞号应跳过该行: %+v", result)
}
errs, _ := result["errors"].([]any)
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
if len(errs) != 1 || errs[0] != want {
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
}
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
t.Fatalf("新建被证件号唯一索引挡下时不应报告成同名新建: %+v", reported)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("不应新建人员,得到 %d 个", athletes)
}
}
// 号码布命中但姓名与库中不一致 → 冲突;决策按号码布为键,keep-new 用表里字段覆盖。
func TestImportResolveConflictByBib(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟伟,,男,,A100,,registered,\n"
decisions := `[{"bib":"A100","action":"keep-new"}]`
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
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(1) || result["created"] != float64(0) {
t.Fatalf("keep-new 应更新已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
var name string
if err := env.ev.QueryRow(`SELECT name FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name); err != nil {
t.Fatalf("查询人员姓名: %v", err)
}
if name != "张伟伟" {
t.Fatalf("keep-new 应用表里的姓名覆盖人员,得到 %s", name)
}
}
// 表里的 age 列写入新建人员。
func TestImportCreatesAthleteWithAge(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,23,,,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(1) || result["skipped"] != float64(0) {
t.Fatalf("应新建 1 条报名且无跳过: %+v", result)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&age); err != nil {
t.Fatalf("查询新建人员: %v", err)
}
if !age.Valid || age.Int64 != 23 {
t.Fatalf("应写入年龄 23,得到 %+v", age)
}
}
// age 非整数或超出 0-120 → 该行报错并跳过,人员与报名都不落库。
func TestImportRejectsInvalidAge(t *testing.T) {
env, _, client := recorderWithSeed(t)
for _, age := range []string{"abc", "-1", "121", "30.5"} {
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男," + age + ",,,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(0) || result["skipped"] != float64(1) {
t.Fatalf("age=%s 应跳过该行: %+v", age, result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "age") {
t.Fatalf("age=%s 应报年龄非法,得到 %+v", age, errs)
}
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("非法行不应建人员,得到 %d 个", athletes)
}
}
// 姓名 + 单位命中且库中证件号为空 → 一并回填年龄,不新建人员。
func TestImportBackfillsAgeForExistingAthlete(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,35,,,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["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("回填应更新已有报名: %+v", result)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("回填不算冲突: %+v", conflicts)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 35 {
t.Fatalf("应回填年龄 35,得到 %+v", age)
}
// 回填后年龄两边相同,再导入一次不算冲突。
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 conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("年龄相同不算冲突: %+v", conflicts)
}
if result["updated"] != float64(1) {
t.Fatalf("二次导入应更新已有报名: %+v", result)
}
}
// 年龄两边都有且不同 → 冲突,快照与差异字段都带 age,本轮不落库。
func TestImportConflictIncludesAgeDiff(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,26,,,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)
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("年龄不同应收集 1 条冲突,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "age" {
t.Fatalf("应只有 age 变化,得到 %+v", changed)
}
if existing := conflict["existing"].(map[string]any); existing["age"] != float64(30) {
t.Fatalf("existing 快照应带库中年龄: %+v", existing)
}
if incoming := conflict["incoming"].(map[string]any); incoming["age"] != float64(26) {
t.Fatalf("incoming 快照应带表里年龄: %+v", incoming)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 30 {
t.Fatalf("冲突不应改库中年龄,得到 %+v", age)
}
}
// 升级前的 10 列表头没有 age 列:不报错,也不能把已有年龄的人误报成冲突。
func TestImportWithoutAgeColumnKeepsExistingAge(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA)
body := importHeaderNoAge + "\nM100,男子,A,张伟,110101199001011234,男,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("缺 age 列应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("缺 age 列不该误报冲突: %+v", conflicts)
}
if result["updated"] != float64(1) {
t.Fatalf("缺 age 列应正常更新: %+v", result)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 30 {
t.Fatalf("缺 age 列不该清掉库中年龄,得到 %+v", age)
}
}
// keep-new 只在表里给了年龄时才覆盖,缺列/空值不能清掉库中已有的年龄。
func TestImportResolveKeepNewAgeOnlyOverwritesWhenGiven(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA)
// 表里 age 为空:keep-new 覆盖单位与姓名,年龄保持库里的值。
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
if status != http.StatusOK || !env2.OK {
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
}
if conflicts, _ := decodeData[map[string]any](t, env2)["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 30 {
t.Fatalf("表里没给年龄时不该清掉库里年龄,得到 %+v", age)
}
// 表里给了 age:keep-new 覆盖成表里的值。
body = importHeader + "\nM100,男子,A,张伟,110101199001011234,男,26,,,registered,\n"
if status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body, decisions); status != http.StatusOK || !env2.OK {
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
}
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 26 {
t.Fatalf("keep-new 应用表里的年龄覆盖,得到 %+v", age)
}
}
// status 列归一:DNS / 退赛 / 空串 / Registered 都能正常入库,库里存的是四个合法值之一。
func TestImportNormalizesStatusColumn(t *testing.T) {
env, _, client := recorderWithSeed(t)
cases := []struct{ cell, want string }{
{"DNS", "dns"},
{"退赛", "scratched"},
{"", "registered"},
{"Registered", "registered"},
}
body := importHeader + "\n"
for i, c := range cases {
body += fmt.Sprintf("M100,男子,A,选手%d,11010119900101%04d,男,,,,%s,\n", i+1, i+1, c.cell)
}
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(len(cases)) || result["skipped"] != float64(0) {
t.Fatalf("四种写法都应导入成功: %+v", result)
}
for i, c := range cases {
idCard := fmt.Sprintf("11010119900101%04d", i+1)
var got string
if err := env.ev.QueryRow(`SELECT e.status FROM entries e JOIN athletes a ON a.id = e.athlete_id
WHERE a.id_card = ?`, idCard).Scan(&got); err != nil {
t.Fatalf("查询 status(%s): %v", c.cell, err)
}
if got != c.want {
t.Errorf("status=%q 应存成 %q,得到 %q", c.cell, c.want, got)
}
}
}
// status 填了认不出的值:该行跳过,错误消息要是中文可读的行级提示,不能是原始 SQLite 报错。
func TestImportRejectsUnknownStatus(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,已报到,\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(0) || result["skipped"] != float64(1) {
t.Fatalf("乱填 status 的行应跳过: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 {
t.Fatalf("应有 1 条行级错误,得到 %+v", errs)
}
msg, _ := errs[0].(string)
for _, want := range []string{"第 2 行", "已报到", "registered / scratched / dq / dns"} {
if !strings.Contains(msg, want) {
t.Fatalf("错误消息应含 %q,得到 %q", want, msg)
}
}
// 该行一个字段都不该落库:既不建人员,也不建报名。
var entries int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entries e JOIN athletes a ON a.id = e.athlete_id
WHERE a.id_card = ?`, "110101199001011234").Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 0 {
t.Fatalf("认不出 status 的行不应落库,得到 %d 条报名", entries)
}
}
// ---------- 图片上传与静态访问 ----------
// newUploadEnv 构造带赛事项目目录的测试环境,返回 uploads 目录的真实路径。
func newUploadEnv(t *testing.T) (*testEnv, string) {
t.Helper()
projectDir := filepath.Join(t.TempDir(), "event_projs", "test")
env := newTestEnvWith(t, func(o *httpapi.Options) { o.ProjectDir = projectDir })
return env, filepath.Join(projectDir, "uploads")
}
// pngContent 是占位 PNG:服务端只按扩展名白名单放行,不解码图片内容。
func pngContent() []byte {
return append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, []byte("fake-png-body")...)
}
// postUpload 以 multipart 字段 file 上传到 /api/uploads。
func (e *testEnv) postUpload(t *testing.T, client *http.Client, filename string, content []byte) (int, envelope) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("构造 multipart: %v", err)
}
if _, err := fw.Write(content); err != nil {
t.Fatalf("写入上传内容: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("关闭 multipart: %v", err)
}
req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/uploads", &buf)
if err != nil {
t.Fatalf("构造请求: %v", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if token := e.csrfToken(client); token != "" {
req.Header.Set("X-CSRF-Token", token)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("上传图片: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var env envelope
raw, _ := io.ReadAll(resp.Body)
if len(bytes.TrimSpace(raw)) > 0 {
if err := json.Unmarshal(raw, &env); err != nil {
t.Fatalf("解析上传响应: %v (%s)", err, raw)
}
}
return resp.StatusCode, env
}
type uploadResultBody struct {
Path string `json:"path"`
URL string `json:"url"`
}
func uploadsDirEntries(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil
}
if err != nil {
t.Fatalf("读取 uploads 目录: %v", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
return names
}
// 上传的图片要真落盘到 <ProjectDir>/uploads,且匿名 <img> 能取到同样的内容(赛事包导出靠这个目录)。
func TestUploadImageThenServeAnonymously(t *testing.T) {
env, uploadsDir := newUploadEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
content := pngContent()
status, body := env.postUpload(t, client, "赛事会标.png", content)
if status != http.StatusOK || !body.OK {
t.Fatalf("上传 png 应 200,得到 %d %+v", status, body.Error)
}
result := decodeData[uploadResultBody](t, body)
if result.Path != result.URL {
t.Fatalf("path 与 url 应一致,得到 %q / %q", result.Path, result.URL)
}
if !strings.HasPrefix(result.Path, "/uploads/") || !strings.HasSuffix(result.Path, ".png") {
t.Fatalf("返回路径应形如 /uploads/xxx.png,得到 %q", result.Path)
}
// 落盘名不能用客户端文件名,否则重名会互相覆盖、也会带出路径注入。
if strings.Contains(result.Path, "赛事会标") {
t.Fatalf("落盘名应为随机名,得到 %q", result.Path)
}
stored := filepath.Join(uploadsDir, filepath.Base(result.Path))
got, err := os.ReadFile(stored)
if err != nil {
t.Fatalf("上传文件未落在 projectDir/uploads: %v", err)
}
if !bytes.Equal(got, content) {
t.Fatalf("落盘内容与上传内容不一致: %q", got)
}
var audits int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'uploads.create'`).Scan(&audits); err != nil {
t.Fatalf("查询上传审计: %v", err)
}
if audits != 1 {
t.Fatalf("上传应写一条 uploads.create 审计,得到 %d", audits)
}
// 大屏与 <img> 不带会话:匿名 GET 必须 200。
resp, err := http.Get(env.server.URL + result.Path)
if err != nil {
t.Fatalf("匿名取图: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("匿名取图应 200,得到 %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "image/png" {
t.Fatalf("Content-Type 应为 image/png,得到 %q", ct)
}
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "max-age") {
t.Fatalf("随机文件名应可长缓存,得到 Cache-Control %q", cc)
}
served, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("读取图片响应: %v", err)
}
if !bytes.Equal(served, content) {
t.Fatalf("静态响应内容与上传内容不一致: %q", served)
}
}
// 只收位图:SVG 可内嵌脚本,匿名同源访问等于存储型 XSS 入口。
func TestUploadRejectsUnsupportedImageType(t *testing.T) {
env, uploadsDir := newUploadEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
cases := []struct {
name string
content []byte
}{
{"logo.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)},
{"logo.txt", []byte("not an image")},
{"logo.png.exe", []byte("not an image")},
}
for _, c := range cases {
status, body := env.postUpload(t, client, c.name, c.content)
if status != http.StatusBadRequest || body.OK {
t.Fatalf("上传 %s 应 400,得到 %d %+v", c.name, status, body.Error)
}
}
if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 {
t.Fatalf("被拒的上传不该落盘,实际有 %v", names)
}
}
// 单文件上限 5 MiB:略超与远超都要 400,且不落盘。
func TestUploadRejectsOversizeImage(t *testing.T) {
env, uploadsDir := newUploadEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
for _, size := range []int{5<<20 + 1, 8 << 20} {
status, body := env.postUpload(t, client, "big.png", bytes.Repeat([]byte{'a'}, size))
if status != http.StatusBadRequest || body.OK {
t.Fatalf("%d 字节应 400,得到 %d %+v", size, status, body.Error)
}
if !strings.Contains(body.Error.Message, "5 MiB") {
t.Fatalf("超限提示应说明上限,得到 %q", body.Error.Message)
}
}
if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 {
t.Fatalf("超限上传不该落盘,实际有 %v", names)
}
}
// 上传是写操作:未登录(guest)一律 401 AUTH_REQUIRED,与其它写接口口径一致。
func TestUploadRequiresRecorder(t *testing.T) {
env, uploadsDir := newUploadEnv(t)
client, _ := env.client()
status, body := env.postUpload(t, client, "logo.png", pngContent())
if status != http.StatusUnauthorized {
t.Fatalf("未登录上传应 401,得到 %d %+v", status, body.Error)
}
if body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("错误码应为 AUTH_REQUIRED,得到 %+v", body.Error)
}
if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 {
t.Fatalf("未登录上传不该落盘,实际有 %v", names)
}
}
// 没配置赛事项目目录时没有落盘位置,明确报 503 而不是 panic。
func TestUploadWithoutProjectDir(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, body := env.postUpload(t, client, "logo.png", pngContent())
if status != http.StatusServiceUnavailable {
t.Fatalf("未配置 ProjectDir 应 503,得到 %d %+v", status, body.Error)
}
}
// 静态访问只认「单层文件名 + 白名单扩展名」,任何穿越写法都不能读到 uploads 之外的文件。
func TestUploadStaticRejectsTraversal(t *testing.T) {
env, uploadsDir := newUploadEnv(t)
root := filepath.Dir(filepath.Dir(uploadsDir)) // projectDir 的上一级(event_projs)
const secret = "top-secret-content"
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("创建 uploads 之外的目录: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte(secret), 0o644); err != nil {
t.Fatalf("写 uploads 之外的文件: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "..", "secret.txt"), []byte(secret), 0o644); err != nil {
t.Fatalf("写 uploads 之外的文件: %v", err)
}
// 不跟随重定向:要看到服务端真正给出的状态码。
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
paths := []string{
"/uploads/..%2F..%2Fconfig.yaml",
"/uploads/..%2F..%2F..%2Fsecret.txt",
"/uploads/%2e%2e%2Fconfig.yaml",
"/uploads/a/b.png",
"/uploads/..%2Fconfig.yaml",
}
for _, path := range paths {
resp, err := client.Get(env.server.URL + path)
if err != nil {
t.Fatalf("请求 %s: %v", path, err)
}
raw, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusBadRequest {
t.Fatalf("%s 应 404/400,得到 %d", path, resp.StatusCode)
}
if strings.Contains(string(raw), secret) {
t.Fatalf("%s 读到了 uploads 之外的文件: %s", path, raw)
}
}
}
// 名字合法但文件不存在时返回 404,不能把目录列表或空图当成功。
func TestUploadStaticMissingFile(t *testing.T) {
env, _ := newUploadEnv(t)
resp, err := http.Get(env.server.URL + "/uploads/0123456789abcdef0123456789abcdef.png")
if err != nil {
t.Fatalf("请求不存在的图片: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("不存在的图片应 404,得到 %d", resp.StatusCode)
}
}
// 上传返回的路径要能直接写进赛事配置,且大屏匿名取图链路通(会标/背景的实际用法)。
func TestUploadedImagePathRoundTripsThroughSettings(t *testing.T) {
env, _ := newUploadEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
status, body := env.postUpload(t, client, "logo.png", pngContent())
if status != http.StatusOK || !body.OK {
t.Fatalf("上传应 200,得到 %d %+v", status, body.Error)
}
path := decodeData[uploadResultBody](t, body).Path
status, body = env.do(client, http.MethodPut, "/api/settings", map[string]any{
"event": map[string]any{"logoPath": path},
"display": map[string]any{"logo": path, "background": path},
}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("把上传路径写进配置应 200,得到 %d %+v", status, body.Error)
}
status, body = env.do(client, http.MethodGet, "/api/settings", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("读取配置应 200,得到 %d %+v", status, body.Error)
}
settings := decodeData[dto.EventSettings](t, body)
if settings.Event.LogoPath == nil || *settings.Event.LogoPath != path {
t.Fatalf("event.logoPath 应保存上传路径 %q,得到 %+v", path, settings.Event.LogoPath)
}
if settings.Display.Logo == nil || *settings.Display.Logo != path {
t.Fatalf("display.logo 应保存上传路径 %q,得到 %+v", path, settings.Display.Logo)
}
// 大屏用的就是这个路径,匿名 GET 必须直接取到图。
resp, err := http.Get(env.server.URL + path)
if err != nil {
t.Fatalf("匿名取图: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("配置里的图片路径应可匿名访问,得到 %d", resp.StatusCode)
}
}
// 小项的 scored / recordEligible 漏传 = 建表默认值(migrations/event/0004 里都是 1),
// 显式 false 才是 false;更新时漏传沿用库里的现值,与单位 / 大项的 active 同口径。
// 与基础数据包导入侧(export_handlers.go 里新建小项默认 true/true)也一致。
func TestEventBoolDefaultsFollowSchemaDefaults(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
boolField := func(t *testing.T, body envelope, field string) bool {
t.Helper()
got, ok := decodeData[map[string]any](t, body)[field].(bool)
if !ok {
t.Fatalf("响应里 %s 不是布尔值: %+v", field, body.Data)
}
return got
}
// 库里也真的按接口回报的值落着,不只是响应体好看。
assertStoredFlags := func(t *testing.T, code string, want int) {
t.Helper()
var scored, recordEligible int
if err := env.ev.QueryRow(`SELECT scored, record_eligible FROM events WHERE code = ?`, code).
Scan(&scored, &recordEligible); err != nil {
t.Fatalf("查询小项 %s 的开关: %v", code, err)
}
if scored != want || recordEligible != want {
t.Fatalf("小项 %s 的开关应为 %d/%d,得到 %d/%d", code, want, want, scored, recordEligible)
}
}
base := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省"}
status, body := env.do(client, http.MethodPost, "/api/events", base, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("建小项应 200,得到 %d %+v", status, body.Error)
}
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
t.Fatalf("漏传开关应取建表默认值 true: %+v", body.Data)
}
assertStoredFlags(t, "E-BOOL", 1)
eventID := int64(decodeData[map[string]any](t, body)["id"].(float64))
// 更新漏传沿用现值:库里是 true/true 时,只改名字不能把它们翻成 false。
rename := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名一)"}
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("改名应 200,得到 %d %+v", status, body.Error)
}
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
t.Fatalf("更新漏传应沿用库里的 true: %+v", body.Data)
}
// 显式 false 才关得掉:接口不能无视调用方的明确意图。
turnOff := map[string]any{
"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名二)",
"scored": false, "recordEligible": false,
}
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), turnOff, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("显式关开关应 200,得到 %d %+v", status, body.Error)
}
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
}
// 反过来:库里是 false/false 时,更新漏传也不能把它打开。
rename["name"] = "开关缺省(改名三)"
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("再改名应 200,得到 %d %+v", status, body.Error)
}
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
t.Fatalf("更新漏传应沿用库里的 false: %+v", body.Data)
}
// 新建时显式传 false 同样照办,缺省值不覆盖调用方的明确意图。
explicitOff := map[string]any{
"sportId": ids.sport, "code": "E-BOOL-OFF", "name": "开关显式关闭",
"scored": false, "recordEligible": false,
}
status, body = env.do(client, http.MethodPost, "/api/events", explicitOff, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("显式传 false 应 200,得到 %d %+v", status, body.Error)
}
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
}
assertStoredFlags(t, "E-BOOL-OFF", 0)
assertStoredFlags(t, "E-BOOL", 0)
}