feat: 人员唯一标识改为证件号 + 导入冲突逐条决策
需求:人员以证件号为准(避免重名);批量导入时若证件号已存在于本地人员数据, 先导入其余人员,再把重复的人单独列出,由用户逐条/多选决定保留已有数据还是新数据。 服务端: - 迁移 0013:athletes 增加 id_card + 部分唯一索引(空值可共存) - athleteDTO/CRUD 读写 idCard,重复返回 409 - 导入表头新增可选列 id_card/gender;有姓名的行必须给证件号 - 匹配顺序:证件号 → 姓名+单位(且库中证件号为空则回填)→ 新建人员 - 证件号命中但姓名/性别/单位不一致 → 冲突,本轮不落库(报名也不建) - 新增 POST /api/entries/import/resolve(file + decisions),keep-existing/keep-new - 10 个新测试覆盖匹配/回填/冲突/决策/唯一索引 前端: - 运动员表单与列表新增「证件号」,搜索支持证件号 - 报名导入:新模板 10 列;导入后冲突面板(每行复选框 + existing↔incoming 差异高亮 + 全选/清空 + 保留已有数据/使用新数据 批量与逐条),未决策项始终可见 - 勾选报名(EntryPicker)缺证件号的运动员禁止勾选并提示 - 新增 import-conflicts.test.ts 等,前端测试 39/39 文档:使用手册(运动员证件号、导入表头与冲突流程)、常见问题(证件号重复怎么办)。 注:报名行的 team_code 与人员绑定单位不一致会计入冲突,由用户决策,不会静默改单位。
This commit is contained in:
1 parent
c9d5b8ea89
commit
24fff8dd08
21 files changed
+1590
-67
No files matched your search
@@ -20,6 +20,7 @@ type athleteDTO struct {
|
||||
Gender *string `json:"gender"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
EmployeeNo *string `json:"employeeNo"`
|
||||
IDCard *string `json:"idCard"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package httpapi
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -526,17 +527,48 @@ func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) er
|
||||
// ---------- 报名表导入 ----------
|
||||
|
||||
type entryImportResult struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
Conflicts []importConflict `json:"conflicts"`
|
||||
}
|
||||
|
||||
// importConflict 是「证件号已存在、但姓名/性别/单位与库中不一致」的一行。
|
||||
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
|
||||
type importConflict struct {
|
||||
Row int `json:"row"`
|
||||
IDCard string `json:"idCard"`
|
||||
AthleteID int64 `json:"athleteId"`
|
||||
Existing importAthleteView `json:"existing"`
|
||||
Incoming importAthleteView `json:"incoming"`
|
||||
ChangedFields []string `json:"changedFields"`
|
||||
}
|
||||
|
||||
// importAthleteView 是冲突两端可比的字段快照(单位用编码,前端好展示)。
|
||||
type importAthleteView struct {
|
||||
Name string `json:"name"`
|
||||
Gender *string `json:"gender"`
|
||||
TeamCode string `json:"teamCode"`
|
||||
EmployeeNo *string `json:"employeeNo"`
|
||||
}
|
||||
|
||||
// importDecision 是冲突决策:keep-existing 保留库中人员字段,keep-new 用表里的字段覆盖。
|
||||
type importDecision struct {
|
||||
IDCard string `json:"idCard"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
const (
|
||||
decisionKeepExisting = "keep-existing"
|
||||
decisionKeepNew = "keep-new"
|
||||
)
|
||||
|
||||
// maxEntryImportBytes 是报名表上传的硬上限(16MB 文件 + multipart 开销)。
|
||||
const maxEntryImportBytes = 32 << 20
|
||||
|
||||
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
|
||||
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
// readImportForm 解析报名表上传(multipart file 字段),返回表格行。
|
||||
func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([][]string, error) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
return nil, badRequest("上传文件解析失败", err.Error())
|
||||
@@ -546,8 +578,12 @@ func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
return nil, badRequest("缺少 file 字段", err.Error())
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
return readTable(filepath.Ext(header.Filename), file)
|
||||
}
|
||||
|
||||
rows, err := readTable(filepath.Ext(header.Filename), file)
|
||||
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
|
||||
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
rows, err := s.readImportForm(w, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -559,6 +595,36 @@ func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveImportHandler 按用户对冲突的逐条决策重跑导入:
|
||||
// 已决策的冲突按决策落库,未决策的冲突继续留在 conflicts 里返回。
|
||||
func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
rows, err := s.readImportForm(w, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw := strings.TrimSpace(r.FormValue("decisions"))
|
||||
if raw == "" {
|
||||
return nil, badRequest("缺少 decisions 字段", nil)
|
||||
}
|
||||
var decisions []importDecision
|
||||
if err := json.Unmarshal([]byte(raw), &decisions); err != nil {
|
||||
return nil, badRequest("decisions 不是合法 JSON", err.Error())
|
||||
}
|
||||
byCard := 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
|
||||
}
|
||||
result, err := s.importEntriesWith(r.Context(), rows, byCard)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "entries.import.resolve", "entry", 0, nil, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".xlsx":
|
||||
@@ -588,13 +654,20 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// importColumns 是报名表必须包含的表头(大小写不敏感)。
|
||||
// importColumns 是报名表必须包含的表头(大小写不敏感);
|
||||
// id_card、gender 为可选列,缺列不报错,按空值处理。
|
||||
var importColumns = []string{
|
||||
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
|
||||
}
|
||||
|
||||
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
|
||||
result := entryImportResult{Errors: []string{}}
|
||||
return s.importEntriesWith(ctx, rows, nil)
|
||||
}
|
||||
|
||||
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
|
||||
// 非 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 {
|
||||
return result, badRequest("报名表为空", nil)
|
||||
}
|
||||
@@ -608,11 +681,13 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里证件号只能出现一次,否则无法判断是不是同一个人。
|
||||
seenIDCards := make(map[string]bool)
|
||||
for n, row := range rows[1:] {
|
||||
line := n + 2
|
||||
cell := func(name string) string {
|
||||
i := index[name]
|
||||
if i >= len(row) {
|
||||
i, ok := index[name]
|
||||
if !ok || i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[i])
|
||||
@@ -655,11 +730,43 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
|
||||
var athleteID *int64
|
||||
if athleteName != "" {
|
||||
if id, err := s.lookupID(ctx, `SELECT id FROM athletes WHERE name = ? AND team_id = ? LIMIT 1`, athleteName, teamID); err == nil {
|
||||
athleteID = &id
|
||||
idCard := cell("id_card")
|
||||
if idCard == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card", line))
|
||||
continue
|
||||
}
|
||||
if seenIDCards[idCard] {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard))
|
||||
continue
|
||||
}
|
||||
seenIDCards[idCard] = true
|
||||
|
||||
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
|
||||
Line: line,
|
||||
Name: athleteName,
|
||||
Gender: normalizeGender(cell("gender")),
|
||||
TeamID: teamID,
|
||||
TeamCode: teamCode,
|
||||
IDCard: idCard,
|
||||
}, decisions)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
|
||||
continue
|
||||
}
|
||||
if matched.Conflict != nil {
|
||||
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
||||
continue
|
||||
}
|
||||
athleteID = &matched.AthleteID
|
||||
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
|
||||
teamID = matched.TeamID
|
||||
}
|
||||
|
||||
created, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
@@ -685,6 +792,15 @@ func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImpor
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// normalizeGender 只认 男/女,其它值(含空)一律忽略。
|
||||
func normalizeGender(raw string) string {
|
||||
switch raw {
|
||||
case "男", "女":
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitNames(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -10,6 +14,65 @@ import (
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
)
|
||||
|
||||
// importHeader 与 ImportSection 模板 / 服务端 importColumns 保持一致。
|
||||
const importHeader = "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)
|
||||
@@ -85,3 +148,308 @@ func TestChangePasswordRejectsTooLong(t *testing.T) {
|
||||
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' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
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)
|
||||
}
|
||||
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 TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,,registered,\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(1) || result["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 id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 1 {
|
||||
t.Fatalf("重复证件号只应建 1 个人员,得到 %d", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 有 athlete_name 的行必须给 id_card。
|
||||
func TestImportRequiresIDCardWhenNameGiven(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,得到 %+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 是可选列:表头缺失不报错(团体行不涉及人员)。
|
||||
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' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,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' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,23 +287,33 @@ func (s *server) deleteTeam(ctx context.Context, id int64) error {
|
||||
|
||||
// ---------- athletes ----------
|
||||
|
||||
const athleteColumns = `id, name, gender, team_id, employee_no, active`
|
||||
const athleteColumns = `id, name, gender, team_id, employee_no, id_card, active`
|
||||
|
||||
func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) {
|
||||
var (
|
||||
a athleteDTO
|
||||
gender, empNo sql.NullString
|
||||
teamID sql.NullInt64
|
||||
a athleteDTO
|
||||
gender, empNo, idCard sql.NullString
|
||||
teamID sql.NullInt64
|
||||
)
|
||||
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &a.Active)
|
||||
err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &a.Active)
|
||||
a.Gender = strPtr(gender)
|
||||
a.EmployeeNo = strPtr(empNo)
|
||||
a.IDCard = strPtr(idCard)
|
||||
if teamID.Valid {
|
||||
a.TeamID = teamID.Int64
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
// isIDCardConflict 判断错误是否来自 athletes.id_card 的唯一索引。
|
||||
// 不能复用 isUniqueViolation:它把 CHECK 约束失败也算进去,会把性别非法误报成证件号重复。
|
||||
func isIDCardConflict(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "athletes.id_card")
|
||||
}
|
||||
|
||||
func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) {
|
||||
query := `SELECT ` + athleteColumns + ` FROM athletes`
|
||||
args := []any{}
|
||||
@@ -345,9 +355,22 @@ func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO,
|
||||
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("证件号已被其它人员使用")
|
||||
}
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`INSERT INTO athletes (name, gender, team_id, employee_no, active) VALUES (?, ?, ?, ?, ?)`,
|
||||
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active)
|
||||
`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) {
|
||||
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||||
}
|
||||
if isForeignKeyViolation(err) {
|
||||
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
||||
}
|
||||
@@ -362,9 +385,21 @@ func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO,
|
||||
}
|
||||
|
||||
func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, 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)
|
||||
}
|
||||
if dup {
|
||||
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||||
}
|
||||
}
|
||||
res, err := s.ev.ExecContext(ctx,
|
||||
`UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, active = ? WHERE id = ?`,
|
||||
in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active, id)
|
||||
`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) {
|
||||
return athleteDTO{}, conflict("证件号已被其它人员使用")
|
||||
}
|
||||
if isForeignKeyViolation(err) {
|
||||
return athleteDTO{}, badRequest("teamId 不存在", nil)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,171 @@ func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// athleteImportRow 是报名表里一行的人员信息(Gender 已归一为 男/女 或空)。
|
||||
type athleteImportRow struct {
|
||||
Line int
|
||||
Name string
|
||||
Gender string
|
||||
TeamID int64
|
||||
TeamCode string
|
||||
IDCard string
|
||||
}
|
||||
|
||||
// athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。
|
||||
type athleteImportMatch struct {
|
||||
AthleteID int64
|
||||
TeamID int64
|
||||
Conflict *importConflict
|
||||
}
|
||||
|
||||
// existingAthlete 是库中已有人员的可比字段。
|
||||
type existingAthlete struct {
|
||||
ID int64
|
||||
Name string
|
||||
Gender *string
|
||||
TeamID int64
|
||||
EmployeeNo *string
|
||||
}
|
||||
|
||||
// matchImportedAthlete 按契约顺序匹配人员:证件号 → 姓名+单位(库中证件号为空则回填)→ 新建。
|
||||
// 证件号命中但姓名/性别/单位与库中不一致时记为冲突;decisions 里对该证件号有决策时按决策落库。
|
||||
func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
|
||||
existing, err := s.athleteByIDCard(ctx, in.IDCard)
|
||||
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
|
||||
}
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return s.matchOrCreateAthleteByName(ctx, in)
|
||||
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)
|
||||
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
|
||||
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)
|
||||
if err != nil {
|
||||
return athleteImportMatch{}, internal(err)
|
||||
}
|
||||
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) {
|
||||
var (
|
||||
a existingAthlete
|
||||
gender, empNo sql.NullString
|
||||
teamID 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 {
|
||||
return existingAthlete{}, err
|
||||
}
|
||||
a.Gender = strPtr(gender)
|
||||
a.EmployeeNo = strPtr(empNo)
|
||||
a.TeamID = teamID.Int64
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// changedAthleteFields 列出表里与库中不一致的字段名,顺序固定便于前端展示。
|
||||
func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string {
|
||||
var changed []string
|
||||
if ex.Name != in.Name {
|
||||
changed = append(changed, "name")
|
||||
}
|
||||
// 性别只认 男/女:表里没填或填了其它值时不做比较,避免误报冲突。
|
||||
if in.Gender != "" && (ex.Gender == nil || *ex.Gender != in.Gender) {
|
||||
changed = append(changed, "gender")
|
||||
}
|
||||
if ex.TeamID != in.TeamID {
|
||||
changed = append(changed, "teamCode")
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
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 {
|
||||
return importConflict{}, err
|
||||
}
|
||||
return importConflict{
|
||||
Row: in.Line,
|
||||
IDCard: in.IDCard,
|
||||
AthleteID: ex.ID,
|
||||
Existing: importAthleteView{
|
||||
Name: ex.Name, Gender: ex.Gender, TeamCode: existingCode, EmployeeNo: ex.EmployeeNo,
|
||||
},
|
||||
Incoming: importAthleteView{
|
||||
Name: in.Name, Gender: nilIfEmpty(in.Gender), TeamCode: in.TeamCode,
|
||||
},
|
||||
ChangedFields: changed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) teamCodeByID(ctx context.Context, id int64) (string, error) {
|
||||
if id == 0 {
|
||||
return "", nil
|
||||
}
|
||||
var code string
|
||||
if err := s.ev.QueryRowContext(ctx, `SELECT code FROM teams WHERE id = ?`, id).Scan(&code); err != nil {
|
||||
return "", internal(err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return internal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回 true 表示新建。
|
||||
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, error) {
|
||||
var existing int64
|
||||
|
||||
@@ -85,3 +85,58 @@ func TestEventWritesWriteChangeLog(t *testing.T) {
|
||||
t.Fatalf("删项目应写 1 条 events void 变更,得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 证件号是人员的唯一标识:可读写、重复 409、空值不互斥(部分唯一索引)。
|
||||
func TestAthleteIDCardUnique(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, "idCard": "110101199001011234", "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["idCard"] != "110101199001011234" {
|
||||
t.Fatalf("建人员应返回 idCard: %+v", created)
|
||||
}
|
||||
athleteID := int64(created["id"].(float64))
|
||||
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
||||
"name": "李四", "gender": "男", "teamId": ids.teamA, "idCard": "110101199001011234", "active": true,
|
||||
}, true)
|
||||
if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" {
|
||||
t.Fatalf("重复证件号应 409 CONFLICT,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
|
||||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", ids.athB), map[string]any{
|
||||
"name": "李强", "gender": "男", "teamId": ids.teamB, "idCard": "110101199001011234", "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, id_card, active) VALUES ('甲', '男', ?, '', 1)`, ids.teamA)
|
||||
env.exec(t, `INSERT INTO athletes (name, gender, team_id, id_card, active) VALUES ('乙', '男', ?, '', 1)`, ids.teamA)
|
||||
if _, err := env.ev.Exec(
|
||||
`INSERT INTO athletes (name, team_id, id_card, active) VALUES ('丙', ?, '110101199001011234', 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["idCard"] != "110101199001011234" {
|
||||
t.Fatalf("读人员应带 idCard,得到 %d %+v", status, got)
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,7 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("DELETE /api/athletes/{id}", s.writeVoid(auth.RoleRecorder, s.deleteAthleteHandler))
|
||||
|
||||
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
|
||||
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))
|
||||
|
||||
// 成绩
|
||||
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
|
||||
|
||||
Reference in new issue
Block a user