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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user