From 24fff8dd08a237f35e91641ca36a5ceee9ea699b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 10 Sep 2026 02:09:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=BA=E5=91=98=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E6=A0=87=E8=AF=86=E6=94=B9=E4=B8=BA=E8=AF=81=E4=BB=B6=E5=8F=B7?= =?UTF-8?q?=20+=20=E5=AF=BC=E5=85=A5=E5=86=B2=E7=AA=81=E9=80=90=E6=9D=A1?= =?UTF-8?q?=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:人员以证件号为准(避免重名);批量导入时若证件号已存在于本地人员数据, 先导入其余人员,再把重复的人单独列出,由用户逐条/多选决定保留已有数据还是新数据。 服务端: - 迁移 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 与人员绑定单位不一致会计入冲突,由用户决策,不会静默改单位。 --- docs/使用手册.md | 27 +- docs/常见问题.md | 14 +- server/internal/httpapi/dto.go | 1 + server/internal/httpapi/handlers_config.go | 142 ++++++- .../internal/httpapi/handlers_config_test.go | 368 ++++++++++++++++++ server/internal/httpapi/repo.go | 53 ++- server/internal/httpapi/repo_import.go | 165 ++++++++ server/internal/httpapi/repo_test.go | 55 +++ server/internal/httpapi/server.go | 1 + .../event/0013_athletes_id_card.sql | 6 + .../components/baseData/AthletesSection.vue | 18 +- .../admin/components/baseData/EntryPicker.vue | 11 +- .../components/baseData/ImportSection.vue | 282 +++++++++++++- .../admin/components/baseData/baseDataApi.ts | 6 + .../components/baseData/entrySelection.ts | 128 +++++- .../admin/components/baseData/mockBaseData.ts | 114 +++++- .../admin/components/baseData/useBaseData.ts | 7 + web/src/admin/tests/entry-selection.test.ts | 38 +- web/src/admin/tests/import-conflicts.test.ts | 179 +++++++++ web/src/shared/api.ts | 9 + web/src/shared/types.ts | 33 ++ 21 files changed, 1590 insertions(+), 67 deletions(-) create mode 100644 server/migrations/event/0013_athletes_id_card.sql create mode 100644 web/src/admin/tests/import-conflicts.test.ts diff --git a/docs/使用手册.md b/docs/使用手册.md index 648b23e..b272674 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -263,9 +263,10 @@ ### 10.6 运动员 -1. 点「新增运动员」,填写姓名 *、工号、所属单位等; -2. 「编辑」修改,「停用」后不再出现在新报名选择中; -3. 个人项目的报名项会关联到运动员,团体项目可以留空。 +1. 点「新增运动员」,填写姓名 *、证件号、性别、工号 / 学号、所属单位等; +2. **证件号是人员的唯一标识**(如身份证号),用于区分重名;同一个证件号不允许重复,重复会被拒绝。已有历史数据可以留空; +3. 「编辑」修改,「停用」后不再出现在新报名选择中; +4. 个人项目的报名项会关联到运动员,团体项目可以留空。 ### 10.7 报名导入 @@ -282,9 +283,23 @@ **表格导入** 1. 点「下载 CSV 模板」获取表头样例; -2. 表头必须包含:`event_code`、`category`、`team_code`、`athlete_name`、`bib`、`seed`、`status`、`member_names`(大小写不敏感); -3. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表); -4. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。 +2. 表头共 10 列:`event_code`、`category`、`team_code`、`athlete_name`、`id_card`、`gender`、`bib`、`seed`、`status`、`member_names`(大小写不敏感)。其中 `id_card`(证件号)与 `gender`(性别)为可选列,缺失不影响导入,其余列必须包含; +3. **只要有姓名的行必须填 `id_card`**,否则该行报错并跳过;`gender` 只认「男」「女」,其他值忽略;同一份文件里同一个证件号重复出现时,重复的行会报错; +4. 人员匹配顺序:先按证件号匹配已有人员;没匹配到,再按「姓名 + 单位」匹配且该人员证件号为空,视为同一人并回填证件号(不算冲突);都没匹配到则新建人员; +5. 把 `.csv` 或 `.xlsx` 拖入虚线框,或点击选择文件(单个文件不超过 16MB;xlsx 只解析第一个工作表); +6. 点「开始导入」,页面显示**新增 / 更新 / 跳过**数量;匹配不到的行会跳过并回显行号,修正后可重新上传。 + +**证件号冲突的处理** + +导入时如果某个证件号在系统里已经存在,但表里的姓名、性别或单位与已有人员不一致,这个人会被列为**冲突**,本次不写入(该人的报名也不会创建)。处理办法: + +1. 导入结果里会单独列出冲突人员,每条显示已有数据、新数据,以及不一致的字段; +2. 用左侧复选框选中一条或多条(也可以全选),再点「保留已有数据」或「使用新数据」: + - **保留已有数据**:人员字段沿用系统里已有的,按这个人的信息建 / 更新报名; + - **使用新数据**:用表里的姓名、性别、单位等更新该人员,再建 / 更新报名; +3. **没有做决定的冲突不会被写入**,可以稍后再处理;全部处理完后冲突列表清空。 + +> 「从已有名单勾选」走的是同一个导入通道,人员匹配与冲突处理规则完全一致。 号码布、种子等字段无法在勾选界面填写,仍需通过表格导入。 diff --git a/docs/常见问题.md b/docs/常见问题.md index 888a4fb..9fb4ffe 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -118,20 +118,26 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 **Q32:导入规则包后积分变了,能撤销吗?** 不能一键撤销。纠错需要重新导入旧规则包,或逐项修正后重新发布。导入前请务必先「预览差异」。 +**Q33:导入时提示证件号重复怎么办?** +证件号是人员的唯一标识,系统不允许两个人员共用同一个证件号,用来区分重名。 + +- **在「运动员」里新增 / 编辑时提示重复**:说明该证件号已属于另一个人员。请先核对是不是同一个人:是,就直接用已有记录;不是,请检查证件号是否填错。已有历史数据可以没有证件号。 +- **表格导入时出现重复**:分两种情况。同一份文件里同一个证件号重复出现,重复的行会报错并跳过,改好后重新上传;证件号在系统中已存在、但姓名、性别或单位不一致的,会列为「冲突」,本次不写入(报名也不会创建),需要在导入结果里勾选后逐条或多选决定「保留已有数据」还是「使用新数据」,没有做决定的人不会被写入。详细步骤见 [使用手册 · 报名导入](./使用手册.md#107-报名导入)。 + --- ## 其他 -**Q33:系统需要联网吗?** +**Q34:系统需要联网吗?** 不需要。完全离线运行,不依赖外网,也不需要在现场安装任何运行时。 -**Q34:页面提示「示例数据」是什么意思?** +**Q35:页面提示「示例数据」是什么意思?** 表示后端未就绪,此时所有改动只停留在界面,不会写入真实赛事。恢复连接后刷新即可。 > 注意区分:顶部若只是显示「连接中断」,说明实时推送断了(数据可能不是最新),但写操作仍会照常提交给服务端并做校验。 -**Q35:为什么登录 / 写操作偶尔被拒绝?** +**Q36:为什么登录 / 写操作偶尔被拒绝?** 系统有频率限制:登录每 IP 每分钟 5 次,写操作每用户每分钟 30 次。稍等一分钟再试。 -**Q36:数据目录在哪?** +**Q37:数据目录在哪?** 由 `config.yaml` 的 `dataDir` 指定,默认 `data`,相对服务端工作目录解析。 diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index 34b2d7a..1e17b38 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -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"` } diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index 5e1f685..983c111 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -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 diff --git a/server/internal/httpapi/handlers_config_test.go b/server/internal/httpapi/handlers_config_test.go index 4070e90..972296e 100644 --- a/server/internal/httpapi/handlers_config_test.go +++ b/server/internal/httpapi/handlers_config_test.go @@ -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) + } + } +} diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go index 0f99303..a1ec6af 100644 --- a/server/internal/httpapi/repo.go +++ b/server/internal/httpapi/repo.go @@ -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) } diff --git a/server/internal/httpapi/repo_import.go b/server/internal/httpapi/repo_import.go index d481864..b22a5ad 100644 --- a/server/internal/httpapi/repo_import.go +++ b/server/internal/httpapi/repo_import.go @@ -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 diff --git a/server/internal/httpapi/repo_test.go b/server/internal/httpapi/repo_test.go index 019dd31..95e75fc 100644 --- a/server/internal/httpapi/repo_test.go +++ b/server/internal/httpapi/repo_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 766e7f6..8a9ddb5 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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)) diff --git a/server/migrations/event/0013_athletes_id_card.sql b/server/migrations/event/0013_athletes_id_card.sql new file mode 100644 index 0000000..db400d5 --- /dev/null +++ b/server/migrations/event/0013_athletes_id_card.sql @@ -0,0 +1,6 @@ +-- 证件号是人员的唯一标识(重名靠它区分)。历史数据没有证件号,允许为空; +-- 用部分唯一索引只约束「填了证件号」的行,空值可以有多条。 +ALTER TABLE athletes ADD COLUMN id_card TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_athletes_id_card + ON athletes(id_card) WHERE id_card IS NOT NULL AND id_card <> ''; diff --git a/web/src/admin/components/baseData/AthletesSection.vue b/web/src/admin/components/baseData/AthletesSection.vue index 3a84d02..70db797 100644 --- a/web/src/admin/components/baseData/AthletesSection.vue +++ b/web/src/admin/components/baseData/AthletesSection.vue @@ -16,6 +16,7 @@ interface AthleteForm { gender: '' | Gender teamId: string employeeNo: string + idCard: string active: boolean } @@ -37,7 +38,7 @@ const confirmTarget = ref(null) const confirmPending = ref(false) function emptyForm(): AthleteForm { - return { name: '', gender: '', teamId: '', employeeNo: '', active: true } + return { name: '', gender: '', teamId: '', employeeNo: '', idCard: '', active: true } } const filtered = computed(() => { @@ -51,6 +52,7 @@ const filtered = computed(() => { return ( athlete.name.toLowerCase().includes(keyword) || (athlete.employeeNo ?? '').toLowerCase().includes(keyword) || + (athlete.idCard ?? '').toLowerCase().includes(keyword) || (team?.name ?? '').toLowerCase().includes(keyword) ) }) @@ -74,6 +76,7 @@ function openEdit(athlete: Athlete): void { gender: athlete.gender ?? '', teamId: String(athlete.teamId), employeeNo: athlete.employeeNo ?? '', + idCard: athlete.idCard ?? '', active: athlete.active, } errors.value = {} @@ -100,6 +103,7 @@ async function submit(): Promise { gender: form.value.gender === '' ? null : form.value.gender, teamId: Number(form.value.teamId), employeeNo: form.value.employeeNo.trim() === '' ? null : form.value.employeeNo.trim(), + idCard: form.value.idCard.trim() === '' ? null : form.value.idCard.trim(), active: form.value.active, } try { @@ -131,6 +135,7 @@ async function toggleActive(athlete: Athlete): Promise { gender: athlete.gender, teamId: athlete.teamId, employeeNo: athlete.employeeNo, + idCard: athlete.idCard, active: !athlete.active, }, athlete.id, @@ -157,7 +162,7 @@ async function toggleActive(athlete: Athlete): Promise {
+ + + diff --git a/web/src/admin/components/baseData/EntryPicker.vue b/web/src/admin/components/baseData/EntryPicker.vue index 44ff65b..ea0ecb6 100644 --- a/web/src/admin/components/baseData/EntryPicker.vue +++ b/web/src/admin/components/baseData/EntryPicker.vue @@ -83,6 +83,11 @@ function isRegistered(athlete: Athlete): boolean { return mode.value === 'individual' && registeredAthleteIds.value.has(athlete.id) } +/** 个人项目的行必须带证件号;团体行运动员为空,不受影响。 */ +function missingIdCard(athlete: Athlete): boolean { + return mode.value === 'individual' && (athlete.idCard === null || athlete.idCard === '') +} + async function loadEntries(id: number): Promise { entriesLoading.value = true try { @@ -133,7 +138,7 @@ async function submit(): Promise { categoryName: category.name, teamCode: team.code, mode: mode.value, - athletes: chosen.map((athlete) => ({ name: athlete.name })), + athletes: chosen.map((athlete) => ({ name: athlete.name, idCard: athlete.idCard })), }) const file = new File([toCsv(rows)], 'selection.csv', { type: 'text/csv' }) const result = await data.importEntries(file) @@ -222,11 +227,12 @@ async function submit(): Promise { v-model="checked" type="checkbox" :value="athlete.id" - :disabled="!data.canWrite.value || isRegistered(athlete)" + :disabled="!data.canWrite.value || isRegistered(athlete) || missingIdCard(athlete)" /> {{ athlete.name }} {{ athlete.employeeNo ?? '—' }} 已报名 + 缺证件号
{{ teamAthletes.length === 0 ? '该单位没有启用中的运动员' : '没有匹配的运动员' }} @@ -238,6 +244,7 @@ async function submit(): Promise {

号码布、种子等字段仍通过下方报名表导入填写。

+

证件号缺失的运动员不能报名个人项目,请先在「运动员」分区补全证件号。

{{ error }}
diff --git a/web/src/admin/components/baseData/ImportSection.vue b/web/src/admin/components/baseData/ImportSection.vue index e5b2ce7..f8dc55f 100644 --- a/web/src/admin/components/baseData/ImportSection.vue +++ b/web/src/admin/components/baseData/ImportSection.vue @@ -1,19 +1,27 @@