feat(import): 报名导入支持中文表头,并给出中/英两套模板下载

需求:主人要「CSV 模板的表头能改一下,或者提供两个按钮,一个中文表头一个英文表头」。

- 解析器加**中文别名**(英文仍是规范名:取值、报错、纯英文模板、勾选导出都用它)。
  12 列,顺序即模板列序:项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 /
  号码布 / 种子 / 批次 / 状态 / 队员名单;另认一批同义写法(小项编码、参赛单位编码、
  运动员姓名、身份证号、道次、批次号、报名状态…)。
  Go 别名表 == 前端 COLUMN_ALIASES == 导入规范文档表格,三方逐列逐名一致(脚本核对)。
- **向后兼容**:纯英文表头一字不改照常导入;回归用**字面量**(不复用常量)钉死老 11 列表头
  与大写/空格变体,避免有人改常量把回归一起改掉。
- **中英可任意混排**,但**同一列出现两种写法(或写两遍)整份拦下**并说明——两列都可能带数据,
  静默取其一等于丢另一列。容错沿用既有先例:BOM(含格子中间的 U+FEFF)、全角空格/全角 ASCII、
  首尾空格、大小写。
- 缺列报错**中英并列**(「缺少列 athlete_name(姓名)」),现场拿中文模板的人一眼看懂。
- 前端两个下载按钮:「下载模板(中文表头)」「下载模板(英文表头)」,列序与列数完全一致,
  文件名可区分;示例数据模式(mock)同步,避免两条路径产出不同模板。
- 文档 `docs/报名名单导入规范.md` 补两套表头与「两套都能导入」的说明(原内容未删)。

端到端(真服务端 + curl):中文模板填两行 → {"created":2} 且库中值取自中文列;
英文模板同一份数据 → {"updated":2} 不重复建档;缺列与表头重复分别给出上述人话报错。
自检:gofmt / go build / go vet / go test 全绿;vue-tsc + 前端 754 例全过。
This commit is contained in:
AskaEth committed 2026-09-13 21:55:37 +08:00
1 parent b1c6d5d54a
commit 9951a69938
8 files changed
+713 -53

No files matched your search

@@ -2527,3 +2527,206 @@ func TestAthleteActivePresenceDefaults(t *testing.T) {
t.Fatalf("显式 active=false 应把人停用,库里得到 %d", storedActive)
}
}
// ── 表头别名:中文表头也要能导入 ──────────────────────────────────────────────
//
// 现场手里同时有两种表:老的纯英文模板、以及新下载的中文模板。英文是规范名
// (取值、报错、纯英文模板都用它),中文只是别名;别名只加不改,纯英文的老表一字不改照常导入。
// importHeaderZH 是中文表头,与 importHeader 逐列对应(同样是 11 列,heat 列同样缺席)。
const importHeaderZH = "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,状态,队员名单"
// importHeaderZHHeat 是带批次列的中文表头,与 importHeaderHeat 逐列对应。
const importHeaderZHHeat = "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,批次,状态,队员名单"
// importHeaderEnglishLegacy 是升级前就在用的纯英文表头,**逐字钉在这里**(故意不复用 importHeader 常量):
// 别名解析只要碰坏了任何一个英文列名,这条立刻红。
const importHeaderEnglishLegacy = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,status,member_names"
// importEntryFields 读一条报名项的号码布 / 种子 / 批次 / 状态,用来验证值确实取自表里对应的那一列。
func importEntryFields(t *testing.T, env *testEnv, idCard string, eventID int64) (string, string, sql.NullInt64, string) {
t.Helper()
var (
bib, seed, status string
heat sql.NullInt64
)
if err := env.ev.QueryRow(
`SELECT COALESCE(bib, ''), COALESCE(seed, ''), heat, status FROM entries
WHERE event_id = ? AND athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
eventID, idCard).Scan(&bib, &seed, &heat, &status); err != nil {
t.Fatalf("读报名项(证件号 %s): %v", idCard, err)
}
return bib, seed, heat, status
}
// 纯英文表头是既有现场文件的口径:别名功能上线后必须一字不改地照常导入。
func TestImportKeepsEnglishHeaderUnchanged(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeaderEnglishLegacy + "\nM100,男子,A,张三,110101199001011234,男,26,101,种子1,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
bib, seed, heat, entryStatus := importEntryFields(t, env, "110101199001011234", ids.event)
if bib != "101" || seed != "种子1" || heat.Valid || entryStatus != "registered" {
t.Fatalf("英文表头各列应原样落库,得到 bib=%q seed=%q heat=%+v status=%q", bib, seed, heat, entryStatus)
}
// 大小写不敏感、首尾空格容错也是既有行为:表头识别的改动不能把它弄丢。
t.Run("大写与首尾空格", func(t *testing.T) {
env, _, client := recorderWithSeed(t)
header := " Event_Code ,CATEGORY, team_code ,ATHLETE_NAME,ID_CARD,GENDER,AGE,BIB,SEED,STATUS,MEMBER_NAMES"
body := header + "\nM100,男子,A,张三,110101199001011234,男,26,101,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
})
}
// 中文表头:列名认得出还不够,每一列的值都要真的从中文那一列取。
func TestImportAcceptsChineseHeader(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeaderZH + "\nM100,男子,A,张三,110101199001011234,男,26,101,种子1,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
bib, seed, _, entryStatus := importEntryFields(t, env, "110101199001011234", ids.event)
if bib != "101" || seed != "种子1" || entryStatus != "registered" {
t.Fatalf("中文列的值应落进对应字段,得到 bib=%q seed=%q status=%q", bib, seed, entryStatus)
}
var age sql.NullInt64
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&age); err != nil {
t.Fatalf("读人员年龄: %v", err)
}
if !age.Valid || age.Int64 != 26 {
t.Fatalf("「年龄」列应写进人员档案,得到 %+v", age)
}
}
// 批次列(heat)的中文写法:别名映射必须与英文列同等对待,值也要落进 entries.heat。
func TestImportAcceptsChineseHeatColumn(t *testing.T) {
env, ids, client := recorderWithSeed(t)
body := importHeaderZHHeat + "\nM100,男子,A,张三,110101199001011234,男,26,101,,2,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
_, _, heat, _ := importEntryFields(t, env, "110101199001011234", ids.event)
if !heat.Valid || heat.Int64 != 2 {
t.Fatalf("「批次」列应写进 entries.heat=2,得到 %+v", heat)
}
}
// 中英混用:每一列各自认名字,列与列之间互不影响(现场常拿英文模板补几列中文)。
func TestImportAcceptsMixedLanguageHeader(t *testing.T) {
env, _, client := recorderWithSeed(t)
header := "event_code,category,team_code,姓名,证件号,性别,年龄,号码布,种子,状态,队员名单"
body := header + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
}
// 同一列在一份表里出现两种写法(中英各来一次,或同一格重复两次)一律拦下:
// 两列都可能有数据,静默取其中一列等于丢掉另一列的内容,报错让人删掉重复的那一列更安全。
func TestImportRejectsSameColumnTwice(t *testing.T) {
for _, tc := range []struct {
name string
header string
wantIn []string
}{
{"中英各一次", "event_code,项目编码,category,team_code,athlete_name,bib,seed,status,member_names",
[]string{"表头重复", "event_code", "项目编码"}},
{"同一写法两次", "event_code,category,team_code,athlete_name,event_code,bib,seed,status,member_names",
[]string{"表头重复", "event_code"}},
{"中文别名重复", "项目编码,小项编码,组别,单位编码,姓名,号码布,种子,状态,队员名单",
[]string{"表头重复", "项目编码", "小项编码"}},
} {
t.Run(tc.name, func(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := tc.header + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusBadRequest || env2.OK {
t.Fatalf("同一列写两遍应回 400,得到 %d %+v", status, env2.Error)
}
for _, want := range tc.wantIn {
if !strings.Contains(env2.Error.Message, want) {
t.Fatalf("message 应包含 %q,得到 %q", want, env2.Error.Message)
}
}
if !strings.Contains(env2.Error.Message, "第 1 行") {
t.Fatalf("message 应点到表头行,得到 %q", env2.Error.Message)
}
})
}
}
// 缺列提示中英并列:手拿中文模板的人看到「缺少列 athlete_name(姓名)」才知道自己缺的是哪一格。
func TestImportMissingColumnMessageIsBilingual(t *testing.T) {
for _, tc := range []struct {
name string
header string
want string
}{
{"中文表头缺姓名", "项目编码,组别,单位编码,证件号,性别,年龄,号码布,种子,状态,队员名单", "缺少列 athlete_name(姓名)"},
{"中文表头缺队员名单", "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,状态", "缺少列 member_names(队员名单)"},
{"英文表头缺小项编码", "category,team_code,athlete_name,bib,seed,status,member_names", "缺少列 event_code(项目编码)"},
} {
t.Run(tc.name, func(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := tc.header + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusBadRequest || env2.OK {
t.Fatalf("缺列应回 400,得到 %d %+v", status, env2.Error)
}
msg := env2.Error.Message
if !strings.Contains(msg, tc.want) {
t.Fatalf("message 应中英并列,期望包含 %q,得到 %q", tc.want, msg)
}
// 提示还要教人往哪看:别的名字(中文模板上没见过的写法)只是其中一种可能。
if !strings.Contains(msg, "第 1 行") || !strings.Contains(msg, "表头") {
t.Fatalf("message 应点到表头行,得到 %q", msg)
}
if !strings.Contains(msg, "中文") || !strings.Contains(msg, "英文") {
t.Fatalf("message 应说明中英表头都认,得到 %q", msg)
}
})
}
}
// 中文表头 + 各种编码:BOM 粘在中文列名上("\ufeff项目编码")是最容易翻车的组合,
// 编码处理(decodeTableText)与表头归一(normalizeImportHeader)两层都要兜住。
func TestImportAcceptsChineseHeaderInEveryEncoding(t *testing.T) {
plain := importHeaderZH + "\nM100,男子,A,张三,110101199001011234,男,26,101,,registered,\n"
for _, tc := range []struct {
name string
body string
}{
{"utf8_bom", "\uFEFF" + plain},
{"gb18030", encodeGB18030(t, plain)},
{"utf16le_bom", encodeUTF16LE(t, plain)},
} {
t.Run(tc.name, func(t *testing.T) {
env, _, client := recorderWithSeed(t)
status, env2 := env.postImport(t, client, "/api/entries/import", tc.body, "")
assertImportedAsZhangSan(t, env, status, env2)
})
}
}
// 表头格自身的容错:中文输入法打出的全角字母、全角空格、格子中间混进的 BOM
// 都不能退化成「缺少列 xxx」——那是升级前就踩过的坑。
func TestImportToleratesFullWidthHeaderCells(t *testing.T) {
env, _, client := recorderWithSeed(t)
header := "EVENT_CODE,\u3000category ,team_code,\uFEFF姓名 ,证件号,性别,年龄,号码布,种子,状态,队员名单"
body := header + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
assertImportedAsZhangSan(t, env, status, env2)
}