package httpapi import ( "bytes" "context" "crypto/rand" "encoding/csv" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "unicode/utf8" "github.com/xuri/excelize/v2" "golang.org/x/text/encoding/simplifiedchinese" textunicode "golang.org/x/text/encoding/unicode" "golang.org/x/text/transform" "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, rateLimited("登录尝试过于频繁,请稍后再试") case err == auth.ErrInvalidCredentials, err == auth.ErrUserInactive: return nil, unauthorized("AUTH_FAILED", 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, unauthorized("AUTH_REQUIRED", "请先登录") } 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, unauthorized("AUTH_FAILED", "原密码不正确") } 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) { includeDeleted := isQueryTrue(r, "includeDeleted") // 已彻底删除的单位不进任何列表,只有 admin 能用显式开关把它们翻出来排查。 if includeDeleted && !middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) { return nil, forbidden("只有管理员可以查看已彻底删除的单位") } return s.listTeams(r.Context(), includeDeleted) } func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) { id, err := pathID(r, "id") if err != nil { return nil, err } t, err := s.getTeam(r.Context(), id) if err != nil { return nil, err } // 彻底删除的单位对非 admin 按不存在处理;admin 仍查得到,便于排查。 if t.DeletedAt != nil && !middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) { return nil, notFound("单位不存在") } return t, nil } 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 } // deleteTeamHandler 默认软删除(停用);带 purge=1 时彻底删除(对界面不可见,行仍在库里)。 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 !isQueryTrue(r, "purge") { if err := s.deleteTeam(r.Context(), id); err != nil { return err } s.audit(r.Context(), "team.delete", "team", id, before, nil) return nil } after, err := s.hideTeam(r.Context(), id) if err != nil { return err } // 记清楚这是「隐藏」而不是物理删除:报名、成绩、流水一行没动,审计要能自证。 s.audit(r.Context(), "team.purge", "team", id, before, map[string]any{ "hidden": true, "deletedAt": after.DeletedAt, }) 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 } // deleteAthleteHandler 默认软删除(停用);带 purge=1 时物理删除人员、报名、成绩并重算受影响的积分。 func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) { id, err := pathID(r, "id") if err != nil { return nil, err } before, err := s.getAthlete(r.Context(), id) if err != nil { return nil, err } if !isQueryTrue(r, "purge") { if err := s.deleteAthlete(r.Context(), id); err != nil { return nil, err } s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil) return nil, nil } purged, err := s.purgeAthlete(r.Context(), id) if err != nil { return nil, err } // 审计带上删除规模与重算过的小项,事后能对上「榜为什么变了」。 s.audit(r.Context(), "athlete.purge", "athlete", id, before, purged) return purged, nil } // isQueryTrue 解析开关型查询参数:只认 1 / true(大小写不敏感),其余值一律当 false。 // 写错的值不会误开开关——purge=0、purge=false 都还是软删除。 func isQueryTrue(r *http.Request, key string) bool { switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key))) { case "1", "true": return true } return false } // ---------- 报名表导入 ---------- 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"` Bib string `json:"bib"` 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"` IDCard *string `json:"idCard"` Bib *string `json:"bib"` Age *int `json:"age"` } // importDecision 是冲突决策:keep-existing 保留库中人员字段,keep-new 用表里的字段覆盖。 // 证件号与号码布二选一作为决策键,证件号优先。 type importDecision struct { IDCard string `json:"idCard"` Bib string `json:"bib"` 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()) } byKey := 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) } key := strings.TrimSpace(d.IDCard) if key == "" { key = strings.TrimSpace(d.Bib) } byKey[key] = d.Action } result, err := s.importEntriesWith(r.Context(), rows, byKey) if err != nil { return nil, err } s.audit(r.Context(), "entries.import.resolve", "entry", 0, nil, result) return result, nil } // setEntriesHeatHandler 批量设置 / 清除一批报名项的批次(PUT /api/entries/heat)。 func (s *server) setEntriesHeatHandler(w http.ResponseWriter, r *http.Request) (any, error) { var in entryHeatPayload if err := decodeJSON(r, &in); err != nil { return nil, err } result, err := s.setEntriesHeat(r.Context(), in.EntryIDs, in.Heat) if err != nil { return nil, err } s.audit(r.Context(), "entry.heat", "entry", 0, nil, map[string]any{"entryIds": result.EntryIDs, "heat": result.Heat, "updated": result.Updated}) 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: text, err := decodeTableText(file) if err != nil { return nil, err } reader := csv.NewReader(strings.NewReader(text)) reader.FieldsPerRecord = -1 reader.TrimLeadingSpace = true rows, err := reader.ReadAll() if err != nil { return nil, badRequest("无法解析 CSV", err.Error()) } return rows, nil } } // 报名表文本可能带的字节序标记。 var ( tableUTF8BOM = []byte{0xEF, 0xBB, 0xBF} tableUTF16LEBOM = []byte{0xFF, 0xFE} tableUTF16BEBOM = []byte{0xFE, 0xFF} ) // decodeTableText 把报名表的字节流解码成 UTF-8 文本。 // // 现场拿到的 CSV 基本都是直接从 Excel 存的,而 Excel 默认不写 UTF-8:中文 Windows 上 // 「另存为 CSV」出的是 GB18030,「CSV UTF-8」会带 BOM,PowerShell 的 Export-Csv 出 UTF-16LE。 // 这三种原样丢给 csv.Reader 都会炸,而且症状很误导—— // 中文单位名、运动员名变乱码;BOM 会粘在第一列表头上("\ufeffevent_code"), // 于是报「报名表缺少列 event_code」,让人以为表头写错了。 func decodeTableText(file io.Reader) (string, error) { // 上游 readImportForm 已用 MaxBytesReader 限住 body,整读不会失控。 data, err := io.ReadAll(file) if err != nil { return "", badRequest("读取报名表失败", err.Error()) } switch { case bytes.HasPrefix(data, tableUTF8BOM): data = data[len(tableUTF8BOM):] case bytes.HasPrefix(data, tableUTF16LEBOM): return decodeUTF16Table(data[len(tableUTF16LEBOM):], textunicode.LittleEndian) case bytes.HasPrefix(data, tableUTF16BEBOM): return decodeUTF16Table(data[len(tableUTF16BEBOM):], textunicode.BigEndian) } if utf8.Valid(data) { return string(data), nil } // GBK 是 GB18030 的子集,按 GB18030 解能一并覆盖。 text, _, err := transform.String(simplifiedchinese.GB18030.NewDecoder(), string(data)) if err != nil { return "", badRequest("报名表编码无法识别,请另存为 UTF-8 或 GB18030 编码的 CSV 后重试", err.Error()) } return text, nil } // decodeUTF16Table 解 UTF-16(BOM 已由调用方摘掉)。 func decodeUTF16Table(data []byte, order textunicode.Endianness) (string, error) { text, _, err := transform.String(textunicode.UTF16(order, textunicode.IgnoreBOM).NewDecoder(), string(data)) if err != nil { return "", badRequest("报名表编码无法识别,请另存为 UTF-8 编码的 CSV 后重试", err.Error()) } return text, nil } // importColumns 是报名表必须包含的表头(大小写不敏感); // id_card、gender、age 为可选列,缺列不报错,按空值处理。 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) seenBibs := 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, s.interceptRow(line, "event_code / team_code 必填")) continue } // status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。 status, err := normalizeImportStatus(cell("status")) if err != nil { result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error())) continue } // heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。 heat, ok := parseImportHeat(cell("heat")) if !ok { result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat)) continue } eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode) if err != nil { result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 不存在", eventCode)) continue } teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode) if err != nil { result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "单位 %s 不存在", teamCode)) continue } // 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档), // 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。 if err := s.reviveTeam(ctx, teamID); err != nil { result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error())) 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, s.interceptRow(line, "组别 %s 不存在", 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, s.interceptRow(line, "小项 %s 没有组别", eventCode)) continue } } // 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。 var athleteID *int64 if athleteName != "" { idCard := cell("id_card") bib := cell("bib") // 证件号与号码布二选一即可,都没有就无法区分同名的两个人。 if idCard == "" && bib == "" { result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib")) continue } age, ok := parseImportAge(cell("age")) if !ok { result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数")) continue } if idCard != "" { if seenIDCards[idCard] { result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "证件号 %s 在报名表内重复出现", idCard)) continue } seenIDCards[idCard] = true } if bib != "" { if seenBibs[bib] { result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib)) continue } seenBibs[bib] = true } matched, err := s.matchImportedAthlete(ctx, athleteImportRow{ Line: line, Name: athleteName, Gender: normalizeGender(cell("gender")), TeamID: teamID, TeamCode: teamCode, IDCard: idCard, Bib: bib, Age: age, }, decisions) if err != nil { result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "%s", 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")), Heat: heat, Status: status, }) switch { case err != nil: result.Skipped++ result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error())) case created: result.Created++ default: result.Updated++ } } return result, nil } // parseImportAge 解析可选的 age 列:空值合法(返回 nil),非整数或超出 0-120 视为非法。 func parseImportAge(raw string) (*int, bool) { if raw == "" { return nil, true } n, err := strconv.Atoi(raw) if err != nil || n < 0 || n > 120 { return nil, false } return &n, true } // parseImportHeat 解析可选的 heat 列:留空表示未分批(nil),非整数或超出 1-maxEntryHeat 视为非法。 func parseImportHeat(raw string) (*int64, bool) { if raw == "" { return nil, true } n, err := strconv.ParseInt(raw, 10, 64) if err != nil || n < 1 || n > maxEntryHeat { return nil, false } return &n, true } // 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 } // ---------- 图片上传与静态访问 ---------- // maxUploadBytes 是单张图片的硬上限;multipart 边界与表头还占少量字节, // 解析上限相应放宽,真实大小由 uploadHandler 里的 LimitReader 兜住。 const ( maxUploadBytes = 5 << 20 maxUploadFormBytes = maxUploadBytes + 1<<20 ) // allowedUploadImageTypes 是允许上传的图片扩展名与对应 Content-Type。 // 收 SVG 会让匿名可访问的同源静态资源变成存储型 XSS 入口,故白名单里没有 .svg。 var allowedUploadImageTypes = map[string]string{ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", } // uploadsCacheControl 命中随机文件名即可长缓存:同名文件内容不会变。 const uploadsCacheControl = "public, max-age=86400" // uploadResult 是上传成功后的访问路径;path 与 url 同值,前端可直接拿去当 。 type uploadResult struct { Path string `json:"path"` URL string `json:"url"` } // uploadsDir 返回当前赛事项目的上传目录(赛事包导出会带上它,跨设备转移靠的就是这里)。 // 未配置项目目录时没有落盘位置,返回 503 而不是 panic。 func (s *server) uploadsDir() (string, error) { dir := strings.TrimSpace(s.projectDir) if dir == "" { return "", serviceUnavailable("STATE_INVALID", "当前未配置赛事项目目录,无法上传图片") } return filepath.Join(dir, "uploads"), nil } // uploadHandler 保存 multipart file 字段里的图片。 // 客户端文件名只用来取扩展名,落盘名是随机十六进制,避免重名覆盖与路径注入。 func (s *server) uploadHandler(w http.ResponseWriter, r *http.Request) (any, error) { dir, err := s.uploadsDir() if err != nil { return nil, err } r.Body = http.MaxBytesReader(w, r.Body, maxUploadFormBytes) if err := r.ParseMultipartForm(maxUploadFormBytes); err != nil { var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { return nil, badRequest("图片超过 5 MiB 上限", nil) } return nil, badRequest("上传内容解析失败", err.Error()) } defer func() { _ = r.MultipartForm.RemoveAll() }() file, header, err := r.FormFile("file") if err != nil { return nil, badRequest("缺少 file 字段", nil) } defer func() { _ = file.Close() }() ext := strings.ToLower(filepath.Ext(header.Filename)) if _, ok := allowedUploadImageTypes[ext]; !ok { return nil, badRequest("只支持 png/jpg/jpeg/webp/gif 图片", ext) } // header.Size 由客户端声明,不可信,按真实读到的字节数判限。 body, err := io.ReadAll(io.LimitReader(file, maxUploadBytes+1)) if err != nil { return nil, badRequest("读取上传内容失败", err.Error()) } if len(body) > maxUploadBytes { return nil, badRequest("图片超过 5 MiB 上限", nil) } if len(body) == 0 { return nil, badRequest("上传内容为空", nil) } name, err := newUploadName(ext) if err != nil { return nil, internal(err) } if err := os.MkdirAll(dir, 0o755); err != nil { return nil, internal(err) } if err := os.WriteFile(filepath.Join(dir, name), body, 0o644); err != nil { return nil, internal(err) } s.audit(r.Context(), "uploads.create", "upload", 0, nil, map[string]any{"file": name, "size": len(body)}) path := "/uploads/" + name return uploadResult{Path: path, URL: path}, nil } // newUploadName 生成随机文件名:不用客户端文件名,既防重名覆盖也防路径注入。 func newUploadName(ext string) (string, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf) + ext, nil } // serveUpload 匿名提供上传目录里的图片:大屏与 不带会话,不能要求登录。 func (s *server) serveUpload(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") ctype, ok := uploadContentType(name) if !ok { s.writeAPIError(w, r, notFound("图片不存在")) return } dir, err := s.uploadsDir() if err != nil { s.writeAPIError(w, r, notFound("图片不存在")) return } // 名字已过白名单校验,这里再用 Base 兜一层,确保只会读到 uploads 内的文件。 file := filepath.Join(dir, filepath.Base(name)) if info, err := os.Stat(file); err != nil || info.IsDir() { s.writeAPIError(w, r, notFound("图片不存在")) return } w.Header().Set("Content-Type", ctype) w.Header().Set("Cache-Control", uploadsCacheControl) http.ServeFile(w, r, file) } // uploadContentType 校验文件名并给出 Content-Type: // 只接受「单层文件名 + 白名单扩展名」,含分隔符、.. 或只有扩展名的名字一律拒绝。 func uploadContentType(name string) (string, bool) { if name == "" || name != filepath.Base(name) || strings.ContainsAny(name, `/\`) { return "", false } ext := strings.ToLower(filepath.Ext(name)) if name == ext { return "", false } ctype, ok := allowedUploadImageTypes[ext] return ctype, ok }