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) }