Files
Scoreboard/server/internal/httpapi/handlers_config.go
T
AskaEth 9d4ee0946c feat: 运动员彻底删除(真物理删除 + 重算积分)+ 报名表 status 容错 + 部署包带导入规范
## 运动员「彻底删除」= 真删除
`DELETE /api/athletes/{id}?purge=1`(不带参数仍是「停用」软删除)。在一个事务里:
删积分流水(含已作废历史流水)→ 删破纪录登记 → 删成绩 → 删报名 → 删人员 →
对受影响的 (小项, 组别) 调 recomputeLedgerTx + applyComputedRanks 重算流水与名次 →
bumpEvent/bumpStandings 推大屏 → 审计 athlete.purge(含删除规模)。
响应带 {deletedEntries, deletedResults, deletedLedger, deletedBreaks, recomputedEvents}。
必须先删 results 再删 entries(results.entry_id 无 ON DELETE);流水按 athlete_id / entry_id /
result_id 三种引用一把清,漏一种就留悬挂。

管理端「运动员」行加「彻底删除」按钮 + 危险确认框(如实写明报名与成绩一并删除、
相关小项排名与积分会重算),成功 toast 报出删除规模。

## 报名表 status 列容错
新增 entry_status.go 的 normalizeImportStatus:大小写不敏感(DNS/Registered 都认),
认「正常/退赛/弃赛/请假/取消资格/犯规/未出场/未到/缺席」等中文别名,留空即 registered,
认不出则给中文行级错误并跳过该行。此前原样入库,撞 entries.status 的 CHECK 约束后
抛原始 SQLite 错误,填表人看不懂。

## 修 isUniqueViolation 把 CHECK 失败误报成「已存在」
SQLite 的 CHECK / NOT NULL 失败也带 "constraint failed",旧匹配把「小项 status 取值非法」
报成 409「小项编码已存在」,现场会去找一个根本不存在的重名编码。收紧为只认
"unique constraint failed"。实测:重复编码仍 409;非法 status 不再误报。

## 安装包平铺《报名名单导入规范》
deploy/prepare-extra.mjs 改清单式,部署包根目录除 使用说明.txt 外新增
报名名单导入规范.txt。

测试:服务端 12 包全绿(新增 athlete_purge_test.go、entry_status_test.go);
前端 vue-tsc 通过、166 例全过;Console 语法检查 17 文件。
另在真二进制 + 真 SQLite 上跑通破坏性路径:删人后本人痕迹全为 0、他人不受影响、
榜单按新排序重算、foreign_key_check 无悬挂。docs/ 与 devdocs/ 同步更新。
2026-09-10 22:37:25 +08:00

1094 lines
33 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 (
"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, &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
}
// 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
}
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, fmt.Sprintf("第 %d 行:event_code / team_code 必填", line))
continue
}
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
status, err := normalizeImportStatus(cell("status"))
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
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")
bib := cell("bib")
// 证件号与号码布二选一即可,都没有就无法区分同名的两个人。
if idCard == "" && bib == "" {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card 或 bib", line))
continue
}
age, ok := parseImportAge(cell("age"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:age 需要是 0-120 的整数", line))
continue
}
if idCard != "" {
if seenIDCards[idCard] {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard))
continue
}
seenIDCards[idCard] = true
}
if bib != "" {
if seenBibs[bib] {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:号码布 %s 在报名表内重复出现", line, 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, 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: status,
})
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
}
// 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
}
// 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 同值,前端可直接拿去当 <img src>。
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 "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID",
message: "当前未配置赛事项目目录,无法上传图片"}
}
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 匿名提供上传目录里的图片:大屏与 <img> 不带会话,不能要求登录。
func (s *server) serveUpload(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
ctype, ok := uploadContentType(name)
if !ok {
writeAPIError(w, s.logger, notFound("图片不存在"))
return
}
dir, err := s.uploadsDir()
if err != nil {
writeAPIError(w, s.logger, notFound("图片不存在"))
return
}
// 名字已过白名单校验,这里再用 Base 兜一层,确保只会读到 uploads 内的文件。
file := filepath.Join(dir, filepath.Base(name))
if info, err := os.Stat(file); err != nil || info.IsDir() {
writeAPIError(w, s.logger, 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
}