## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`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` 两张)。
915 lines
41 KiB
Go
915 lines
41 KiB
Go
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)
|
||
}
|