Files
Scoreboard/server/internal/httpapi/repo_import.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

581 lines
24 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"`
}
// 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:
return s.createImportedAthlete(ctx, in)
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
}
created.SameNameNewPerson = &sameNameNewPerson{
Name: in.Name,
TeamCode: in.TeamCode,
NewIDCard: in.IDCard,
ExistingIDCard: strValue(ex.IDCard),
NewBib: in.Bib,
ExistingBib: strValue(ex.Bib),
}
return created, nil
}
return s.matchExistingAthlete(ctx, in, ex, decisions)
default:
return s.matchAmbiguousCandidates(ctx, in, candidates, decisions)
}
}
// 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")
}
// 性别只认 男/女:表里没填或填了其它值时不做比较,避免误报冲突。
if in.Gender != "" && (ex.Gender == nil || *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)
}
}
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
}
// entryStatusRankable 与 domain.EntryStatus.rankable() 同一口径:
// 空值按 registered 处理,只有 registered 可参与排名(见 domain/types.go)。
// 那个方法是 domain 包内未导出的,httpapi 侧只能在此复述口径;domain 那边改了这里要一起改。
func entryStatusRankable(status string) bool {
return status == "" || status == string(domain.EntryStatusRegistered)
}
// upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回落点信息,Created 表示新建。
//
// 旧 status 必须在 UPDATE 之前读出来:调用方要靠它判断「这次导入有没有把
// 正常参赛改成退赛 / 失格」,进而决定要不要重算这一组(见 recomputeImportedScopes)。
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):
if _, 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); err != nil {
return entryImportOutcome{}, err
}
return entryImportOutcome{Created: true, EventID: in.EventID, CategoryID: in.CategoryID}, 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
}
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)
}