feat: 人员年龄/号码布、图片上传、多显示器菜单、Console 两栏、exe 改名等一批改动

人员与报名:
- 人员唯一标识改为「证件号 或 号码布」二选一(0013/0014 迁移 + 导入匹配/回填/冲突)
- 人员新增「年龄」(0015 迁移):表单/列表/导入模板表头 11 列;冲突比较只在两边
  都有值且不同时才算差异,旧数据不误报
- 冲突决策键改为「证件号优先 → 号码布」(前端原先只用 idCard,号码布行会互相覆盖)
- 报名选择器支持无证件号但有号码布的运动员

大屏与 Console:
- 大屏控制新增「显示器」菜单:可给指定显示器重建全屏窗口;单屏/主显示器强制窗口化,
  限制在 Console 侧判定,不信任页面传参
- Console 服务管理窗口改左右两栏,运行日志独占右栏满高;窗口默认尺寸 1280×860
- 大屏右上角时钟精确到秒(同时把刷新间隔 15s 改为 1s)

图片与配置:
- 新增图片上传 POST /api/uploads(recorder+,png/jpg/webp/gif,≤5MiB)与匿名静态
  访问 GET /uploads/{name}(防目录穿越);文件落赛事项目目录 uploads/,随赛事包
  一起导出/导入
- 「赛事信息」新增赛事会标 / 大屏会标 / 大屏背景图三个上传项
- 参赛单位 logo 输入框右侧加上传按钮

关于页与构建:
- 构建时间打通:version.BuildTime + /api/system/about 返回 + build.bat 注入
  (用 PowerShell 取时间戳,避免本地化 %DATE% 的非 ASCII 星期)

安装包:
- 主程序改名 Scoreboard_Console.exe(win.executableName;productName/显示名不变)
- 记住上次安装目录:自定义 NSIS 宏优先沿用自存路径(卸载不清除),并在 customInit
  里补写回 electron-builder 的键,避免安装模式页把 $INSTDIR 打回默认

自检:服务端 12 包全绿;前端 vue-tsc + build 通过、node --test 58/58;console 12 文件通过。
This commit is contained in:
AskaEth committed 2026-09-10 09:05:06 +08:00
1 parent 24fff8dd08
commit 6483acf9ad
50 files changed
+2980 -395

No files matched your search

+2
View File
@@ -21,6 +21,8 @@ type athleteDTO struct {
TeamID int64 `json:"teamId"`
EmployeeNo *string `json:"employeeNo"`
IDCard *string `json:"idCard"`
Bib *string `json:"bib"`
Age *int `json:"age"`
Active bool `json:"active"`
}
+2 -3
View File
@@ -107,9 +107,8 @@ func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any,
}
opts := export.ProjectExportOptions{Name: name, AppVersion: s.version}
if s.projectDir != "" {
// uploads 可能还没建过;只在目录存在时打包,否则 copyTree 会报错。
uploads := filepath.Join(s.projectDir, "uploads")
// uploads 与图片上传共用同一路径约定;目录可能还没建过,只在存在时打包,否则 copyTree 会报错。
if uploads, err := s.uploadsDir(); err == nil {
if info, err := os.Stat(uploads); err == nil && info.IsDir() {
opts.UploadsDir = uploads
}
+203 -12
View File
@@ -2,12 +2,17 @@ package httpapi
import (
"context"
"crypto/rand"
"encoding/csv"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
@@ -534,11 +539,12 @@ type entryImportResult struct {
Conflicts []importConflict `json:"conflicts"`
}
// importConflict 是「证件号已存在、但姓名/性别/单位与库中不一致」的一行。
// importConflict 是「证件号或号码布已存在、但人员字段与库中不一致」的一行。
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
type importConflict struct {
Row int `json:"row"`
IDCard string `json:"idCard"`
Bib string `json:"bib"`
AthleteID int64 `json:"athleteId"`
Existing importAthleteView `json:"existing"`
Incoming importAthleteView `json:"incoming"`
@@ -551,11 +557,16 @@ type importAthleteView struct {
Gender *string `json:"gender"`
TeamCode string `json:"teamCode"`
EmployeeNo *string `json:"employeeNo"`
IDCard *string `json:"idCard"`
Bib *string `json:"bib"`
Age *int `json:"age"`
}
// importDecision 是冲突决策:keep-existing 保留库中人员字段,keep-new 用表里的字段覆盖。
// 证件号与号码布二选一作为决策键,证件号优先。
type importDecision struct {
IDCard string `json:"idCard"`
Bib string `json:"bib"`
Action string `json:"action"`
}
@@ -610,14 +621,18 @@ func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (a
if err := json.Unmarshal([]byte(raw), &decisions); err != nil {
return nil, badRequest("decisions 不是合法 JSON", err.Error())
}
byCard := make(map[string]string, len(decisions))
byKey := make(map[string]string, len(decisions))
for _, d := range decisions {
if d.Action != decisionKeepExisting && d.Action != decisionKeepNew {
return nil, badRequest("action 只能是 keep-existing 或 keep-new", d.Action)
}
byCard[strings.TrimSpace(d.IDCard)] = d.Action
key := strings.TrimSpace(d.IDCard)
if key == "" {
key = strings.TrimSpace(d.Bib)
}
byKey[key] = d.Action
}
result, err := s.importEntriesWith(r.Context(), rows, byCard)
result, err := s.importEntriesWith(r.Context(), rows, byKey)
if err != nil {
return nil, err
}
@@ -655,7 +670,7 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
}
// importColumns 是报名表必须包含的表头(大小写不敏感);
// id_card、gender 为可选列,缺列不报错,按空值处理。
// id_card、gender、age 为可选列,缺列不报错,按空值处理。
var importColumns = []string{
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
}
@@ -665,7 +680,7 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
}
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
// 非 nil 时按决策处理证件号命中的冲突,未出现在 decisions 里的冲突继续收集。
// 非 nil 时按决策处理证件号/号码布命中的冲突,未出现在 decisions 里的冲突继续收集。
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}}
if len(rows) < 2 {
@@ -681,8 +696,9 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
}
}
// 同一文件里证件号只能出现一次,否则无法判断是不是同一个人。
// 同一文件里证件号、号码布都只能出现一次,否则无法判断是不是同一个人。
seenIDCards := make(map[string]bool)
seenBibs := make(map[string]bool)
for n, row := range rows[1:] {
line := n + 2
cell := func(name string) string {
@@ -735,17 +751,35 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
var athleteID *int64
if athleteName != "" {
idCard := cell("id_card")
if idCard == "" {
bib := cell("bib")
// 证件号与号码布二选一即可,都没有就无法区分同名的两个人。
if idCard == "" && bib == "" {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card", line))
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card 或 bib", line))
continue
}
if seenIDCards[idCard] {
age, ok := parseImportAge(cell("age"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard))
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:age 需要是 0-120 的整数", line))
continue
}
seenIDCards[idCard] = true
if idCard != "" {
if seenIDCards[idCard] {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard))
continue
}
seenIDCards[idCard] = true
}
if bib != "" {
if seenBibs[bib] {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:号码布 %s 在报名表内重复出现", line, bib))
continue
}
seenBibs[bib] = true
}
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
Line: line,
@@ -754,6 +788,8 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
TeamID: teamID,
TeamCode: teamCode,
IDCard: idCard,
Bib: bib,
Age: age,
}, decisions)
if err != nil {
result.Skipped++
@@ -792,6 +828,18 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
return result, nil
}
// parseImportAge 解析可选的 age 列:空值合法(返回 nil),非整数或超出 0-120 视为非法。
func parseImportAge(raw string) (*int, bool) {
if raw == "" {
return nil, true
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 || n > 120 {
return nil, false
}
return &n, true
}
// normalizeGender 只认 男/女,其它值(含空)一律忽略。
func normalizeGender(raw string) string {
switch raw {
@@ -814,3 +862,146 @@ func splitNames(raw string) []string {
}
return out
}
// ---------- 图片上传与静态访问 ----------
// maxUploadBytes 是单张图片的硬上限;multipart 边界与表头还占少量字节,
// 解析上限相应放宽,真实大小由 uploadHandler 里的 LimitReader 兜住。
const (
maxUploadBytes = 5 << 20
maxUploadFormBytes = maxUploadBytes + 1<<20
)
// allowedUploadImageTypes 是允许上传的图片扩展名与对应 Content-Type。
// 收 SVG 会让匿名可访问的同源静态资源变成存储型 XSS 入口,故白名单里没有 .svg。
var allowedUploadImageTypes = map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
}
// uploadsCacheControl 命中随机文件名即可长缓存:同名文件内容不会变。
const uploadsCacheControl = "public, max-age=86400"
// uploadResult 是上传成功后的访问路径;path 与 url 同值,前端可直接拿去当 <img src>。
type uploadResult struct {
Path string `json:"path"`
URL string `json:"url"`
}
// uploadsDir 返回当前赛事项目的上传目录(赛事包导出会带上它,跨设备转移靠的就是这里)。
// 未配置项目目录时没有落盘位置,返回 503 而不是 panic。
func (s *server) uploadsDir() (string, error) {
dir := strings.TrimSpace(s.projectDir)
if dir == "" {
return "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID",
message: "当前未配置赛事项目目录,无法上传图片"}
}
return filepath.Join(dir, "uploads"), nil
}
// uploadHandler 保存 multipart file 字段里的图片。
// 客户端文件名只用来取扩展名,落盘名是随机十六进制,避免重名覆盖与路径注入。
func (s *server) uploadHandler(w http.ResponseWriter, r *http.Request) (any, error) {
dir, err := s.uploadsDir()
if err != nil {
return nil, err
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadFormBytes)
if err := r.ParseMultipartForm(maxUploadFormBytes); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return nil, badRequest("图片超过 5 MiB 上限", nil)
}
return nil, badRequest("上传内容解析失败", err.Error())
}
defer func() { _ = r.MultipartForm.RemoveAll() }()
file, header, err := r.FormFile("file")
if err != nil {
return nil, badRequest("缺少 file 字段", nil)
}
defer func() { _ = file.Close() }()
ext := strings.ToLower(filepath.Ext(header.Filename))
if _, ok := allowedUploadImageTypes[ext]; !ok {
return nil, badRequest("只支持 png/jpg/jpeg/webp/gif 图片", ext)
}
// header.Size 由客户端声明,不可信,按真实读到的字节数判限。
body, err := io.ReadAll(io.LimitReader(file, maxUploadBytes+1))
if err != nil {
return nil, badRequest("读取上传内容失败", err.Error())
}
if len(body) > maxUploadBytes {
return nil, badRequest("图片超过 5 MiB 上限", nil)
}
if len(body) == 0 {
return nil, badRequest("上传内容为空", nil)
}
name, err := newUploadName(ext)
if err != nil {
return nil, internal(err)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, internal(err)
}
if err := os.WriteFile(filepath.Join(dir, name), body, 0o644); err != nil {
return nil, internal(err)
}
s.audit(r.Context(), "uploads.create", "upload", 0, nil,
map[string]any{"file": name, "size": len(body)})
path := "/uploads/" + name
return uploadResult{Path: path, URL: path}, nil
}
// newUploadName 生成随机文件名:不用客户端文件名,既防重名覆盖也防路径注入。
func newUploadName(ext string) (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf) + ext, nil
}
// serveUpload 匿名提供上传目录里的图片:大屏与 <img> 不带会话,不能要求登录。
func (s *server) serveUpload(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
ctype, ok := uploadContentType(name)
if !ok {
writeAPIError(w, s.logger, notFound("图片不存在"))
return
}
dir, err := s.uploadsDir()
if err != nil {
writeAPIError(w, s.logger, notFound("图片不存在"))
return
}
// 名字已过白名单校验,这里再用 Base 兜一层,确保只会读到 uploads 内的文件。
file := filepath.Join(dir, filepath.Base(name))
if info, err := os.Stat(file); err != nil || info.IsDir() {
writeAPIError(w, s.logger, notFound("图片不存在"))
return
}
w.Header().Set("Content-Type", ctype)
w.Header().Set("Cache-Control", uploadsCacheControl)
http.ServeFile(w, r, file)
}
// uploadContentType 校验文件名并给出 Content-Type:
// 只接受「单层文件名 + 白名单扩展名」,含分隔符、.. 或只有扩展名的名字一律拒绝。
func uploadContentType(name string) (string, bool) {
if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
return "", false
}
ext := strings.ToLower(filepath.Ext(name))
if name == ext {
return "", false
}
ctype, ok := allowedUploadImageTypes[ext]
return ctype, ok
}
+757 -15
View File
@@ -8,14 +8,21 @@ import (
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// importHeader 与 ImportSection 模板 / 服务端 importColumns 保持一致。
const importHeader = "event_code,category,team_code,athlete_name,id_card,gender,bib,seed,status,member_names"
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) {
@@ -153,7 +160,7 @@ func TestChangePasswordRejectsTooLong(t *testing.T) {
func TestImportCreatesAthleteWithIDCard(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
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)
@@ -194,7 +201,7 @@ func TestImportCreatesAthleteWithIDCard(t *testing.T) {
func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,,registered,\n"
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)
@@ -229,7 +236,7 @@ func TestImportCollectsConflictWithoutWriting(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,registered,\n"
body := importHeader + "\nM100,男子,B,张伟,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)
@@ -283,8 +290,8 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
"M100,男子,A,张三,110101199001011234,男,,,registered,\n" +
"M100,男子,A,张三,110101199001011234,男,,,registered,\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)
@@ -307,22 +314,22 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
}
}
// 有 athlete_name 的行必须给 id_card。
func TestImportRequiresIDCardWhenNameGiven(t *testing.T) {
// 有 athlete_name 的行必须给 id_card 或 bib,两个都缺才报错。
func TestImportRequiresIDCardOrBibWhenNameGiven(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张三,,男,,,registered,\n"
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)
t.Fatalf("缺证件号与号码布应跳过: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "id_card") {
t.Fatalf("应报缺少 id_card,得到 %+v", errs)
t.Fatalf("应报缺少 id_card 或 bib,得到 %+v", errs)
}
var athletes int
@@ -334,7 +341,7 @@ func TestImportRequiresIDCardWhenNameGiven(t *testing.T) {
}
}
// id_card / gender 是可选列:表头缺失不报错(团体行不涉及人员)。
// id_card / gender / age 是可选列:表头缺失不报错(团体行不涉及人员)。
func TestImportToleratesMissingOptionalColumns(t *testing.T) {
env, _, client := recorderWithSeed(t)
@@ -355,7 +362,7 @@ func TestImportResolveKeepExisting(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,registered,\n"
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
// 未给决策时,冲突原样返回。
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]")
@@ -410,7 +417,7 @@ func TestImportResolveKeepNew(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,registered,\n"
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 {
@@ -444,7 +451,7 @@ func TestImportResolveKeepNew(t *testing.T) {
// decisions 非法时返回 400。
func TestImportResolveRejectsBadDecisions(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
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)
@@ -453,3 +460,738 @@ func TestImportResolveRejectsBadDecisions(t *testing.T) {
}
}
}
// 只给号码布(没有证件号)也能新建人员,人员与报名都写入号码布。
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)
}
}
// 证件号命中但号码布与库中不同 → 冲突,快照与差异字段都带 bib,本轮不落库。
func TestImportConflictIncludesBibDiff(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)
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
}
conflict := conflicts[0].(map[string]any)
if conflict["bib"] != "A200" {
t.Fatalf("冲突定位应带表里的号码布: %+v", conflict)
}
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "bib" {
t.Fatalf("应只有 bib 变化,得到 %+v", changed)
}
existing := conflict["existing"].(map[string]any)
if existing["bib"] != "A100" {
t.Fatalf("existing 快照应带库中号码布: %+v", existing)
}
incoming := conflict["incoming"].(map[string]any)
if incoming["bib"] != "A200" {
t.Fatalf("incoming 快照应带表里号码布: %+v", incoming)
}
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)
}
}
// 号码布命中但姓名与库中不一致 → 冲突;决策按号码布为键,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)
}
}
// ---------- 图片上传与静态访问 ----------
// 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)
}
}
@@ -25,6 +25,8 @@ func (s *server) systemRoutes(mux *http.ServeMux) {
func (s *server) systemAbout(w http.ResponseWriter, r *http.Request) (any, error) {
return map[string]any{
"internalSignature": version.InternalSignature,
// 构建时间同样是编译期注入的,未注入时为空串,由前端显示「未注入」。
"buildTime": version.BuildTime,
}, nil
}
+68 -31
View File
@@ -287,18 +287,20 @@ func (s *server) deleteTeam(ctx context.Context, id int64) error {
// ---------- athletes ----------
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, active`
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age, active`
func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) {
var (
a athleteDTO
gender, empNo, idCard sql.NullString
teamID sql.NullInt64
a athleteDTO
gender, empNo, idCard, bib sql.NullString
teamID, age sql.NullInt64
)
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &a.Active)
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &bib, &age, &a.Active)
a.Gender = strPtr(gender)
a.EmployeeNo = strPtr(empNo)
a.IDCard = strPtr(idCard)
a.Bib = strPtr(bib)
a.Age = intPtr(age)
if teamID.Valid {
a.TeamID = teamID.Int64
}
@@ -314,6 +316,14 @@ func isIDCardConflict(err error) bool {
return strings.Contains(strings.ToLower(err.Error()), "athletes.id_card")
}
// isBibConflict 判断错误是否来自 athletes.bib 的唯一索引,文案要与证件号冲突区分开。
func isBibConflict(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "athletes.bib")
}
func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) {
query := `SELECT ` + athleteColumns + ` FROM athletes`
args := []any{}
@@ -351,30 +361,36 @@ func (s *server) getAthlete(ctx context.Context, id int64) (athleteDTO, error) {
return a, nil
}
// validateAthleteAge 年龄可以留空,填了就必须落在 0–120(JSON 已保证是整数)。
func validateAthleteAge(age *int) error {
if age != nil && (*age < 0 || *age > 120) {
return badRequest("age 需要在 0-120 之间", nil)
}
return nil
}
func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) {
if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 {
return athleteDTO{}, badRequest("name 与 teamId 必填", nil)
}
// 证件号是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。
if in.IDCard != nil && *in.IDCard != "" {
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? LIMIT 1`, *in.IDCard)
if err != nil {
return athleteDTO{}, internal(err)
}
if dup {
return athleteDTO{}, conflict("证件号已被其它人员使用")
}
if err := validateAthleteAge(in.Age); err != nil {
return athleteDTO{}, err
}
// 证件号与号码布都是唯一标识:先查重给出明确 409,并发下再由唯一索引兜底。
if err := s.checkAthleteIdentity(ctx, in, 0); err != nil {
return athleteDTO{}, err
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO athletes (name, gender, team_id, employee_no, id_card, active) VALUES (?, ?, ?, ?, ?, ?)`,
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), in.Active)
if isIDCardConflict(err) {
`INSERT INTO athletes (name, gender, team_id, employee_no, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), nullStr(in.Bib), nullInt(in.Age), in.Active)
switch {
case isIDCardConflict(err):
return athleteDTO{}, conflict("证件号已被其它人员使用")
}
if isForeignKeyViolation(err) {
case isBibConflict(err):
return athleteDTO{}, conflict("号码布已被其它人员使用")
case isForeignKeyViolation(err):
return athleteDTO{}, badRequest("teamId 不存在", nil)
}
if err != nil {
case err != nil:
return athleteDTO{}, internal(err)
}
id, err := lastInsertID(res)
@@ -384,26 +400,47 @@ func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO,
return s.getAthlete(ctx, id)
}
func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) {
// checkAthleteIdentity 校验证件号/号码布是否已被他人占用(id 为 0 表示新建)。
func (s *server) checkAthleteIdentity(ctx context.Context, in athleteDTO, id int64) error {
if in.IDCard != nil && *in.IDCard != "" {
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE id_card = ? AND id <> ? LIMIT 1`, *in.IDCard, id)
if err != nil {
return athleteDTO{}, internal(err)
return internal(err)
}
if dup {
return athleteDTO{}, conflict("证件号已被其它人员使用")
return conflict("证件号已被其它人员使用")
}
}
if in.Bib != nil && *in.Bib != "" {
dup, err := exists(ctx, s.ev, `SELECT 1 FROM athletes WHERE bib = ? AND id <> ? LIMIT 1`, *in.Bib, id)
if err != nil {
return internal(err)
}
if dup {
return conflict("号码布已被其它人员使用")
}
}
return nil
}
func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) {
if err := validateAthleteAge(in.Age); err != nil {
return athleteDTO{}, err
}
if err := s.checkAthleteIdentity(ctx, in, id); err != nil {
return athleteDTO{}, err
}
res, err := s.ev.ExecContext(ctx,
`UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, id_card = ?, active = ? WHERE id = ?`,
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), in.Active, id)
if isIDCardConflict(err) {
`UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, id_card = ?, bib = ?, age = ?, active = ? WHERE id = ?`,
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), nullStr(in.IDCard), nullStr(in.Bib), nullInt(in.Age), in.Active, id)
switch {
case isIDCardConflict(err):
return athleteDTO{}, conflict("证件号已被其它人员使用")
}
if isForeignKeyViolation(err) {
case isBibConflict(err):
return athleteDTO{}, conflict("号码布已被其它人员使用")
case isForeignKeyViolation(err):
return athleteDTO{}, badRequest("teamId 不存在", nil)
}
if err != nil {
case err != nil:
return athleteDTO{}, internal(err)
}
if err := requireChanges(res, "运动员不存在"); err != nil {
+142 -51
View File
@@ -14,7 +14,7 @@ func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64
return id, nil
}
// athleteImportRow 是报名表里一行的人员信息(Gender 已归一为 男/女 或空)。
// athleteImportRow 是报名表里一行的人员信息(Gender 已归一为 男/女 或空,Age 为空表示表里没填)。
type athleteImportRow struct {
Line int
Name string
@@ -22,6 +22,16 @@ type athleteImportRow struct {
TeamID int64
TeamCode string
IDCard string
Bib string
Age *int
}
// decisionKey 是冲突决策的键:优先证件号,没有证件号时用号码布。
func (in athleteImportRow) decisionKey() string {
if in.IDCard != "" {
return in.IDCard
}
return in.Bib
}
// athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。
@@ -38,91 +48,133 @@ type existingAthlete struct {
Gender *string
TeamID int64
EmployeeNo *string
IDCard *string
Bib *string
Age *int
}
// matchImportedAthlete 按契约顺序匹配人员:证件号 → 姓名+单位(库中证件号为空则回填)→ 新建。
// 证件号命中但姓名/性别/单位与库中不一致时记为冲突;decisions 里对该证件号有决策时按决策落库。
// matchImportedAthlete 按契约顺序匹配人员:证件号 → 号码布 → 姓名+单位(库中证件号为空则回填)→ 新建。
// 命中人员字段与表里不一致时记为冲突;decisions 里对同一身份有决策时按决策落库。
func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
existing, err := s.athleteByIDCard(ctx, in.IDCard)
existing, err := s.athleteByIdentity(ctx, in)
switch {
case err == nil:
changed := changedAthleteFields(existing, in)
if len(changed) == 0 {
return athleteImportMatch{AthleteID: existing.ID, TeamID: in.TeamID}, nil
}
conflict, err := s.importConflictFor(ctx, in, existing, changed)
if err != nil {
return athleteImportMatch{}, err
}
action, decided := decisions[in.IDCard]
switch {
case !decided:
return athleteImportMatch{Conflict: &conflict}, nil
case action == decisionKeepExisting:
// 人员字段保持库里的值,报名也跟随库里的单位。
return athleteImportMatch{AthleteID: existing.ID, TeamID: existing.TeamID}, nil
default:
if err := s.overwriteAthleteFromImport(ctx, existing.ID, in); err != nil {
return athleteImportMatch{}, err
}
return athleteImportMatch{AthleteID: existing.ID, TeamID: in.TeamID}, nil
}
return s.matchExistingAthlete(ctx, in, existing, decisions)
case errors.Is(err, sql.ErrNoRows):
return s.matchOrCreateAthleteByName(ctx, in)
return s.matchOrCreateAthleteByName(ctx, in, decisions)
default:
return athleteImportMatch{}, internal(err)
}
}
// matchOrCreateAthleteByName 处理证件号未命中的情况:
// 姓名+单位命中且库中证件号为空 → 视为同一人并回填证件号;否则新建人员。
func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow) (athleteImportMatch, error) {
var id int64
err := s.ev.QueryRowContext(ctx,
`SELECT id FROM athletes WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '')
ORDER BY id LIMIT 1`, in.Name, in.TeamID).Scan(&id)
// athleteByIdentity 先按证件号精确匹配,未命中再按号码布精确匹配。
func (s *server) athleteByIdentity(ctx context.Context, in athleteImportRow) (existingAthlete, error) {
if in.IDCard != "" {
ex, err := s.athleteByIDCard(ctx, in.IDCard)
if !errors.Is(err, sql.ErrNoRows) {
return ex, err
}
}
if in.Bib != "" {
return s.athleteByBib(ctx, in.Bib)
}
return existingAthlete{}, sql.ErrNoRows
}
// matchExistingAthlete 处理证件号/号码布命中的人员:无差异时直接复用并补空字段,
// 有差异时走冲突流程,decisions 里对该身份有决策才落库。
func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
changed := changedAthleteFields(ex, in)
if len(changed) == 0 {
if err := s.backfillEmptyAthleteFields(ctx, ex, in); err != nil {
return athleteImportMatch{}, err
}
return athleteImportMatch{AthleteID: ex.ID, TeamID: in.TeamID}, nil
}
conflict, err := s.importConflictFor(ctx, in, ex, changed)
if err != nil {
return athleteImportMatch{}, err
}
action, decided := decisions[in.decisionKey()]
switch {
case !decided:
return athleteImportMatch{Conflict: &conflict}, nil
case action == decisionKeepExisting:
// 人员字段保持库里的值,报名也跟随库里的单位。
return athleteImportMatch{AthleteID: ex.ID, TeamID: ex.TeamID}, nil
default:
if err := s.overwriteAthleteFromImport(ctx, ex.ID, in); err != nil {
return athleteImportMatch{}, err
}
return athleteImportMatch{AthleteID: ex.ID, TeamID: in.TeamID}, nil
}
}
// matchOrCreateAthleteByName 处理证件号/号码布都未命中的情况:
// 姓名+单位命中且库中证件号为空 → 视为同一人(差异按冲突处理);否则新建人员。
func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
existing, err := s.athleteByNameTeam(ctx, in.Name, in.TeamID)
switch {
case err == nil:
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, id); err != nil {
return athleteImportMatch{}, internal(err)
}
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
return s.matchExistingAthlete(ctx, in, existing, decisions)
case !errors.Is(err, sql.ErrNoRows):
return athleteImportMatch{}, internal(err)
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO athletes (name, gender, team_id, id_card, active) VALUES (?, ?, ?, ?, 1)`,
in.Name, nullStr(&in.Gender), in.TeamID, in.IDCard)
`INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age))
if err != nil {
return athleteImportMatch{}, internal(err)
}
id, err = lastInsertID(res)
id, err := lastInsertID(res)
if err != nil {
return athleteImportMatch{}, internal(err)
}
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
}
func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) {
// importAthleteColumns 是人员匹配要读的字段,证件号/号码布/年龄用于回填与冲突判断。
const importAthleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age`
func scanExistingAthlete(row interface{ Scan(...any) error }) (existingAthlete, error) {
var (
a existingAthlete
gender, empNo sql.NullString
teamID sql.NullInt64
a existingAthlete
gender, empNo, idCard, bib sql.NullString
teamID, age sql.NullInt64
)
err := s.ev.QueryRowContext(ctx,
`SELECT id, name, gender, team_id, employee_no FROM athletes WHERE id_card = ? LIMIT 1`, idCard).
Scan(&a.ID, &a.Name, &gender, &teamID, &empNo)
if err != nil {
if err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &bib, &age); err != nil {
return existingAthlete{}, err
}
a.Gender = strPtr(gender)
a.EmployeeNo = strPtr(empNo)
a.IDCard = strPtr(idCard)
a.Bib = strPtr(bib)
a.Age = intPtr(age)
a.TeamID = teamID.Int64
return a, nil
}
func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) {
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes WHERE id_card = ? LIMIT 1`, idCard))
}
func (s *server) athleteByBib(ctx context.Context, bib string) (existingAthlete, error) {
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes WHERE bib = ? LIMIT 1`, bib))
}
// athleteByNameTeam 按「姓名+单位」找库中还没填证件号的人,用于回填身份字段。
func (s *server) athleteByNameTeam(ctx context.Context, name string, teamID int64) (existingAthlete, error) {
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes
WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '')
ORDER BY id LIMIT 1`, name, teamID))
}
// changedAthleteFields 列出表里与库中不一致的字段名,顺序固定便于前端展示。
// 证件号/号码布只在两边都有值且不同时算冲突;库中为空属于待回填,不算冲突。
func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string {
var changed []string
if ex.Name != in.Name {
@@ -132,12 +184,47 @@ func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string {
if in.Gender != "" && (ex.Gender == nil || *ex.Gender != in.Gender) {
changed = append(changed, "gender")
}
// 年龄只在两边都有值且不同时算冲突:历史数据没有年龄,缺一边属于待补,不算冲突。
if in.Age != nil && ex.Age != nil && *ex.Age != *in.Age {
changed = append(changed, "age")
}
if ex.TeamID != in.TeamID {
changed = append(changed, "teamCode")
}
if identityDiffers(ex.IDCard, in.IDCard) {
changed = append(changed, "idCard")
}
if identityDiffers(ex.Bib, in.Bib) {
changed = append(changed, "bib")
}
return changed
}
// identityDiffers 判断身份字段(证件号/号码布)是否冲突:两边都有值且不同才算。
func identityDiffers(dbVal *string, inVal string) bool {
return inVal != "" && dbVal != nil && *dbVal != "" && *dbVal != inVal
}
// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄,已有值不动(差异已由 changedAthleteFields 判过)。
func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthlete, in athleteImportRow) error {
if in.IDCard != "" && (ex.IDCard == nil || *ex.IDCard == "") {
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, ex.ID); err != nil {
return internal(err)
}
}
if in.Bib != "" && (ex.Bib == nil || *ex.Bib == "") {
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET bib = ? WHERE id = ?`, in.Bib, ex.ID); err != nil {
return internal(err)
}
}
if in.Age != nil && ex.Age == nil {
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET age = ? WHERE id = ?`, *in.Age, ex.ID); err != nil {
return internal(err)
}
}
return nil
}
func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex existingAthlete, changed []string) (importConflict, error) {
existingCode, err := s.teamCodeByID(ctx, ex.TeamID)
if err != nil {
@@ -146,12 +233,15 @@ func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex
return importConflict{
Row: in.Line,
IDCard: in.IDCard,
Bib: in.Bib,
AthleteID: ex.ID,
Existing: importAthleteView{
Name: ex.Name, Gender: ex.Gender, TeamCode: existingCode, EmployeeNo: ex.EmployeeNo,
IDCard: ex.IDCard, Bib: ex.Bib, Age: ex.Age,
},
Incoming: importAthleteView{
Name: in.Name, Gender: nilIfEmpty(in.Gender), TeamCode: in.TeamCode,
IDCard: nilIfEmpty(in.IDCard), Bib: nilIfEmpty(in.Bib), Age: in.Age,
},
ChangedFields: changed,
}, nil
@@ -169,11 +259,12 @@ func (s *server) teamCodeByID(ctx context.Context, id int64) (string, error) {
}
// overwriteAthleteFromImport 用表里的字段覆盖库中人员(keep-new 决策)。
// 表里没填性别时保留库里的值,避免决策后反而丢字段。
// 表里没填的字段保留库里的值,避免决策后反而丢字段。
func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in athleteImportRow) error {
if _, err := s.ev.ExecContext(ctx,
`UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender) WHERE id = ?`,
in.Name, in.TeamID, nullStr(&in.Gender), id); err != nil {
`UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender),
id_card = COALESCE(?, id_card), bib = COALESCE(?, bib), age = COALESCE(?, age) WHERE id = ?`,
in.Name, in.TeamID, nullStr(&in.Gender), nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age), id); err != nil {
return internal(err)
}
return nil
+140
View File
@@ -1,8 +1,10 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
@@ -140,3 +142,141 @@ func TestAthleteIDCardUnique(t *testing.T) {
t.Fatalf("读人员应带 idCard,得到 %d %+v", status, got)
}
}
// 号码布与证件号同为唯一标识:可读写、重复 409(文案区分字段)、空值不互斥。
func TestAthleteBibUnique(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "bib": "A001", "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("仅给号码布应能建人员,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["bib"] != "A001" {
t.Fatalf("建人员应返回 bib: %+v", created)
}
athleteID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "李四", "gender": "男", "teamId": ids.teamA, "bib": "A001", "active": true,
}, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
t.Fatalf("重复号码布应 409 CONFLICT,得到 %d %+v", status, env2.Error)
}
if !strings.Contains(env2.Error.Message, "号码布") {
t.Fatalf("409 文案应点明号码布,得到 %+v", env2.Error)
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athB), map[string]any{
"name": "李强", "gender": "男", "teamId": ids.teamB, "bib": "A001", "active": true,
}, true)
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
t.Fatalf("改人员到重复号码布应 409,得到 %d %+v", status, env2.Error)
}
// 空号码布是合法的,多条空值不能互相冲突;唯一索引兜底重复写入。
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "王五", "gender": "女", "teamId": ids.teamA, "active": true,
}, true)
if status != http.StatusOK {
t.Fatalf("空号码布应可建,得到 %d %+v", status, env2.Error)
}
env.exec(t, `INSERT INTO athletes (name, gender, team_id, bib, active) VALUES ('甲', '男', ?, '', 1)`, ids.teamA)
env.exec(t, `INSERT INTO athletes (name, gender, team_id, bib, active) VALUES ('乙', '男', ?, '', 1)`, ids.teamA)
if _, err := env.ev.Exec(
`INSERT INTO athletes (name, team_id, bib, active) VALUES ('丙', ?, 'A001', 1)`, ids.teamA); err == nil {
t.Fatalf("唯一索引应拒绝重复号码布")
}
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", athleteID), nil, false)
got := decodeData[map[string]any](t, env2)
if status != http.StatusOK || got["bib"] != "A001" {
t.Fatalf("读人员应带 bib,得到 %d %+v", status, got)
}
}
// 年龄可读可写;越界或非整数返回 400,不填时为空。
func TestAthleteAgeReadWriteAndRange(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": 30, "active": true,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("建人员应 200,得到 %d %+v", status, env2.Error)
}
created := decodeData[map[string]any](t, env2)
if created["age"] != float64(30) {
t.Fatalf("建人员应返回 age: %+v", created)
}
athleteID := int64(created["id"].(float64))
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/athletes/%d", athleteID), nil, false)
if got := decodeData[map[string]any](t, env2); status != http.StatusOK || got["age"] != float64(30) {
t.Fatalf("读人员应带 age,得到 %d %+v", status, got)
}
// 边界值 0 与 120 合法。
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": 0, "active": true,
}, true)
if status != http.StatusOK || decodeData[map[string]any](t, env2)["age"] != float64(0) {
t.Fatalf("age=0 应可保存,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": 120, "active": true,
}, true)
if status != http.StatusOK || decodeData[map[string]any](t, env2)["age"] != float64(120) {
t.Fatalf("age=120 应可保存,得到 %d %+v", status, env2.Error)
}
for _, age := range []int{-1, 121} {
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", athleteID), map[string]any{
"name": "张三", "gender": "男", "teamId": ids.teamA, "age": age, "active": true,
}, true)
if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
t.Fatalf("age=%d 应 400,得到 %d %+v", age, status, env2.Error)
}
}
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "李四", "gender": "男", "teamId": ids.teamA, "age": 121, "active": true,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("新建年龄越界应 400,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "赵六", "gender": "男", "teamId": ids.teamA, "age": "abc", "active": true,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("年龄非整数应 400,得到 %d %+v", status, env2.Error)
}
// 不填年龄是合法的,返回空值。
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "王五", "gender": "女", "teamId": ids.teamA, "active": true,
}, true)
if status != http.StatusOK {
t.Fatalf("不填年龄应可建,得到 %d %+v", status, env2.Error)
}
if created = decodeData[map[string]any](t, env2); created["age"] != nil {
t.Fatalf("没填年龄应返回 null: %+v", created)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, athleteID).Scan(&age); err != nil {
t.Fatalf("查询人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 120 {
t.Fatalf("越界的 400 不该改库中年龄,得到 %+v", age)
}
}
+5
View File
@@ -249,6 +249,11 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/revisions", s.read(auth.RoleRecorder, s.listRevisionsHandler))
mux.HandleFunc("GET /api/revisions/{id}", s.read(auth.RoleRecorder, s.getRevisionDetailHandler))
// 图片上传与静态访问(会标/背景/单位图标)。上传落 <ProjectDir>/uploads/,随赛事包导出;
// 静态访问要匿名放行,大屏的 <img> 才能不带会话直接取图。
mux.HandleFunc("POST /api/uploads", s.write(auth.RoleRecorder, s.uploadHandler))
mux.HandleFunc("GET /uploads/{name}", s.serveUpload)
// 由其他模块负责的接口:先占位,避免落到静态资源处理器。
mux.HandleFunc("/api/export/", s.notWired("导出"))
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
+46
View File
@@ -9,6 +9,7 @@ import (
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/version"
)
// waitClosed 等待关闭回调触发;没触发就报错。
@@ -133,6 +134,51 @@ func TestShutdownByToken(t *testing.T) {
}
}
// GET /api/system/about:版本包里的 buildTime 原样返回;未注入时是空串(前端据此显示「未注入」)。
// 顺带锁住权限:guest 依旧读不到(该端点靠它取内部署名)。
func TestSystemAboutReturnsBuildTime(t *testing.T) {
env := newTestEnv(t)
env.createUser("recorder", "recorder-pass", "recorder")
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodGet, "/api/system/about", nil, false); status != http.StatusForbidden {
t.Fatalf("guest 读 about 应 403,得到 %d", status)
}
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
// 模拟 ldflags 注入:直接改包级变量,用例结束后恢复,避免影响其他用例。
original := version.BuildTime
t.Cleanup(func() { version.BuildTime = original })
version.BuildTime = "2026-09-10 08:50:12 +08:00"
status, env2 := env.do(client, http.MethodGet, "/api/system/about", nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 读 about 应 200,得到 %d %+v", status, env2.Error)
}
var about map[string]any
if err := json.Unmarshal(env2.Data, &about); err != nil {
t.Fatalf("解析 about: %v (%s)", err, env2.Data)
}
if about["buildTime"] != "2026-09-10 08:50:12 +08:00" {
t.Fatalf("buildTime 应原样返回注入值,得到 %#v", about["buildTime"])
}
if _, ok := about["internalSignature"]; !ok {
t.Fatalf("about 仍应带 internalSignature: %v", about)
}
// 未注入时字段仍在、值为空串,前端才能稳定判断「未注入」而不是解析失败。
version.BuildTime = ""
_, env2 = env.do(client, http.MethodGet, "/api/system/about", nil, false)
if err := json.Unmarshal(env2.Data, &about); err != nil {
t.Fatalf("解析 about: %v", err)
}
if got, ok := about["buildTime"]; !ok || got != "" {
t.Fatalf("未注入时 buildTime 应为空串,得到 %#v(存在=%v)", got, ok)
}
}
// GET /api/rules/switches:recorder 可读、guest 403,只返回布尔开关。
func TestRuleSwitchesReadableByRecorder(t *testing.T) {
env := newTestEnv(t)
+7
View File
@@ -6,3 +6,10 @@ package version
// 源码里必须永远保持为空字符串:真实署名只存在于构建机的 deploy/signature.local.txt
// (该文件已 gitignore),因此公开仓库里既没有值、也改不动它。
var InternalSignature = ""
// BuildTime 是构建时间,**由构建脚本用 -ldflags 注入**,编译期写进二进制。
//
// 源码里必须永远保持为空字符串:实际值由 deploy/build.bat 在构建时用
// -X .../internal/version.BuildTime=<时间戳> 填进来。没注入(例如直接 go build)时为空,
// 管理端「关于」页据此显示「未注入」,不编造时间。
var BuildTime = ""