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:
1 parent
b1c6d5d54a
commit
9951a69938
8 files changed
+713
-53
No files matched your search
@@ -988,10 +988,118 @@ func decodeUTF16Table(data []byte, order textunicode.Endianness, missingBOM bool
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// importColumns 是报名表必须包含的表头(大小写不敏感);
|
||||
// id_card、gender、age 为可选列,缺列不报错,按空值处理。
|
||||
var importColumns = []string{
|
||||
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
|
||||
// importColumn 是一列表头的全部可接受写法。
|
||||
//
|
||||
// name 是规范名(英文):取值、报错、纯英文模板都用它;aliases 是中文别名,供现场用中文表头填表。
|
||||
// 一列同时认英文名与中文名,纯英文的老文件一字不改照常导入(别名只加不改)。
|
||||
type importColumn struct {
|
||||
name string
|
||||
aliases []string
|
||||
optional bool
|
||||
}
|
||||
|
||||
// importColumns 是报名表的全部列,顺序即模板的列顺序(与前端 REQUIRED_COLUMNS、
|
||||
// docs/报名名单导入规范.md 第二节的表格逐列一致)。
|
||||
//
|
||||
// 中文别名一律取导入规范文档里出现过的措辞;改这里必须同步改
|
||||
// web/src/admin/components/baseData/entrySelection.ts 的 COLUMN_ALIASES,
|
||||
// 否则会出现「示例数据模式能导入、连上服务端却报缺列」。
|
||||
var importColumns = []importColumn{
|
||||
{name: "event_code", aliases: []string{"项目编码", "小项编码"}},
|
||||
{name: "category", aliases: []string{"组别", "组别名称"}},
|
||||
{name: "team_code", aliases: []string{"单位编码", "参赛单位编码"}},
|
||||
{name: "athlete_name", aliases: []string{"姓名", "运动员姓名"}},
|
||||
// id_card、gender、age、heat 是可缺省的列:缺列不报错,按空值处理。
|
||||
{name: "id_card", aliases: []string{"证件号", "身份证号"}, optional: true},
|
||||
{name: "gender", aliases: []string{"性别"}, optional: true},
|
||||
{name: "age", aliases: []string{"年龄"}, optional: true},
|
||||
{name: "bib", aliases: []string{"号码布"}},
|
||||
{name: "seed", aliases: []string{"种子", "道次"}},
|
||||
{name: "heat", aliases: []string{"批次", "批次号"}, optional: true},
|
||||
{name: "status", aliases: []string{"状态", "报名状态"}},
|
||||
{name: "member_names", aliases: []string{"队员名单"}},
|
||||
}
|
||||
|
||||
// label 是报错时用的列名:英文规范名 + 首选中文别名。缺列提示中英并列,
|
||||
// 手拿中文模板的人看到「缺少列 athlete_name(姓名)」能立刻对上自己表头那一格。
|
||||
func (c importColumn) label() string {
|
||||
if len(c.aliases) == 0 {
|
||||
return c.name
|
||||
}
|
||||
return c.name + "(" + c.aliases[0] + ")"
|
||||
}
|
||||
|
||||
// normalizeImportHeader 把一格表头归一成可比对的写法:去首尾空白(含全角空格)、
|
||||
// 全角 ASCII / 全角空格转半角、U+FEFF 丢弃、转小写。
|
||||
//
|
||||
// 现场的表多半是中文 Excel 存出来的:列名可能带全角空格、被输入法写成全角字母,
|
||||
// 或者粘贴时把字节序标记 U+FEFF 带进了第一格。这些都不该变成「缺少列 xxx」——
|
||||
// 已有的编码处理(decodeTableText)只摘掉文件开头的 BOM,格子内部的还得在这里兜住。
|
||||
func normalizeImportHeader(raw string) string {
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(raw))
|
||||
for _, r := range raw {
|
||||
switch {
|
||||
case r == '\uFEFF':
|
||||
// BOM 粘在表头上正是「缺少列 event_code」的经典成因,直接丢掉。
|
||||
case r == '\u3000':
|
||||
builder.WriteRune(' ')
|
||||
case r >= '\uFF01' && r <= '\uFF5E':
|
||||
// 全角 ASCII 与半角一一对应,码点相差 0xFEE0。
|
||||
builder.WriteRune(r - 0xFEE0)
|
||||
default:
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(builder.String()))
|
||||
}
|
||||
|
||||
// lookupImportColumn 认一格表头,返回它的规范列名。
|
||||
// 英文名与中文别名同等对待,认不出的(多余列、备注列)返回 false 由调用方忽略。
|
||||
func lookupImportColumn(raw string) (string, bool) {
|
||||
header := normalizeImportHeader(raw)
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, column := range importColumns {
|
||||
if header == column.name {
|
||||
return column.name, true
|
||||
}
|
||||
for _, alias := range column.aliases {
|
||||
if header == alias {
|
||||
return column.name, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// importColumnIndex 把表头行转成「规范列名 → 下标」。
|
||||
//
|
||||
// 中英混用是允许的:每一列各自按名字认,互不影响(现场把一份中文表的一部分列换成英文名,
|
||||
// 或者拿英文模板补上几列中文,都还导得动)。
|
||||
//
|
||||
// 但**同一列在一份表里出现两种写法**(既有 event_code 又有 项目编码,或同一格重复出现两次)
|
||||
// 一律拦下:两列都可能有数据,系统无从判断该信哪一列,静默取其中一列等于把另一列的数据丢了。
|
||||
// 报错点名这一列与两种写法,让人删掉多余的那一列——这比猜一个更安全。
|
||||
func importColumnIndex(header []string) (map[string]int, error) {
|
||||
index := make(map[string]int, len(importColumns))
|
||||
spelled := make(map[string]string, len(importColumns))
|
||||
for position, raw := range header {
|
||||
name, ok := lookupImportColumn(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cell := strings.TrimSpace(raw)
|
||||
if prev, duplicate := spelled[name]; duplicate {
|
||||
return nil, badRequest(fmt.Sprintf(
|
||||
"报名表表头重复:第 1 行里同一列出现了两种写法(%s、%s),请只保留一列再导入(列名中英文都可以,但不能一列写两遍)",
|
||||
prev, cell), nil)
|
||||
}
|
||||
spelled[name] = cell
|
||||
index[name] = position
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
|
||||
@@ -1007,15 +1115,20 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
if len(rows) < 2 {
|
||||
return result, badRequest("报名表为空", nil)
|
||||
}
|
||||
index := make(map[string]int, len(rows[0]))
|
||||
for i, h := range rows[0] {
|
||||
index[strings.ToLower(strings.TrimSpace(h))] = i
|
||||
index, err := importColumnIndex(rows[0])
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, col := range importColumns {
|
||||
if _, ok := index[col]; !ok {
|
||||
for _, column := range importColumns {
|
||||
if column.optional {
|
||||
continue
|
||||
}
|
||||
if _, ok := index[column.name]; !ok {
|
||||
// 表头类报错必须点到「表头行」本身:用户常在表头上方加一行标题,
|
||||
// 只说「缺少列 X」会让他去翻数据行,找不到问题在哪。
|
||||
return result, badRequest("报名表缺少列 "+col+":第 1 行应是表头(列名与导入模板一致),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil)
|
||||
// 列名中英并列:手上是中文模板的人一眼对得上,不会以为系统只认英文。
|
||||
return result, badRequest("报名表缺少列 "+column.label()+
|
||||
":第 1 行应是表头(列名与导入模板一致,中文与英文表头都认),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user