Files
Scoreboard/server/internal/httpapi/handlers_config_test.go
T
AskaEth 1b8a37ff8c feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘
三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开):

一、分工作表报名模板(新增能力)
- 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板,
  一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。
- 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。
- 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。
- 单表 CSV/XLSX 导入路径不回归。

二、号码布判重(修误判)
- 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。
- 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行;
  号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。
- 团体行的号码布一并纳入查重。

三、自动备份的关停与落盘(修数据安全隐患)
- export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限,
  超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。
- vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件;
  Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。
- 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件,
  避免半截 .db 被列进备份列表、进而被「一键恢复」选中。

文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
2026-09-14 01:10:16 +08:00

2890 lines
123 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"
"reflect"
"slices"
"strings"
"testing"
"time"
"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)
}
}
// 同一份表里同一证件号、同一小项同一个组别出现两次 = 真重复:
// 两行都解析到同一个人(不新建第二份档案、不报「证件号已被占用」),
// 但要不要覆盖已落库的那条报名由用户决定,所以次行进冲突面板并带 duplicate 标记;
// 同时给一条 notices 提示,说明这个证件号在本表里出现了两次。
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["created"] != float64(1) || result["updated"] != float64(0) || result["skipped"] != float64(0) {
t.Fatalf("首行新建、次行等决策(既不算更新也不算跳过): %+v", result)
}
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("同证件号重复不应报行级错误,得到 %+v", errs)
}
notices, _ := result["notices"].([]any)
wantNotice := "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)"
if len(notices) != 1 || notices[0] != wantNotice {
t.Fatalf("应给一条重复提示 %q,得到 %+v", wantNotice, notices)
}
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("同一个小项同一个组别的重复行应进冲突面板,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
if conflict["duplicate"] != true || conflict["row"] != float64(3) {
t.Fatalf("重复行应带 duplicate 标记并指向次行: %+v", conflict)
}
var athleteID int64
if err := env.ev.QueryRow(`SELECT id FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athleteID); err != nil {
t.Fatalf("查询人员: %v", err)
}
if conflict["athleteId"] != float64(athleteID) {
t.Fatalf("重复行应指向同一个人 %d: %+v", athleteID, conflict)
}
// 同一个人只应建 1 份档案(次行绝不能再新建或撞 id_card 唯一索引)。
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 TestImportIDCardHitGoesToConflictNotTakenError(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(0) {
t.Fatalf("证件号命中应进冲突面板,既不算新建也不算跳过: %+v", result)
}
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("证件号能认到人就不该报「已被占用」,得到 %+v", errs)
}
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
if conflict["athleteId"] != float64(ids.athleteA) || conflict["duplicate"] != false {
t.Fatalf("冲突应指向证件号命中的人: %+v", conflict)
}
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 3 || changed[0] != "name" || changed[1] != "teamCode" || changed[2] != "bib" {
t.Fatalf("姓名 / 单位 / 号码布三处差异都该列出,得到 %+v", changed)
}
// 库里的张伟一行没动,也没有多出一个人。
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)
}
// 证件号两侧都没有值也要如实带上,现场看到的「原 / 新」两栏才完整。
if item["newIdCard"] != "" || item["existingIdCard"] != "" {
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 = 'entry.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)
}
}
// B01:报名这一族的审计动作必须共用单数前缀 entry.(导入、批次、批量退赛/恢复/移出都算)。
// 用例真发三个写请求,再走日志页同一条查询(`/api/audit-logs?action=entry.`,子串模糊)
// 断言三条都在 —— 只看字符串常量的话,改回 entries.import 这类回归照样能绿。
func TestEntryAuditActionsShareOnePrefix(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// ① 报名表导入(新增一个人 + 一条报名)。
body := importHeader + "\nM100,男子,A,前缀用例选手,110101199001019999,男,,X900,,registered,\n"
if status, env2 := env.postImport(t, client, "/api/entries/import", body, ""); status != http.StatusOK || !env2.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
}
// ② 批量设置批次。
if status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA}, "heat": 3,
}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("设置批次应 200,得到 %d %+v", status, env2.Error)
}
// ③ 批量退赛。
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "scratch", "ids": []int64{ids.entryB}, "reason": "前缀用例",
}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
}
want := []string{"entry.import", "entry.heat", "entry.scratch"}
// /api/audit-logs 是 admin-only;审计在响应之后落库,轮询到齐再断言。
env.createUser("admin", "admin-pass", "admin")
adminClient, _ := env.client()
env.login(adminClient, "admin", "admin-pass")
found := map[string]bool{}
for i := 0; i < 100 && len(found) < len(want); i++ {
_, env2 := env.do(adminClient, http.MethodGet, "/api/audit-logs?action=entry.&size=200", nil, false)
items, _ := decodeData[map[string]any](t, env2)["items"].([]any)
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
continue
}
action, _ := item["action"].(string)
if slices.Contains(want, action) {
found[action] = true
}
}
if len(found) < len(want) {
time.Sleep(10 * time.Millisecond)
}
}
for _, action := range want {
if !found[action] {
t.Fatalf("按 entry. 前缀应能筛到 %s,实际只筛到 %+v", action, found)
}
}
}
// 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)
}
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("号码布命中且对方没有证件号,不该报错: %+v", errs)
}
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 TestImportRejectsDuplicateBibInFile(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
"M100,男子,A,张三,110101199001011234,男,,B2001,,registered,\n" +
"M100,男子,A,李四,110101199002021234,男,,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 TestImportRepeatedBibWithoutIDCardIsOnePerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
secondEvent := 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)
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, secondEvent)
body := importHeader + "\n" +
"M100,男子,A,张三,,男,26,B2001,,registered,\n" +
"M200,男子,A,张三,,男,26,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(2) || result["skipped"] != float64(0) {
t.Fatalf("同一个人两个小项都应落库: %+v", result)
}
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("号码布相同但证件号都没填,不该报重号: %+v", errs)
}
notices, _ := result["notices"].([]any)
want := "号码布 B2001 在本表中出现 2 次,证件号没填齐,已视为同一人报名多个项目"
if len(notices) != 1 || notices[0] != want {
t.Fatalf("应给一条号码布重复提示 %q,得到 %+v", want, notices)
}
// 号码布是同一个人的标识:只应建 1 份档案,两条报名挂在同一个人身上。
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)
}
var entries int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B2001')`).Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 2 {
t.Fatalf("同一个人报两个小项应各有一条报名,得到 %d", entries)
}
}
// 号码布相同、两行证件号也相同 → 同一个人报多个项目,照旧放行;
// 该说的提示由既有那条「证件号出现 N 次」承担,不要再叠一条号码布的,免得同一件事说两遍。
func TestImportRepeatedBibWithSameIDCardIsOnePerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
body := importHeader + "\n" +
"M100,男子,A,张三,110101199001011234,男,26,B2001,,registered,\n" +
"M100,女子,A,张三,110101199001011234,男,26,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(2) || result["skipped"] != float64(0) {
t.Fatalf("同一个人两个组别都应落库: %+v", result)
}
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("同一个人共用号码布不该报重号: %+v", errs)
}
notices, _ := result["notices"].([]any)
want := "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)"
if len(notices) != 1 || notices[0] != want {
t.Fatalf("只应给证件号那条提示 %q,得到 %+v", want, notices)
}
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 TestImportSameBibSameScopeTwiceKeepsOneEntry(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
"M100,男子,A,张三,,男,26,B2001,,registered,\n" +
"M100,男子,A,张三,,男,26,B2001,,dns,\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["updated"] != float64(1) || result["skipped"] != float64(0) {
t.Fatalf("首行新建、次行更新同一条报名: %+v", result)
}
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("同一人同一组别的重复行不该报行级错误: %+v", errs)
}
notices, _ := result["notices"].([]any)
want := "号码布 B2001 在本表中出现 2 次,其中同一小项同一组别有重复行:" +
"同一个人的同一组别只保留一条报名(后一行覆盖前一行),请核对表格"
if len(notices) != 1 || notices[0] != want {
t.Fatalf("应提示同一组别重复 %q,得到 %+v", want, notices)
}
var entries int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B2001')`).Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 1 {
t.Fatalf("同一个人同一组别只应有一条报名,得到 %d", entries)
}
// 留下的那条是次行的值:后一行覆盖前一行。
var entryStatus string
if err := env.ev.QueryRow(
`SELECT status FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B2001')`).Scan(&entryStatus); err != nil {
t.Fatalf("查询报名状态: %v", err)
}
if entryStatus != "dns" {
t.Fatalf("同一条报名应取后一行的状态 dns,得到 %s", entryStatus)
}
}
// 号码布为空的行不进号码布判重:既不会与其他行串成重号,也不会多出号码布提示,
// 该行自己的身份校验(证件号或号码布二选一)一字不变。
func TestImportRowsWithoutBibUnaffected(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
body := importHeader + "\n" +
"M100,男子,A,张三,,男,,,registered,\n" +
"M100,女子,A,李四,110101199003031234,女,24,,,registered,\n" +
"M100,女子,A,王五,,女,25,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(2) || result["skipped"] != float64(1) {
t.Fatalf("两条无号码布的行照常导入,缺身份的那行照旧跳过: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "必须填 id_card 或 bib") {
t.Fatalf("缺证件号又没有号码布的行应报既有那条错误: %+v", errs)
}
if notices, _ := result["notices"].([]any); len(notices) != 0 {
t.Fatalf("号码布只出现一次,不该有提示: %+v", notices)
}
}
// 证件号优先:库里某人 id_card=X / bib=A100,表里同证件号但号码布写错成 A200 时,
// 命中同一个人(不新建、不报「证件号已被占用」),号码布差异按既有口径进冲突面板;
// 决策 keep-new 后号码布按表里的值更新。
func TestImportIDCardPriorityIgnoresWrongBib(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["created"] != float64(0) || result["skipped"] != float64(0) {
t.Fatalf("冲突行不落库也不跳过: %+v", result)
}
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("证件号命中同一个人,不该报「已被占用」: %+v", errs)
}
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("号码布差异应进冲突面板,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
if conflict["athleteId"] != float64(ids.athleteA) || conflict["duplicate"] != false {
t.Fatalf("冲突应指向证件号命中的那个人: %+v", conflict)
}
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "bib" {
t.Fatalf("应只有号码布差异,得到 %+v", changed)
}
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
t.Fatalf("证件号命中同一个人时不该报告成同名新建: %+v", reported)
}
// 未决策前库里那个人一个字段都不动,也没有多出一个人。
var bib string
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
t.Fatalf("查询人员: %v", err)
}
if bib != "A100" {
t.Fatalf("未决策不应改号码布,得到 %s", 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)
}
// 决策键是证件号(证件号优先),keep-new 用表里的号码布覆盖。
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 conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
}
if result["updated"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("keep-new 应更新已有报名: %+v", result)
}
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
t.Fatalf("查询人员: %v", err)
}
if bib != "A200" {
t.Fatalf("keep-new 应用表里的号码布覆盖,得到 %s", bib)
}
}
// 证件号与号码布都没命中 → 按「姓名 + 单位」命中且对方既没证件号也没号码布:
// 认定同一个人,把表里的证件号与号码布一并回填,不新建档案。
func TestImportBackfillsIDCardAndBibBySameNameTeam(t *testing.T) {
env, ids, client := recorderWithSeed(t)
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) || result["skipped"] != float64(0) {
t.Fatalf("姓名 + 单位命中应更新已有报名: %+v", result)
}
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("回填不该报行级错误: %+v", errs)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("两边都没登记身份字段时回填不算冲突: %+v", conflicts)
}
var (
idCard sql.NullString
bib sql.NullString
)
if err := env.ev.QueryRow(`SELECT id_card, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard, &bib); err != nil {
t.Fatalf("查询人员: %v", err)
}
if !idCard.Valid || idCard.String != "110101199001011234" {
t.Fatalf("应回填证件号,得到 %+v", idCard)
}
if !bib.Valid || bib.String != "A100" {
t.Fatalf("应回填号码布,得到 %+v", bib)
}
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)
}
}
// 姓名 + 单位命中、对方有不同号码布、且表里的证件号也对不上任何人 → 同名的另一个人:
// 新建第二份档案并报告(新档案拿表里的证件号与号码布)。
func TestImportSameNameDifferentBibWithUnmatchedIDCardCreatesSecondPerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,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)
want := map[string]any{
"name": "张伟", "teamCode": "A",
"newIdCard": "110101199001011234", "existingIdCard": "",
"newBib": "2001", "existingBib": "1001",
}
for key, value := range want {
if item[key] != value {
t.Fatalf("同名新建报告 %s 应为 %q,得到 %+v", key, value, 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)
}
}
// 姓名 + 单位命中、对方有另一个证件号、表里也没给号码布 → 同名的另一个人(与号码布不同同一条路径)。
func TestImportSameNameDifferentIDCardCreatesSecondPerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001019999', bib = '1001' WHERE id = ?`, ids.athleteA)
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("证件号不同应新建报名: %+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["newIdCard"] != "110101199001011234" || item["existingIdCard"] != "110101199001019999" {
t.Fatalf("同名新建报告应带两侧证件号: %+v", item)
}
if item["newBib"] != "" || item["existingBib"] != "1001" {
t.Fatalf("表里没给号码布时应是空串、库里那条如实带上: %+v", item)
}
}
// 同一份表里同一证件号出现多次(同一个人报多个组别):两行都解析到同一个档案、各自落一条报名,
// 只给 notices 提示,绝不新建第二个人、也绝不撞 id_card 唯一索引。
func TestImportDuplicateIDCardAcrossScopesResolvesToOnePerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 加一个组别,让同一人的两行落在不同 (小项, 组别) 上——那才是真正的「报多个组别」。
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
body := importHeader + "\n" +
"M100,男子,A,张三,110101199001011234,男,,A100,,registered,\n" +
"M100,女子,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["created"] != float64(2) || result["skipped"] != float64(0) {
t.Fatalf("同一人的两条报名都应落库: %+v", result)
}
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("同证件号重复不该报行级错误,得到 %+v", errs)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("不同组别不是真重复,不该进冲突面板: %+v", conflicts)
}
notices, _ := result["notices"].([]any)
if len(notices) != 1 || notices[0] != "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)" {
t.Fatalf("应给一条重复提示,得到 %+v", notices)
}
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)
}
var entries int
if err := env.ev.QueryRow(
`SELECT COUNT(*) FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
"110101199001011234").Scan(&entries); err != nil {
t.Fatalf("统计报名: %v", err)
}
if entries != 2 {
t.Fatalf("同一个人两个组别应各有一条报名,得到 %d", entries)
}
}
// 同一份表里同一证件号两行的号码布不同(号码布写错):两行仍是同一个人(冲突指向同一个 athleteId),
// 号码布差异按既有口径进冲突面板,不新建第二个人、不报「证件号已被占用」。
func TestImportDuplicateIDCardDifferentBibStaysOnePerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
body := importHeader + "\n" +
"M100,男子,A,张三,110101199001011234,男,,B1,,registered,\n" +
"M100,女子,A,张三,110101199001011234,男,,B2,,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 errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("不该报「证件号已被占用」这类行级错误,得到 %+v", errs)
}
notices, _ := result["notices"].([]any)
if len(notices) != 1 || notices[0] != "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)" {
t.Fatalf("应给一条重复提示,得到 %+v", notices)
}
var athleteID int64
if err := env.ev.QueryRow(`SELECT id FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athleteID); err != nil {
t.Fatalf("查询人员: %v", err)
}
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("号码布写错应进冲突面板,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
if conflict["athleteId"] != float64(athleteID) {
t.Fatalf("次行必须解析到同一个人 %d,得到 %+v", athleteID, conflict)
}
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "bib" {
t.Fatalf("应只有号码布差异,得到 %+v", changed)
}
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)
}
}
// 号码布已经在库里被 A 占用,表里这一行的证件号又对不上 A → 两个人共用一个号码布:
// 按既有号码布防呆口径报行级错误并跳过,绝不静默改库、也不新建第二个占号的人。
func TestImportRejectsBibTakenByDifferentIDCard(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001019999', 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["created"] != float64(0) || result["skipped"] != float64(1) {
t.Fatalf("两人共用一个号码布应跳过该行: %+v", result)
}
errs, _ := result["errors"].([]any)
want := "第 2 行:号码布 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 (
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)
}
}
// 号码布命中但姓名与库中不一致 → 冲突;决策按号码布为键,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)
}
}
// ---------- 导入改「谁还算数」必须重算 ----------
// importZhangWeiIDCard 是种子人员张伟在导入用例里用的证件号(先写库,导入才能按证件号命中他)。
const importZhangWeiIDCard = "110101199001011234"
// importZhangWeiRow 拼一行「M100 / 男子 / A / 张伟」的报名表内容;表头见 importHeader。
func importZhangWeiRow(status string) string {
return "M100,男子,A,张伟," + importZhangWeiIDCard + ",男,,,," + status + ",\n"
}
// seedZhangWeiIDCard 给张伟补一个证件号:证件号优先的匹配口径下,导入才认得出是同一个人。
func seedZhangWeiIDCard(t *testing.T, env *testEnv, ids seedIDs) {
t.Helper()
env.exec(t, `UPDATE athletes SET id_card = ? WHERE id = ?`, importZhangWeiIDCard, ids.athleteA)
}
// runImportScenario 用与批量动作同一串操作跑一遍「名单导入改状态」:
// 8 人报名 → 录完前 4 条并发布 →(withSettle 时)立即结算 → 导入把第 1 名改成 status。
//
// 直接复用 entries_batch_test.go 的 runScenario,是为了让两个入口(批量退赛 / 名单导入)
// 的最终数据能逐字节对照:它们改的是同一件事(谁还算数)。
func runImportScenario(t *testing.T, live, withSettle bool, status string) batchRun {
t.Helper()
return runScenario(t, live, withSettle,
func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64) {
t.Helper()
seedZhangWeiIDCard(t, env, ids)
body := importHeader + "\n" + importZhangWeiRow(status)
if st, e := env.postImport(t, client, "/api/entries/import", body, ""); st != http.StatusOK || !e.OK {
t.Fatalf("导入应 200,得到 %d %+v", st, e.Error)
}
})
}
// 同一批导入里多行改状态、且都落在同一组:按 (小项, 组别) 去重,只重算一次、只推一次榜。
// 导入动辄几百行,逐行推榜会让大屏收一串中间态、也会把现场拖慢。
func TestImportManyStatusChangesPushOnce(t *testing.T) {
env, ids, client := recorderWithSeed(t)
seedZhangWeiIDCard(t, env, ids)
env.exec(t, `UPDATE athletes SET id_card = ? WHERE id = ?`, "110101199001015678", ids.athB)
publishResults(t, env, client, ids.event, "import-many",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
pushesBefore := standingsPushes(t, env)
body := importHeader + "\n" + importZhangWeiRow("scratched") +
"M100,男子,B,李强,110101199001015678,男,,,,scratched,\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(2) || result["skipped"] != float64(0) {
t.Fatalf("两行都应就地更新,得到 %+v", result)
}
// 一个组只推一次榜(两行都改了状态,但 scope 去重成一次重算)。
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
t.Fatalf("同一组的多行改动只应推 1 次榜单,得到 %d", got)
}
// 两人都退出名次与流水:整组没有可排名成绩,榜单归零。
if got := ledgerRowsOf(t, env, ids.event, nil); len(got) != 0 {
t.Fatalf("两人都退赛后不该有有效流水,得到 %v", got)
}
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); len(got) != 0 {
t.Fatalf("两人都退赛后不该占名次,得到 %v", got)
}
if got := standingsTotal(t, env, client); got != 0 {
t.Fatalf("榜单总分应归零,得到 %v", got)
}
}
// 导入把已发布成绩的人改成退赛:流水作废、名次让出来给别人、榜单被推。
// 现场「改完名单重新导入一遍」很自然,不重算就会留下「名单说退赛、榜上还挂着 10 分与金牌」。
func TestImportStatusChangeRecomputes(t *testing.T) {
env, ids, client := recorderWithSeed(t)
seedZhangWeiIDCard(t, env, ids)
publishResults(t, env, client, ids.event, "import-status",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
// 前置:A 第 1 名 10 分、B 第 2 名 8 分。
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
t.Fatalf("前置条件:A 应是 10 分,得到 %v", got)
}
pushesBefore := standingsPushes(t, env)
status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+importZhangWeiRow("scratched"), "")
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) || result["skipped"] != float64(0) {
t.Fatalf("应就地更新 1 条报名(不是新建、不跳过),得到 %+v", result)
}
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
t.Fatalf("A 应已改成退赛,得到 %s", got)
}
// 流水作废:旧流水留着当证据,有效流水一条不剩。
if got := activeLedgerPoints(t, env, ids.entryA); len(got) != 0 {
t.Fatalf("退赛后不应再有有效流水,得到 %v", got)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryA); n == 0 {
t.Fatalf("旧流水应被作废留痕,得到 %d 条", n)
}
// 名次让出来:退赛的人不再占名次,B 升到第 1 名。
if got := ranksOf(t, env, []int64{ids.entryA}); len(got) != 0 {
t.Fatalf("退赛的报名项不该再占名次,得到 %v", got)
}
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
t.Fatalf("B 应升到第 1 名,得到 %d", got)
}
// 榜单跟着变:A 归零,B 从第 2 名的 8 分变成第 1 名的 10 分。
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
t.Fatalf("A 单位总分应归零,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
t.Fatalf("B 单位总分应变成 10,得到 %v", got)
}
// 大屏要收到新榜单,否则现场看到的还是「退赛者拿金牌」的旧画面。
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
t.Fatalf("导入改状态应推 1 次榜单(standings replace),得到 %d", got)
}
}
// 名单导入与批量退赛是同一口径:实时 / 延迟两种结算模式下最终数据逐字节一致。
func TestImportScratchMatchesRealTime(t *testing.T) {
for _, withSettle := range []bool{true, false} {
live := runImportScenario(t, true, withSettle, "scratched")
deferred := runImportScenario(t, false, withSettle, "scratched")
if withSettle {
assertSameBefore(t, "名单导入改状态", live, deferred)
}
assertSameAfter(t, "名单导入改状态", live, deferred)
// 期望值与「批量退赛第 1 名」同一份:退赛者退出名次与流水,剩下 3 人 10/8/6。
wantLedger := []string{
entryLedgerRow(live.entries[1], live.teamB, 10),
entryLedgerRow(live.entries[2], live.teamA, 8),
entryLedgerRow(live.entries[3], live.teamB, 6),
}
if !reflect.DeepEqual(deferred.after.ledger, wantLedger) {
t.Fatalf("导入退赛后流水应重算成 %v,得到 %v", wantLedger, deferred.after.ledger)
}
wantRanks := map[int64]int{live.entries[1]: 1, live.entries[2]: 2, live.entries[3]: 3}
if !reflect.DeepEqual(deferred.after.ranks, wantRanks) {
t.Fatalf("导入退赛后名次应为 %v,得到 %v", wantRanks, deferred.after.ranks)
}
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
t.Fatalf("延迟结算下导入改状态也应推 1 次榜单,得到 %d", got)
}
}
}
// 反例保护:只改不影响算分的字段(号码布 / 名单 / 批次)不触发重算,也不推榜单。
// 导入动辄几百行,逐行无条件重算会把现场拖慢,也会让大屏收到一串无意义刷新。
func TestImportNonScoringChangeDoesNotRecompute(t *testing.T) {
env, ids, client := recorderWithSeed(t)
seedZhangWeiIDCard(t, env, ids)
publishResults(t, env, client, ids.event, "import-non-scoring",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
ledgerBefore := ledgerRowsOf(t, env, ids.event, nil)
ranksBefore := ranksOf(t, env, []int64{ids.entryA, ids.entryB})
standingsBefore := rawData(t, env, client, "/api/standings")
pushesBefore := standingsPushes(t, env)
// 号码布 / 种子 / 团体名单都是展示字段;status 写 registered,与库中现值相同。
body := importHeader + "\nM100,男子,A,张伟," + importZhangWeiIDCard + ",男,,A-777,S-3,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["skipped"] != float64(0) {
t.Fatalf("应就地更新 1 条报名,得到 %+v", result)
}
// 先证明这一行确实写了(否则「没重算」可能只是行被跳过了)。
var bib, seed string
if err := env.ev.QueryRow(`SELECT COALESCE(bib, ''), COALESCE(seed, '') FROM entries WHERE id = ?`, ids.entryA).
Scan(&bib, &seed); err != nil {
t.Fatalf("读报名项: %v", err)
}
if bib != "A-777" || seed != "S-3" {
t.Fatalf("号码布 / 种子应已落库,得到 bib=%q seed=%q", bib, seed)
}
if got := ledgerRowsOf(t, env, ids.event, nil); !reflect.DeepEqual(got, ledgerBefore) {
t.Fatalf("不影响算分的改动不该动流水:%v → %v", ledgerBefore, got)
}
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); !reflect.DeepEqual(got, ranksBefore) {
t.Fatalf("不影响算分的改动不该动名次:%v → %v", ranksBefore, got)
}
if got := rawData(t, env, client, "/api/standings"); got != standingsBefore {
t.Fatalf("不影响算分的改动不该动榜单:\n之前 %s\n之后 %s", standingsBefore, got)
}
if got := standingsPushes(t, env); got != pushesBefore {
t.Fatalf("不影响算分的改动不该推榜单:%d → %d", pushesBefore, got)
}
}
// 幂等:同一份名单连导两次,第二次不该再改动流水与榜单(状态已经是 scratched,口径没变)。
func TestImportStatusChangeIsIdempotent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
seedZhangWeiIDCard(t, env, ids)
publishResults(t, env, client, ids.event, "import-idempotent",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
body := importHeader + "\n" + importZhangWeiRow("scratched")
pushesBefore := standingsPushes(t, env)
for i := 0; i < 2; i++ {
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("第 %d 次导入应 200,得到 %d %+v", i+1, status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
if result["updated"] != float64(1) || result["skipped"] != float64(0) {
t.Fatalf("第 %d 次导入应就地更新 1 条,得到 %+v", i+1, result)
}
if i == 0 {
// 第一次确实改了口径:推一次榜。
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
t.Fatalf("第一次导入应推 1 次榜单,得到 %d", got)
}
continue
}
// 第二次:状态没变,不该再重算、也不该再推榜。
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
t.Fatalf("重复导入不该再推榜单,得到 %d 次(期望仍是 1)", got)
}
if got := activeLedgerPoints(t, env, ids.entryA); len(got) != 0 {
t.Fatalf("重复导入不该让退赛者重新有流水,得到 %v", got)
}
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
t.Fatalf("重复导入后 B 仍应是第 1 名,得到 %d", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
t.Fatalf("重复导入后 B 单位总分应仍是 10,得到 %v", got)
}
}
}
// ---------- 图片上传与静态访问 ----------
// 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)
}
// ---------- 上传图片的删除与孤儿清理 ----------
// uploadOrphansBody 对应 GET /api/uploads/orphans 的 data。
type uploadOrphansBody struct {
Items []uploadOrphanBody `json:"items"`
Total int `json:"total"`
TotalBytes int64 `json:"totalBytes"`
}
type uploadOrphanBody struct {
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
type uploadDeleteFailureBody struct {
Name string `json:"name"`
Reason string `json:"reason"`
}
type uploadDeleteBody struct {
Deleted []string `json:"deleted"`
Failed []uploadDeleteFailureBody `json:"failed"`
}
// uploadAdminEnv 建好带项目目录的环境并登录 admin:
// 删文件与孤儿清理是系统级资源管理,只有 admin 能做(与字体删除同一档)。
func uploadAdminEnv(t *testing.T) (*testEnv, string, *http.Client) {
t.Helper()
env, uploadsDir := newUploadEnv(t)
env.createUser("admin", "admin-pass", "admin")
client, _ := env.client()
env.login(client, "admin", "admin-pass")
return env, uploadsDir, client
}
// uploadOne 上传一张图片,返回 /uploads/<随机名>。
func uploadOne(t *testing.T, env *testEnv, client *http.Client) string {
t.Helper()
status, body := env.postUpload(t, client, "logo.png", pngContent())
if status != http.StatusOK || !body.OK {
t.Fatalf("上传图片应 200,得到 %d %+v", status, body.Error)
}
return decodeData[uploadResultBody](t, body).Path
}
func uploadBaseName(path string) string {
return path[strings.LastIndex(path, "/")+1:]
}
type teamCreatedBody struct {
ID int64 `json:"id"`
}
// createTeamWithLogo 建一个挂 logo 的单位,返回 id;logo 引用点是 teams.logo_path。
func createTeamWithLogo(t *testing.T, env *testEnv, client *http.Client, code, name, logo string) int64 {
t.Helper()
status, body := env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": code, "name": name, "logoPath": logo, "active": true,
}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("建单位应 200,得到 %d %+v", status, body.Error)
}
return decodeData[teamCreatedBody](t, body).ID
}
// putImageRef 改一处赛事设置的图片引用(path 传空串表示清除)。
func putImageRef(t *testing.T, env *testEnv, client *http.Client, key, path string) {
t.Helper()
event := map[string]any{}
display := map[string]any{}
switch key {
case "event.logo_path":
event["logoPath"] = path
case "display.logo":
display["logo"] = path
case "display.background":
display["background"] = path
default:
t.Fatalf("未知的引用点 %s", key)
}
status, body := env.do(client, http.MethodPut, "/api/settings",
// requestId 必须带上目标值:服务端按 body.requestId 做幂等回放,
// 「先设成 A 再清空」用同一个 id 会让第二次直接回放第一次的响应,清空根本没落库。
map[string]any{"event": event, "display": display, "requestId": "req-ref-" + key + "-" + path}, true)
if status != http.StatusOK {
t.Fatalf("设置 %s 应 200,得到 %d %+v", key, status, body.Error)
}
}
func listOrphans(t *testing.T, env *testEnv, client *http.Client) uploadOrphansBody {
t.Helper()
status, body := env.do(client, http.MethodGet, "/api/uploads/orphans", nil, false)
if status != http.StatusOK || !body.OK {
t.Fatalf("列孤儿图片应 200,得到 %d %+v", status, body.Error)
}
return decodeData[uploadOrphansBody](t, body)
}
// 孤儿清单只列「没有任何引用点用到」的图片:赛事会标与单位图标都要被认出来。
func TestListUploadOrphansOnlyUnreferenced(t *testing.T) {
env, uploadsDir, client := uploadAdminEnv(t)
eventLogo := uploadOne(t, env, client)
teamLogo := uploadOne(t, env, client)
free := uploadOne(t, env, client)
if names := uploadsDirEntries(t, uploadsDir); len(names) != 3 {
t.Fatalf("应先落盘 3 张图片,实际 %v", names)
}
putImageRef(t, env, client, "event.logo_path", eventLogo)
createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo)
orphans := listOrphans(t, env, client)
if orphans.Total != 1 || len(orphans.Items) != 1 {
t.Fatalf("只应剩 1 张孤儿,得到 total=%d items=%+v", orphans.Total, orphans.Items)
}
item := orphans.Items[0]
if item.Name != uploadBaseName(free) {
t.Fatalf("孤儿应是未被引用的那张,得到 %q,期望 %q", item.Name, uploadBaseName(free))
}
if item.URL != free {
t.Fatalf("孤儿 URL = %q,期望 %q", item.URL, free)
}
if item.Size != int64(len(pngContent())) {
t.Fatalf("孤儿大小 = %d,期望 %d", item.Size, len(pngContent()))
}
if _, err := time.Parse(time.RFC3339, item.ModifiedAt); err != nil {
t.Fatalf("修改时间应是 RFC3339,得到 %q(%v)", item.ModifiedAt, err)
}
if orphans.TotalBytes != item.Size {
t.Fatalf("总占用 = %d,期望 %d", orphans.TotalBytes, item.Size)
}
// 把图配到大屏会标上,它就不再是孤儿。
putImageRef(t, env, client, "display.logo", free)
if after := listOrphans(t, env, client); after.Total != 0 || len(after.Items) != 0 {
t.Fatalf("全部被引用后应没有孤儿,得到 %+v", after)
}
}
// 没有任何上传时是空清单而不是错误,便于前端直接渲染空态。
func TestListUploadOrphansEmptyWhenNoUploads(t *testing.T) {
env, _, client := uploadAdminEnv(t)
orphans := listOrphans(t, env, client)
if orphans.Total != 0 || orphans.TotalBytes != 0 || orphans.Items == nil {
t.Fatalf("空目录应回空数组而不是 null:%+v(items=%#v)", orphans, orphans.Items)
}
}
// 删除被引用的图片必须 409,并把「被谁引用」写进提示;被引用的文件不能被删掉。
func TestDeleteUploadRejectsReferenced(t *testing.T) {
env, uploadsDir, client := uploadAdminEnv(t)
eventLogo := uploadOne(t, env, client)
displayLogo := uploadOne(t, env, client)
background := uploadOne(t, env, client)
teamLogo := uploadOne(t, env, client)
createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo)
putImageRef(t, env, client, "event.logo_path", eventLogo)
putImageRef(t, env, client, "display.logo", displayLogo)
putImageRef(t, env, client, "display.background", background)
cases := []struct {
path string
label string
}{
{eventLogo, "赛事会标"},
{displayLogo, "大屏会标"},
{background, "大屏背景图"},
{teamLogo, "单位「第一中学」的单位图标"},
}
for _, c := range cases {
status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(c.path), nil, true)
if status != http.StatusConflict {
t.Fatalf("删除被引用的 %s 应 409,得到 %d %+v", c.path, status, body.Error)
}
if body.Error == nil || !strings.Contains(body.Error.Message, c.label) {
t.Fatalf("409 提示应说明引用位置 %q:%+v", c.label, body.Error)
}
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(c.path))); err != nil {
t.Fatalf("被引用的图片不能被删除 %s: %v", c.path, err)
}
}
// 清掉引用后同一张图就能删掉,文件确实消失。
putImageRef(t, env, client, "event.logo_path", "")
if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(eventLogo), nil, true); status != http.StatusOK {
t.Fatalf("不再被引用的图片应可删除,得到 %d %+v", status, body.Error)
}
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(eventLogo))); !os.IsNotExist(err) {
t.Fatalf("删除后文件应消失: %v", err)
}
// 名字合法但文件不存在 → 404;非法文件名一律 404,不做任何删除尝试。
if status, _ := env.do(client, http.MethodDelete, "/api/uploads/0123456789abcdef0123456789abcdef.png", nil, true); status != http.StatusNotFound {
t.Fatalf("删除不存在的图片应 404,得到 %d", status)
}
if status, _ := env.do(client, http.MethodDelete, "/api/uploads/notes.txt", nil, true); status != http.StatusNotFound {
t.Fatalf("非图片扩展名应 404,得到 %d", status)
}
}
// 被「彻底删除」(可恢复)的单位仍算引用:恢复后图标必须还在,所以不能当孤儿删掉。
func TestUploadOrphansCountsHiddenTeam(t *testing.T) {
env, _, client := uploadAdminEnv(t)
logo := uploadOne(t, env, client)
teamID := createTeamWithLogo(t, env, client, "T1", "第一中学", logo)
if status, body := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", teamID), nil, true); status != http.StatusOK {
t.Fatalf("彻底删除单位应 200,得到 %d %+v", status, body.Error)
}
if orphans := listOrphans(t, env, client); orphans.Total != 0 {
t.Fatalf("隐藏单位的 logo 仍算被引用,不应进孤儿清单:%+v", orphans.Items)
}
if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(logo), nil, true); status != http.StatusConflict {
t.Fatalf("隐藏单位的 logo 应拒删,得到 %d %+v", status, body.Error)
}
}
// 批量删除只动点名的文件:被引用的、不存在的、非法的逐条回报且不动文件。
func TestDeleteUploadOrphansBatch(t *testing.T) {
env, uploadsDir, client := uploadAdminEnv(t)
used := uploadOne(t, env, client)
orphanA := uploadOne(t, env, client)
orphanB := uploadOne(t, env, client)
createTeamWithLogo(t, env, client, "T1", "第一中学", used)
names := []string{uploadBaseName(orphanA), uploadBaseName(orphanB), uploadBaseName(used), "0123456789abcdef0123456789abcdef.png", "../config.yaml"}
status, body := env.do(client, http.MethodPost, "/api/uploads/orphans/delete",
map[string]any{"names": names, "requestId": "req-orphan-del-1"}, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("批量删除应 200,得到 %d %+v", status, body.Error)
}
result := decodeData[uploadDeleteBody](t, body)
if len(result.Deleted) != 2 || result.Deleted[0] != uploadBaseName(orphanA) || result.Deleted[1] != uploadBaseName(orphanB) {
t.Fatalf("只应删掉两张孤儿,得到 %+v", result.Deleted)
}
if len(result.Failed) != 3 {
t.Fatalf("应有 3 条失败回报,得到 %+v", result.Failed)
}
reasonOf := func(name string) string {
for _, item := range result.Failed {
if item.Name == name {
return item.Reason
}
}
t.Fatalf("失败回报里缺少 %s:%+v", name, result.Failed)
return ""
}
if reason := reasonOf(uploadBaseName(used)); !strings.Contains(reason, "仍被引用") || !strings.Contains(reason, "单位「第一中学」") {
t.Fatalf("被引用条目的原因应点名引用位置,得到 %q", reason)
}
if reason := reasonOf("0123456789abcdef0123456789abcdef.png"); !strings.Contains(reason, "文件不存在") {
t.Fatalf("缺失文件应报不存在,得到 %q", reason)
}
if reason := reasonOf("../config.yaml"); !strings.Contains(reason, "文件名非法") {
t.Fatalf("非法文件名应被拒,得到 %q", reason)
}
for _, name := range []string{uploadBaseName(orphanA), uploadBaseName(orphanB)} {
if _, err := os.Stat(filepath.Join(uploadsDir, name)); !os.IsNotExist(err) {
t.Fatalf("孤儿 %s 应已删除: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(used))); err != nil {
t.Fatalf("被引用的图片不能被批量删除: %v", err)
}
var audits int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'uploads.delete'`).Scan(&audits); err != nil {
t.Fatalf("查询删除审计: %v", err)
}
if audits != 1 {
t.Fatalf("批量删除应写一条 uploads.delete 审计,得到 %d", audits)
}
// 空清单直接拒绝,不能让「没传 names」变成「什么都没删但显示成功」。
if status, _ := env.do(client, http.MethodPost, "/api/uploads/orphans/delete",
map[string]any{"names": []string{}, "requestId": "req-orphan-del-2"}, true); status != http.StatusBadRequest {
t.Fatalf("空 names 应 400,得到 %d", status)
}
}
// 删除与清理是系统级资源管理:guest 401、recorder 403。
func TestUploadDeleteRequiresAdmin(t *testing.T) {
env, uploadsDir, admin := uploadAdminEnv(t)
orphan := uploadBaseName(uploadOne(t, env, admin))
env.createUser("recorder", "recorder-pass", "recorder")
guest, _ := env.client()
// 各自用对应的方法:GET 那条走的是 /api/uploads/{name} 之外的兜底路由,用错方法量到的是 404 而不是权限。
if status, _ := env.do(guest, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读孤儿清单应 403,得到 %d", status)
}
if status, _ := env.do(guest, http.MethodDelete, "/api/uploads/"+orphan, nil, false); status != http.StatusUnauthorized {
t.Fatalf("guest 删图片应 401,得到 %d", status)
}
if status, _ := env.do(guest, http.MethodPost, "/api/uploads/orphans/delete", map[string]any{"names": []string{orphan}}, false); status != http.StatusUnauthorized {
t.Fatalf("guest 批量删孤儿应 401,得到 %d", status)
}
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
if status, _ := env.do(recorder, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden {
t.Fatalf("recorder 读孤儿清单应 403,得到 %d", status)
}
if status, _ := env.do(recorder, http.MethodDelete, "/api/uploads/"+orphan, nil, true); status != http.StatusForbidden {
t.Fatalf("recorder 删图片应 403,得到 %d", status)
}
if status, _ := env.do(recorder, http.MethodPost, "/api/uploads/orphans/delete",
map[string]any{"names": []string{orphan}, "requestId": "req-orphan-rec"}, true); status != http.StatusForbidden {
t.Fatalf("recorder 批量删孤儿应 403,得到 %d", status)
}
// 权限不足的请求不能有任何副作用:那张孤儿必须还在。
if _, err := os.Stat(filepath.Join(uploadsDir, orphan)); err != nil {
t.Fatalf("权限不足的删除请求不该动文件: %v", err)
}
}
// 删除接口不能靠路径穿越打到 uploads 之外:uploads 同级的图片扩展名文件必须毫发无损。
func TestDeleteUploadRejectsTraversal(t *testing.T) {
env, uploadsDir, client := uploadAdminEnv(t)
projectDir := filepath.Dir(uploadsDir)
const secret = "outside-secret"
if err := os.MkdirAll(projectDir, 0o755); err != nil {
t.Fatalf("创建项目目录: %v", err)
}
outside := filepath.Join(projectDir, "secret.png")
if err := os.WriteFile(outside, []byte(secret), 0o644); err != nil {
t.Fatalf("写 uploads 之外的文件: %v", err)
}
paths := []string{
"/api/uploads/..%2Fsecret.png",
"/api/uploads/%2e%2e%2Fsecret.png",
"/api/uploads/a%2Fb.png",
}
for _, path := range paths {
status, _ := env.do(client, http.MethodDelete, path, nil, true)
if status == http.StatusOK {
t.Fatalf("%s 不应删成功", path)
}
if body, err := os.ReadFile(outside); err != nil || string(body) != secret {
t.Fatalf("uploads 之外的文件被动了 %s: %v %q", path, err, body)
}
}
}
// 建运动员漏传 active 必须默认「启用」(与单位 / 大项 / 小项同口径):
// Go 的 bool 零值是 false,静默建成「停用」的人在报名选择器里等于不存在——
// 少写一个字段就变成另一种东西,只能靠 decodeJSONPresence 的「键是否存在」判断。
func TestAthleteActivePresenceDefaults(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 漏传 active:建出来是启用的,库里的 active 也真的是 1。
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "无active", "gender": "男", "teamId": ids.teamA,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建人员应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["active"] != true {
t.Fatalf("漏传 active 应建成启用,得到 %+v", created)
}
newID := int64(created["id"].(float64))
var storedActive int
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
t.Fatalf("读人员启用状态: %v", err)
}
if storedActive != 1 {
t.Fatalf("漏传 active 应在库里落 1,得到 %d", storedActive)
}
// 显式 false 仍然是「停用」(不能把默认值做成「永远启用」)。
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "显式停用", "gender": "女", "teamId": ids.teamA, "active": false,
}, true)
if status != http.StatusOK {
t.Fatalf("显式 active=false 应 200,得到 %d %+v", status, env2.Error)
}
if decodeData[map[string]any](t, env2)["active"] != false {
t.Fatalf("显式 active=false 应建成停用")
}
// 更新时漏传 active 沿用库里的现值:不能把在用的人静默停用。
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", newID), map[string]any{
"name": "无active改名", "gender": "男", "teamId": ids.teamA,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
}
if decodeData[map[string]any](t, env2)["active"] != true {
t.Fatalf("更新漏传 active 应沿用启用状态,得到 %+v", decodeData[map[string]any](t, env2))
}
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
t.Fatalf("读人员启用状态: %v", err)
}
if storedActive != 1 {
t.Fatalf("更新漏传 active 不该把人停用,库里得到 %d", storedActive)
}
// 显式 false 的更新仍能把人停用(「漏传 = 沿用」不是「永远不能停用」)。
if status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", newID), map[string]any{
"name": "无active改名", "gender": "男", "teamId": ids.teamA, "active": false,
}, true); status != http.StatusOK {
t.Fatalf("显式停用应 200,得到 %d %+v", status, env2.Error)
}
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
t.Fatalf("读人员启用状态: %v", err)
}
if storedActive != 0 {
t.Fatalf("显式 active=false 应把人停用,库里得到 %d", storedActive)
}
}
// ── 表头别名:中文表头也要能导入 ──────────────────────────────────────────────
//
// 现场手里同时有两种表:老的纯英文模板、以及新下载的中文模板。英文是规范名
// (取值、报错、纯英文模板都用它),中文只是别名;别名只加不改,纯英文的老表一字不改照常导入。
// importHeaderZH 是中文表头,与 importHeader 逐列对应(同样是 11 列,heat 列同样缺席)。
const importHeaderZH = "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,状态,队员名单"
// importHeaderZHHeat 是带批次列的中文表头,与 importHeaderHeat 逐列对应。
const importHeaderZHHeat = "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,批次,状态,队员名单"
// importHeaderEnglishLegacy 是升级前就在用的纯英文表头,**逐字钉在这里**(故意不复用 importHeader 常量):
// 别名解析只要碰坏了任何一个英文列名,这条立刻红。
const importHeaderEnglishLegacy = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,status,member_names"
// importEntryFields 读一条报名项的号码布 / 种子 / 批次 / 状态,用来验证值确实取自表里对应的那一列。
func importEntryFields(t *testing.T, env *testEnv, idCard string, eventID int64) (string, string, sql.NullInt64, string) {
t.Helper()
var (
bib, seed, status string
heat sql.NullInt64
)
if err := env.ev.QueryRow(
`SELECT COALESCE(bib, ''), COALESCE(seed, ''), heat, status FROM entries
WHERE event_id = ? AND athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
eventID, idCard).Scan(&bib, &seed, &heat, &status); err != nil {
t.Fatalf("读报名项(证件号 %s): %v", idCard, err)
}
return bib, seed, heat, status
}
// 纯英文表头是既有现场文件的口径:别名功能上线后必须一字不改地照常导入。
func TestImportKeepsEnglishHeaderUnchanged(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeaderEnglishLegacy + "\nM100,男子,A,张三,110101199001011234,男,26,101,种子1,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
bib, seed, heat, entryStatus := importEntryFields(t, env, "110101199001011234", ids.event)
if bib != "101" || seed != "种子1" || heat.Valid || entryStatus != "registered" {
t.Fatalf("英文表头各列应原样落库,得到 bib=%q seed=%q heat=%+v status=%q", bib, seed, heat, entryStatus)
}
// 大小写不敏感、首尾空格容错也是既有行为:表头识别的改动不能把它弄丢。
t.Run("大写与首尾空格", func(t *testing.T) {
env, _, client := recorderWithSeed(t)
header := " Event_Code ,CATEGORY, team_code ,ATHLETE_NAME,ID_CARD,GENDER,AGE,BIB,SEED,STATUS,MEMBER_NAMES"
body := header + "\nM100,男子,A,张三,110101199001011234,男,26,101,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
})
}
// 中文表头:列名认得出还不够,每一列的值都要真的从中文那一列取。
func TestImportAcceptsChineseHeader(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeaderZH + "\nM100,男子,A,张三,110101199001011234,男,26,101,种子1,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
bib, seed, _, entryStatus := importEntryFields(t, env, "110101199001011234", ids.event)
if bib != "101" || seed != "种子1" || entryStatus != "registered" {
t.Fatalf("中文列的值应落进对应字段,得到 bib=%q seed=%q status=%q", bib, seed, entryStatus)
}
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 != 26 {
t.Fatalf("「年龄」列应写进人员档案,得到 %+v", age)
}
}
// 批次列(heat)的中文写法:别名映射必须与英文列同等对待,值也要落进 entries.heat。
func TestImportAcceptsChineseHeatColumn(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeaderZHHeat + "\nM100,男子,A,张三,110101199001011234,男,26,101,,2,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
_, _, heat, _ := importEntryFields(t, env, "110101199001011234", ids.event)
if !heat.Valid || heat.Int64 != 2 {
t.Fatalf("「批次」列应写进 entries.heat=2,得到 %+v", heat)
}
}
// 中英混用:每一列各自认名字,列与列之间互不影响(现场常拿英文模板补几列中文)。
func TestImportAcceptsMixedLanguageHeader(t *testing.T) {
env, _, client := recorderWithSeed(t)
header := "event_code,category,team_code,姓名,证件号,性别,年龄,号码布,种子,状态,队员名单"
body := header + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
}
// 同一列在一份表里出现两种写法(中英各来一次,或同一格重复两次)一律拦下:
// 两列都可能有数据,静默取其中一列等于丢掉另一列的内容,报错让人删掉重复的那一列更安全。
func TestImportRejectsSameColumnTwice(t *testing.T) {
for _, tc := range []struct {
name string
header string
wantIn []string
}{
{"中英各一次", "event_code,项目编码,category,team_code,athlete_name,bib,seed,status,member_names",
[]string{"表头重复", "event_code", "项目编码"}},
{"同一写法两次", "event_code,category,team_code,athlete_name,event_code,bib,seed,status,member_names",
[]string{"表头重复", "event_code"}},
{"中文别名重复", "项目编码,小项编码,组别,单位编码,姓名,号码布,种子,状态,队员名单",
[]string{"表头重复", "项目编码", "小项编码"}},
} {
t.Run(tc.name, func(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := tc.header + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusBadRequest || env2.OK {
t.Fatalf("同一列写两遍应回 400,得到 %d %+v", status, env2.Error)
}
for _, want := range tc.wantIn {
if !strings.Contains(env2.Error.Message, want) {
t.Fatalf("message 应包含 %q,得到 %q", want, env2.Error.Message)
}
}
if !strings.Contains(env2.Error.Message, "第 1 行") {
t.Fatalf("message 应点到表头行,得到 %q", env2.Error.Message)
}
})
}
}
// 缺列提示中英并列:手拿中文模板的人看到「缺少列 athlete_name(姓名)」才知道自己缺的是哪一格。
func TestImportMissingColumnMessageIsBilingual(t *testing.T) {
for _, tc := range []struct {
name string
header string
want string
}{
{"中文表头缺姓名", "项目编码,组别,单位编码,证件号,性别,年龄,号码布,种子,状态,队员名单", "缺少列 athlete_name(姓名)"},
{"中文表头缺队员名单", "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,状态", "缺少列 member_names(队员名单)"},
{"英文表头缺小项编码", "category,team_code,athlete_name,bib,seed,status,member_names", "缺少列 event_code(项目编码)"},
} {
t.Run(tc.name, func(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := tc.header + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusBadRequest || env2.OK {
t.Fatalf("缺列应回 400,得到 %d %+v", status, env2.Error)
}
msg := env2.Error.Message
if !strings.Contains(msg, tc.want) {
t.Fatalf("message 应中英并列,期望包含 %q,得到 %q", tc.want, msg)
}
// 提示还要教人往哪看:别的名字(中文模板上没见过的写法)只是其中一种可能。
if !strings.Contains(msg, "第 1 行") || !strings.Contains(msg, "表头") {
t.Fatalf("message 应点到表头行,得到 %q", msg)
}
if !strings.Contains(msg, "中文") || !strings.Contains(msg, "英文") {
t.Fatalf("message 应说明中英表头都认,得到 %q", msg)
}
})
}
}
// 中文表头 + 各种编码:BOM 粘在中文列名上("\ufeff项目编码")是最容易翻车的组合,
// 编码处理(decodeTableText)与表头归一(normalizeImportHeader)两层都要兜住。
func TestImportAcceptsChineseHeaderInEveryEncoding(t *testing.T) {
plain := importHeaderZH + "\nM100,男子,A,张三,110101199001011234,男,26,101,,registered,\n"
for _, tc := range []struct {
name string
body string
}{
{"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)
})
}
}
// 表头格自身的容错:中文输入法打出的全角字母、全角空格、格子中间混进的 BOM
// 都不能退化成「缺少列 xxx」——那是升级前就踩过的坑。
func TestImportToleratesFullWidthHeaderCells(t *testing.T) {
env, _, client := recorderWithSeed(t)
header := "EVENT_CODE,\u3000category ,team_code,\uFEFF姓名 ,证件号,性别,年龄,号码布,种子,状态,队员名单"
body := header + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
}