Files
Scoreboard/server/internal/httpapi/repo_import.go
T
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

840 lines
37 KiB
Go
Raw 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
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
func (s *server) lookupID(ctx context.Context, query string, args ...any) (int64, error) {
var id int64
if err := s.ev.QueryRowContext(ctx, query, args...).Scan(&id); err != nil {
return 0, err
}
return id, nil
}
// athleteImportRow 是报名表里一行的人员信息(Gender 已归一为 男/女 或空,Age 为空表示表里没填)。
type athleteImportRow struct {
Line int
Name string
Gender string
TeamID int64
TeamCode string
IDCard string
Bib string
Age *int
// Duplicate 表示这一行是「同一份表里同一个证件号 + 同一个小项同一个组别」的重复行。
// 同一个人报多个组别是合法的,只有落进同一个组别才是真重复;真重复两行的字段可能完全一样,
// 但要不要用表里这行覆盖已经落库的报名必须由用户决定,所以它一律进冲突面板。
Duplicate bool
}
// decisionKey 是冲突决策的键:优先证件号,没有证件号时用号码布。
func (in athleteImportRow) decisionKey() string {
if in.IDCard != "" {
return in.IDCard
}
return in.Bib
}
// athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。
// SameNameNewPerson 非空表示本次按「同名的另一个人」新建了档案(只作报告,不拦截)。
// Skip 表示本行是已决策为「保留已有」的真重复行:连报名也不写,库里那条原样不动。
type athleteImportMatch struct {
AthleteID int64
TeamID int64
Conflict *importConflict
SameNameNewPerson *sameNameNewPerson
Skip bool
}
// sameNameNewPerson 是「新建的档案与库中已有的同名人员疑似重复」的清单项:
// 导入响应里单独列出来给现场核对,避免新建了档案却毫无提示。
// 证件号与号码布都要带上:现场要核对的是「表里这两个号有没有写错」。
type sameNameNewPerson struct {
Name string `json:"name"`
TeamCode string `json:"teamCode"`
NewIDCard string `json:"newIdCard"`
ExistingIDCard string `json:"existingIdCard"`
NewBib string `json:"newBib"`
ExistingBib string `json:"existingBib"`
// Reason 说明为什么判成「另一个人」:identity = 证件号 / 号码布两边都有值且对不上;
// team = 这个单位下一个人都没有,但别的单位下有同名档案(多半是换了单位)。
// 现场核对的重点完全不同,所以两者要分得开。
Reason string `json:"reason"`
// ExistingTeamCode 是库中那条同名档案的单位编码(换单位场景要核对的就是它)。
ExistingTeamCode string `json:"existingTeamCode"`
}
// sameNameReason* 是 sameNameNewPerson.Reason 的取值。
const (
// sameNameReasonIdentity:表里的证件号(或号码布)与库中那条同名档案对不上 —— 多半是号写错了。
sameNameReasonIdentity = "identity"
// sameNameReasonTeam:本次这个单位下查不到同名的,但别的单位下有 —— 换了单位(也可能真是两个人同名)。
sameNameReasonTeam = "team"
)
// existingAthlete 是库中已有人员的可比字段。
type existingAthlete struct {
ID int64
Name string
Gender *string
TeamID int64
EmployeeNo *string
IDCard *string
Bib *string
Age *int
}
// matchImportedAthlete 按新口径匹配人员:证件号 → 号码布 → 姓名 + 单位 → 新建。
//
// 证件号才是人员的唯一标识,命中即认定同一个人(号码布差异按既有冲突口径处理,该进面板就进);
// 证件号没命中(或表里没给)才退到号码布。号码布命中的那个人已经拿着另一个证件号时,
// 说明「两个不同的人共用一个号码布」,这不是认人分歧而是数据错误,按既有号码布防呆口径报行级错误。
func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
if in.IDCard != "" {
ex, err := s.athleteByIDCard(ctx, in.IDCard)
switch {
case err == nil:
return s.matchExistingAthlete(ctx, in, ex, decisions)
case !errors.Is(err, sql.ErrNoRows):
return athleteImportMatch{}, internal(err)
}
}
if in.Bib != "" {
ex, err := s.athleteByBib(ctx, in.Bib)
switch {
case err == nil:
if in.IDCard != "" && ex.IDCard != nil && *ex.IDCard != "" && *ex.IDCard != in.IDCard {
// 号码布在库里有主,且那个人的证件号与表里对不上:两个人共用一个号码布。
// 沿用 createImportedAthlete 撞号码布唯一索引时的同一句话,现场看到的口径一致。
return athleteImportMatch{}, conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib))
}
return s.matchExistingAthlete(ctx, in, ex, decisions)
case !errors.Is(err, sql.ErrNoRows):
return athleteImportMatch{}, internal(err)
}
}
return s.matchByNameTeam(ctx, in, decisions)
}
// matchByNameTeam 处理「证件号与号码布都没命中」的情况,按姓名 + 单位找候选:
// 0 条 → 新建(并核对别的单位下有没有同名档案);1 条且身份字段有一边没填或都对得上 → 命中并回填;
// 1 条但两边都有值且都对不上 → 同名的另一个人,新建并报告;多条 → 仍有歧义,进冲突面板等用户决策。
func (s *server) matchByNameTeam(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) {
candidates, err := s.athletesByNameTeam(ctx, in.Name, in.TeamID)
if err != nil {
return athleteImportMatch{}, internal(err)
}
switch len(candidates) {
case 0:
created, err := s.createImportedAthlete(ctx, in)
if err != nil {
return athleteImportMatch{}, err
}
// 换了单位的人正是走到这一支:这个单位下查不到同名的,别的单位下却有一个。
// 不能替用户认人(同名不同单位往往真是两个人),但更不能悄悄多建一份档案 ——
// 两份档案各自带报名,成绩与积分从此分家(清单 A13)。
reported, err := s.sameNameElsewhere(ctx, in)
if err != nil {
return athleteImportMatch{}, err
}
created.SameNameNewPerson = reported
return created, nil
case 1:
ex := candidates[0]
// 两边都给了证件号 / 号码布、且对不上时才算「同名的另一个人」:
// 一边没给只是「未指定」,不能凭空多建一个缺身份的同名人。
// 证件号能对上的人前面就命中了,走到这里说明表里的证件号确实不属于他。
if identityDiffers(ex.IDCard, in.IDCard) || identityDiffers(ex.Bib, in.Bib) {
created, err := s.createImportedAthlete(ctx, in)
if err != nil {
return athleteImportMatch{}, err
}
reported, err := s.sameNameReport(ctx, in, ex, sameNameReasonIdentity)
if err != nil {
return athleteImportMatch{}, err
}
created.SameNameNewPerson = reported
return created, nil
}
return s.matchExistingAthlete(ctx, in, ex, decisions)
default:
return s.matchAmbiguousCandidates(ctx, in, candidates, decisions)
}
}
// sameNameReport 组装一条「新建的档案与库中这条同名人员疑似重复」的报告。
// existing 是库中那条同名档案(同单位的候选,或跨单位找到的那条),reason 说明为什么怀疑重复。
func (s *server) sameNameReport(ctx context.Context, in athleteImportRow, existing existingAthlete, reason string) (*sameNameNewPerson, error) {
code, err := s.teamCodeByID(ctx, existing.TeamID)
if err != nil {
return nil, err
}
return &sameNameNewPerson{
Name: in.Name,
TeamCode: in.TeamCode,
NewIDCard: in.IDCard,
ExistingIDCard: strValue(existing.IDCard),
NewBib: in.Bib,
ExistingBib: strValue(existing.Bib),
Reason: reason,
ExistingTeamCode: code,
}, nil
}
// sameNameElsewhere 找库中同名、但不在本次这个单位下的人员,用于报告「换了单位」造成的新建档案。
// 只报第一条(与多人候选只以第一条为代表同一口径):现场要核对的是「这个名字是不是本来就该是
// 同一个人」,列一串同名人反而看不出重点。
func (s *server) sameNameElsewhere(ctx context.Context, in athleteImportRow) (*sameNameNewPerson, error) {
ex, err := scanExistingAthlete(s.ev.QueryRowContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes WHERE name = ? AND team_id <> ? ORDER BY id LIMIT 1`,
in.Name, in.TeamID))
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, nil
case err != nil:
return nil, internal(err)
}
return s.sameNameReport(ctx, in, ex, sameNameReasonTeam)
}
// matchAmbiguousCandidates 处理姓名 + 单位命中多条候选:不能替用户挑人,一律进冲突面板。
// 决策键是整行身份(证件号优先、否则号码布),无法区分同一行的多个候选,
// 所以面板只以第一条候选为代表;有决策时按决策作用于该候选。
func (s *server) matchAmbiguousCandidates(ctx context.Context, in athleteImportRow, candidates []existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
ex := candidates[0]
if _, decided := decisions[in.decisionKey()]; decided {
return s.matchExistingAthlete(ctx, in, ex, decisions)
}
conflict, err := s.importConflictFor(ctx, in, ex, changedAthleteFields(ex, in), in.Duplicate)
if err != nil {
return athleteImportMatch{}, err
}
return athleteImportMatch{Conflict: &conflict}, nil
}
// createImportedAthlete 新建人员:证件号是唯一标识,能走到新建说明它在库里还没有主,
// 但录入错误 / 并发仍可能让唯一索引撞上,撞上时明确报出占用者是谁。
func (s *server) createImportedAthlete(ctx context.Context, in athleteImportRow) (athleteImportMatch, error) {
if err := s.idCardTakenError(ctx, in.IDCard, 0); err != nil {
return athleteImportMatch{}, err
}
res, err := s.ev.ExecContext(ctx,
`INSERT INTO athletes (name, gender, team_id, id_card, bib, age, active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
in.Name, nullStr(&in.Gender), in.TeamID, nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age))
switch {
case isIDCardConflict(err):
// 并发下唯一索引兜底:再查一次占用者,把「撞上谁」说清楚。
if taken := s.idCardTakenError(ctx, in.IDCard, 0); taken != nil {
return athleteImportMatch{}, taken
}
return athleteImportMatch{}, internal(err)
case isBibConflict(err):
return athleteImportMatch{}, conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib))
case err != nil:
return athleteImportMatch{}, internal(err)
}
id, err := lastInsertID(res)
if err != nil {
return athleteImportMatch{}, internal(err)
}
return athleteImportMatch{AthleteID: id, TeamID: in.TeamID}, nil
}
// idCardTakenError 检查证件号是否已被另一个人占用,占用时返回可直接给现场看的中文错误。
// 证件号优先匹配的人本来就会命中占用者、走冲突面板;这里兜的是「新建 / 回填一个库里已有主的证件号」
// 这条异常路径(正常导入走不到,录入错误与并发下才会):撞号时必须报清楚占用者是谁,
// 不允许静默改库或静默跳过。excludeID 是被更新的人(新建传 0),允许他自己继续持有该证件号。
func (s *server) idCardTakenError(ctx context.Context, idCard string, excludeID int64) error {
if idCard == "" {
return nil
}
var (
name string
bib sql.NullString
)
err := s.ev.QueryRowContext(ctx,
`SELECT name, bib FROM athletes WHERE id_card = ? AND id <> ? ORDER BY id LIMIT 1`, idCard, excludeID).
Scan(&name, &bib)
switch {
case errors.Is(err, sql.ErrNoRows):
return nil
case err != nil:
return internal(err)
}
occupied := "未填"
if bib.Valid && bib.String != "" {
occupied = bib.String
}
return conflict(fmt.Sprintf("证件号 %s 已被另一个人占用(姓名 %s,号码布 %s)", idCard, name, occupied))
}
// importRowMessage 把业务错误转成行级提示:去掉「CONFLICT:」这类 code 前缀,只留中文 message。
func importRowMessage(err error) string {
var api *apiError
if errors.As(err, &api) && api.message != "" {
return api.message
}
return err.Error()
}
// matchExistingAthlete 处理证件号 / 号码布 / 姓名 + 单位命中的人员:无差异时直接复用并补空字段,
// 有差异时走冲突流程,decisions 里对该身份有决策才落库。
// 真重复行(同证件号 + 同小项同组别)同样要用户拍板:未决策时进面板;已决策为保留已有时连报名都不写。
func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) {
changed := changedAthleteFields(ex, in)
action, decided := decisions[in.decisionKey()]
if in.Duplicate {
if !decided {
conflict, err := s.importConflictFor(ctx, in, ex, changed, true)
if err != nil {
return athleteImportMatch{}, err
}
return athleteImportMatch{Conflict: &conflict}, nil
}
if action == decisionKeepExisting {
// 保留已有:人员字段不动,库里那条报名也不动——本行整个跳过。
return athleteImportMatch{AthleteID: ex.ID, TeamID: ex.TeamID, Skip: true}, nil
}
}
if len(changed) == 0 {
if err := s.backfillEmptyAthleteFields(ctx, ex, in); err != nil {
return athleteImportMatch{}, err
}
return athleteImportMatch{AthleteID: ex.ID, TeamID: in.TeamID}, nil
}
conflict, err := s.importConflictFor(ctx, in, ex, changed, false)
if err != nil {
return athleteImportMatch{}, err
}
switch {
case !decided:
return athleteImportMatch{Conflict: &conflict}, nil
case action == decisionKeepExisting:
// 人员字段保持库里的值,报名也跟随库里的单位。
return athleteImportMatch{AthleteID: ex.ID, TeamID: ex.TeamID}, nil
default:
if err := s.overwriteAthleteFromImport(ctx, ex.ID, in); err != nil {
return athleteImportMatch{}, err
}
return athleteImportMatch{AthleteID: ex.ID, TeamID: in.TeamID}, nil
}
}
// importAthleteColumns 是人员匹配要读的字段,证件号/号码布/年龄用于回填与冲突判断。
const importAthleteColumns = `id, name, gender, team_id, employee_no, id_card, bib, age`
func scanExistingAthlete(row interface{ Scan(...any) error }) (existingAthlete, error) {
var (
a existingAthlete
gender, empNo, idCard, bib sql.NullString
teamID, age sql.NullInt64
)
if err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &idCard, &bib, &age); err != nil {
return existingAthlete{}, err
}
a.Gender = strPtr(gender)
a.EmployeeNo = strPtr(empNo)
a.IDCard = strPtr(idCard)
a.Bib = strPtr(bib)
a.Age = intPtr(age)
a.TeamID = teamID.Int64
return a, nil
}
func (s *server) athleteByBib(ctx context.Context, bib string) (existingAthlete, error) {
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes WHERE bib = ? LIMIT 1`, bib))
}
// athleteByIDCard 按证件号找人员。证件号上有部分唯一索引,最多一行。
func (s *server) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) {
return scanExistingAthlete(s.ev.QueryRowContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes WHERE id_card = ? LIMIT 1`, idCard))
}
// athletesByNameTeam 按「姓名 + 单位」列出全部候选,供调用方按候选数量决定是回填、新建还是进冲突面板。
// 证件号与号码布都已在前面按优先级查过,这里不再过滤。
func (s *server) athletesByNameTeam(ctx context.Context, name string, teamID int64) ([]existingAthlete, error) {
rows, err := s.ev.QueryContext(ctx,
`SELECT `+importAthleteColumns+` FROM athletes WHERE name = ? AND team_id = ? ORDER BY id`, name, teamID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
out := make([]existingAthlete, 0, 1)
for rows.Next() {
ex, err := scanExistingAthlete(rows)
if err != nil {
return nil, err
}
out = append(out, ex)
}
return out, rows.Err()
}
// changedAthleteFields 列出表里与库中不一致的字段名,顺序固定便于前端展示。
// 证件号/号码布只在两边都有值且不同时算冲突;库中为空属于待回填,不算冲突。
func changedAthleteFields(ex existingAthlete, in athleteImportRow) []string {
var changed []string
if ex.Name != in.Name {
changed = append(changed, "name")
}
// 性别与证件号/号码布同一口径(清单 A09):表里没填(含填了 男/女 以外的值)= 不参与比较;
// 库中为空 = 待回填,不算差异。之前是「库里为空就算差异」,一份没填性别列的旧名单会让
// 所有库里性别为空的人全掉进冲突面板(这些人本来还在静默回填证件号/号码布/年龄),
// 而性别又从不回填 —— 现场只能逐条点「使用新数据」,与 devdocs/05 §7 的匹配表相反。
// 只有两边都有值且不同(男 ↔ 女)才是真冲突。
if in.Gender != "" && ex.Gender != nil && *ex.Gender != "" && *ex.Gender != in.Gender {
changed = append(changed, "gender")
}
// 年龄只在两边都有值且不同时算冲突:历史数据没有年龄,缺一边属于待补,不算冲突。
if in.Age != nil && ex.Age != nil && *ex.Age != *in.Age {
changed = append(changed, "age")
}
if ex.TeamID != in.TeamID {
changed = append(changed, "teamCode")
}
if identityDiffers(ex.IDCard, in.IDCard) {
changed = append(changed, "idCard")
}
if identityDiffers(ex.Bib, in.Bib) {
changed = append(changed, "bib")
}
return changed
}
// identityDiffers 判断身份字段(证件号/号码布)是否冲突:两边都有值且不同才算。
func identityDiffers(dbVal *string, inVal string) bool {
return inVal != "" && dbVal != nil && *dbVal != "" && *dbVal != inVal
}
// backfillEmptyAthleteFields 只补库中为空的证件号/号码布/年龄/性别,已有值不动(差异已由 changedAthleteFields 判过)。
func (s *server) backfillEmptyAthleteFields(ctx context.Context, ex existingAthlete, in athleteImportRow) error {
if in.IDCard != "" && (ex.IDCard == nil || *ex.IDCard == "") {
if err := s.idCardTakenError(ctx, in.IDCard, ex.ID); err != nil {
return err
}
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, ex.ID); err != nil {
if isIDCardConflict(err) {
if taken := s.idCardTakenError(ctx, in.IDCard, ex.ID); taken != nil {
return taken
}
}
return internal(err)
}
}
if in.Bib != "" && (ex.Bib == nil || *ex.Bib == "") {
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET bib = ? WHERE id = ?`, in.Bib, ex.ID); err != nil {
return internal(err)
}
}
if in.Age != nil && ex.Age == nil {
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET age = ? WHERE id = ?`, *in.Age, ex.ID); err != nil {
return internal(err)
}
}
// 性别与证件号/号码布/年龄一并回填:历史名单里性别为空是常态,口径就是「库中为空 = 待回填」,
// 不回填就等于把这批人永远留在空性别上,下一份表再导入还会被当成差异(清单 A09)。
if in.Gender != "" && (ex.Gender == nil || *ex.Gender == "") {
if _, err := s.ev.ExecContext(ctx, `UPDATE athletes SET gender = ? WHERE id = ?`, in.Gender, ex.ID); err != nil {
return internal(err)
}
}
return nil
}
func (s *server) importConflictFor(ctx context.Context, in athleteImportRow, ex existingAthlete, changed []string, duplicate bool) (importConflict, error) {
existingCode, err := s.teamCodeByID(ctx, ex.TeamID)
if err != nil {
return importConflict{}, err
}
return importConflict{
Row: in.Line,
IDCard: in.IDCard,
Bib: in.Bib,
AthleteID: ex.ID,
Duplicate: duplicate,
Existing: importAthleteView{
Name: ex.Name, Gender: ex.Gender, TeamCode: existingCode, EmployeeNo: ex.EmployeeNo,
IDCard: ex.IDCard, Bib: ex.Bib, Age: ex.Age,
},
Incoming: importAthleteView{
Name: in.Name, Gender: nilIfEmpty(in.Gender), TeamCode: in.TeamCode,
IDCard: nilIfEmpty(in.IDCard), Bib: nilIfEmpty(in.Bib), Age: in.Age,
},
ChangedFields: changed,
}, nil
}
func (s *server) teamCodeByID(ctx context.Context, id int64) (string, error) {
if id == 0 {
return "", nil
}
var code string
if err := s.ev.QueryRowContext(ctx, `SELECT code FROM teams WHERE id = ?`, id).Scan(&code); err != nil {
return "", internal(err)
}
return code, nil
}
// overwriteAthleteFromImport 用表里的字段覆盖库中人员(keep-new 决策)。
// 表里没填的字段保留库里的值,避免决策后反而丢字段。
func (s *server) overwriteAthleteFromImport(ctx context.Context, id int64, in athleteImportRow) error {
if in.IDCard != "" {
if err := s.idCardTakenError(ctx, in.IDCard, id); err != nil {
return err
}
}
if _, err := s.ev.ExecContext(ctx,
`UPDATE athletes SET name = ?, team_id = ?, gender = COALESCE(?, gender),
id_card = COALESCE(?, id_card), bib = COALESCE(?, bib), age = COALESCE(?, age) WHERE id = ?`,
in.Name, in.TeamID, nullStr(&in.Gender), nullStr(&in.IDCard), nullStr(&in.Bib), nullInt(in.Age), id); err != nil {
switch {
case isIDCardConflict(err):
if taken := s.idCardTakenError(ctx, in.IDCard, id); taken != nil {
return taken
}
case isBibConflict(err):
return conflict(fmt.Sprintf("号码布 %s 已被其它人员使用", in.Bib))
}
return internal(err)
}
return nil
}
// entryImportOutcome 是一次导入写入的落点,供调用方判断「要不要重算这一组」。
//
// 报名导入会改 entries.status(现场「改完名单重新导入一遍」很自然),
// 而 status 正是「谁还算数」的开关:把正常参赛的人改成退赛/失格,
// 他在名次与流水里就必须当场消失,否则会出现「名单说退赛、榜上还挂着分数与金牌」。
type entryImportOutcome struct {
// Created 表示这次是新建报名项:名单变了,团体平均口径(basis=aggregate + avg_mode=all)
// 的分母跟着变,所以新建同样要重算这一组。
Created bool
// EventID / CategoryID 是这条报名项所在的 (小项, 组别);重算与推榜的最小单位。
EventID int64
CategoryID int64
// StatusChanged 表示「可排名 / 不可排名」跨了界(registered ↔ scratched / dq / dns)。
// 同一侧的状态改动(scratched → dq)不影响谁算数,不触发重算。
StatusChanged bool
// MovedEntries 是这次新建报名时顺带移出的「旧单位过期报名」(换单位场景)。
// 它不参与重算判断(Created 已经覆盖),只作报告:数据口径是对的,但操作员看不见
// 就等于没发生,调用方要把它转成 notices[] 说出来(清单 A10)。
MovedEntries []movedSameScopeEntry
}
// movedSameScopeEntry 报告一条「换单位后旧单位那条报名已被移出」。
// 姓名与新/旧单位都要带上:现场要核对的正是「这个人是不是真的换了单位」。
type movedSameScopeEntry struct {
Name string
OldTeamCode string
NewTeamCode string
}
// entryStatusRankable 与 domain.EntryStatus.rankable() 同一口径:
// 空值按 registered 处理,只有 registered 可参与排名(见 domain/types.go)。
// 那个方法是 domain 包内未导出的,httpapi 侧只能在此复述口径;domain 那边改了这里要一起改。
func entryStatusRankable(status string) bool {
return status == "" || status == string(domain.EntryStatusRegistered)
}
// staleScopeEntry 是「同一个人 + 同一个小项 + 同一个组别」下、单位与本次导入不同的旧报名。
type staleScopeEntry struct {
ID int64
Name string
TeamCode string
}
// releaseStaleSameScopeEntries 在给这个人**新建**报名之前,处置同一个小项同一个组别里
// 单位与本次不同的旧报名。
//
// entries 的唯一键是 (category_id, team_id, athlete_id),换了单位就绕开了它:keep-new 把人员
// 改到新单位后,旧单位那条报名会原样留着 —— 同一个人在同一个组别出现两条,成绩录入页看得见
// 两条,现场各录一次成绩就是同一人同一组别两条成绩、重复计分(清单 A10);
// docs/报名名单导入规范.md 也承诺同一个项目的报名「不会产生重复报名」。
//
// 处置只有两种,都不许凭空销毁数据:
// - 旧报名没有任何成绩痕迹(results / score_ledger / record_breaks 全空)→ 按批量移出的口径
// 物理移出。它只是一条过期的名单行,删掉不丢任何数据;新单位这条紧接着新建,
// 调用方拿到 Created 就会重算这一组(与 recomputeImportedScopes 同一口径,
// 团体平均口径的分母也跟着这一增一减重算)。
// - 旧报名已经留下成绩痕迹 → 一行不动并拦下本行:删掉等于抹掉已经产生的名次与积分,
// 那属于「成绩 / 榜单」的处置,必须由用户到成绩录入页用批量移出显式做
// (removeEntriesTx 会连带清流水并重算),导入不许替他做这个决定。
//
// 返回被移出的旧报名(可能为空):调用方要拿它给操作员一条 notices 提示(清单 A10)。
func (s *server) releaseStaleSameScopeEntries(ctx context.Context, in entryDTO) ([]staleScopeEntry, error) {
// 团体行(athlete_id 为空)不受影响:同一个组别里两个单位各报一支队伍本来就是正常的。
if in.AthleteID == nil {
return nil, nil
}
rows, err := s.ev.QueryContext(ctx,
`SELECT e.id, a.name, t.code FROM entries e
JOIN athletes a ON a.id = e.athlete_id
JOIN teams t ON t.id = e.team_id
WHERE e.event_id = ? AND e.category_id = ? AND e.athlete_id = ? AND e.team_id <> ?
ORDER BY e.id`, in.EventID, in.CategoryID, *in.AthleteID, in.TeamID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
stale := make([]staleScopeEntry, 0, 1)
for rows.Next() {
var item staleScopeEntry
if err := rows.Scan(&item.ID, &item.Name, &item.TeamCode); err != nil {
return nil, internal(err)
}
stale = append(stale, item)
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
for _, item := range stale {
scored, err := s.entryHasResultTraces(ctx, item.ID)
if err != nil {
return nil, err
}
if scored {
return nil, staleScoredEntryError(item)
}
}
for _, item := range stale {
// 上面已确认这条旧报名没有任何成绩痕迹,所以只删报名行本身:批量移出(removeEntriesTx)
// 还要连带清流水 / 成绩 / 破纪录,是因为那边允许移出已有成绩的报名,这里没有可清的。
if _, err := s.ev.ExecContext(ctx, `DELETE FROM entries WHERE id = ?`, item.ID); err != nil {
// 并发下刚有人给这条旧报名录了成绩:results 对 entries 没有 ON DELETE,外键会挡住删除。
// 那就按「已有成绩」处理并拦下本行,绝不强行删掉别人的成绩痕迹。
if isForeignKeyViolation(err) {
return nil, staleScoredEntryError(item)
}
return nil, internal(err)
}
}
return stale, nil
}
// entryHasResultTraces 判断一条报名是否已经留下成绩痕迹:录过成绩(草稿也算)、
// 有过流水、或登记过破纪录,任意一样成立就不再是「可以随手移出的过期名单行」。
func (s *server) entryHasResultTraces(ctx context.Context, entryID int64) (bool, error) {
var traces int
if err := s.ev.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM results WHERE entry_id = ?)
OR EXISTS(SELECT 1 FROM score_ledger WHERE entry_id = ?)
OR EXISTS(SELECT 1 FROM record_breaks WHERE entry_id = ?)`,
entryID, entryID, entryID).Scan(&traces); err != nil {
return false, internal(err)
}
return traces != 0, nil
}
// staleScoredEntryError 是「旧报名已有成绩」时的行级提示:不能只说不能做,必须给出下一步。
// 返回纯 error 而不是 apiError:调用方(handlers_config.go 的导入主循环)对 upsert 的失败
// 用 err.Error() 原样回显,包成 apiError 会多出「CONFLICT:」前缀,现场看不懂。
func staleScoredEntryError(item staleScopeEntry) error {
return fmt.Errorf("%s 在本组别还有一条单位 %s 的报名(已录过成绩),换单位后同一个人在同一组别不能留两条;请先到成绩录入页把单位 %s 那条移出,再重新上传这一行",
item.Name, item.TeamCode, item.TeamCode)
}
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。
//
// 纯团体报名(athlete_id 为空)另加**队伍**这一段:同一个单位在同一个组别可以派多个队参赛
// (迁移 0025),所以查重键是「组别 + 单位 + 队伍」,队伍为空串就是「不区分队伍」的老口径。
// 带运动员的报名照旧只认 (组别, 单位, 运动员):同一个人在同一组别不会因为队伍多报一次。
//
// 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把
// 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。
//
// 唯一键是 (category_id, team_id, athlete_id),换了单位就绕开了它:新建之前必须先处置
// 同一个小项同一个组别里另一单位的那条旧报名(见 releaseStaleSameScopeEntries)。
func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (entryImportOutcome, error) {
var (
existing int64
oldStatus string
err error
)
if in.AthleteID != nil {
err = s.ev.QueryRowContext(ctx,
`SELECT id, status FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id = ?`,
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 AND squad = ?`,
in.CategoryID, in.TeamID, entrySquad(in)).Scan(&existing, &oldStatus)
}
status := orDefault(in.Status, "registered")
switch {
case errors.Is(err, sql.ErrNoRows):
moved, err := s.releaseStaleSameScopeEntries(ctx, in)
if err != nil {
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, squad)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status, entrySquad(in))
if err != nil {
return entryImportOutcome{}, s.importedEntryConflictError(ctx, in, err)
}
newID, err := res.LastInsertId()
if err != nil {
return entryImportOutcome{}, internal(err)
}
if err := replaceEntryShares(ctx, s.ev, newID, in.TeamID, in.TeamShares); err != nil {
return entryImportOutcome{}, err
}
outcome := entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}
if len(moved) > 0 {
// 只在真移出过旧报名时查一次新单位编码:这是给现场看的提示文案的一部分,
// 平时的导入不为它付代价。
newCode, err := s.teamCodeByID(ctx, in.TeamID)
if err != nil {
return entryImportOutcome{}, err
}
for _, item := range moved {
outcome.MovedEntries = append(outcome.MovedEntries, movedSameScopeEntry{
Name: item.Name, OldTeamCode: item.TeamCode, NewTeamCode: newCode,
})
}
}
return outcome, nil
case err != nil:
return entryImportOutcome{}, err
default:
if _, err := s.ev.ExecContext(ctx,
`UPDATE entries SET member_names = ?, bib = ?, seed = ?, heat = ?, status = ? WHERE id = ?`,
marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat),
status, existing); err != nil {
return entryImportOutcome{}, err
}
// 份额整组重写:同一行重传要幂等,改了写法(联队改回单单位、换成员)也要跟着变,
// 只增不改会把上一次的配置一直用下去(见 replaceEntryShares)。
if err := replaceEntryShares(ctx, s.ev, existing, in.TeamID, in.TeamShares); err != nil {
return entryImportOutcome{}, err
}
return entryImportOutcome{
EventID: in.EventID,
CategoryID: in.CategoryID,
StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status),
}, nil
}
}
// 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 重算报名导入动过的 (小项, 组别),并推小项与榜单。
//
// 改的是「谁还算数」,与批量退赛/恢复/移出、作废成绩、撤销发布同一口径:
// 延迟结算(standings.live_on_publish=false)下也必须当场重算并推榜,
// 否则名单里明明已经退赛的人会一直挂着名次与分数,直到下一次结算。
//
// 性能:调用方只把「新建过报名项」或「可排名状态跨了界」的组传进来,且已按组去重,
// 所以几百行的导入通常只落到个位数组别;每组一次 recomputeLedgerTx。
// 全部组共用一个事务:任一失败就整体回滚,不会留下「只重算了一半」的榜;
// 榜单只在最后推一次,避免大屏收一串中间态(推榜次数也是用例的观察点)。
func (s *server) recomputeImportedScopes(ctx context.Context, scopes []entryScope, actorID int64) error {
if len(scopes) == 0 {
return nil
}
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
eventIDs := make([]int64, 0, 2)
for _, scope := range scopes {
// 改成退赛 / 失格的人不能再占名次:results.rank 这一列必须让出来。
// applyComputedRanks 只写不清,不清就会留下「名单说退赛、名次还挂在他名下」——
// 导出的分项成绩表读的正是这一列,会把他继续列成第 N 名。
// 与批量退赛同口径(entries_batch 对退赛行显式 UPDATE ... rank = NULL)。
if _, err := tx.ExecContext(ctx,
`UPDATE results SET rank = NULL
WHERE state = 'published' AND rank IS NOT NULL
AND entry_id IN (SELECT id FROM entries
WHERE event_id = ? AND category_id = ? AND status <> 'registered')`,
scope.eventID, scope.categoryID); err != nil {
return internal(err)
}
score, err := recomputeLedgerTx(ctx, tx, scope.eventID, scope.categoryID, actorID, "报名导入后重算")
if err != nil {
return err
}
// 只重建流水不够:成绩表看的是 results.rank(个人名次),必须像发布那样把名次写回去。
if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil {
return err
}
if !slices.Contains(eventIDs, scope.eventID) {
eventIDs = append(eventIDs, scope.eventID)
}
}
for _, eventID := range eventIDs {
if err := s.bumpEvent(ctx, tx, eventID); err != nil {
return err
}
}
if err := s.bumpStandings(ctx, tx, actorID); err != nil {
return err
}
return tx.Commit()
}
// addEntryScope 把受影响范围按出现顺序去重:同一批导入里每个 (小项, 组别) 只重算一次。
func addEntryScope(scopes []entryScope, eventID, categoryID int64) []entryScope {
scope := entryScope{eventID: eventID, categoryID: categoryID}
if slices.Contains(scopes, scope) {
return scopes
}
return append(scopes, scope)
}