Files
Scoreboard/server/internal/httpapi/handlers_config_test.go
T
AskaEth 53a0cb225c feat: 备份恢复 / 图片清理 / 单位恢复 / 审计过滤 / 模板引擎 M1 + 两个导入缺陷修复
用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:

**新增能力**
- 备份恢复:`POST /api/backup/restore`(admin)。先把校验做在拍快照之前(只读打开 + integrity_check +
  关键表校验 + 异类库标志表互斥),再用 VACUUM INTO 拍「恢复前安全副本」,然后关连接、整文件替换、
  触发优雅重启;`-wal` 还在时直接拒绝(宁可失败也不让旧库 WAL 套到新库上);恢复后审计直写"恢复之后
  还在的那份库"(不走通用 Auditor,否则记录只会落在被替换掉的旧文件上)。顺带修掉:裸服务端连续恢复
  第二次会在拍副本处崩出一句看不懂的 500,现在回 503「上次恢复后尚未重启,请先重启服务端」。
- 安全副本保留策略:每类库保留最近 5 份,独立于 .db 的 20 份滚动清理。
- 图片管理:`DELETE /api/uploads/{name}`(被引用 409 并列出引用点)、`GET /api/uploads/orphans`、
  `POST /api/uploads/orphans/delete`(只删列出的孤儿,宁漏勿误),全部 admin(与字体删除同档);
  `deploy/backup.bat` 补上 uploads/ 归档(logs 不归档:可再生的诊断且会无上限增长)。
- 已彻底删除单位的恢复:`POST /api/teams/{id}/restore`(与 hideTeam 同档)。幂等;恢复不会把「停用」
  变成「启用」;真机确认恢复前后榜单逐字节一致、change_log 最大值不变(数据一行没动,不需要重算)。
  界面加「显示已彻底删除的单位」(仅 admin)与恢复按钮;非 admin 的删除确认框里写明"本账号无法自行恢复"。
- 审计日志按 `action` 过滤(子串模糊,输入里的 %/_/\ 转义成字面量)。
- 删除死表 recent_projects(新增 system 迁移 0006,未动已应用的迁移)。
- points_override 不再"设了没效果":写入侧 400 + 引擎侧守卫,提示改用「积分调整」。
- 联队份额空壳清理(UnionAttribution/UnionSplit/Entry.TeamShares/resolvedShares 等;TeamShare 类型保留,
  因为 export/realtime/奖牌流水三处在生产路径上构造它)。
- 自定义大屏模板 M1:插值引擎(parseTemplate/renderTemplate),任何情况都不抛异常、未定义→空串+警告、
  警告按 (code,path) 合并计数、无 eval/innerHTML,57 个新用例。

**两个真缺陷(前两个已随 0.1.6 出包)**
- 基础数据包导入「排序」列口径不一致:小项/组别留空保持原值,单位/大项留空被写成 0(plan 没从库里
  现值起手)→ 四张表统一为「留空保持原值」。
- 同类第二处:布尔列「整列被删掉」被当成"取默认值",会把库里所有停用的单位/大项静默改回启用 →
  改为「缺列 = 不动」(写侧与解析侧都收口,补了"缺列保持/空格子=是/显式否=停用"三组对照用例)。

**删死代码**
- createEntry / updateEntry / deleteEntry / entryEventID / checkCategoryInEvent / getEntry /
  normalizeEntryStatus / ExportBaseDataXLSX(全仓 grep 零引用)。⚠️ 随之记入文档:「组别必须属于该小项」
  这道校验现在只由导入路径"按小项+组别名解析"天然保证,将来若新增从请求体接收 categoryId 的接口,
  必须自己补校验(库层没有复合外键)。

**文档**
- devdocs 全面同步(01/02/03/04/05/06/07/09/11/12/13/README;含身份口径、延迟结算、大屏单栏新版式、
  新增路由与设置键、R18/R19 风险);docs 复核 + 第二轮(身份口径三个易写反的分支、图片删除、备份覆盖、
  审计过滤、单位恢复、去掉硬编码版本号、上线检查清单 +5 项);debug/使用说明 6 份逐份 diff 一致。

测试:服务端 13 包(连跑两遍)、前端 406 例、Console 28 例 + 18 文件语法检查 —— 全绿。
版本号 dev_0.1.6 → dev_0.1.7。
2026-09-11 19:26:36 +08:00

1815 lines
71 KiB
Go
Raw Blame History

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