feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)

用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
This commit is contained in:
AskaEth committed 2026-09-11 17:14:02 +08:00
1 parent 8dd599bd97
commit 631c8dd8e1
80 files changed
+12468 -519

No files matched your search

+236 -40
View File
@@ -231,12 +231,12 @@ func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
}
}
// 证件号命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
// 号码布命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,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)
@@ -285,8 +285,8 @@ func TestImportCollectsConflictWithoutWriting(t *testing.T) {
}
}
// 同一文件里证件号重复出现 → 该行报错并跳过。
func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
// 同一文件里证件号重复出现不再报错:证件号是可选档案信息,不参与匹配也不拦截。
func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeader + "\n" +
@@ -297,12 +297,14 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
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)
if result["skipped"] != float64(0) {
t.Fatalf("同表重复证件号不应再拦截: %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 1 || !strings.Contains(errs[0].(string), "重复") {
t.Fatalf("应报证件号重复,得到 %+v", errs)
if errs, _ := result["errors"].([]any); len(errs) != 0 {
t.Fatalf("不应有行级报错,得到 %+v", errs)
}
if result["created"] != float64(1) || result["updated"] != float64(1) {
t.Fatalf("首行新建、次行按姓名+单位命中同一人并更新报名: %+v", result)
}
var athletes int
@@ -310,7 +312,118 @@ func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
t.Fatalf("统计人员: %v", err)
}
if athletes != 1 {
t.Fatalf("重复证件号只应建 1 个人员,得到 %d", athletes)
t.Fatalf("同一个人只应建 1 个人员,得到 %d", athletes)
}
}
// 新人带的证件号已被库里另一个人占用 → 明确的中文行级错误,不静默改库、不静默跳过。
func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
// 另一个人(李四 / 号码布 B200)拿着已被张伟占用的证件号来建档。
body := importHeader + "\nM100,男子,B,李四,110101199001011234,男,,B200,,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 {
t.Fatalf("应报 1 条行级错误,得到 %+v", errs)
}
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
if errs[0] != want {
t.Fatalf("错误文案不对:\n 得到 %q\n 期望 %q", errs[0], want)
}
// 库里的张伟一行没动,也没有多出一个人。
var (
name string
bib string
count int
)
if err := env.ev.QueryRow(`SELECT name, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name, &bib); err != nil {
t.Fatalf("查询人员: %v", err)
}
if name != "张伟" || bib != "A100" {
t.Fatalf("撞号不应改动库里已有人员,得到 name=%s bib=%s", name, bib)
}
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&count); err != nil {
t.Fatalf("统计人员: %v", err)
}
if count != 2 {
t.Fatalf("撞号不应新建人员,得到 %d 个", count)
}
}
// 号码布不同 → 按「同名的另一个人」新建档案,并在 sameNameNewPersons 里单独报告,不进冲突面板。
func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,,男,,2001,,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)
}
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
t.Fatalf("同名不同号码布不进冲突面板: %+v", conflicts)
}
reported, _ := result["sameNameNewPersons"].([]any)
if len(reported) != 1 {
t.Fatalf("应报告 1 条同名新建,得到 %+v", reported)
}
item := reported[0].(map[string]any)
if item["name"] != "张伟" || item["teamCode"] != "A" || item["newBib"] != "2001" || item["existingBib"] != "1001" {
t.Fatalf("同名新建报告字段不对: %+v", item)
}
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE name = '张伟' AND team_id = ?`, ids.teamA).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if athletes != 2 {
t.Fatalf("应新建第二个人,得到 %d 个", athletes)
}
// 原档案的号码布保持不动,新档案拿表里的号码布。
var oldBib string
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&oldBib); err != nil {
t.Fatalf("查询原人员: %v", err)
}
if oldBib != "1001" {
t.Fatalf("原档案号码布不应被改,得到 %s", oldBib)
}
}
// 姓名 + 单位命中多条候选:不能替用户挑人,进冲突面板等决策。
func TestImportAmbiguousNameTeamGoesToConflictPanel(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 同单位两个「张伟」,都没有号码布,表里来第三个候选信息 → 歧义。
env.exec(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA)
body := importHeader + "\nM100,男子,A,张伟,,男,,9001,,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(0) {
t.Fatalf("歧义行不落库也不跳过: %+v", result)
}
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
}
if conflicts[0].(map[string]any)["athleteId"] != float64(ids.athleteA) {
t.Fatalf("歧义冲突应以第一条候选为代表: %+v", conflicts[0])
}
}
@@ -360,9 +473,9 @@ func TestImportToleratesMissingOptionalColumns(t *testing.T) {
// 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)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n"
// 未给决策时,冲突原样返回。
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]")
@@ -415,9 +528,9 @@ func TestImportResolveKeepExisting(t *testing.T) {
// 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)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,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 {
@@ -618,44 +731,36 @@ func TestImportRejectsDuplicateBibInFile(t *testing.T) {
}
}
// 证件号命中但号码布与库中不同 → 冲突,快照与差异字段都带 bib,本轮不落库。
func TestImportConflictIncludesBibDiff(t *testing.T) {
// 同名 + 号码布不同本应按「同名的另一个人」新建,但表里的证件号已被库里那个人占用时,
// 唯一索引的底线更高:明确报出占用者,既不静默改库也不静默跳过。
func TestImportSameNameDifferentBibButTakenIDCard(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A200,,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)
t.Fatalf("导入应 200(行级报错),得到 %d %+v", status, env2.Error)
}
result := decodeData[map[string]any](t, env2)
conflicts, _ := result["conflicts"].([]any)
if len(conflicts) != 1 {
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
if result["skipped"] != float64(1) || result["created"] != float64(0) {
t.Fatalf("撞号应跳过该行: %+v", result)
}
conflict := conflicts[0].(map[string]any)
if conflict["bib"] != "A200" {
t.Fatalf("冲突定位应带表里的号码布: %+v", conflict)
errs, _ := result["errors"].([]any)
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
if len(errs) != 1 || errs[0] != want {
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
}
changed, _ := conflict["changedFields"].([]any)
if len(changed) != 1 || changed[0] != "bib" {
t.Fatalf("应只有 bib 变化,得到 %+v", changed)
}
existing := conflict["existing"].(map[string]any)
if existing["bib"] != "A100" {
t.Fatalf("existing 快照应带库中号码布: %+v", existing)
}
incoming := conflict["incoming"].(map[string]any)
if incoming["bib"] != "A200" {
t.Fatalf("incoming 快照应带表里号码布: %+v", incoming)
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
t.Fatalf("新建被证件号唯一索引挡下时不应报告成同名新建: %+v", reported)
}
var bib string
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
t.Fatalf("查询人员号码布: %v", err)
var athletes int
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
t.Fatalf("统计人员: %v", err)
}
if bib != "A100" {
t.Fatalf("冲突不应改库中号码布,得到 %s", bib)
if athletes != 2 {
t.Fatalf("不应新建人员,得到 %d 个", athletes)
}
}
@@ -1266,3 +1371,94 @@ func TestUploadedImagePathRoundTripsThroughSettings(t *testing.T) {
t.Fatalf("配置里的图片路径应可匿名访问,得到 %d", resp.StatusCode)
}
}
// 小项的 scored / recordEligible 漏传 = 建表默认值(migrations/event/0004 里都是 1),
// 显式 false 才是 false;更新时漏传沿用库里的现值,与单位 / 大项的 active 同口径。
// 与基础数据包导入侧(export_handlers.go 里新建小项默认 true/true)也一致。
func TestEventBoolDefaultsFollowSchemaDefaults(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
boolField := func(t *testing.T, body envelope, field string) bool {
t.Helper()
got, ok := decodeData[map[string]any](t, body)[field].(bool)
if !ok {
t.Fatalf("响应里 %s 不是布尔值: %+v", field, body.Data)
}
return got
}
// 库里也真的按接口回报的值落着,不只是响应体好看。
assertStoredFlags := func(t *testing.T, code string, want int) {
t.Helper()
var scored, recordEligible int
if err := env.ev.QueryRow(`SELECT scored, record_eligible FROM events WHERE code = ?`, code).
Scan(&scored, &recordEligible); err != nil {
t.Fatalf("查询小项 %s 的开关: %v", code, err)
}
if scored != want || recordEligible != want {
t.Fatalf("小项 %s 的开关应为 %d/%d,得到 %d/%d", code, want, want, scored, recordEligible)
}
}
base := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省"}
status, body := env.do(client, http.MethodPost, "/api/events", base, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("建小项应 200,得到 %d %+v", status, body.Error)
}
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
t.Fatalf("漏传开关应取建表默认值 true: %+v", body.Data)
}
assertStoredFlags(t, "E-BOOL", 1)
eventID := int64(decodeData[map[string]any](t, body)["id"].(float64))
// 更新漏传沿用现值:库里是 true/true 时,只改名字不能把它们翻成 false。
rename := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名一)"}
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("改名应 200,得到 %d %+v", status, body.Error)
}
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
t.Fatalf("更新漏传应沿用库里的 true: %+v", body.Data)
}
// 显式 false 才关得掉:接口不能无视调用方的明确意图。
turnOff := map[string]any{
"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名二)",
"scored": false, "recordEligible": false,
}
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), turnOff, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("显式关开关应 200,得到 %d %+v", status, body.Error)
}
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
}
// 反过来:库里是 false/false 时,更新漏传也不能把它打开。
rename["name"] = "开关缺省(改名三)"
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("再改名应 200,得到 %d %+v", status, body.Error)
}
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
t.Fatalf("更新漏传应沿用库里的 false: %+v", body.Data)
}
// 新建时显式传 false 同样照办,缺省值不覆盖调用方的明确意图。
explicitOff := map[string]any{
"sportId": ids.sport, "code": "E-BOOL-OFF", "name": "开关显式关闭",
"scored": false, "recordEligible": false,
}
status, body = env.do(client, http.MethodPost, "/api/events", explicitOff, true)
if status != http.StatusOK || !body.OK {
t.Fatalf("显式传 false 应 200,得到 %d %+v", status, body.Error)
}
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
}
assertStoredFlags(t, "E-BOOL-OFF", 0)
assertStoredFlags(t, "E-BOOL", 0)
}