需求:人员以证件号为准(避免重名);批量导入时若证件号已存在于本地人员数据, 先导入其余人员,再把重复的人单独列出,由用户逐条/多选决定保留已有数据还是新数据。 服务端: - 迁移 0013:athletes 增加 id_card + 部分唯一索引(空值可共存) - athleteDTO/CRUD 读写 idCard,重复返回 409 - 导入表头新增可选列 id_card/gender;有姓名的行必须给证件号 - 匹配顺序:证件号 → 姓名+单位(且库中证件号为空则回填)→ 新建人员 - 证件号命中但姓名/性别/单位不一致 → 冲突,本轮不落库(报名也不建) - 新增 POST /api/entries/import/resolve(file + decisions),keep-existing/keep-new - 10 个新测试覆盖匹配/回填/冲突/决策/唯一索引 前端: - 运动员表单与列表新增「证件号」,搜索支持证件号 - 报名导入:新模板 10 列;导入后冲突面板(每行复选框 + existing↔incoming 差异高亮 + 全选/清空 + 保留已有数据/使用新数据 批量与逐条),未决策项始终可见 - 勾选报名(EntryPicker)缺证件号的运动员禁止勾选并提示 - 新增 import-conflicts.test.ts 等,前端测试 39/39 文档:使用手册(运动员证件号、导入表头与冲突流程)、常见问题(证件号重复怎么办)。 注:报名行的 team_code 与人员绑定单位不一致会计入冲突,由用户决策,不会静默改单位。
817 lines
23 KiB
Go
817 lines
23 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"encoding/csv"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/xuri/excelize/v2"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||
)
|
||
|
||
// ---------- 认证 ----------
|
||
|
||
func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
var in loginInput
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
if strings.TrimSpace(in.Username) == "" || in.Password == "" {
|
||
return nil, badRequest("username 与 password 必填", nil)
|
||
}
|
||
|
||
ip := middleware.ClientIP(r)
|
||
u, token, err := s.auth.Authenticate(r.Context(), in.Username, in.Password, ip, r.UserAgent())
|
||
switch {
|
||
case err == auth.ErrRateLimited:
|
||
return nil, &apiError{status: http.StatusTooManyRequests, code: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试"}
|
||
case err == auth.ErrInvalidCredentials, err == auth.ErrUserInactive:
|
||
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: err.Error()}
|
||
case err != nil:
|
||
return nil, internal(err)
|
||
}
|
||
|
||
s.auth.SetSessionCookie(w, token)
|
||
csrf, err := auth.NewCSRFToken()
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
s.auth.SetCSRFCookie(w, csrf)
|
||
// 登录时请求 ctx 里还是 guest,Auditor 取不到用户,需在审计条目里显式带上 actorID。
|
||
middleware.Record(r.Context(), middleware.AuditEntry{
|
||
Action: "auth.login", EntityType: "user", EntityID: u.ID, ActorID: u.ID,
|
||
After: map[string]any{"username": u.Username},
|
||
})
|
||
return userDTO(u), nil
|
||
}
|
||
|
||
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
if c, err := r.Cookie(auth.CookieSession); err == nil {
|
||
if err := s.auth.Logout(r.Context(), c.Value); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
}
|
||
s.auth.ClearSessionCookie(w)
|
||
s.auth.ClearCSRFCookie(w)
|
||
return nil, nil
|
||
}
|
||
|
||
func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
u := middleware.User(r.Context())
|
||
return userDTO(u), nil
|
||
}
|
||
|
||
type changePasswordInput struct {
|
||
CurrentPassword string `json:"currentPassword"`
|
||
NewPassword string `json:"newPassword"`
|
||
}
|
||
|
||
// changePassword 修改当前登录用户的密码:先验原密码,再改密并作废全部会话。
|
||
func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
u := middleware.User(r.Context())
|
||
if u.Role == auth.RoleGuest {
|
||
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"}
|
||
}
|
||
|
||
var in changePasswordInput
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
if len(in.NewPassword) < 8 {
|
||
return nil, badRequest("新密码至少 8 位", nil)
|
||
}
|
||
if len(in.NewPassword) > auth.MaxPasswordBytes {
|
||
return nil, badRequest(fmt.Sprintf("新密码不能超过 %d 字节", auth.MaxPasswordBytes), nil)
|
||
}
|
||
if in.NewPassword == in.CurrentPassword {
|
||
return nil, badRequest("新密码不能与原密码相同", nil)
|
||
}
|
||
// 会话被盗后不能直接改密,必须再验一次原密码
|
||
if err := s.auth.VerifyPassword(r.Context(), u.ID, in.CurrentPassword); err != nil {
|
||
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: "原密码不正确"}
|
||
}
|
||
if err := s.auth.SetPassword(r.Context(), u.Username, in.NewPassword, false); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
|
||
// SetPassword 已作废全部会话,这里清掉 Cookie 让前端引导重新登录
|
||
s.auth.ClearSessionCookie(w)
|
||
s.auth.ClearCSRFCookie(w)
|
||
s.audit(r.Context(), "auth.change_password", "user", u.ID, nil, map[string]any{"username": u.Username})
|
||
return map[string]any{"relogin": true}, nil
|
||
}
|
||
|
||
func userDTO(u *auth.User) currentUserDTO {
|
||
dto := currentUserDTO{
|
||
ID: u.ID,
|
||
Username: u.Username,
|
||
DisplayName: u.DisplayName,
|
||
Role: u.Role.String(),
|
||
MustChangePassword: u.MustChangePassword,
|
||
}
|
||
if u.Role == auth.RoleGuest {
|
||
dto.Username = "guest"
|
||
dto.DisplayName = "访客"
|
||
}
|
||
if u.LastLoginAt != "" {
|
||
v := u.LastLoginAt
|
||
dto.LastLoginAt = &v
|
||
}
|
||
return dto
|
||
}
|
||
|
||
// ---------- settings ----------
|
||
|
||
func (s *server) getSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
return s.getSettings(r.Context())
|
||
}
|
||
|
||
func (s *server) updateSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
var in settingsPatch
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||
before, err := s.getSettings(r.Context())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updated, err := s.updateSettings(r.Context(), in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "settings.update", "settings", 0, before, updated)
|
||
return updated, nil
|
||
})
|
||
}
|
||
|
||
// ---------- teams ----------
|
||
|
||
func (s *server) listTeamsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
return s.listTeams(r.Context())
|
||
}
|
||
|
||
func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.getTeam(r.Context(), id)
|
||
}
|
||
|
||
func (s *server) createTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
var in teamDTO
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
created, err := s.createTeam(r.Context(), in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "team.create", "team", created.ID, nil, created)
|
||
return created, nil
|
||
}
|
||
|
||
func (s *server) updateTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var in teamDTO
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
before, err := s.getTeam(r.Context(), id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updated, err := s.updateTeam(r.Context(), id, in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "team.update", "team", id, before, updated)
|
||
return updated, nil
|
||
}
|
||
|
||
func (s *server) deleteTeamHandler(w http.ResponseWriter, r *http.Request) error {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
before, err := s.getTeam(r.Context(), id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.deleteTeam(r.Context(), id); err != nil {
|
||
return err
|
||
}
|
||
s.audit(r.Context(), "team.delete", "team", id, before, nil)
|
||
return nil
|
||
}
|
||
|
||
// ---------- sports ----------
|
||
|
||
func (s *server) listSportsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
return s.listSports(r.Context())
|
||
}
|
||
|
||
func (s *server) getSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.getSport(r.Context(), id)
|
||
}
|
||
|
||
func (s *server) createSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
var in sportDTO
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
created, err := s.createSport(r.Context(), in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "sport.create", "sport", created.ID, nil, created)
|
||
return created, nil
|
||
}
|
||
|
||
func (s *server) updateSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var in sportDTO
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
before, err := s.getSport(r.Context(), id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updated, err := s.updateSport(r.Context(), id, in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "sport.update", "sport", id, before, updated)
|
||
return updated, nil
|
||
}
|
||
|
||
func (s *server) deleteSportHandler(w http.ResponseWriter, r *http.Request) error {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
before, err := s.getSport(r.Context(), id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.deleteSport(r.Context(), id); err != nil {
|
||
return err
|
||
}
|
||
s.audit(r.Context(), "sport.delete", "sport", id, before, nil)
|
||
return nil
|
||
}
|
||
|
||
// ---------- events ----------
|
||
|
||
func (s *server) listEventsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
sportID, _ := int64Query(r, "sportId")
|
||
return s.listEvents(r.Context(), sportID)
|
||
}
|
||
|
||
func (s *server) getEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.getEvent(r.Context(), id)
|
||
}
|
||
|
||
func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
var in eventDTO
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
created, err := s.createEvent(r.Context(), in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "event.create", "event", created.ID, nil, created)
|
||
return created, nil
|
||
}
|
||
|
||
func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var in eventDTO
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
before, err := s.getEvent(r.Context(), id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updated, err := s.updateEvent(r.Context(), id, in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "event.update", "event", id, before, updated)
|
||
return updated, nil
|
||
}
|
||
|
||
func (s *server) deleteEventHandler(w http.ResponseWriter, r *http.Request) error {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
before, err := s.getEvent(r.Context(), id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.deleteEvent(r.Context(), id); err != nil {
|
||
return err
|
||
}
|
||
s.audit(r.Context(), "event.delete", "event", id, before, nil)
|
||
return nil
|
||
}
|
||
|
||
func (s *server) listEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.listEntriesWithResults(r.Context(), id)
|
||
}
|
||
|
||
func (s *server) listCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.listCategories(r.Context(), id)
|
||
}
|
||
|
||
func (s *server) replaceCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var in categoriesPayload
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
before, err := s.listCategories(r.Context(), id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updated, err := s.replaceCategories(r.Context(), id, in.Categories)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "event.categories.replace", "event", id, before, updated)
|
||
return updated, nil
|
||
}
|
||
|
||
func (s *server) listChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.listChannels(r.Context(), id)
|
||
}
|
||
|
||
func (s *server) replaceChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var in channelsPayload
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
before, err := s.listChannels(r.Context(), id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updated, err := s.replaceChannels(r.Context(), id, in.Channels)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "event.channels.replace", "event", id, before, updated)
|
||
return updated, nil
|
||
}
|
||
|
||
// ---------- scoring profiles ----------
|
||
|
||
func (s *server) listProfilesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
return s.listProfiles(r.Context())
|
||
}
|
||
|
||
func (s *server) getProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.getProfile(r.Context(), id)
|
||
}
|
||
|
||
func (s *server) createProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
var in profileDTO
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
created, err := s.createProfile(r.Context(), in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "profile.create", "scoring_profile", created.ID, nil, created)
|
||
return created, nil
|
||
}
|
||
|
||
func (s *server) updateProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var in profileDTO
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
before, err := s.getProfile(r.Context(), id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updated, err := s.updateProfile(r.Context(), id, in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "profile.update", "scoring_profile", id, before, updated)
|
||
return updated, nil
|
||
}
|
||
|
||
// ---------- athletes ----------
|
||
|
||
func (s *server) listAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
teamID, _ := int64Query(r, "teamId")
|
||
return s.listAthletes(r.Context(), teamID)
|
||
}
|
||
|
||
func (s *server) getAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.getAthlete(r.Context(), id)
|
||
}
|
||
|
||
func (s *server) createAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
var in athleteDTO
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
created, err := s.createAthlete(r.Context(), in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "athlete.create", "athlete", created.ID, nil, created)
|
||
return created, nil
|
||
}
|
||
|
||
func (s *server) updateAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var in athleteDTO
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
before, err := s.getAthlete(r.Context(), id)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updated, err := s.updateAthlete(r.Context(), id, in)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "athlete.update", "athlete", id, before, updated)
|
||
return updated, nil
|
||
}
|
||
|
||
func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) error {
|
||
id, err := pathID(r, "id")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
before, err := s.getAthlete(r.Context(), id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.deleteAthlete(r.Context(), id); err != nil {
|
||
return err
|
||
}
|
||
s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil)
|
||
return nil
|
||
}
|
||
|
||
// ---------- 报名表导入 ----------
|
||
|
||
type entryImportResult struct {
|
||
Created int `json:"created"`
|
||
Updated int `json:"updated"`
|
||
Skipped int `json:"skipped"`
|
||
Errors []string `json:"errors"`
|
||
Conflicts []importConflict `json:"conflicts"`
|
||
}
|
||
|
||
// importConflict 是「证件号已存在、但姓名/性别/单位与库中不一致」的一行。
|
||
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
|
||
type importConflict struct {
|
||
Row int `json:"row"`
|
||
IDCard string `json:"idCard"`
|
||
AthleteID int64 `json:"athleteId"`
|
||
Existing importAthleteView `json:"existing"`
|
||
Incoming importAthleteView `json:"incoming"`
|
||
ChangedFields []string `json:"changedFields"`
|
||
}
|
||
|
||
// importAthleteView 是冲突两端可比的字段快照(单位用编码,前端好展示)。
|
||
type importAthleteView struct {
|
||
Name string `json:"name"`
|
||
Gender *string `json:"gender"`
|
||
TeamCode string `json:"teamCode"`
|
||
EmployeeNo *string `json:"employeeNo"`
|
||
}
|
||
|
||
// importDecision 是冲突决策:keep-existing 保留库中人员字段,keep-new 用表里的字段覆盖。
|
||
type importDecision struct {
|
||
IDCard string `json:"idCard"`
|
||
Action string `json:"action"`
|
||
}
|
||
|
||
const (
|
||
decisionKeepExisting = "keep-existing"
|
||
decisionKeepNew = "keep-new"
|
||
)
|
||
|
||
// maxEntryImportBytes 是报名表上传的硬上限(16MB 文件 + multipart 开销)。
|
||
const maxEntryImportBytes = 32 << 20
|
||
|
||
// readImportForm 解析报名表上传(multipart file 字段),返回表格行。
|
||
func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([][]string, error) {
|
||
r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
|
||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||
return nil, badRequest("上传文件解析失败", err.Error())
|
||
}
|
||
file, header, err := r.FormFile("file")
|
||
if err != nil {
|
||
return nil, badRequest("缺少 file 字段", err.Error())
|
||
}
|
||
defer func() { _ = file.Close() }()
|
||
return readTable(filepath.Ext(header.Filename), file)
|
||
}
|
||
|
||
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
|
||
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
rows, err := s.readImportForm(w, r)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result, err := s.importEntries(r.Context(), rows)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "entries.import", "entry", 0, nil, result)
|
||
return result, nil
|
||
}
|
||
|
||
// resolveImportHandler 按用户对冲突的逐条决策重跑导入:
|
||
// 已决策的冲突按决策落库,未决策的冲突继续留在 conflicts 里返回。
|
||
func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
rows, err := s.readImportForm(w, r)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
raw := strings.TrimSpace(r.FormValue("decisions"))
|
||
if raw == "" {
|
||
return nil, badRequest("缺少 decisions 字段", nil)
|
||
}
|
||
var decisions []importDecision
|
||
if err := json.Unmarshal([]byte(raw), &decisions); err != nil {
|
||
return nil, badRequest("decisions 不是合法 JSON", err.Error())
|
||
}
|
||
byCard := make(map[string]string, len(decisions))
|
||
for _, d := range decisions {
|
||
if d.Action != decisionKeepExisting && d.Action != decisionKeepNew {
|
||
return nil, badRequest("action 只能是 keep-existing 或 keep-new", d.Action)
|
||
}
|
||
byCard[strings.TrimSpace(d.IDCard)] = d.Action
|
||
}
|
||
result, err := s.importEntriesWith(r.Context(), rows, byCard)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "entries.import.resolve", "entry", 0, nil, result)
|
||
return result, nil
|
||
}
|
||
|
||
func readTable(ext string, file io.Reader) ([][]string, error) {
|
||
switch strings.ToLower(ext) {
|
||
case ".xlsx":
|
||
f, err := excelize.OpenReader(file)
|
||
if err != nil {
|
||
return nil, badRequest("无法解析 xlsx", err.Error())
|
||
}
|
||
defer func() { _ = f.Close() }()
|
||
sheets := f.GetSheetList()
|
||
if len(sheets) == 0 {
|
||
return nil, badRequest("xlsx 没有工作表", nil)
|
||
}
|
||
rows, err := f.GetRows(sheets[0])
|
||
if err != nil {
|
||
return nil, badRequest("读取 xlsx 失败", err.Error())
|
||
}
|
||
return rows, nil
|
||
default:
|
||
reader := csv.NewReader(file)
|
||
reader.FieldsPerRecord = -1
|
||
reader.TrimLeadingSpace = true
|
||
rows, err := reader.ReadAll()
|
||
if err != nil {
|
||
return nil, badRequest("无法解析 CSV", err.Error())
|
||
}
|
||
return rows, nil
|
||
}
|
||
}
|
||
|
||
// importColumns 是报名表必须包含的表头(大小写不敏感);
|
||
// id_card、gender 为可选列,缺列不报错,按空值处理。
|
||
var importColumns = []string{
|
||
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
|
||
}
|
||
|
||
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
|
||
return s.importEntriesWith(ctx, rows, nil)
|
||
}
|
||
|
||
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
|
||
// 非 nil 时按决策处理证件号命中的冲突,未出现在 decisions 里的冲突继续收集。
|
||
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
|
||
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}}
|
||
if len(rows) < 2 {
|
||
return result, badRequest("报名表为空", nil)
|
||
}
|
||
index := make(map[string]int, len(rows[0]))
|
||
for i, h := range rows[0] {
|
||
index[strings.ToLower(strings.TrimSpace(h))] = i
|
||
}
|
||
for _, col := range importColumns {
|
||
if _, ok := index[col]; !ok {
|
||
return result, badRequest("报名表缺少列 "+col, nil)
|
||
}
|
||
}
|
||
|
||
// 同一文件里证件号只能出现一次,否则无法判断是不是同一个人。
|
||
seenIDCards := make(map[string]bool)
|
||
for n, row := range rows[1:] {
|
||
line := n + 2
|
||
cell := func(name string) string {
|
||
i, ok := index[name]
|
||
if !ok || i >= len(row) {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(row[i])
|
||
}
|
||
eventCode := cell("event_code")
|
||
categoryName := cell("category")
|
||
teamCode := cell("team_code")
|
||
athleteName := cell("athlete_name")
|
||
if eventCode == "" || teamCode == "" {
|
||
result.Skipped++
|
||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:event_code / team_code 必填", line))
|
||
continue
|
||
}
|
||
|
||
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
|
||
if err != nil {
|
||
result.Skipped++
|
||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 不存在", line, eventCode))
|
||
continue
|
||
}
|
||
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
|
||
if err != nil {
|
||
result.Skipped++
|
||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:单位 %s 不存在", line, teamCode))
|
||
continue
|
||
}
|
||
var categoryID int64
|
||
if categoryName != "" {
|
||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
|
||
if err != nil {
|
||
result.Skipped++
|
||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:组别 %s 不存在", line, categoryName))
|
||
continue
|
||
}
|
||
} else {
|
||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
|
||
if err != nil {
|
||
result.Skipped++
|
||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 没有组别", line, eventCode))
|
||
continue
|
||
}
|
||
}
|
||
|
||
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
|
||
var athleteID *int64
|
||
if athleteName != "" {
|
||
idCard := cell("id_card")
|
||
if idCard == "" {
|
||
result.Skipped++
|
||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card", line))
|
||
continue
|
||
}
|
||
if seenIDCards[idCard] {
|
||
result.Skipped++
|
||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard))
|
||
continue
|
||
}
|
||
seenIDCards[idCard] = true
|
||
|
||
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
|
||
Line: line,
|
||
Name: athleteName,
|
||
Gender: normalizeGender(cell("gender")),
|
||
TeamID: teamID,
|
||
TeamCode: teamCode,
|
||
IDCard: idCard,
|
||
}, decisions)
|
||
if err != nil {
|
||
result.Skipped++
|
||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
|
||
continue
|
||
}
|
||
if matched.Conflict != nil {
|
||
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
||
continue
|
||
}
|
||
athleteID = &matched.AthleteID
|
||
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
|
||
teamID = matched.TeamID
|
||
}
|
||
|
||
created, err := s.upsertImportedEntry(ctx, entryDTO{
|
||
EventID: eventID,
|
||
CategoryID: categoryID,
|
||
TeamID: teamID,
|
||
AthleteID: athleteID,
|
||
MemberNames: splitNames(cell("member_names")),
|
||
Bib: nilIfEmpty(cell("bib")),
|
||
Seed: nilIfEmpty(cell("seed")),
|
||
Status: orDefault(cell("status"), "registered"),
|
||
})
|
||
switch {
|
||
case err != nil:
|
||
result.Skipped++
|
||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
|
||
case created:
|
||
result.Created++
|
||
default:
|
||
result.Updated++
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// normalizeGender 只认 男/女,其它值(含空)一律忽略。
|
||
func normalizeGender(raw string) string {
|
||
switch raw {
|
||
case "男", "女":
|
||
return raw
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func splitNames(raw string) []string {
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ',' || r == '/' })
|
||
out := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
if s := strings.TrimSpace(p); s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|