package httpapi import ( "context" "database/sql" "errors" ) 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 } // decisionKey 是冲突决策的键:优先证件号,没有证件号时用号码布。 func (in athleteImportRow) decisionKey() string { if in.IDCard != "" { return in.IDCard } return in.Bib } // athleteImportMatch 是人员匹配结果;Conflict 非空表示本轮不落库,等用户决策。 type athleteImportMatch struct { AthleteID int64 TeamID int64 Conflict *importConflict } // existingAthlete 是库中已有人员的可比字段。 type existingAthlete struct { ID int64 Name string Gender *string TeamID int64 EmployeeNo *string IDCard *string Bib *string Age *int } // matchImportedAthlete 按契约顺序匹配人员:证件号 → 号码布 → 姓名+单位(库中证件号为空则回填)→ 新建。 // 命中人员字段与表里不一致时记为冲突;decisions 里对同一身份有决策时按决策落库。 func (s *server) matchImportedAthlete(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) { existing, err := s.athleteByIdentity(ctx, in) switch { case err == nil: return s.matchExistingAthlete(ctx, in, existing, decisions) case errors.Is(err, sql.ErrNoRows): return s.matchOrCreateAthleteByName(ctx, in, decisions) default: return athleteImportMatch{}, internal(err) } } // athleteByIdentity 先按证件号精确匹配,未命中再按号码布精确匹配。 func (s *server) athleteByIdentity(ctx context.Context, in athleteImportRow) (existingAthlete, error) { if in.IDCard != "" { ex, err := s.athleteByIDCard(ctx, in.IDCard) if !errors.Is(err, sql.ErrNoRows) { return ex, err } } if in.Bib != "" { return s.athleteByBib(ctx, in.Bib) } return existingAthlete{}, sql.ErrNoRows } // matchExistingAthlete 处理证件号/号码布命中的人员:无差异时直接复用并补空字段, // 有差异时走冲突流程,decisions 里对该身份有决策才落库。 func (s *server) matchExistingAthlete(ctx context.Context, in athleteImportRow, ex existingAthlete, decisions map[string]string) (athleteImportMatch, error) { changed := changedAthleteFields(ex, in) 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) if err != nil { return athleteImportMatch{}, err } action, decided := decisions[in.decisionKey()] 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 } } // matchOrCreateAthleteByName 处理证件号/号码布都未命中的情况: // 姓名+单位命中且库中证件号为空 → 视为同一人(差异按冲突处理);否则新建人员。 func (s *server) matchOrCreateAthleteByName(ctx context.Context, in athleteImportRow, decisions map[string]string) (athleteImportMatch, error) { existing, err := s.athleteByNameTeam(ctx, in.Name, in.TeamID) switch { case err == nil: return s.matchExistingAthlete(ctx, in, existing, decisions) case !errors.Is(err, sql.ErrNoRows): return athleteImportMatch{}, internal(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)) if 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 } // 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) athleteByIDCard(ctx context.Context, idCard string) (existingAthlete, error) { return scanExistingAthlete(s.ev.QueryRowContext(ctx, `SELECT `+importAthleteColumns+` FROM athletes WHERE id_card = ? LIMIT 1`, idCard)) } 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)) } // athleteByNameTeam 按「姓名+单位」找库中还没填证件号的人,用于回填身份字段。 func (s *server) athleteByNameTeam(ctx context.Context, name string, teamID int64) (existingAthlete, error) { return scanExistingAthlete(s.ev.QueryRowContext(ctx, `SELECT `+importAthleteColumns+` FROM athletes WHERE name = ? AND team_id = ? AND (id_card IS NULL OR id_card = '') ORDER BY id LIMIT 1`, name, teamID)) } // 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.ev.ExecContext(ctx, `UPDATE athletes SET id_card = ? WHERE id = ?`, in.IDCard, ex.ID); err != nil { 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) (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, 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 _, 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 { return internal(err) } return nil } // upsertImportedEntry 按「组别 + 单位 + 运动员」去重;返回 true 表示新建。 func (s *server) upsertImportedEntry(ctx context.Context, in entryDTO) (bool, error) { var existing int64 var err error if in.AthleteID != nil { err = s.ev.QueryRowContext(ctx, `SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id = ?`, in.CategoryID, in.TeamID, *in.AthleteID).Scan(&existing) } else { err = s.ev.QueryRowContext(ctx, `SELECT id FROM entries WHERE category_id = ? AND team_id = ? AND athlete_id IS NULL`, in.CategoryID, in.TeamID).Scan(&existing) } 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, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered")); err != nil { return false, err } return true, nil case err != nil: return false, err default: if _, err := s.ev.ExecContext(ctx, `UPDATE entries SET member_names = ?, bib = ?, seed = ?, status = ? WHERE id = ?`, marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), existing); err != nil { return false, err } return false, nil } }