Files
AskaEth 7a3e4ad365 feat(entry): 团体项目多队(迁移 0025)+ 修联队团体名次 / 大屏分项成绩 / 预览算分口径;版本 dev_0.5.5
## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍);
  唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL`
  —— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。
- 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。
- 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。
- 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。
  改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。
- **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。

## 二、修:团体名次控件没体现联队
联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**;
成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。
被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。

## 三、修:大屏「分项成绩」排名错乱
现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。
**根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。
修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。
复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。
顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。

## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸)
现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。
1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0;
2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10);
3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字;
4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。
延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。

## 五、修:手录名次项目的「成绩」列空白
`displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→
新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。

## 文档
`docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、
`docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。
- 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。

## 版本
`dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。

## 下一批
`dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。
2026-09-14 11:45:32 +08:00

330 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// importHeaderHeat 是带批次列的 12 列表头(升级前的列数,比现在少一个「队伍」列)。
// 刻意写成字面量:它同时证明「现场手里列少的旧表格照常导入」,不跟着模板一起变。
const importHeaderHeat = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
// 批量设置批次:成功写库、GET 下发、再传 null 清除;重复提交幂等。
func TestSetEntriesHeatSetsAndClears(t *testing.T) {
env, ids, client := recorderWithSeed(t)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, ids.entryB},
"heat": 2,
}, true)
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["heat"] != float64(2) {
t.Fatalf("批量设置结果不对: %+v", result)
}
var heat int
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&heat); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if heat != 2 {
t.Fatalf("entryA 的批次应为 2,得到 %d", heat)
}
// 下发路径也要带上 heat:录入页靠 GET /api/events/{id}/entries 拿批次。
status, env2 = env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", ids.event), nil, false)
if status != http.StatusOK || !env2.OK {
t.Fatalf("读报名列表应 200,得到 %d %+v", status, env2.Error)
}
items := getEntryBoard(t, env, client, ids.event).Entries
if len(items) != 2 {
t.Fatalf("报名列表应有 2 条,得到 %d", len(items))
}
for _, item := range items {
if item["heat"] != float64(2) {
t.Fatalf("报名列表应下发 heat=2,得到 %+v", item["heat"])
}
}
// 幂等:同一批再设一次批次号,结果一致。
status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, ids.entryB},
"heat": 2,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("重复设置同一批次应 200,得到 %d %+v", status, env2.Error)
}
if again := decodeData[map[string]any](t, env2); again["updated"] != float64(2) {
t.Fatalf("重复设置应同样更新 2 条,得到 %+v", again)
}
// 传 null 清除为「未分批」。
status, env2 = env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, ids.entryB},
"heat": nil,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("清除批次应 200,得到 %d %+v", status, env2.Error)
}
if cleared := decodeData[map[string]any](t, env2); cleared["heat"] != nil {
t.Fatalf("清除后 heat 应为 null,得到 %+v", cleared["heat"])
}
var nullHeat any
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, ids.entryA).Scan(&nullHeat); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if nullHeat != nil {
t.Fatalf("清除后 entries.heat 应为 NULL,得到 %v", nullHeat)
}
}
// 跨小项批量设置一定是选错行:必须拒绝,且一条都不能改。
func TestSetEntriesHeatRejectsCrossEvent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
otherEvent := env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
otherCategory := env.insert(t,
`INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, otherEvent)
otherEntry := env.insert(t,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`,
otherEvent, otherCategory, ids.teamB, ids.athB)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA, otherEntry},
"heat": 1,
}, true)
if status != http.StatusBadRequest {
t.Fatalf("跨小项设置批次应 400,得到 %d %+v", status, env2.Error)
}
assertNoHeat(t, env, ids.entryA)
assertNoHeat(t, env, otherEntry)
}
// entryIds 为空 / 全部非法、以及 heat 越界都必须被拒。
func TestSetEntriesHeatValidatesInput(t *testing.T) {
env, ids, client := recorderWithSeed(t)
cases := []struct {
name string
body map[string]any
}{
{"entryIds 为空", map[string]any{"entryIds": []int64{}, "heat": 1}},
{"entryIds 缺失", map[string]any{"heat": 1}},
{"entryIds 全是非法 id", map[string]any{"entryIds": []int64{0, -3}, "heat": 1}},
{"heat 为 0", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 0}},
{"heat 为负", map[string]any{"entryIds": []int64{ids.entryA}, "heat": -1}},
{"heat 超上限", map[string]any{"entryIds": []int64{ids.entryA}, "heat": 100000}},
{"报名项不存在", map[string]any{"entryIds": []int64{ids.entryA, 999999}, "heat": 1}},
}
for _, tc := range cases {
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", tc.body, true)
if status != http.StatusBadRequest {
t.Errorf("%s 应 400,得到 %d %+v", tc.name, status, env2.Error)
continue
}
if env2.Error == nil || env2.Error.Code != "VALIDATION_FAILED" {
t.Errorf("%s 应返回 VALIDATION_FAILED,得到 %+v", tc.name, env2.Error)
}
}
assertNoHeat(t, env, ids.entryA)
}
// entryHeatBatchLimit 与 POST /api/entries/batch 的 `maxEntryBatch` 刻意同值 500:
// 两个接口处理的都是「一批报名项」,上限与文案就该是同一句(服务端直接复用同一个常量)。
// 用例在 httpapi 包外拿不到常量,写字面量;上限改动时这里会红。
const entryHeatBatchLimit = 500
// 超限(上限 + 1):400,响应体与既有 400 同构,且一条报名项的批次都没改。
//
// 501 条全是同一个小项里的**真实**报名项(复用 partial_publish_test.go 的 seedBatch:
// 同样是在 seed 基础上补齐到 N 条):删掉上限检查后这一批会被真的写进库,
// 用例只能靠状态码与文案变红,不会靠「部分报名项不存在」之类的旁证蒙对。
func TestSetEntriesHeatRejectsOverLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := seedBatch(t, env, ids, entryHeatBatchLimit+1)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": targets, "heat": 1}, true)
if status != http.StatusBadRequest {
t.Fatalf("超限应 400,得到 %d %+v", status, env2.Error)
}
if env2.OK || env2.Error == nil {
t.Fatalf("超限应回 {ok:false,error},得到 %+v", env2)
}
want := fmt.Sprintf("一次最多处理 %d 条报名项,请分批提交", entryHeatBatchLimit)
if env2.Error.Message != want {
t.Fatalf("超限文案应为 %q,得到 %q", want, env2.Error.Message)
}
// 「与既有 400 同构」:拿同一个接口上既有的入参错误逐字段比,只允许 message 不同。
refStatus, ref := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": []int64{}, "heat": 1}, true)
if refStatus != http.StatusBadRequest || ref.Error == nil {
t.Fatalf("对照组(entryIds 为空)应 400,得到 %d %+v", refStatus, ref)
}
if env2.OK != ref.OK || env2.Error.Code != ref.Error.Code ||
(env2.Error.Detail == nil) != (ref.Error.Detail == nil) {
t.Fatalf("超限的 400 应与既有 400 同构,得到 %+v / %+v", env2.Error, ref.Error)
}
// 先校验、后写库:被拒的请求一条批次都不能落。
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE heat IS NOT NULL`); n != 0 {
t.Fatalf("超限被拒后不该写任何批次,得到 %d 条", n)
}
}
// 正好等于上限必须放行:边界是「超过」才拒,500 条同小项的报名项一次写成同一批次。
func TestSetEntriesHeatAllowsExactlyAtLimit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
targets := seedBatch(t, env, ids, entryHeatBatchLimit)
status, env2 := env.do(client, http.MethodPut, "/api/entries/heat",
map[string]any{"entryIds": targets, "heat": 2}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("正好 %d 条应放行,得到 %d %+v", entryHeatBatchLimit, status, env2.Error)
}
if result := decodeData[map[string]any](t, env2); result["updated"] != float64(entryHeatBatchLimit) {
t.Fatalf("应更新 %d 条,得到 %+v", entryHeatBatchLimit, result)
}
assertCount(t, env, `SELECT COUNT(*) FROM entries WHERE heat = 2`, entryHeatBatchLimit)
}
// 权限:guest 未登录 401;recorder 放行;批次写操作进审计。
func TestSetEntriesHeatPermissionAndAudit(t *testing.T) {
env, ids, _ := recorderWithSeed(t)
guest, _ := env.client()
status, env2 := env.do(guest, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA}, "heat": 1,
}, false)
if status != http.StatusUnauthorized || env2.Error == nil || env2.Error.Code != "AUTH_REQUIRED" {
t.Fatalf("guest 设置批次应 401 AUTH_REQUIRED,得到 %d %+v", status, env2.Error)
}
assertNoHeat(t, env, ids.entryA)
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
status, env2 = env.do(recorder, http.MethodPut, "/api/entries/heat", map[string]any{
"entryIds": []int64{ids.entryA}, "heat": 3,
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("recorder 设置批次应 200,得到 %d %+v", status, env2.Error)
}
var (
action, after string
)
if err := env.ev.QueryRow(
`SELECT action, after_json FROM audit_logs WHERE action = 'entry.heat' ORDER BY id DESC LIMIT 1`).
Scan(&action, &after); err != nil {
t.Fatalf("查批次审计: %v", err)
}
if !strings.Contains(after, `"heat":3`) {
t.Fatalf("审计里应记下批次号 3,得到 %s", after)
}
}
func assertNoHeat(t *testing.T, env *testEnv, entryID int64) {
t.Helper()
var heat any
if err := env.ev.QueryRow(`SELECT heat FROM entries WHERE id = ?`, entryID).Scan(&heat); err != nil {
t.Fatalf("读 entries.heat: %v", err)
}
if heat != nil {
t.Fatalf("entry %d 的批次应保持未分批,得到 %v", entryID, heat)
}
}
// 导入带 heat 列:正常值入库、留空 = 未分批、非法值走行级拦截且其余行照常导入。
func TestImportEntriesWithHeatColumn(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeaderHeat + "\n" +
"M100,男子,A,张三,110101199001011234,男,,,,1,registered,\n" +
"M100,男子,A,李强,110101199001011235,男,,,,2,registered,\n" +
"M100,男子,B,王芳,110101199001011236,女,,,,3,registered,\n" +
"M100,男子,A,赵六,110101199001011237,男,,,,abc,registered,\n" +
"M100,男子,B,孙七,110101199001011238,男,,,,0,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(3) || result["skipped"] != float64(2) {
t.Fatalf("应新建 3 条、跳过 2 条,得到 %+v", result)
}
errs, _ := result["errors"].([]any)
if len(errs) != 2 {
t.Fatalf("应有 2 条行级错误,得到 %+v", errs)
}
for _, raw := range errs {
text, _ := raw.(string)
if !strings.Contains(text, "heat") {
t.Errorf("行级错误应说明 heat 非法,得到 %q", text)
}
}
// 正常值入库;heat 非法的两行被拦,不影响同一份表里的其余行。
for idCard, want := range map[string]int64{
"110101199001011234": 1,
"110101199001011235": 2,
"110101199001011236": 3,
} {
var heat int64
if err := env.ev.QueryRow(
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = ?`, idCard).
Scan(&heat); err != nil {
t.Fatalf("读 %s 的报名批次: %v", idCard, err)
}
if heat != want {
t.Errorf("%s 的批次应为 %d,得到 %d", idCard, want, heat)
}
}
// 非法值的两行没有落库。
assertCount(t, env, `SELECT COUNT(*) FROM athletes WHERE id_card IN ('110101199001011237','110101199001011238')`, 0)
// 表头没有 heat 列时(老模板)照常导入,批次为未分批。
status, env2 = env.postImport(t, client, "/api/entries/import", importHeader+
"\nM100,男子,A,周八,110101199001011239,男,,,,registered,\n", "")
if status != http.StatusOK || !env2.OK {
t.Fatalf("无 heat 列的老模板应照常导入,得到 %d %+v", status, env2.Error)
}
var heat any
if err := env.ev.QueryRow(
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011239'`).
Scan(&heat); err != nil {
t.Fatalf("读老模板导入的报名批次: %v", err)
}
if heat != nil {
t.Fatalf("无 heat 列时应为未分批,得到 %v", heat)
}
}
// 导入留空 = 未分批:显式带 heat 列但留空,写入 NULL。
func TestImportEntriesHeatBlankMeansUnbatched(t *testing.T) {
env, _, client := recorderWithSeed(t)
body := importHeaderHeat + "\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)
}
if result := decodeData[map[string]any](t, env2); result["created"] != float64(1) {
t.Fatalf("应新建 1 条,得到 %+v", result)
}
var heat any
if err := env.ev.QueryRow(
`SELECT e.heat FROM entries e JOIN athletes a ON a.id = e.athlete_id WHERE a.id_card = '110101199001011234'`).
Scan(&heat); err != nil {
t.Fatalf("读报名批次: %v", err)
}
if heat != nil {
t.Fatalf("留空应为未分批(NULL),得到 %v", heat)
}
}