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`。
This commit is contained in:
AskaEth committed 2026-09-14 11:45:32 +08:00
1 parent 9663d5bfb0
commit 7a3e4ad365
67 files changed
+4658 -228

No files matched your search

@@ -0,0 +1,90 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// seedCompareNoneEventNoCategory 造一个 compare=none 的个人小项,**不带任何组别**。
//
// 这样的现场以前能把 basis=result 的通道存进去:recomputeLedgerTx 是逐组别算的
// (scoring.go 里 `for _, cat := range cats`),没有组别就一轮都不跑,domain.ScoreEvent 的
// ErrCompareRequired 根本到不了,通道配置就这么落库了。带组别的小项则会在这里撞 409,但那是
// 原始领域错误(「domain: basis=result 的通道要求小项配置 compare=asc/desc」),操作员看不出该改什么。
func seedCompareNoneEventNoCategory(t *testing.T, env *testEnv, ids seedIDs, code, name string) int64 {
t.Helper()
return env.insert(t,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (?, ?, ?, 'individual', 'manual', 'none', 'none', 1, 1, 'ongoing')`, ids.sport, code, name)
}
// compare=none 的小项不得配 basis=result / basis=aggregate 的通道——这条守卫以前只在规则包导入里
// (rules/diff.go checkCompareCompatible),管理端手工配的这条路没人拦:现场形态就是一个台球小项
// (compare=none,名次靠人工录入)被配上 basis=result 的通道,一直配到发布才 409。
func TestReplaceChannelsRejectsResultBasisOnCompareNoneEvent(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
eventID := seedCompareNoneEventNoCategory(t, env, ids, "Z_CN", "台球")
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
channels := fmt.Sprintf("/api/events/%d/channels", eventID)
// 两种 basis 都要拦,且提示要点名小项、说清原因是 compare=none、并给出可执行的改法。
for _, basis := range []string{"result", "aggregate"} {
status, resp := env.do(client, http.MethodPut, channels, map[string]any{
"channels": []map[string]any{{"scope": "athlete", "basis": basis, "profileId": profileID}},
}, true)
if status != http.StatusBadRequest || resp.Error == nil {
t.Fatalf("compare=none 配 basis=%s 应 400,得到 %d %+v", basis, status, resp.Error)
}
for _, want := range []string{"台球", "compare=none", basis, "手动录名次"} {
if !strings.Contains(resp.Error.Message, want) {
t.Fatalf("basis=%s 的 400 文案应含 %q,得到 %q", basis, want, resp.Error.Message)
}
}
}
// 整包拒收:一个字节都不写。
status, resp := env.do(client, http.MethodGet, channels, nil, false)
if status != http.StatusOK || !resp.OK {
t.Fatalf("读通道列表应 200,得到 %d %+v", status, resp.Error)
}
if list := decodeData[[]map[string]any](t, resp); len(list) != 0 {
t.Fatalf("被拒的保存不该写下任何通道,得到 %+v", list)
}
// 合法的改法照常放行:改成 basis=manual。
status, resp = env.do(client, http.MethodPut, channels, map[string]any{
"channels": []map[string]any{{"scope": "athlete", "basis": "manual", "profileId": profileID}},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("compare=none 配 basis=manual 应 200,得到 %d %+v", status, resp.Error)
}
}
// 对照:有比较方向的小项照常可以配 basis=result / aggregate,这条守卫不能误伤正常配置。
func TestReplaceChannelsAllowsResultBasisWhenCompareIsSet(t *testing.T) {
env := newTestEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
var profileID int64
if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil {
t.Fatalf("读取积分模板: %v", err)
}
status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event), map[string]any{
"channels": []map[string]any{
{"scope": "athlete", "basis": "result", "profileId": profileID},
{"scope": "team", "basis": "aggregate", "profileId": profileID},
},
}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("compare=asc 的小项配 result/aggregate 应 200,得到 %d %+v", status, resp.Error)
}
}
+9 -4
View File
@@ -112,10 +112,15 @@ type profileDTO struct {
}
type entryDTO struct {
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
TeamID int64 `json:"teamId"`
ID int64 `json:"id"`
EventID int64 `json:"eventId"`
CategoryID int64 `json:"categoryId"`
TeamID int64 `json:"teamId"`
// Squad 是「这个单位在本组别的第几支队」(自由文本:一队 / 二队 / 青年队)。
// 空串 = 不区分队伍:单队单位与升级前的老数据都是它,行为与升级前完全一致(见迁移 0025)。
// 只对纯团体报名(athleteId 为空)有意义:带运动员的报名按 (组别, 单位, 运动员) 唯一,
// 同一个人在同一组别不会因为 squad 而多报一次。
Squad string `json:"squad"`
AthleteID *int64 `json:"athleteId"`
MemberNames []string `json:"memberNames"`
Bib *string `json:"bib"`
@@ -436,8 +436,8 @@ func entryTemplateHeader(language string) []string {
// 联队(devdocs/19)怎么写,是模板必须教给填表人的事:单位编码写 `A+B`,加号左边是主单位,
// 积分平均分、奖牌两边各记一枚。中英各一份,与表头语言一致。
//
// 文案只能挂在**不会变成数据的载体**上:表头行是导入器认模板的锚点(必须留在第 1 行、12 列
// 一字不动),工作表名是「小项@组别」的范围锚点(也不能多出一张说明表,否则整份文件按分工作表
// 文案只能挂在**不会变成数据的载体**上:表头行是导入器认模板的锚点(必须留在第 1 行、列名与
// importColumns 逐列一致),工作表名是「小项@组别」的范围锚点(也不能多出一张说明表,否则整份文件按分工作表
// 口径解析时会对不上号)。所以这里用两处:单位编码表头的**单元格批注**(红三角一眼能看见)
// 与这一列的**输入提示**(点中格子就弹出来)。
const (
@@ -505,6 +505,7 @@ var entryTemplateWidths = map[string]float64{
"heat": 8,
"status": 12,
"member_names": 26,
"squad": 12,
}
const defaultEntryTemplateWidth = 14
@@ -659,7 +660,7 @@ func writeEntriesTemplateXLSX(out io.Writer, sheets []entriesTemplateSheet) erro
// 整列一条输入提示(点中格子就弹出)。
//
// 只动这两种载体,不碰任何单元格的值与行列结构:填好的表原样传回来时,导入器读到的仍是
// 「第 1 行 12 列表头 + 数据行」,批注与输入提示不是数据。
// 「第 1 行表头 + 数据行」(列名与 importColumns 一致),批注与输入提示不是数据。
func writeEntryJointTeamHint(f *excelize.File, sheet entriesTemplateSheet) error {
column := entryTeamCodeColumn()
if column < 0 || column >= len(sheet.Headers) || sheet.JointHint == "" {
@@ -187,16 +187,16 @@ func TestEntriesTemplateOneSheetPerEventCategory(t *testing.T) {
}
}
// sheetImportHeader 是分工作表模板的完整 12 列表头(含批次列),与服务端模板逐列一致。
// sheetImportHeader 是分工作表模板的完整 13 列表头(含批次列与队伍列),与服务端模板逐列一致。
// 既有测试用的 importHeader 少了 heat 列,这里不能复用它,否则每一列都会串位。
const sheetImportHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
const sheetImportHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names,squad"
// entryTemplateHeaderForTest 是中文表头(与 importColumns 的中文别名、前端模板同源)。
func entryTemplateHeaderForTest(t *testing.T) []string {
t.Helper()
return []string{
"项目编码", "组别", "单位编码", "姓名", "证件号", "性别", "年龄",
"号码布", "种子", "批次", "状态", "队员名单",
"号码布", "种子", "批次", "状态", "队员名单", "队伍",
}
}
@@ -214,7 +214,7 @@ func TestEntriesTemplateEnglishHeader(t *testing.T) {
}
want := []string{
"event_code", "category", "team_code", "athlete_name", "id_card", "gender", "age",
"bib", "seed", "heat", "status", "member_names",
"bib", "seed", "heat", "status", "member_names", "squad",
}
if !equalStrings(rows[0], want) {
t.Fatalf("英文模板表头应为规范列名,得到 %v", rows[0])
+2 -1
View File
@@ -7,7 +7,8 @@ import (
"testing"
)
// importHeaderHeat 是带批次列的 12 列表头(与 ImportSection 模板一致)。
// importHeaderHeat 是带批次列的 12 列表头(升级前的列数,比现在少一个「队伍」列)。
// 刻意写成字面量:它同时证明「现场手里列少的旧表格照常导入」,不跟着模板一起变。
const importHeaderHeat = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
// 批量设置批次:成功写库、GET 下发、再传 null 清除;重复提交幂等。
+107
View File
@@ -0,0 +1,107 @@
package httpapi
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
)
// 本文件是「队名」的手工修改:PUT /api/entries/{id}/squad(队名本身见迁移 0025)。
//
// 为什么需要这条接口:报名只有导入一条写入路径,而导入的 upsert 键是 (组别, 单位, 队名)。
// 想把已经报的「二队」改成「青年队」,再导一次只会**新增第二支队**(老那条仍挂在库里)——
// 「填错一个字就得删掉重建」在现场是不能接受的。
// updateEntrySquad 改一条团体报名的队名,返回更新后的完整读数。
//
// **不重算**,这是有意的:squad 只是给现场看的标签(榜单、导出件、大屏把「第一分公司」
// 显示成「第一分公司青年队」)。它不参与名次 / 积分 / 奖牌的任何一个口径——全仓读 squad 的
// 只有导出与报名读数这两处展示代码,引擎(internal/domain)与 score_ledger / results /
// channel_ranks 一个字都不看它。所以改它时算分输出的每个字节都不变,这里也就没有
// recomputeLedgerTx:白算一遍不但慢,还会白白推一次榜(大屏会跟着闪一下没变的名次)。
// 同档的另一个先例是批次 entries.heat,同样是纯展示字段,改它也不重算(见 setEntriesHeat)。
func (s *server) updateEntrySquad(ctx context.Context, entryID int64, squad string) (entryDTO, error) {
// trim 统一收在这里:导入侧读单元格时已经 trim 过(handlers_config.go 的 cell()),
// 手工改走的是另一条路,不 trim 就会把「一队」与「一队 」当成两个队。
squad = strings.TrimSpace(squad)
entry, err := s.getEntry(ctx, entryID)
if err != nil {
return entryDTO{}, err
}
// 带运动员的报名按 (组别, 单位, 运动员) 唯一,队名根本不参与它的查重键
// (0025 的部分索引带 WHERE athlete_id IS NULL)。写进去既不生效,又会让人以为
// 「这个人报了两个队」,所以直接拒掉并说清原因。
if entry.AthleteID != nil {
return entryDTO{}, badRequest("这条报名带着运动员,队名只对团体报名生效:队名分的是「同一个单位在同一个组别的第几支队」,带运动员的报名按(组别、单位、运动员)唯一,改队名变不出第二支队", nil)
}
// 同组别同单位已经有这个队名时先拦下来:撞索引的报错是英文的约束原文,
// 现场看不出是哪个单位、哪个队,也不知道该改哪一格。
var other int64
err = s.ev.QueryRowContext(ctx,
`SELECT id FROM entries
WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL AND squad = ? AND id <> ?`,
entry.CategoryID, entry.TeamID, squad, entryID).Scan(&other)
switch {
case err == nil:
return entryDTO{}, badRequest(squadTakenMessage(entry.TeamCode, squad), nil)
case !errors.Is(err, sql.ErrNoRows):
return entryDTO{}, internal(err)
}
if _, err := s.ev.ExecContext(ctx, `UPDATE entries SET squad = ? WHERE id = ?`, squad, entryID); err != nil {
// 预检与 UPDATE 之间不是原子的(两个录入员同时把两条报名改成同一个队名,
// 或导入正好在这两步之间落库):真撞上唯一索引时也要翻成同一句人话。
if isUniqueViolation(err) {
return entryDTO{}, badRequest(squadTakenMessage(entry.TeamCode, squad), nil)
}
return entryDTO{}, internal(err)
}
return s.getEntry(ctx, entryID)
}
// squadTakenMessage 是「这个队名在本组别已经被同单位占了」的现场文案。
//
// 队名非空那一档导入侧也在用(importedEntryConflictError 的并发分支):两处说的若不是
// 同一句话,操作员在导入里学到的那句到了改队名页面就不成立,只能重新猜系统在拦什么。
//
// 队名留空那一档与导入侧**故意不同**:那边点的是报名表里的「队伍列」,这里没有那一格可点,
// 只能点「队名留空」。其余口径两边一致:空 = 不区分队伍,一个单位在一个组别只能有一条。
func squadTakenMessage(teamCode, squad string) string {
if squad == "" {
return fmt.Sprintf("单位 %s 在本组别已经有一条不分队的团体报名:队名留空就是不区分队伍,一个单位在一个组别只能有一条;要派两个队请把两支队分别写清队名(例如 一队 / 二队)", teamCode)
}
return fmt.Sprintf("单位 %s 在本组别已经有一条队伍「%s」的报名:「同一个单位同一个组别的同一个队」只能报一条;要派两个队请换一个队伍名(例如 二队)", teamCode, squad)
}
// updateEntrySquadHandler 是 PUT /api/entries/{id}/squad:改一条团体报名的队名,
// 成功后返回这条报名的完整读数(与份额那条接口一样,前端拿它直接刷新那一行)。
//
// 权限 / CSRF 与 PUT /api/entries/{id}/team-shares 同档:它改的也是报名。
func (s *server) updateEntrySquadHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in entrySquadPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
before, err := s.getEntry(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateEntrySquad(r.Context(), id, in.Squad)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entry.squad.update", "entry", id,
map[string]any{"squad": before.Squad}, map[string]any{"squad": updated.Squad})
return updated, nil
})
}
@@ -0,0 +1,325 @@
package httpapi_test
import (
"database/sql"
"fmt"
"net/http"
"strings"
"testing"
)
// 手工改队名:PUT /api/entries/{id}/squad。
//
// 为什么要有这条接口:报名只有导入一条写入路径,而导入的 upsert 键是 (组别, 单位, 队名)。
// 想把已经报的「二队」改成「青年队」,再导一次只会**新增第二支队**,老那条还挂在库里——
// 现场「填错一个字就得删掉重建」是不能接受的。
//
// 口径(迁移 0025):队名是自由文本、首尾空白 trim 掉;空串 = 不区分队伍(合法值,
// 不是「把这条报名清掉」);只对纯团体报名(athlete_id 为空)有意义。
// updateSquad 调一次改队名接口。
func updateSquad(t *testing.T, env *testEnv, client *http.Client, entryID int64, squad string, csrf bool) (int, envelope) {
t.Helper()
return env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/squad", entryID),
map[string]any{"squad": squad}, csrf)
}
// squadDumpThree 是「A 一队 / A 二队 / B 不分队」三条团体报名的落库事实。
const squadDumpThree = "team_code=A squad=一队 \nteam_code=A squad=二队 \nteam_code=B squad= \n"
// 现场场景:A 的「二队」填错了一个字,改成「青年队」——改的是**原来那一条**,
// 不是新增第二支队,老那条也不该留下。
func TestUpdateEntrySquadRenamesInPlace(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"),
squadImportRow("BADM", "男子", "B", "", "孙七/周八"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
if got := squadDump(t, env, eventID); got != squadDumpThree {
t.Fatalf("前置条件不成立:\n得到 %s期望 %s", got, squadDumpThree)
}
entryID := entryIDOfSquad(t, env, eventID, "二队")
status, resp = updateSquad(t, env, client, entryID, "青年队", true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("改队名应 200,得到 %d %+v", status, resp.Error)
}
// 返回更新后的完整读数(前端拿它直接刷新那一行)。
updated := decodeData[map[string]any](t, resp)
if updated["id"] != float64(entryID) {
t.Fatalf("回的应是原来那一条报名 %d,得到 %+v", entryID, updated["id"])
}
if updated["squad"] != "青年队" {
t.Fatalf("回显的队名 = %v,期望 青年队", updated["squad"])
}
if updated["teamCode"] != "A" || updated["teamName"] != "第一分公司" {
t.Fatalf("回显应带单位编码与名称(前端要显示「A 青年队」),得到 %+v", updated)
}
if updated["memberNames"] == nil {
t.Fatalf("回显应是完整读数(含队员名单),得到 %+v", updated)
}
// 落库:还是 3 条,队名换了,没有多出第四支队伍。
want := "team_code=A squad=一队 \nteam_code=A squad=青年队 \nteam_code=B squad= \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("改完队名落库不对:\n得到 %s期望 %s", got, want)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); n != 3 {
t.Fatalf("改队名不该新增报名,得到 %d 条", n)
}
// 报名列表(录入页 / 大屏读的就是它)要带上新队名。
board := getEntryBoard(t, env, client, eventID)
found := false
for _, entry := range board.Entries {
if entry["id"] == float64(entryID) {
found = true
if entry["squad"] != "青年队" {
t.Fatalf("报名列表里的队名 = %v,期望 青年队", entry["squad"])
}
}
}
if !found {
t.Fatalf("报名列表里找不到报名 %d", entryID)
}
// 审计:照份额那套记前后值,事后对账能看出改过哪一格。
// waitAudit 取的是 after_json,改动前的队名在 before_json 里(同一行,等它落库后再读)。
after := waitAudit(t, env, "entry.squad.update", entryID)
if !strings.Contains(after, "青年队") {
t.Fatalf("审计的 after 应记下新队名,得到 %s", after)
}
var before sql.NullString
if err := env.ev.QueryRow(`SELECT before_json FROM audit_logs WHERE action = ? AND entity_id = ?`,
"entry.squad.update", entryID).Scan(&before); err != nil {
t.Fatalf("读审计的 before: %v", err)
}
if !strings.Contains(before.String, "二队") {
t.Fatalf("审计的 before 应记下原队名,得到 %s", before.String)
}
}
// 队名首尾空白一律 trim(导入侧读单元格时已经 trim 过,手工改走的是另一条路);
// 只留空白等于空串 = **不区分队伍**,是合法值,不是「把这条报名清掉」。
func TestUpdateEntrySquadTrimsAndAcceptsBlank(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
two := entryIDOfSquad(t, env, eventID, "二队")
status, resp = updateSquad(t, env, client, two, " 青年队 ", true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("队名带空白应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp)["squad"]; got != "青年队" {
t.Fatalf("队名应 trim 首尾空白(含全角空格),得到 %q", got)
}
want := "team_code=A squad=一队 \nteam_code=A squad=青年队 \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("trim 后落库不对:\n得到 %s期望 %s", got, want)
}
// 只留空白 → 空串(不区分队伍):报名还在,只是这条不再挂队名。
status, resp = updateSquad(t, env, client, two, " ", true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("清空队名应 200,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp)["squad"]; got != "" {
t.Fatalf("只留空白的队名应落成空串,得到 %q", got)
}
want = "team_code=A squad=一队 \nteam_code=A squad= \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("清空队名后落库不对:\n得到 %s期望 %s", got, want)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); n != 2 {
t.Fatalf("空队名是「不区分队伍」,不是删报名:应仍是 2 条,得到 %d 条", n)
}
}
// 拦下的请求必须给人话,且一个字节都不落库。
func TestUpdateEntrySquadRejectsBadPayloads(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 第一个小项:A 报了「一队」与不分队各一条,B 也有一条「一队」。
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "", "王五/赵六"),
squadImportRow("BADM", "男子", "B", "一队", "孙七/周八"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
baseline := squadDump(t, env, eventID)
soloEntry := entryIDOfSquad(t, env, eventID, "一队")
// 第二个小项:A 只有「一队」与「二队」两条,没有不分队那条——用来试「改成不分队会不会撞」。
soloEvent := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'VOL2', '排球', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport)
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, soloEvent)
if status, resp := importCSVWithHeader(t, env, client, "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,status,member_names,squad",
squadImportRow("VOL2", "男子", "A", "一队", "张伟/李强"),
squadImportRow("VOL2", "男子", "A", "二队", "王五/赵六")); status != http.StatusOK || !resp.OK {
t.Fatalf("第二个小项导入失败:%d %+v", status, resp.Error)
}
twoEntry := entryIDOfSquad(t, env, soloEvent, "二队")
cases := []struct {
name string
id int64
squad string
csrf bool
status int
wantMsg string
}{
{
name: "撞上同单位同组别的另一个队", id: twoEntry, squad: "一队", csrf: true,
status: http.StatusBadRequest,
wantMsg: "单位 A 在本组别已经有一条队伍「一队」的报名",
},
{
name: "改成不分队,但已经有一条不分队的", id: soloEntry, squad: " ", csrf: true,
status: http.StatusBadRequest,
wantMsg: "单位 A 在本组别已经有一条不分队的团体报名",
},
{
// 队名只对纯团体报名生效:带运动员的报名按 (组别, 单位, 运动员) 唯一。
name: "带运动员的报名", id: ids.entryB, squad: "一队", csrf: true,
status: http.StatusBadRequest,
wantMsg: "队名只对团体报名生效",
},
{
name: "报名不存在", id: 999999, squad: "一队", csrf: true,
status: http.StatusNotFound, wantMsg: "报名项不存在",
},
{
// 与 PUT /api/entries/{id}/team-shares 同档:改动报名必须带 CSRF。
name: "缺 CSRF", id: twoEntry, squad: "青年队", csrf: false,
status: http.StatusForbidden, wantMsg: "CSRF 校验失败",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
status, resp := updateSquad(t, env, client, tc.id, tc.squad, tc.csrf)
if status != tc.status {
t.Fatalf("应 %d,得到 %d %+v", tc.status, status, resp)
}
if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.wantMsg) {
t.Fatalf("报错应含 %q,得到 %+v", tc.wantMsg, resp.Error)
}
// 报错里不该出现 SQLite 的英文原文:现场读不懂,照着它也找不到该改哪一格。
if strings.Contains(resp.Error.Message, "UNIQUE constraint failed") {
t.Fatalf("不该把 SQLite 原文丢给现场:%s", resp.Error.Message)
}
})
}
if got := squadDump(t, env, eventID); got != baseline {
t.Fatalf("被拒的请求改动了报名表:\n之前 %s之后 %s", baseline, got)
}
wantSolo := "team_code=A squad=一队 \nteam_code=A squad=二队 \n"
if got := squadDump(t, env, soloEvent); got != wantSolo {
t.Fatalf("被拒的请求改动了第二个小项的报名表:\n得到 %s期望 %s", got, wantSolo)
}
}
// 未登录一样进不来(与其它报名写接口同档)。
func TestUpdateEntrySquadRequiresLogin(t *testing.T) {
env, _, client := recorderWithSeed(t)
anon, _ := env.client()
if status, resp := updateSquad(t, env, anon, 1, "一队", false); status != http.StatusUnauthorized {
t.Fatalf("未登录改队名应 401,得到 %d %+v", status, resp)
}
// 登录了但这条报名不存在 → 404(照本仓既有风格)。
if status, resp := updateSquad(t, env, client, 999999, "一队", true); status != http.StatusNotFound {
t.Fatalf("改不存在的报名应 404,得到 %d %+v", status, resp)
}
}
// 同一个 requestId 重复提交回放首次结果,不重复执行(与其它写接口同一套幂等)。
func TestUpdateEntrySquadIdempotentByRequestID(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "二队", "张伟/李强"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
entryID := entryIDOfSquad(t, env, eventID, "二队")
path := fmt.Sprintf("/api/entries/%d/squad", entryID)
if status, resp := env.do(client, http.MethodPut, path,
map[string]any{"squad": "青年队", "requestId": "squad-1"}, true); status != http.StatusOK {
t.Fatalf("第一次改队名应 200,得到 %d %+v", status, resp.Error)
}
// 同一个 requestId、换一个队名再来一次:必须是回放,库里仍是第一次的结果。
status, resp = env.do(client, http.MethodPut, path,
map[string]any{"squad": "少年队", "requestId": "squad-1"}, true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("重复提交应回放首次结果,得到 %d %+v", status, resp.Error)
}
if got := decodeData[map[string]any](t, resp)["squad"]; got != "青年队" {
t.Fatalf("回放的应是首次结果(青年队),得到 %v", got)
}
want := "team_code=A squad=青年队 \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("重复提交不该再改一次库:\n得到 %s期望 %s", got, want)
}
}
// 队名只是显示用的标签:改名前后**一切算分输出**逐字节相同。
//
// 这是「改队名不重算」这条决定的正面证据:积分流水、成绩名次、积分榜上的总分与金银铜
// 一个都不该动。真要动了,说明队名混进了算分口径,那才是 bug。
func TestUpdateEntrySquadDoesNotChangeScoring(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"),
squadImportRow("BADM", "男子", "B", "", "孙七/周八"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
publishResults(t, env, client, eventID, "squad",
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "一队"), "resultValue": 21, "resultText": "21", "status": "ok"},
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "二队"), "resultValue": 18, "resultText": "18", "status": "ok"},
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, ""), "resultValue": 15, "resultText": "15", "status": "ok"},
)
assertStanding(t, env, client, ids.teamA, 25, 1, 1, 0)
assertStanding(t, env, client, ids.teamB, 8, 0, 0, 1)
// 算分输出:积分流水(含作废标记)与成绩名次,逐字节对拍。
scoring := func() string {
return tableDump(t, env, `SELECT id, entry_id, team_id, points, voided FROM score_ledger ORDER BY id`) +
tableDump(t, env, `SELECT entry_id, COALESCE(rank, 0), state FROM results ORDER BY entry_id`)
}
before := scoring()
status, resp = updateSquad(t, env, client, entryIDOfSquad(t, env, eventID, "二队"), "青年队", true)
if status != http.StatusOK || !resp.OK {
t.Fatalf("改队名应 200,得到 %d %+v", status, resp.Error)
}
if after := scoring(); after != before {
t.Fatalf("改队名动了算分输出:\n之前 %s之后 %s", before, after)
}
assertStanding(t, env, client, ids.teamA, 25, 1, 1, 0)
assertStanding(t, env, client, ids.teamB, 8, 0, 0, 1)
}
@@ -0,0 +1,93 @@
package httpapi
import (
"database/sql"
"errors"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 撞上唯一索引时给现场的必须是人话:SQLite 的原文是
// 「UNIQUE constraint failed: entries.category_id, entries.team_id, entries.squad」,
// 里面没有单位、没有队伍,操作员照着它找不出该改哪一格。
//
// 这条分支只有**并发导入同一份名单**(或库里那条报名正好在查重与落库之间建好)才会走到,
// HTTP 层造不稳定,所以在函数级别钉住文案:三种情形(同一个队 / 不分队 / 同一个人)
// 说的都得是「哪个单位、哪个队、怎么办」。
func TestImportedEntryConflictErrorMessage(t *testing.T) {
db := openSquadTestDB(t)
s := &server{ev: db}
unique := errors.New("UNIQUE constraint failed: entries.category_id, entries.team_id, entries.squad")
athleteID := int64(7)
cases := []struct {
name string
in entryDTO
want []string
}{
{
name: "同一个单位的同一个队",
in: entryDTO{TeamID: 1, Squad: "一队"},
want: []string{"单位 A", "队伍「一队」", "同一个单位同一个组别的同一个队", "二队"},
},
{
name: "同一个单位的不分队报名",
in: entryDTO{TeamID: 1},
want: []string{"单位 A", "不分队", "队伍列留空", "一队 / 二队"},
},
{
name: "同一个人在同一组别",
in: entryDTO{TeamID: 1, AthleteID: &athleteID, Squad: "一队"},
want: []string{"单位 A", "同一个人在同一组别只能报一条"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := s.importedEntryConflictError(t.Context(), tc.in, unique)
if err == nil {
t.Fatal("唯一键冲突应被翻译成人话,得到 nil")
}
msg := err.Error()
for _, want := range tc.want {
if !strings.Contains(msg, want) {
t.Errorf("报错里少了 %q:%s", want, msg)
}
}
// 英文的约束原文留给日志,不该出现在给现场看的这一句里。
if strings.Contains(msg, "UNIQUE constraint failed") {
t.Errorf("报错不该把 SQLite 原文丢给现场:%s", msg)
}
})
}
// 不是唯一键冲突的错误(外键、CHECK、锁超时)原样返回:
// 把它们说成「重复报名」会把排查方向带偏。
other := errors.New("FOREIGN KEY constraint failed")
if got := s.importedEntryConflictError(t.Context(), entryDTO{TeamID: 1}, other); got != other {
t.Fatalf("非唯一键冲突应原样返回,得到 %v", got)
}
}
// openSquadTestDB 开一个空的赛事库(跑完全部迁移),造一个单位 A 供报错文案使用。
func openSquadTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := "file:" + filepath.Join(t.TempDir(), "event.db") + "?_pragma=foreign_keys(1)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { _ = db.Close() })
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
if _, err := db.Exec(`INSERT INTO teams(code, name) VALUES ('A', '第一分公司')`); err != nil {
t.Fatalf("造单位: %v", err)
}
return db
}
+331
View File
@@ -0,0 +1,331 @@
package httpapi_test
import (
"fmt"
"net/http"
"strings"
"testing"
)
// 队伍(squad):同一个单位在同一个组别可以派多个队参赛(羽毛球双打那种:一个单位两个队,
// 两个队各占一个名次)。
//
// 口径(迁移 0025):
// - squad 是自由文本(一队 / 二队 / 青年队 都能写),**空串 = 不区分队伍**——老数据与单队单位的
// 默认值,行为与升级前一模一样(一个单位在这个组别一条团体报名);
// - 只对**纯团体报名**(athlete_id 为空)放开:带运动员的报名仍按 (组别, 单位, 运动员) 唯一,
// 同一个人在同一组别不会因为这次改动而报两次;
// - 一个单位两个队都进前三就是 2 枚牌,引擎本来就按报名逐条记牌,不走特判。
// importHeaderSquad 是带「队伍」列的表头:新列**加在最后**,既有表格一列不错位。
const importHeaderSquad = importHeader + ",squad"
// importHeaderSquadZH 是中文别名表头(队伍 = importColumns 里 squad 的首选别名)。
const importHeaderSquadZH = importHeaderZH + ",队伍"
// squadImportRow 拼一行团体报名(无姓名 = 团体行),列序与 importHeaderSquad 一致。
func squadImportRow(eventCode, category, teamCode, squad, members string) string {
return strings.Join([]string{eventCode, category, teamCode, "",
"", "", "", "", "", "registered", members, squad}, ",")
}
// importCSVWithHeader 用指定表头上传一份单表 CSV。
func importCSVWithHeader(t *testing.T, env *testEnv, client *http.Client, header string, rows ...string) (int, envelope) {
t.Helper()
content := header + "\n" + strings.Join(rows, "\n") + "\n"
return env.postImport(t, client, "/api/entries/import", content, "")
}
// seedDoublesEvent 建一个「羽毛球双打」形态的小项:team 类型 + 按成绩排名的团体通道
// (团体报名项各录各的成绩、各占各的名次),返回小项与组别 id。
func seedDoublesEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID int64) {
t.Helper()
eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'BADM', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport)
categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order)
VALUES (?, 'team', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "team_top6"))
return eventID, categoryID
}
// squadDump 导出某小项「单位 → 队伍」的落库事实(按报名 id 排序,稳定可比)。
// 刻意不带报名 id:id 由种子数据占位,断言里写死 id 只会让用例变脆。
func squadDump(t *testing.T, env *testEnv, eventID int64) string {
t.Helper()
return tableDump(t, env, fmt.Sprintf(
`SELECT t.code AS team_code, e.squad
FROM entries e JOIN teams t ON t.id = e.team_id
WHERE e.event_id = %d ORDER BY e.id`, eventID))
}
// 现场场景:一个单位派两个队报名。升级前第二条会被唯一索引拦住(一条团体报名),
// 现在两条都要落库、各自有名次、各自拿牌。
func TestImportSquadCreatesSecondTeamOfSameUnit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "二队", "王五/赵六"),
squadImportRow("BADM", "男子", "B", "", "孙七/周八"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if len(result.Errors) != 0 {
t.Fatalf("不该有报错行:%v", result.Errors)
}
if result.Created != 3 {
t.Fatalf("一个单位两个队应是 3 条报名(A 两条 + B 一条),得到 created=%d updated=%d:%s",
result.Created, result.Updated, squadDump(t, env, eventID))
}
// 落库:两个队的 squad 各自留着,空串那条就是老口径「不区分队伍」。
rows, err := env.ev.Query(`SELECT t.code, e.squad FROM entries e JOIN teams t ON t.id = e.team_id
WHERE e.event_id = ? ORDER BY e.id`, eventID)
if err != nil {
t.Fatalf("读报名: %v", err)
}
defer func() { _ = rows.Close() }()
var got []string
for rows.Next() {
var code, squad string
if err := rows.Scan(&code, &squad); err != nil {
t.Fatalf("扫描报名: %v", err)
}
got = append(got, code+":"+squad)
}
want := "A:一队 A:二队 B:"
if joined := strings.Join(got, " "); joined != want {
t.Fatalf("落库的队伍不对:\n得到 %s\n期望 %s", joined, want)
}
// 名次:一队第 1、二队第 2、B 第 3 —— 两个队都进前三,A 拿 2 枚牌(金 1 银 1)。
publishResults(t, env, client, eventID, "squad",
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "一队"), "resultValue": 21, "resultText": "21", "status": "ok"},
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, "二队"), "resultValue": 18, "resultText": "18", "status": "ok"},
map[string]any{"entryId": entryIDOfSquad(t, env, eventID, ""), "resultValue": 15, "resultText": "15", "status": "ok"},
)
assertStanding(t, env, client, ids.teamA, 25, 1, 1, 0)
assertStanding(t, env, client, ids.teamB, 8, 0, 0, 1)
// 报名列表要把队伍带出来(前端据此区分「A 一队 / A 二队」)。
board := getEntryBoard(t, env, client, eventID)
byID := map[float64]string{}
for _, entry := range board.Entries {
id, _ := entry["id"].(float64)
squad, _ := entry["squad"].(string)
byID[id] = squad
}
for _, want := range []struct {
squad string
}{{"一队"}, {"二队"}, {""}} {
id := float64(entryIDOfSquad(t, env, eventID, want.squad))
if byID[id] != want.squad {
t.Errorf("报名 %v 的队伍 = %q,期望 %q", id, byID[id], want.squad)
}
}
}
// 老表零变化:表里没有队伍列(或整列留空)时,一个单位在一个组别仍然只有一条团体报名——
// 第二条是**更新**那一条,不是新增(升级前的行为一字不改)。
func TestImportSquadKeepsLegacyBehaviourWhenColumnEmpty(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
// 先走升级前的老表头(连队伍列都没有)。
status, resp := importCSV(t, env, client, jointImportRow("BADM", "男子", "A"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("不带队伍列的老表应照常导入,得到 %d %+v", status, resp.Error)
}
if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 {
t.Fatalf("老表应新建 1 条:%+v", result)
}
// 同一份老表再传一次:更新那一条,不新增(老行为)。
status, resp = importCSV(t, env, client, jointImportRow("BADM", "男子", "A"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("老表重传应照常更新,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 0 || result.Updated != 1 || result.Skipped != 0 {
t.Fatalf("同一份老表重传应是「新建 0 / 更新 1」,得到 %+v", result)
}
// 带队伍列的表格、但那一格留空:同样是不区分队伍的老口径(B 与 A 各一条)。
status, resp = importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "B", "", "孙七/周八"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("队伍留空应照常导入,得到 %d %+v", status, resp.Error)
}
want := "team_code=A squad= \nteam_code=B squad= \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("老口径落库不对:\n得到 %s期望 %s", got, want)
}
}
// 同一个队重复导入是幂等的(后一行覆盖前一行);换一个队伍名才是第二个队。
func TestImportSquadSameTeamTwiceIsIdempotent(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强"),
squadImportRow("BADM", "男子", "A", "一队", "张伟/李强/王五"),
)
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || result.Updated != 1 || result.Skipped != 0 {
t.Fatalf("同一个队重传应是「新建 1 / 更新 1」(后一行覆盖前一行),得到 %+v", result)
}
if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); got != 1 {
t.Fatalf("同一个队重传后库里应只有 1 条报名,得到 %d 条:%s", got, squadDump(t, env, eventID))
}
// 后一行覆盖前一行:队员名单以最后一行写的为准。
if got := tableDump(t, env, fmt.Sprintf(`SELECT member_names FROM entries WHERE event_id = %d`, eventID)); !strings.Contains(got, "王五") {
t.Fatalf("后一行应覆盖前一行的队员名单,得到 %s", got)
}
// 换一个队伍名 = 第二个队:新增一条。
status, resp = importCSVWithHeader(t, env, client, importHeaderSquad,
squadImportRow("BADM", "男子", "A", "二队", "李四/赵六"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入第二个队应 200,得到 %d %+v", status, resp.Error)
}
if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 {
t.Fatalf("第二个队应新增 1 条:%+v", result)
}
}
// 中文别名:队伍 / 队名 / 队伍名 都认(现场拿到的是中文模板)。
func TestImportSquadChineseAliases(t *testing.T) {
for _, alias := range []string{"队伍", "队名", "队伍名"} {
t.Run(alias, func(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
header := strings.TrimSuffix(importHeaderSquadZH, "队伍") + alias
status, resp := importCSVWithHeader(t, env, client, header,
squadImportRow("BADM", "男子", "A", "青年队", "张伟/李强"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("中文表头 %s 应能导入,得到 %d %+v", alias, status, resp.Error)
}
if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 {
t.Fatalf("应新建 1 条:%+v", result)
}
want := "team_code=A squad=青年队 \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("中文表头落库不对:\n得到 %s期望 %s", got, want)
}
})
}
}
// 带运动员的报名不受队伍影响:同一个人在同一组别写两个队伍名,仍是同一条报名(不会报两次),
// 并且要有一条 notices 说清「队伍没写进库里」——那一格填了就不该悄悄消失。
func TestImportSquadIgnoredForAthleteRows(t *testing.T) {
env, ids, client := recorderWithSeed(t)
// 另开一个个人小项:种子里的 M100 有两条报名,掺进来没法一眼看清新增了几条。
eventID := 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)
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, eventID)
athleteRow := func(squad string) string {
return strings.Join([]string{"M200", "男子", "A", "张三", "110101199001011234", "男", "26",
"101", "", "registered", "", squad}, ",")
}
status, resp := importCSVWithHeader(t, env, client, importHeaderSquad, athleteRow("一队"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("导入个人行应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 {
t.Fatalf("个人行应新建 1 条:%+v", result)
}
if joined := strings.Join(result.Notices, ";"); !strings.Contains(joined, "队伍只对团体报名") {
t.Fatalf("个人行填了队伍要给一条提示,得到 %v", result.Notices)
}
if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ? AND squad <> ''`, eventID); got != 0 {
t.Fatalf("个人报名的 squad 必须是空串,得到 %d 条非空", got)
}
// 同一个人换个队伍名重传:还是同一条(同一个人在同一组别不会因为队伍报两次)。
status, resp = importCSVWithHeader(t, env, client, importHeaderSquad, athleteRow("二队"))
if status != http.StatusOK || !resp.OK {
t.Fatalf("重传应 200,得到 %d %+v", status, resp.Error)
}
result = decodeData[entryImportResultBody](t, resp)
if result.Created != 0 || result.Updated != 1 {
t.Fatalf("同一个人换个队伍名重传应仍是那一条(新建 0 / 更新 1),得到 %+v", result)
}
if got := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); got != 1 {
t.Fatalf("同一个人在同一组别应只有 1 条报名,得到 %d 条", got)
}
}
// 模板要在最后多一列「队伍 / squad」:新列追加在表尾,现场手里少一列的旧表格一列都不会错位。
func TestEntriesTemplatePutsSquadLast(t *testing.T) {
env, _, client := recorderWithSeed(t)
for _, tc := range []struct{ lang, want string }{{"zh", "队伍"}, {"en", "squad"}} {
f, _ := env.fetchTemplate(t, client, tc.lang)
rows, err := f.GetRows(f.GetSheetList()[0])
if err != nil {
t.Fatalf("[%s] 读模板: %v", tc.lang, err)
}
header := rows[0]
if len(header) != 13 {
t.Fatalf("[%s] 模板表头应是 13 列(原 12 列 + 队伍),得到 %d 列:%v", tc.lang, len(header), header)
}
if header[len(header)-1] != tc.want {
t.Fatalf("[%s] 队伍列必须在最后一列(%q),得到 %q", tc.lang, tc.want, header[len(header)-1])
}
}
}
// 老模板(12 列,没有队伍列)照常导入:这是「新列加在最后」的全部意义——
// 现场手里那些列少的表格一列都不会错位,导入结果与升级前完全一样。
func TestImportSheetTemplateWithoutSquadColumnStillWorks(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID, _ := seedDoublesEvent(t, env, ids)
const legacyHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
data := buildTestXLSX(t, []testSheet{
{name: "羽毛球双打@男子", rows: [][]string{
strings.Split(legacyHeader, ","),
{"", "", "A", "", "", "", "", "301", "", "1", "registered", "张伟/李强"},
}},
})
status, resp := env.uploadEntries(t, client, "t.xlsx", data)
if status != http.StatusOK || !resp.OK {
t.Fatalf("老模板应照常导入,得到 %d %+v", status, resp.Error)
}
result := decodeData[entryImportResultBody](t, resp)
if result.Created != 1 || len(result.Errors) != 0 {
t.Fatalf("应新建 1 条且无报错:%+v", result)
}
want := "team_code=A squad= \n"
if got := squadDump(t, env, eventID); got != want {
t.Fatalf("老模板落库的队伍应是空串(不区分队伍):\n得到 %s期望 %s", got, want)
}
}
// entryIDOfSquad 取某小项里某个队的报名 id(空串 = 不区分队伍的那条)。
func entryIDOfSquad(t *testing.T, env *testEnv, eventID int64, squad string) int64 {
t.Helper()
var id int64
if err := env.ev.QueryRow(`SELECT id FROM entries WHERE event_id = ? AND squad = ? ORDER BY id LIMIT 1`,
eventID, squad).Scan(&id); err != nil {
t.Fatalf("读队伍 %q 的报名: %v", squad, err)
}
return id
}
@@ -1048,6 +1048,9 @@ var importColumns = []importColumn{
{name: "heat", aliases: []string{"批次", "批次号"}, optional: true},
{name: "status", aliases: []string{"状态", "报名状态"}},
{name: "member_names", aliases: []string{"队员名单"}},
// squad(队伍)挂在**最后**:新列追加在表尾,现场手里那些少一列的旧表格一列都不会错位。
// 空串 = 不区分队伍,与老口径一致;同一个单位在同一个组别派两个队就靠它区分(迁移 0025)。
{name: "squad", aliases: []string{"队伍", "队名", "队伍名"}, optional: true},
}
// label 是报错时用的列名:英文规范名 + 首选中文别名。缺列提示中英并列,
@@ -1228,6 +1231,9 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
categoryName := cell("category")
teamCode := cell("team_code")
athleteName := cell("athlete_name")
// 队伍是自由文本(一队 / 二队 / 青年队),不做任何归一:现场怎么写就怎么存,
// 「一队」与「一 队」是两个队(系统没有立场替现场合并)。
squad := cell("squad")
if teamCode == "" || (table.scope == nil && eventCode == "") {
result.Skipped++
if table.scope == nil {
@@ -1432,11 +1438,21 @@ func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, de
teamShares = nil
}
// 队伍只对**纯团体报名**(这一行没填姓名)生效。个人报名按 (组别, 单位, 运动员) 唯一,
// squad 不参与那条判断——填了就写不进库,所以要说一句,不能悄悄丢掉这一格。
if athleteID != nil && squad != "" {
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行填了队伍「%s」,但这一行有运动员姓名、按个人报名处理:队伍只对团体报名(不填姓名那几行)生效,这一格没有写进库里",
line, squad))
squad = ""
}
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
EventID: eventID,
CategoryID: categoryID,
TeamID: teamID,
TeamShares: teamShares,
Squad: squad,
AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(bib),
@@ -10,7 +10,7 @@ import (
// 积分平均分、奖牌两边各记一枚。
//
// 说明只能挂在「不会变成数据」的载体上:表头行是导入器认模板的锚点(必须留在第 1 行、
// 12 列一成不变),工作表名是「小项@组别」的范围锚点(也不能多出说明表),
// 列名与 importColumns 逐列一致),工作表名是「小项@组别」的范围锚点(也不能多出说明表),
// 所以这里用**单元格批注**(表头「单位编码」那一格)+ **该列的输入提示**(选中即弹出)。
func TestEntriesTemplateExplainsJointTeamCode(t *testing.T) {
env, _, client := recorderWithSeed(t)
+8
View File
@@ -53,6 +53,14 @@ type entryTeamSharesPayload struct {
RequestID string `json:"requestId"`
}
// entrySquadPayload 是 PUT /api/entries/{id}/squad 的请求体:改一条团体报名的队名。
// 队名是自由文本,服务端只 trim 首尾空白;空串(或只有空白)是**合法值**,
// 表示「不区分队伍」,不是「清掉这条报名」(见迁移 0025 与 updateEntrySquad)。
type entrySquadPayload struct {
Squad string `json:"squad"`
RequestID string `json:"requestId"`
}
// entryBatchPayload 是报名项批量操作的请求体:
// action 决定语义(scratch 退赛可恢复 / restore 恢复 / remove 物理移出报名),
// ids 是勾选的报名项(重复会自动去重),reason 可选、只进审计便于事后对账。
+24 -3
View File
@@ -712,10 +712,29 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
// 先确认小项存在(与 replaceCategories 同一处理由):不做这一步,小项不存在时
// 非空表撞 event_id 外键、却被报成「profileId 不存在」,让人去查一个没问题的模板。
if _, err := eventByIDQ(ctx, tx, eventID); err != nil {
event, err := eventByIDQ(ctx, tx, eventID)
if err != nil {
return nil, err
}
// compare=none 的小项排不出名次(名次靠人工录入),把 basis=result / aggregate 的通道存进去,
// 要等到发布时才会被 domain.ScoreEvent 以 ErrCompareRequired 拒绝(409 STATE_INVALID)——
// 那时成绩已经录了一半,回退代价高。规则包导入侧早有这条守卫
// (rules/diff.go checkCompareCompatible),手工在管理端配的这条路一直没人拦,在这里补上:
// 保存前整包拒收,一个字节都不写,并把「哪条通道、为什么」说清楚。
if event.Compare == string(domain.CompareNone) {
for i, c := range in {
if c.Basis != string(domain.BasisResult) && c.Basis != string(domain.BasisAggregate) {
continue
}
return nil, badRequest(fmt.Sprintf(
"小项「%s」的比较方向是「不比较」(compare=none),排不出名次:第 %d 条通道的 basis=%s 要按成绩排名,"+
"服务端算不出分、发布会失败。请把它改成「手动录名次」(basis=manual),"+
"或先给小项设置比较方向(compare=asc/desc)",
event.Name, i+1, c.Basis), map[string]any{"index": i, "basis": c.Basis, "compare": event.Compare})
}
}
if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil {
return nil, internal(err)
}
@@ -777,8 +796,9 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe
// ---------- entries ----------
// squad 挂在最后:列的追加不该动到既有列的下标(scanEntry 按位置扫,加在中间会整列错位)。
const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names,
e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name`
e.bib, e.seed, e.heat, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name, e.squad`
func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
var (
@@ -794,7 +814,8 @@ func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) {
categoryName sql.NullString
)
if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames,
&bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil {
&bib, &seed, &heat, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName,
&e.Squad); err != nil {
return e, err
}
e.AthleteID = i64Ptr(athleteID)
+52 -6
View File
@@ -646,6 +646,10 @@ func staleScoredEntryError(item staleScopeEntry) error {
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。
//
// 纯团体报名(athlete_id 为空)另加**队伍**这一段:同一个单位在同一个组别可以派多个队参赛
// (迁移 0025),所以查重键是「组别 + 单位 + 队伍」,队伍为空串就是「不区分队伍」的老口径。
// 带运动员的报名照旧只认 (组别, 单位, 运动员):同一个人在同一组别不会因为队伍多报一次。
//
// 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把
// 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。
//
@@ -663,8 +667,8 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing, &oldStatus)
} else {
err = s.ev.QueryRowContext(ctx,
`SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`,
in.CategoryID, in.TeamID).Scan(&existing, &oldStatus)
`SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL AND squad = ?`,
in.CategoryID, in.TeamID, entrySquad(in)).Scan(&existing, &oldStatus)
}
status := orDefault(in.Status, "registered")
@@ -675,12 +679,12 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
return entryImportOutcome{}, err
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, heat, status, squad)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status)
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, entrySquad(in))
if err != nil {
return entryImportOutcome{}, err
return entryImportOutcome{}, s.importedEntryConflictError(ctx, in, err)
}
newID, err := res.LastInsertId()
if err != nil {
@@ -726,6 +730,48 @@ func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImp
}
}
// entrySquad 返回该写进 entries.squad 的值:**队伍只对纯团体报名有意义**。
//
// 带运动员的报名按 (组别, 单位, 运动员) 唯一(表级 UNIQUE,不含 squad)。那一路一律写空串,
// 两件事同时成立:同一个人换个队伍写法重传仍是同一条(UPDATE,不会撞唯一键),
// 库里也不会出现「个人报名带着一队 / 二队」这种没人会去看的字段值。
func entrySquad(in entryDTO) string {
if in.AthleteID != nil {
return ""
}
return in.Squad
}
// importedEntryConflictError 把 INSERT 撞上唯一索引翻译成人话。
//
// 索引 idx_entries_team_squad_unique 的键是 (组别, 单位, 队伍) WHERE athlete_id IS NULL;
// 上面的查重与这次 INSERT 不是原子的,两个人同时导同一份名单、或库里那条报名正好在这两步之间
// 落库,就会撞上它。SQLite 原文是「UNIQUE constraint failed: entries.category_id,
// entries.team_id, entries.squad」——现场看不出是哪个单位、哪个队,也不知道该改哪一格,
// 所以在这里换成点名单位与队伍的一句话。
//
// 非唯一键冲突(外键、CHECK、锁超时)原样返回:那些不是「重复报名」,换成重复报名的说法会把人带偏。
func (s *server) importedEntryConflictError(ctx context.Context, in entryDTO, err error) error {
if !isUniqueViolation(err) {
return err
}
teamCode, codeErr := s.teamCodeByID(ctx, in.TeamID)
if codeErr != nil {
// 连单位编码都查不出来时宁可回原始错误:猜一句「单位 X 重复」会把排查方向带错。
return err
}
if in.AthleteID != nil {
return fmt.Errorf("单位 %s 在本组别已经有这个人的报名:同一个人在同一组别只能报一条,请把重复行合并后再导入", teamCode)
}
if in.Squad == "" {
return fmt.Errorf("单位 %s 在本组别已经有一条不分队的团体报名:队伍列留空就是不区分队伍,一个单位在一个组别只能有一条;"+
"要派两个队请在队伍列分别写清(例如 一队 / 二队)", teamCode)
}
// 队名非空那一句与「手工改队名」接口共用(entry_squad.go):两处说的若不是同一句话,
// 操作员在导入里学到的那句到了改队名页面就不成立。
return errors.New(squadTakenMessage(teamCode, in.Squad))
}
// recomputeImportedScopes 重算报名导入动过的 (小项, 组别),并推小项与榜单。
//
// 改的是「谁还算数」,与批量退赛/恢复/移出、作废成绩、撤销发布同一口径:
+3
View File
@@ -279,6 +279,9 @@ func (s *server) routes(mux *http.ServeMux) {
// 联队份额(devdocs/19):改一条报名的成员单位与权重,改完当场重算并推榜。
// 与其它报名写入同档权限(recorder):它改的是报名,权限不该比报名导入更宽。
mux.HandleFunc("PUT /api/entries/{id}/team-shares", s.write(auth.RoleRecorder, s.updateEntryTeamSharesHandler))
// 队名(迁移 0025):一个单位在一个组别可以派多个队,填错了要能直接改,不能靠「删掉重建」。
// 与上面那条同档权限(它改的也是报名);队名只是展示标签,改它不重算(见 updateEntrySquad)。
mux.HandleFunc("PUT /api/entries/{id}/squad", s.write(auth.RoleRecorder, s.updateEntrySquadHandler))
// 成绩
mux.HandleFunc("GET /api/results", s.public(s.listResultsHandler))
@@ -0,0 +1,82 @@
package httpapi_test
import (
"fmt"
"net/http"
"testing"
)
// 现场那份数据的服务端侧:台球(纯团体、名次靠人工录入)六个单位的名次只填在录入页底部的
// 「团体名次」卡片里(channel_manual_ranks),服务端要按规则包 team_top6 算出 15/10/8/4/2/1,
// 六个单位合计 40 分。
//
// 与 web/src/admin/tests/scoring-team-manual.test.ts 的第一个用例同输入同期望:录入页的实时预览
// 曾只读行上的 rank,这份数据在预览里显示成每个单位 0 分,而服务端一直算的是 40 分。
func TestTeamManualRanksSixUnitsProduce40Points(t *testing.T) {
env, ids, client := recorderWithSeed(t)
eventID := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type,
compare, scored, multiplier, status)
VALUES (?, 'BALL', '台球', 'team', 'manual', 'none', 'none', 1, 1, 'ongoing')`, ids.sport)
categoryID := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '混合组', 1, 0)`, eventID)
addTeamManualChannel(t, env, eventID)
// 六个参赛单位:名次不挂报名项,单位本身就是主体。
units := make([]int64, 0, 6)
ranks := make([]map[string]any, 0, 6)
for i := 1; i <= 6; i++ {
teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active)
VALUES (?, ?, ?, ?, 1)`, fmt.Sprintf("T%d", i), fmt.Sprintf("第 %d 单位", i), fmt.Sprintf("T%d", i), i)
units = append(units, teamID)
ranks = append(ranks, map[string]any{"teamId": teamID, "rank": i})
}
status, resp := putTeamRanks(t, env, client, eventID, categoryID, ranks)
if status != http.StatusOK || !resp.OK {
t.Fatalf("写六个单位的团体名次应 200,得到 %d %+v", status, resp.Error)
}
got := pointsByTeamDB(t, env, eventID)
want := []float64{15, 10, 8, 4, 2, 1}
total := 0.0
for i, teamID := range units {
total += got[teamID]
if got[teamID] != want[i] {
t.Fatalf("单位 %d(第 %d 名)= %v,期望 %v(全部:%v)", teamID, i+1, got[teamID], want[i], got)
}
}
if total != 40 {
t.Fatalf("六个单位合计 = %v,期望 40", total)
}
// 团体名次的分不挂任何报名项(现场成绩表里一行 rank 都没有)。
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger
WHERE voided = 0 AND (entry_id IS NOT NULL OR result_id IS NOT NULL)`); n != 0 {
t.Fatalf("团体名次的流水不该挂报名项,得到 %d 条", n)
}
}
// pointsByTeamDB 读某小项未作废流水里各单位的分值合计。
func pointsByTeamDB(t *testing.T, env *testEnv, eventID int64) map[int64]float64 {
t.Helper()
rows, err := env.ev.Query(`SELECT team_id, COALESCE(SUM(points), 0) FROM score_ledger
WHERE voided = 0 AND event_id = ? AND team_id IS NOT NULL GROUP BY team_id`, eventID)
if err != nil {
t.Fatalf("读流水: %v", err)
}
defer func() { _ = rows.Close() }()
out := make(map[int64]float64)
for rows.Next() {
var teamID int64
var points float64
if err := rows.Scan(&teamID, &points); err != nil {
t.Fatalf("扫流水: %v", err)
}
out[teamID] = points
}
if err := rows.Err(); err != nil {
t.Fatalf("遍历流水: %v", err)
}
return out
}