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:
AskaEth committed 2026-09-10 02:09:15 +08:00
1 parent c9d5b8ea89
commit 24fff8dd08
21 files changed
+1590 -67

No files matched your search

+1
View File
@@ -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"`
}
+129 -13
View File
@@ -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)
}
}
}
+44 -9
View File
@@ -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)
}
+165
View File
@@ -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
+55
View File
@@ -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)
}
}
+1
View File
@@ -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))