fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛) results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」, 团体手工通道把个人名次当团体名次又按团体表发一遍分。 - 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建) 与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键, 避免规则包重导时级联删掉录入数据) - results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回; 团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底, 个人报名项的名次绝不进团体通道 - writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked / realtime.loadRanked 三份同源 SQL 一起改读新表 - 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证) ## 奖牌结算开关与奖牌通道(P1(5),新功能) - events.award_medals + 通道 count_to_medals / count_to_team_medals (迁移默认 1,升级不改变既有榜单) - 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位) - 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算 - 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、 录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks) ## 身份口径改回证件号(P1(15),推翻第三十六轮) - 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard - 同表重复证件号 = 同一人报多个组别(notices 提示,不报错); 只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate) - 补齐后端缺失但前端/文档已在依赖的三处契约 ## 其余 P1 - 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径, 被限流的请求挂起并由心跳兜底补发,不丢刷新) - 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 / 积分模板 awardRanks → 400 或 404,不再 500 - 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、 报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价) - 配置改动触发重算:通道 / 小项参数 / 积分模板 - 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算 (服务对外监听之前完成,避免升级后奖牌榜全 0) - 示例数据模式下破坏性操作被阻断,不再假成功 - Console:修复「只剩一块屏」后屏幕监听器被永久摘掉 - 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常 ## 文档 - docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、 常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、 部署指南、上线检查清单 - 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步) 自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build; Console 35 例 + 语法检查。
This commit is contained in:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -232,7 +233,7 @@ func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
||||
// 证件号命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
||||
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
@@ -286,7 +287,10 @@ func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里证件号重复出现不再报错:证件号是可选档案信息,不参与匹配也不拦截。
|
||||
// 同一份表里同一证件号、同一小项同一个组别出现两次 = 真重复:
|
||||
// 两行都解析到同一个人(不新建第二份档案、不报「证件号已被占用」),
|
||||
// 但要不要覆盖已落库的那条报名由用户决定,所以次行进冲突面板并带 duplicate 标记;
|
||||
// 同时给一条 notices 提示,说明这个证件号在本表里出现了两次。
|
||||
func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
@@ -298,16 +302,35 @@ func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["skipped"] != float64(0) {
|
||||
t.Fatalf("同表重复证件号不应再拦截: %+v", result)
|
||||
if result["created"] != float64(1) || result["updated"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("首行新建、次行等决策(既不算更新也不算跳过): %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("不应有行级报错,得到 %+v", errs)
|
||||
t.Fatalf("同证件号重复不应报行级错误,得到 %+v", errs)
|
||||
}
|
||||
if result["created"] != float64(1) || result["updated"] != float64(1) {
|
||||
t.Fatalf("首行新建、次行按姓名+单位命中同一人并更新报名: %+v", result)
|
||||
notices, _ := result["notices"].([]any)
|
||||
wantNotice := "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)"
|
||||
if len(notices) != 1 || notices[0] != wantNotice {
|
||||
t.Fatalf("应给一条重复提示 %q,得到 %+v", wantNotice, notices)
|
||||
}
|
||||
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("同一个小项同一个组别的重复行应进冲突面板,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["duplicate"] != true || conflict["row"] != float64(3) {
|
||||
t.Fatalf("重复行应带 duplicate 标记并指向次行: %+v", conflict)
|
||||
}
|
||||
var athleteID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athleteID); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if conflict["athleteId"] != float64(athleteID) {
|
||||
t.Fatalf("重复行应指向同一个人 %d: %+v", athleteID, conflict)
|
||||
}
|
||||
|
||||
// 同一个人只应建 1 份档案(次行绝不能再新建或撞 id_card 唯一索引)。
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
@@ -317,28 +340,36 @@ func TestImportAcceptsDuplicateIDCardInFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 新人带的证件号已被库里另一个人占用 → 明确的中文行级错误,不静默改库、不静默跳过。
|
||||
func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) {
|
||||
// 证件号优先:表里的证件号在库里有主时,命中那个人走冲突面板,而不是报「证件号已被占用」。
|
||||
// 姓名 / 单位 / 号码布都对不上也只当差异交给用户决策——认人是按证件号,不是按号码布或姓名。
|
||||
func TestImportIDCardHitGoesToConflictNotTakenError(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
// 另一个人(李四 / 号码布 B200)拿着已被张伟占用的证件号来建档。
|
||||
// 另一个人(李四 / 号码布 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)
|
||||
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)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("证件号命中应进冲突面板,既不算新建也不算跳过: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
if len(errs) != 1 {
|
||||
t.Fatalf("应报 1 条行级错误,得到 %+v", errs)
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("证件号能认到人就不该报「已被占用」,得到 %+v", errs)
|
||||
}
|
||||
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
|
||||
if errs[0] != want {
|
||||
t.Fatalf("错误文案不对:\n 得到 %q\n 期望 %q", errs[0], want)
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["athleteId"] != float64(ids.athleteA) || conflict["duplicate"] != false {
|
||||
t.Fatalf("冲突应指向证件号命中的人: %+v", conflict)
|
||||
}
|
||||
changed, _ := conflict["changedFields"].([]any)
|
||||
if len(changed) != 3 || changed[0] != "name" || changed[1] != "teamCode" || changed[2] != "bib" {
|
||||
t.Fatalf("姓名 / 单位 / 号码布三处差异都该列出,得到 %+v", changed)
|
||||
}
|
||||
|
||||
// 库里的张伟一行没动,也没有多出一个人。
|
||||
@@ -351,17 +382,18 @@ func TestImportRejectsIDCardTakenByAnotherPerson(t *testing.T) {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if name != "张伟" || bib != "A100" {
|
||||
t.Fatalf("撞号不应改动库里已有人员,得到 name=%s bib=%s", name, bib)
|
||||
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)
|
||||
t.Fatalf("证件号命中不应新建人员,得到 %d 个", count)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布不同 → 按「同名的另一个人」新建档案,并在 sameNameNewPersons 里单独报告,不进冲突面板。
|
||||
// 号码布不同(表里证件号也对不上)→ 按「同名的另一个人」新建档案,
|
||||
// 并在 sameNameNewPersons 里带上证件号 / 号码布两侧取值单独报告,不进冲突面板。
|
||||
func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
|
||||
@@ -386,6 +418,10 @@ func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
|
||||
if item["name"] != "张伟" || item["teamCode"] != "A" || item["newBib"] != "2001" || item["existingBib"] != "1001" {
|
||||
t.Fatalf("同名新建报告字段不对: %+v", item)
|
||||
}
|
||||
// 证件号两侧都没有值也要如实带上,现场看到的「原 / 新」两栏才完整。
|
||||
if item["newIdCard"] != "" || item["existingIdCard"] != "" {
|
||||
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 {
|
||||
@@ -671,7 +707,7 @@ func TestImportBackfillsBibForExistingAthlete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布命中时,表里多给的证件号也回填进库中为空的人员。
|
||||
// 证件号未命中 → 退回号码布命中:对方没登记证件号 → 认定同一个人并回填证件号,不新建。
|
||||
func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
@@ -685,6 +721,12 @@ func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||||
t.Fatalf("号码布命中应更新已有报名: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("号码布命中且对方没有证件号,不该报错: %+v", errs)
|
||||
}
|
||||
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 {
|
||||
@@ -703,7 +745,7 @@ func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里号码布重复出现 → 该行报错并跳过。
|
||||
// 同一文件里两个不同的人共用一个号码布 → 该行报错并跳过;同一个人报多个组别时号码布可以重复。
|
||||
func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
@@ -732,28 +774,114 @@ func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同名 + 号码布不同本应按「同名的另一个人」新建,但表里的证件号已被库里那个人占用时,
|
||||
// 唯一索引的底线更高:明确报出占用者,既不静默改库也不静默跳过。
|
||||
func TestImportSameNameDifferentBibButTakenIDCard(t *testing.T) {
|
||||
// 证件号优先:库里某人 id_card=X / bib=A100,表里同证件号但号码布写错成 A200 时,
|
||||
// 命中同一个人(不新建、不报「证件号已被占用」),号码布差异按既有口径进冲突面板;
|
||||
// 决策 keep-new 后号码布按表里的值更新。
|
||||
func TestImportIDCardPriorityIgnoresWrongBib(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)
|
||||
if result["skipped"] != float64(1) || result["created"] != float64(0) {
|
||||
t.Fatalf("撞号应跳过该行: %+v", result)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("冲突行不落库也不跳过: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
want := "第 2 行:证件号 110101199001011234 已被另一个人占用(姓名 张伟,号码布 A100)"
|
||||
if len(errs) != 1 || errs[0] != want {
|
||||
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("证件号命中同一个人,不该报「已被占用」: %+v", errs)
|
||||
}
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("号码布差异应进冲突面板,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["athleteId"] != float64(ids.athleteA) || conflict["duplicate"] != false {
|
||||
t.Fatalf("冲突应指向证件号命中的那个人: %+v", conflict)
|
||||
}
|
||||
changed, _ := conflict["changedFields"].([]any)
|
||||
if len(changed) != 1 || changed[0] != "bib" {
|
||||
t.Fatalf("应只有号码布差异,得到 %+v", changed)
|
||||
}
|
||||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||||
t.Fatalf("新建被证件号唯一索引挡下时不应报告成同名新建: %+v", reported)
|
||||
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)
|
||||
}
|
||||
if bib != "A100" {
|
||||
t.Fatalf("未决策不应改号码布,得到 %s", bib)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 决策键是证件号(证件号优先),keep-new 用表里的号码布覆盖。
|
||||
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 conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
|
||||
}
|
||||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||||
t.Fatalf("keep-new 应更新已有报名: %+v", result)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if bib != "A200" {
|
||||
t.Fatalf("keep-new 应用表里的号码布覆盖,得到 %s", bib)
|
||||
}
|
||||
}
|
||||
|
||||
// 证件号与号码布都没命中 → 按「姓名 + 单位」命中且对方既没证件号也没号码布:
|
||||
// 认定同一个人,把表里的证件号与号码布一并回填,不新建档案。
|
||||
func TestImportBackfillsIDCardAndBibBySameNameTeam(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,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)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["updated"] != float64(1) || result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("姓名 + 单位命中应更新已有报名: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("回填不该报行级错误: %+v", errs)
|
||||
}
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("两边都没登记身份字段时回填不算冲突: %+v", conflicts)
|
||||
}
|
||||
|
||||
var (
|
||||
idCard sql.NullString
|
||||
bib sql.NullString
|
||||
)
|
||||
if err := env.ev.QueryRow(`SELECT id_card, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard, &bib); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
if !idCard.Valid || idCard.String != "110101199001011234" {
|
||||
t.Fatalf("应回填证件号,得到 %+v", idCard)
|
||||
}
|
||||
if !bib.Valid || bib.String != "A100" {
|
||||
t.Fatalf("应回填号码布,得到 %+v", bib)
|
||||
}
|
||||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||||
t.Fatalf("一边没给身份字段只是「未指定」,不该按同名的另一个人新建: %+v", reported)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
@@ -761,7 +889,223 @@ func TestImportSameNameDifferentBibButTakenIDCard(t *testing.T) {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 2 {
|
||||
t.Fatalf("不应新建人员,得到 %d 个", athletes)
|
||||
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 姓名 + 单位命中、对方有不同号码布、且表里的证件号也对不上任何人 → 同名的另一个人:
|
||||
// 新建第二份档案并报告(新档案拿表里的证件号与号码布)。
|
||||
func TestImportSameNameDifferentBibWithUnmatchedIDCardCreatesSecondPerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,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)
|
||||
want := map[string]any{
|
||||
"name": "张伟", "teamCode": "A",
|
||||
"newIdCard": "110101199001011234", "existingIdCard": "",
|
||||
"newBib": "2001", "existingBib": "1001",
|
||||
}
|
||||
for key, value := range want {
|
||||
if item[key] != value {
|
||||
t.Fatalf("同名新建报告 %s 应为 %q,得到 %+v", key, value, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 姓名 + 单位命中、对方有另一个证件号、表里也没给号码布 → 同名的另一个人(与号码布不同同一条路径)。
|
||||
func TestImportSameNameDifferentIDCardCreatesSecondPerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001019999', bib = '1001' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
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("证件号不同应新建报名: %+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["newIdCard"] != "110101199001011234" || item["existingIdCard"] != "110101199001019999" {
|
||||
t.Fatalf("同名新建报告应带两侧证件号: %+v", item)
|
||||
}
|
||||
if item["newBib"] != "" || item["existingBib"] != "1001" {
|
||||
t.Fatalf("表里没给号码布时应是空串、库里那条如实带上: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一份表里同一证件号出现多次(同一个人报多个组别):两行都解析到同一个档案、各自落一条报名,
|
||||
// 只给 notices 提示,绝不新建第二个人、也绝不撞 id_card 唯一索引。
|
||||
func TestImportDuplicateIDCardAcrossScopesResolvesToOnePerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
// 加一个组别,让同一人的两行落在不同 (小项, 组别) 上——那才是真正的「报多个组别」。
|
||||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,A100,,registered,\n" +
|
||||
"M100,女子,A,张三,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)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(2) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("同一人的两条报名都应落库: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("同证件号重复不该报行级错误,得到 %+v", errs)
|
||||
}
|
||||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||||
t.Fatalf("不同组别不是真重复,不该进冲突面板: %+v", conflicts)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
if len(notices) != 1 || notices[0] != "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)" {
|
||||
t.Fatalf("应给一条重复提示,得到 %+v", notices)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
var entries int
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT COUNT(*) FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
|
||||
"110101199001011234").Scan(&entries); err != nil {
|
||||
t.Fatalf("统计报名: %v", err)
|
||||
}
|
||||
if entries != 2 {
|
||||
t.Fatalf("同一个人两个组别应各有一条报名,得到 %d", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一份表里同一证件号两行的号码布不同(号码布写错):两行仍是同一个人(冲突指向同一个 athleteId),
|
||||
// 号码布差异按既有口径进冲突面板,不新建第二个人、不报「证件号已被占用」。
|
||||
func TestImportDuplicateIDCardDifferentBibStaysOnePerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,,B1,,registered,\n" +
|
||||
"M100,女子,A,张三,110101199001011234,男,,B2,,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 errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("不该报「证件号已被占用」这类行级错误,得到 %+v", errs)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
if len(notices) != 1 || notices[0] != "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)" {
|
||||
t.Fatalf("应给一条重复提示,得到 %+v", notices)
|
||||
}
|
||||
|
||||
var athleteID int64
|
||||
if err := env.ev.QueryRow(`SELECT id FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athleteID); err != nil {
|
||||
t.Fatalf("查询人员: %v", err)
|
||||
}
|
||||
conflicts, _ := result["conflicts"].([]any)
|
||||
if len(conflicts) != 1 {
|
||||
t.Fatalf("号码布写错应进冲突面板,得到 %+v", conflicts)
|
||||
}
|
||||
conflict := conflicts[0].(map[string]any)
|
||||
if conflict["athleteId"] != float64(athleteID) {
|
||||
t.Fatalf("次行必须解析到同一个人 %d,得到 %+v", athleteID, conflict)
|
||||
}
|
||||
changed, _ := conflict["changedFields"].([]any)
|
||||
if len(changed) != 1 || changed[0] != "bib" {
|
||||
t.Fatalf("应只有号码布差异,得到 %+v", changed)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布已经在库里被 A 占用,表里这一行的证件号又对不上 A → 两个人共用一个号码布:
|
||||
// 按既有号码布防呆口径报行级错误并跳过,绝不静默改库、也不新建第二个占号的人。
|
||||
func TestImportRejectsBibTakenByDifferentIDCard(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `UPDATE athletes SET id_card = '110101199001019999', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||||
|
||||
body := importHeader + "\nM100,男子,A,李四,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)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(0) || result["skipped"] != float64(1) {
|
||||
t.Fatalf("两人共用一个号码布应跳过该行: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
want := "第 2 行:号码布 A100 已被其它人员使用"
|
||||
if len(errs) != 1 || errs[0] != want {
|
||||
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
|
||||
}
|
||||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||||
t.Fatalf("被号码布防呆挡下时不该报告成同名新建: %+v", reported)
|
||||
}
|
||||
|
||||
// 库里那条一个字段都没动,也没多出一个人。
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,6 +1399,253 @@ func TestImportRejectsUnknownStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 导入改「谁还算数」必须重算 ----------
|
||||
|
||||
// importZhangWeiIDCard 是种子人员张伟在导入用例里用的证件号(先写库,导入才能按证件号命中他)。
|
||||
const importZhangWeiIDCard = "110101199001011234"
|
||||
|
||||
// importZhangWeiRow 拼一行「M100 / 男子 / A / 张伟」的报名表内容;表头见 importHeader。
|
||||
func importZhangWeiRow(status string) string {
|
||||
return "M100,男子,A,张伟," + importZhangWeiIDCard + ",男,,,," + status + ",\n"
|
||||
}
|
||||
|
||||
// seedZhangWeiIDCard 给张伟补一个证件号:证件号优先的匹配口径下,导入才认得出是同一个人。
|
||||
func seedZhangWeiIDCard(t *testing.T, env *testEnv, ids seedIDs) {
|
||||
t.Helper()
|
||||
env.exec(t, `UPDATE athletes SET id_card = ? WHERE id = ?`, importZhangWeiIDCard, ids.athleteA)
|
||||
}
|
||||
|
||||
// runImportScenario 用与批量动作同一串操作跑一遍「名单导入改状态」:
|
||||
// 8 人报名 → 录完前 4 条并发布 →(withSettle 时)立即结算 → 导入把第 1 名改成 status。
|
||||
//
|
||||
// 直接复用 entries_batch_test.go 的 runScenario,是为了让两个入口(批量退赛 / 名单导入)
|
||||
// 的最终数据能逐字节对照:它们改的是同一件事(谁还算数)。
|
||||
func runImportScenario(t *testing.T, live, withSettle bool, status string) batchRun {
|
||||
t.Helper()
|
||||
return runScenario(t, live, withSettle,
|
||||
func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64) {
|
||||
t.Helper()
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
body := importHeader + "\n" + importZhangWeiRow(status)
|
||||
if st, e := env.postImport(t, client, "/api/entries/import", body, ""); st != http.StatusOK || !e.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", st, e.Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 同一批导入里多行改状态、且都落在同一组:按 (小项, 组别) 去重,只重算一次、只推一次榜。
|
||||
// 导入动辄几百行,逐行推榜会让大屏收一串中间态、也会把现场拖慢。
|
||||
func TestImportManyStatusChangesPushOnce(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
env.exec(t, `UPDATE athletes SET id_card = ? WHERE id = ?`, "110101199001015678", ids.athB)
|
||||
publishResults(t, env, client, ids.event, "import-many",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
body := importHeader + "\n" + importZhangWeiRow("scratched") +
|
||||
"M100,男子,B,李强,110101199001015678,男,,,,scratched,\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(2) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("两行都应就地更新,得到 %+v", result)
|
||||
}
|
||||
// 一个组只推一次榜(两行都改了状态,但 scope 去重成一次重算)。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("同一组的多行改动只应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
// 两人都退出名次与流水:整组没有可排名成绩,榜单归零。
|
||||
if got := ledgerRowsOf(t, env, ids.event, nil); len(got) != 0 {
|
||||
t.Fatalf("两人都退赛后不该有有效流水,得到 %v", got)
|
||||
}
|
||||
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); len(got) != 0 {
|
||||
t.Fatalf("两人都退赛后不该占名次,得到 %v", got)
|
||||
}
|
||||
if got := standingsTotal(t, env, client); got != 0 {
|
||||
t.Fatalf("榜单总分应归零,得到 %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 导入把已发布成绩的人改成退赛:流水作废、名次让出来给别人、榜单被推。
|
||||
// 现场「改完名单重新导入一遍」很自然,不重算就会留下「名单说退赛、榜上还挂着 10 分与金牌」。
|
||||
func TestImportStatusChangeRecomputes(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
publishResults(t, env, client, ids.event, "import-status",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
// 前置:A 第 1 名 10 分、B 第 2 名 8 分。
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||||
t.Fatalf("前置条件:A 应是 10 分,得到 %v", got)
|
||||
}
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+importZhangWeiRow("scratched"), "")
|
||||
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) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("应就地更新 1 条报名(不是新建、不跳过),得到 %+v", result)
|
||||
}
|
||||
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||||
t.Fatalf("A 应已改成退赛,得到 %s", got)
|
||||
}
|
||||
|
||||
// 流水作废:旧流水留着当证据,有效流水一条不剩。
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); len(got) != 0 {
|
||||
t.Fatalf("退赛后不应再有有效流水,得到 %v", got)
|
||||
}
|
||||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryA); n == 0 {
|
||||
t.Fatalf("旧流水应被作废留痕,得到 %d 条", n)
|
||||
}
|
||||
// 名次让出来:退赛的人不再占名次,B 升到第 1 名。
|
||||
if got := ranksOf(t, env, []int64{ids.entryA}); len(got) != 0 {
|
||||
t.Fatalf("退赛的报名项不该再占名次,得到 %v", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
|
||||
t.Fatalf("B 应升到第 1 名,得到 %d", got)
|
||||
}
|
||||
// 榜单跟着变:A 归零,B 从第 2 名的 8 分变成第 1 名的 10 分。
|
||||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||||
t.Fatalf("A 单位总分应归零,得到 %v", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
|
||||
t.Fatalf("B 单位总分应变成 10,得到 %v", got)
|
||||
}
|
||||
// 大屏要收到新榜单,否则现场看到的还是「退赛者拿金牌」的旧画面。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("导入改状态应推 1 次榜单(standings replace),得到 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 名单导入与批量退赛是同一口径:实时 / 延迟两种结算模式下最终数据逐字节一致。
|
||||
func TestImportScratchMatchesRealTime(t *testing.T) {
|
||||
for _, withSettle := range []bool{true, false} {
|
||||
live := runImportScenario(t, true, withSettle, "scratched")
|
||||
deferred := runImportScenario(t, false, withSettle, "scratched")
|
||||
if withSettle {
|
||||
assertSameBefore(t, "名单导入改状态", live, deferred)
|
||||
}
|
||||
assertSameAfter(t, "名单导入改状态", live, deferred)
|
||||
|
||||
// 期望值与「批量退赛第 1 名」同一份:退赛者退出名次与流水,剩下 3 人 10/8/6。
|
||||
wantLedger := []string{
|
||||
entryLedgerRow(live.entries[1], live.teamB, 10),
|
||||
entryLedgerRow(live.entries[2], live.teamA, 8),
|
||||
entryLedgerRow(live.entries[3], live.teamB, 6),
|
||||
}
|
||||
if !reflect.DeepEqual(deferred.after.ledger, wantLedger) {
|
||||
t.Fatalf("导入退赛后流水应重算成 %v,得到 %v", wantLedger, deferred.after.ledger)
|
||||
}
|
||||
wantRanks := map[int64]int{live.entries[1]: 1, live.entries[2]: 2, live.entries[3]: 3}
|
||||
if !reflect.DeepEqual(deferred.after.ranks, wantRanks) {
|
||||
t.Fatalf("导入退赛后名次应为 %v,得到 %v", wantRanks, deferred.after.ranks)
|
||||
}
|
||||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||||
t.Fatalf("延迟结算下导入改状态也应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 反例保护:只改不影响算分的字段(号码布 / 名单 / 批次)不触发重算,也不推榜单。
|
||||
// 导入动辄几百行,逐行无条件重算会把现场拖慢,也会让大屏收到一串无意义刷新。
|
||||
func TestImportNonScoringChangeDoesNotRecompute(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
publishResults(t, env, client, ids.event, "import-non-scoring",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
|
||||
ledgerBefore := ledgerRowsOf(t, env, ids.event, nil)
|
||||
ranksBefore := ranksOf(t, env, []int64{ids.entryA, ids.entryB})
|
||||
standingsBefore := rawData(t, env, client, "/api/standings")
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
// 号码布 / 种子 / 团体名单都是展示字段;status 写 registered,与库中现值相同。
|
||||
body := importHeader + "\nM100,男子,A,张伟," + importZhangWeiIDCard + ",男,,A-777,S-3,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["skipped"] != float64(0) {
|
||||
t.Fatalf("应就地更新 1 条报名,得到 %+v", result)
|
||||
}
|
||||
// 先证明这一行确实写了(否则「没重算」可能只是行被跳过了)。
|
||||
var bib, seed string
|
||||
if err := env.ev.QueryRow(`SELECT COALESCE(bib, ''), COALESCE(seed, '') FROM entries WHERE id = ?`, ids.entryA).
|
||||
Scan(&bib, &seed); err != nil {
|
||||
t.Fatalf("读报名项: %v", err)
|
||||
}
|
||||
if bib != "A-777" || seed != "S-3" {
|
||||
t.Fatalf("号码布 / 种子应已落库,得到 bib=%q seed=%q", bib, seed)
|
||||
}
|
||||
|
||||
if got := ledgerRowsOf(t, env, ids.event, nil); !reflect.DeepEqual(got, ledgerBefore) {
|
||||
t.Fatalf("不影响算分的改动不该动流水:%v → %v", ledgerBefore, got)
|
||||
}
|
||||
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); !reflect.DeepEqual(got, ranksBefore) {
|
||||
t.Fatalf("不影响算分的改动不该动名次:%v → %v", ranksBefore, got)
|
||||
}
|
||||
if got := rawData(t, env, client, "/api/standings"); got != standingsBefore {
|
||||
t.Fatalf("不影响算分的改动不该动榜单:\n之前 %s\n之后 %s", standingsBefore, got)
|
||||
}
|
||||
if got := standingsPushes(t, env); got != pushesBefore {
|
||||
t.Fatalf("不影响算分的改动不该推榜单:%d → %d", pushesBefore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:同一份名单连导两次,第二次不该再改动流水与榜单(状态已经是 scratched,口径没变)。
|
||||
func TestImportStatusChangeIsIdempotent(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedZhangWeiIDCard(t, env, ids)
|
||||
publishResults(t, env, client, ids.event, "import-idempotent",
|
||||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||||
)
|
||||
body := importHeader + "\n" + importZhangWeiRow("scratched")
|
||||
pushesBefore := standingsPushes(t, env)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("第 %d 次导入应 200,得到 %d %+v", i+1, status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["updated"] != float64(1) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("第 %d 次导入应就地更新 1 条,得到 %+v", i+1, result)
|
||||
}
|
||||
if i == 0 {
|
||||
// 第一次确实改了口径:推一次榜。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("第一次导入应推 1 次榜单,得到 %d", got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 第二次:状态没变,不该再重算、也不该再推榜。
|
||||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||||
t.Fatalf("重复导入不该再推榜单,得到 %d 次(期望仍是 1)", got)
|
||||
}
|
||||
if got := activeLedgerPoints(t, env, ids.entryA); len(got) != 0 {
|
||||
t.Fatalf("重复导入不该让退赛者重新有流水,得到 %v", got)
|
||||
}
|
||||
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
|
||||
t.Fatalf("重复导入后 B 仍应是第 1 名,得到 %d", got)
|
||||
}
|
||||
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
|
||||
t.Fatalf("重复导入后 B 单位总分应仍是 10,得到 %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 图片上传与静态访问 ----------
|
||||
|
||||
// newUploadEnv 构造带赛事项目目录的测试环境,返回 uploads 目录的真实路径。
|
||||
@@ -1812,3 +2403,71 @@ func TestDeleteUploadRejectsTraversal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 建运动员漏传 active 必须默认「启用」(与单位 / 大项 / 小项同口径):
|
||||
// Go 的 bool 零值是 false,静默建成「停用」的人在报名选择器里等于不存在——
|
||||
// 少写一个字段就变成另一种东西,只能靠 decodeJSONPresence 的「键是否存在」判断。
|
||||
func TestAthleteActivePresenceDefaults(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
// 漏传 active:建出来是启用的,库里的 active 也真的是 1。
|
||||
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
||||
"name": "无active", "gender": "男", "teamId": ids.teamA,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("建人员应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
created := decodeData[map[string]any](t, env2)
|
||||
if created["active"] != true {
|
||||
t.Fatalf("漏传 active 应建成启用,得到 %+v", created)
|
||||
}
|
||||
newID := int64(created["id"].(float64))
|
||||
var storedActive int
|
||||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||||
t.Fatalf("读人员启用状态: %v", err)
|
||||
}
|
||||
if storedActive != 1 {
|
||||
t.Fatalf("漏传 active 应在库里落 1,得到 %d", storedActive)
|
||||
}
|
||||
|
||||
// 显式 false 仍然是「停用」(不能把默认值做成「永远启用」)。
|
||||
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
||||
"name": "显式停用", "gender": "女", "teamId": ids.teamA, "active": false,
|
||||
}, true)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("显式 active=false 应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if decodeData[map[string]any](t, env2)["active"] != false {
|
||||
t.Fatalf("显式 active=false 应建成停用")
|
||||
}
|
||||
|
||||
// 更新时漏传 active 沿用库里的现值:不能把在用的人静默停用。
|
||||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", newID), map[string]any{
|
||||
"name": "无active改名", "gender": "男", "teamId": ids.teamA,
|
||||
}, true)
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if decodeData[map[string]any](t, env2)["active"] != true {
|
||||
t.Fatalf("更新漏传 active 应沿用启用状态,得到 %+v", decodeData[map[string]any](t, env2))
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||||
t.Fatalf("读人员启用状态: %v", err)
|
||||
}
|
||||
if storedActive != 1 {
|
||||
t.Fatalf("更新漏传 active 不该把人停用,库里得到 %d", storedActive)
|
||||
}
|
||||
|
||||
// 显式 false 的更新仍能把人停用(「漏传 = 沿用」不是「永远不能停用」)。
|
||||
if status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", newID), map[string]any{
|
||||
"name": "无active改名", "gender": "男", "teamId": ids.teamA, "active": false,
|
||||
}, true); status != http.StatusOK {
|
||||
t.Fatalf("显式停用应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||||
t.Fatalf("读人员启用状态: %v", err)
|
||||
}
|
||||
if storedActive != 0 {
|
||||
t.Fatalf("显式 active=false 应把人停用,库里得到 %d", storedActive)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user