Files
Scoreboard/server/internal/httpapi/repo_import.go
T
AskaEth 830a4dbdb7 feat(joint-team): 联队 + 份额分成 —— 迁移 0024、引擎按份额拆、导出 / 大屏 / 录入三路打通;版本 dev_0.5.3
## 需求
现场 6 项球类比赛里,有两个单位因参赛人数不足**合并成一个单位参赛**。
做法不是新建「联队单位」(那样榜单会多出第 8 个单位,且分和奖牌分不到原单位头上),
而是**一条报名挂多个单位、按份额拆**。

## 拍板口径
- 份额**可配、默认均分**
- 积分**平均拆、接受 .5**(团体第 1 名 15 分 → 两个单位各 7.5 分)
- 奖牌**各记一枚**(两个单位各 1 枚完整金牌,不是各 0.5 枚)
- 显示上把联队名字写全(形如 `第一分公司+第二分公司联队`),成员单位的 logo 都放
- 导入表里单位编码写 `A+B`

## 一、核心层(数据模型 + 引擎 + 存储 + 报名写入 + 导入解析)
- 迁移 `0024_entry_team_shares.sql`:新建 `entry_team_shares(entry_id, team_id, points_share,
  medal_share, sort_order)`。**两个口径分开表达**是刻意的——积分要拆成小数(7.5)、奖牌要整枚
  各记,用一个数表达就会把「7.5 分」和「半枚金牌」绑在一起,奖牌榜立刻错。
  `entries.team_id` 保留为**主单位**;老数据 `INSERT OR IGNORE` 回填成「主单位独占 100%」。
- 引擎:`ResolveShares`(引擎与读侧共用)、`splitSharePoints`(按权重归一、**末位吞余数**,
  各份之和恒等于原来那一份)、`TeamShare.Medal`(缺省整枚)。积分按权重拆,奖牌每个份额各记整枚。
- 报名写入:`PUT /api/entries/{id}/team-shares`(recorder 及以上,保存后当场重算该小项组别并推榜)。
- 导入:`team_code` 支持 `A+B`(主单位 = 加号左边,均分,奖牌各整枚);非法形态
  (`A+` / `+B` / `A++B` / `A+A` / 成员 >5 / 单位不存在)**行级拦下、不落库**,给人话报错。

## 二、导出与导入模板
- 11 份导出件:联队写全成员单位(`第一分公司+第二分公司联队`),并注明**分拆给了哪几家、各多少**
  (份额占比 + 各单位实得积分,取自按份额拆完的流水)。
- 单位奖牌明细:奖牌格写「金牌(联队 …,各单位各记 1 枚)」;含联队时副标题说明
  「各单位奖牌数之和会大于实际颁发数」——这是口径 3 的必然结果,**不是 bug**。
- 按单位筛选导出把成员单位一起算上(否则 B 单位导自己的名单会看不到自己参加的那场联队比赛)。
- 分工作表模板:`A+B` 写法挂在「单位编码」表头的**单元格批注 + 输入提示**(中英各一份);
  **表头文字、列序、12 列、工作表命名一个字未动**(动了模板就导不回去)。

## 三、前端与大屏
- 录入页 / 复核页「单位」列:联队显示 `联队 第一分公司 67%(权重 2) 第二分公司 33%`,
  点该格打开「成员单位与份额」弹窗(加/删成员、改权重、主单位徽标、实时比例预览)。
  **普通报名(单单位)的文案、元素、类名一字未动**,只多了一个 `@click` 入口与悬停提示。
- 大屏:分项成绩 / 实时赛况单位列显示联队全名并**放上两个单位的 logo**(单位列宽按该行的
  logo 数留位);底栏同理。

## 四、验证(都留了证据)
- **老数据逐字节不变**(改算分引擎的硬门槛):引擎层 golden `a2aa1c97…`、接口层 golden
  `aa6cea37…`,两个哈希由主会话**在修前树上亲手重跑确认**;导出层另有 11 份导出件逐行对拍,
  差异 **0 处**。
- 前端 773 → **809 例**全绿(旧用例一行未改);服务端 14 包两轮全绿;Console 68 例全绿。
- 真机联调:录入页份额弹窗、大屏两个 logo、奖牌榜 A/B 各 1 金、写路径(移除成员 → 保存 → 200)。

## 五、版本
`dev_0.5.2` → **`dev_0.5.3`**(本批含数据迁移 0024 + 新功能,必须与上一包区分开);
安装器素材重生成(版本号烙在位图里,实测只有 `brand.bmp` 的 1x/2x 两张变化,其余 92 张逐字节未变)。
2026-09-14 03:52:34 +08:00

794 lines
34 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 表示新建。
//
// 旧 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`,
in.CategoryID, in.TeamID).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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames),
nullStr(in.Bib), nullStr(in.Seed), nullI64(in.Heat), status)
if err != nil {
return entryImportOutcome{}, 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
}
}
// 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)
}