Files
Scoreboard/server/internal/httpapi/repo_import.go
T
AskaEth 99c91f99f7 feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」

**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。

## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。

## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
  `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。

## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
  1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
  2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
  3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
  反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。

## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
  改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。

## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。

## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
  ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。

## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。

## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 14:05:48 +08:00

915 lines
41 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
// MembersChanged 表示这次更新改动了报名的**队员关联**(换人、加人)。
// 队员决定谁拿到个人分与个人奖牌,所以它必须重算——与 status 跨界同一类「谁还算数」的改动,
// 而与姓名 / 号码布 / 队伍 / 批次那类纯展示字段不同(那些改了不重算)。
MembersChanged 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
}
if _, err := s.syncImportedEntryMembers(ctx, newID, in); 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
}
membersChanged, err := s.syncImportedEntryMembers(ctx, existing, in)
if err != nil {
return entryImportOutcome{}, err
}
return entryImportOutcome{
EventID: in.EventID,
CategoryID: in.CategoryID,
StatusChanged: entryStatusRankable(oldStatus) != entryStatusRankable(status),
MembersChanged: membersChanged,
}, nil
}
}
// syncImportedEntryMembers 把导入这一行认出来的队员整组写到报名上(新增与更新两条路都要调),
// 返回这次有没有真的改动队员关联。
//
// **一格认出了人才重写**:认出了号码布的一格就是这条报名的完整队员名单 —— 整组重写(先删后插,
// 与份额同一口径),重传同一行幂等,删掉一个号码布那个人就跟着从队里出去。
// 一个号码布都认不出来(整列留空、只写姓名、或号码布认不出人)时这一格**只当文字**,
// 已有的队员关联一行都不动:
// - 老表(队员列只写姓名)在升级后重传必须幂等 —— 迁移按「号码布优先、其次姓名+单位唯一」回填
// 出来的队员,不能因为重传一份没有号码布的名单就被悄悄清掉(那等于让人无声无息掉出个人积分榜);
// - 界面上勾好的队员也不能被「用模板重导一遍」冲掉(模板的队员名单列本来就是空的);
// - 要清空队员走界面(PUT /api/entries/{id}/athletes 传空数组),那是明确表达出来的意图。
// 这条与「认不上就只当文字、别报错」是同一句话:认不出的格子对这条报名不产生任何影响。
//
// 返回值只比**人的集合**,不比顺序:队员顺序只影响「队里有谁」的排列,名次与积分不看它
// (见 entry_athletes 表注释),所以只是换了个写法重传不该把这一组白算一遍。
// 关联真的变了就必须重算——队员决定谁拿到个人分与个人奖牌,不重算就是「名单里换人了、榜上还是旧人」。
//
// 带运动员的报名一个字节都不写:它的身份就是那一个运动员,队员只属于「只报单位」的团体报名
// (与 PUT /api/entries/{id}/athletes 的同一条口径)。
func (s *server) syncImportedEntryMembers(ctx context.Context, entryID int64, in entryDTO) (bool, error) {
if in.AthleteID != nil || len(in.Athletes) == 0 {
return false, nil
}
before, err := entryAthleteIDsQ(ctx, s.ev, entryID)
if err != nil {
return false, err
}
after := entryAthleteIDs(in.Athletes)
if err := replaceEntryAthletes(ctx, s.ev, entryID, in.Athletes); err != nil {
return false, err
}
return !slices.Equal(before, after), nil
}
// entryAthleteIDsQ 读一条报名当前的队员 id(升序),用于判断这次导入有没有改动关联。
func entryAthleteIDsQ(ctx context.Context, q queryer, entryID int64) ([]int64, error) {
rows, err := q.QueryContext(ctx,
`SELECT athlete_id FROM entry_athletes WHERE entry_id = ? ORDER BY athlete_id`, entryID)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := []int64{}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, internal(err)
}
out = append(out, id)
}
return out, rows.Err()
}
// entryAthleteIDs 把要写进去的队员摊成升序的 id 列表(与 entryAthleteIDsQ 同一种形状好比较)。
func entryAthleteIDs(athletes []entryAthleteDTO) []int64 {
out := make([]int64, 0, len(athletes))
for _, a := range athletes {
out = append(out, a.AthleteID)
}
slices.Sort(out)
return out
}
// 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)
}