Files
Scoreboard/server/internal/httpapi/handlers_config.go
T
AskaEth 55051fd633 fix(import): 导入提示改成「能照着改」的话 + 模板给「姓名」「队员名单」加说明;版本 dev_0.5.9
## 起因(主人原话)
> 我应该如何用表格导入团体报名? 我现在不管怎么填的话,它都提示:
> 「第2行填写了队伍 但是这一行有运动员姓名 按个人报名处理」。但实际上我们程序里,团体报名是可以包含运动员姓名的。

## 核实结论:不是功能 bug,是提示没用
系统用「**`姓名` 列有没有填**」区分个人 / 团体报名(数据模型决定的:一条报名要么属于某个人、要么属于一支队)。
**团体报名的队员要填在「队员名单」列**(写号码布),`姓名` 列留空;队员就是这条报名的成员。
原来那条提示**只说了后果、没说动作**,所以现场只能反复换填法重试。

## 改了什么
1. **两条行级提示改成「后果 + 两步动作」**(保留旧文案里的关键词,既有断言一字未改仍通过):
   - 队伍那条:`第 N 行填了队伍「一队」,但这一行也填了运动员姓名,系统按个人报名处理:队伍只对团体报名生效,这一格没有写进库里。要报团体(一支队)请把姓名列留空、把队员的号码布填到「队员名单」列;这一行确实是个人报名,把「队伍」这一格删掉即可`
   - 队员名单那条:同口径(`…队员只对团体报名生效,这一格只作文字名单保留、没有关联到人。要报团体请把姓名列留空、队员的号码布就填在「队员名单」列…`)
2. **导入模板加说明**:原来只有「单位编码」表头挂了批注;现在给 **`姓名`**(填了就是个人报名、团体请留空)与 **`队员名单`**(团体报名的队员写这里、**写号码布**才会关联到人)两个表头**各加一条批注 + 输入提示**,中英文两套。
3. ⚠️ **模板的表头文字、列序、13 列、工作表命名一个字没动**(动了模板就导不回去,有测试逐字钉着)。

## 文档
`docs/报名名单导入规范.md`(新增「个人报名 vs 团体报名」对照表 + 两条提示原文收进常见报错 + 队员名单分隔符说明)、
`docs/使用手册.md`(在工作台导入那节点明判据)、`docs/升级说明.md`(新增 `dev_0.5.9` 一节)。

## 验证
- **先红后绿**;全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **944 例** 0 fail / Console 68 例 0 fail。

## 版本
`dev_0.5.8` → **`dev_0.5.9`**(**无数据迁移**);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 23:40:31 +08:00

2188 lines
84 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"
"database/sql"
"encoding/csv"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"time"
"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},
})
out := userDTO(u)
// 刚建好的会话到期时间 = 此刻 + 当前有效期(与 createSession 同一请求内,误差在毫秒级);
// 下发它,界面登录后就能立刻显示「当前会话将于 … 过期」,不用等下一次 /auth/me。
expiresAt := time.Now().UTC().Add(s.auth.SessionTTL()).Format(time.RFC3339)
out.SessionExpiresAt = &expiresAt
return out, nil
}
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
u := middleware.User(r.Context())
if c, err := r.Cookie(auth.CookieSession); err == nil {
if err := s.auth.Logout(r.Context(), c.Value); err != nil {
return nil, internal(err)
}
}
// 登出是真删会话行(服务端强制下线),属于写操作,得和登录 / 改密一样留审计:
// 现场问「谁在什么时候把谁的会话撤了」时,只有这几条动作能对上。
// 访客没有会话可撤,不记(否则 audit_logs 里会出现 user_id 为空的行)。
if u.Role != auth.RoleGuest {
s.audit(r.Context(), "auth.logout", "user", u.ID, nil, map[string]any{"username": u.Username})
}
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())
out := userDTO(u)
if u.Role == auth.RoleGuest {
return out, nil
}
// 顺带把会话 Cookie 的期限同步到最新的到期时间:
// 滑动续期只改服务端 expires_at,Cookie 的 Max-Age 不跟着走的话,
// 浏览器会在「登录时刻 + 有效期」处丢掉 Cookie,人还是被登出。
// /auth/me 是管理端启动与定时心跳都会走的接口,放在这里就够。
c, err := r.Cookie(auth.CookieSession)
if err != nil || c.Value == "" {
return out, nil
}
_, sess, err := s.auth.Lookup(r.Context(), c.Value)
if err != nil || sess == nil {
return out, nil
}
expiresAt := sess.ExpiresAt.UTC().Format(time.RFC3339)
out.SessionExpiresAt = &expiresAt
csrf := ""
if c, err := r.Cookie(auth.CookieCSRF); err == nil {
csrf = c.Value
}
if csrf == "" {
// 会话有效但 CSRF Cookie 不在了(浏览器清理、升级/换端口后残留、手工拷了会话 Cookie):
// 双提交方案下没有它,所有写操作会永久 403,而唯一的自愈路径是「退出后重新登录」——
// 刷新页面走的还是这条路径,补不回来。这里就地签发一枚新 token 并下发,
// 会话与 token 的绑定关系没变(token 不落库、只做双提交比对),不影响任何安全口径。
token, err := auth.NewCSRFToken()
if err != nil {
// crypto/rand 失败已经意味着整个进程不可信,但 /auth/me 是只读接口,
// 不能因为补票失败就把它变成 500:记下来,本次不补发。
s.logger.Error("补发 CSRF Cookie 失败", "err", err)
} else {
csrf = token
}
}
s.auth.RefreshSessionCookies(w, c.Value, csrf, sess.ExpiresAt)
return out, 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
}
// 下限按「字符」判:3 个汉字是 9 字节,按字节判会把前端(按字符数)拒绝的口令放进来,
// 同一口令界面拒、接口收,现场只会以为是前端坏了。上限仍按字节——
// bcrypt 只吃前 72 字节,这是算法边界,不是长度口径。
if utf8.RuneCountInString(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)
}
// 会话被盗后不能直接改密,必须再验一次原密码。
// 这里不能用 401:本请求的会话是好的,只是填错了原密码,
// 401 会被前端当成「会话失效」处理(历史上真出现过改完密码登不回去)。
if err := s.auth.VerifyPassword(r.Context(), u.ID, in.CurrentPassword); err != nil {
return nil, wrongCredential("原密码不正确")
}
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
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
// 缺省 = 启用。Go 的 bool 零值是 false,漏传 active 会被静默建成「停用」,
// 而停用的单位在界面上等于不存在 —— 脚本与导入侧踩过一次就很难查。
if !keys.has("active") {
in.Active = true
}
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
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
before, err := s.getTeam(r.Context(), id)
if err != nil {
return nil, err
}
// 更新仍是「全量覆盖」语义,但漏传 active 不等于「把它停用」:沿用库里的现值。
// 想停用请显式传 false,或走 DELETE(那才是界面上那个「停用」)。
if !keys.has("active") {
in.Active = before.Active
}
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
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
// 与建单位同口径:缺省 = 启用(漏传 active 不该建成一个在界面上不存在的大项)。
if !keys.has("active") {
in.Active = true
}
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
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
before, err := s.getSport(r.Context(), id)
if err != nil {
return nil, err
}
// 同单位:漏传 active 沿用现值,不静默停用。
if !keys.has("active") {
in.Active = before.Active
}
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
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
// 与建单位 / 大项同口径:缺省 = 建表默认值(migrations/event/0004_sports_events.sql 里
// scored 与 record_eligible 都 DEFAULT 1)。Go 的 bool 零值是 false,漏传会被静默建成
// 「只排名不计分、不能破纪录」的小项 —— 这与导入侧按建表默认值建档的口径也不一致。
if !keys.has("scored") {
in.Scored = true
}
if !keys.has("recordEligible") {
in.RecordEligible = true
}
// award_medals 同样是 DEFAULT 1(migration 0020):漏传 ≠ 关掉奖牌结算。
if !keys.has("awardMedals") {
in.AwardMedals = true
}
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
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
before, err := s.getEvent(r.Context(), id)
if err != nil {
return nil, err
}
// 更新仍是全量覆盖语义,但漏传这两个开关不等于「关掉它」:沿用库里的现值,
// 与 active 在单位 / 大项上的处理一致。
if !keys.has("scored") {
in.Scored = before.Scored
}
if !keys.has("recordEligible") {
in.RecordEligible = before.RecordEligible
}
// 奖牌结算是 DEFAULT 1 的新开关:漏传等于「保持原值」,不能当成「关掉」。
if !keys.has("awardMedals") {
in.AwardMedals = before.AwardMedals
}
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
}
// listEntriesHandler 返回成绩录入页的完整读数(成绩行 + 团体名次,见 entryBoardDTO)。
// 旧客户端把它当成绩行数组用;设计 17 §七之后同一响应多带一个 teamRanks,前端两种形状都能读。
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.listEntryBoard(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
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
before, err := s.getProfile(r.Context(), id)
if err != nil {
return nil, err
}
// 漏传 lastPlaceZero 不能当成「用户把最后一名归零关掉了」:PUT 是全量覆盖语义,
// 而 Go 的 bool 零值就是 false——只想改个名字的调用方会把这一项静默关掉,
// 算分行为跟着变,界面上却看不出是谁改的(同族的 active 已按这个口径处理)。
if !keys.has("lastPlaceZero") {
in.LastPlaceZero = before.LastPlaceZero
}
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
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
// 缺省 = 启用(与单位 / 大项 / 小项同口径):漏传 active 不该把新人静默建成「停用」,
// 停用的人在报名选择器里等于不存在——少写一个字段就变成另一种东西,只能靠键是否存在判断。
if !keys.has("active") {
in.Active = true
}
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
keys, err := decodeJSONPresence(r, &in)
if err != nil {
return nil, err
}
before, err := s.getAthlete(r.Context(), id)
if err != nil {
return nil, err
}
// 更新仍是「全量覆盖」语义,但漏传 active 不等于「把他停用」:沿用库里的现值。
// 想停用请显式传 false(或走 DELETE / 批量停用)。
if !keys.has("active") {
in.Active = before.Active
}
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"`
Notices []string `json:"notices"`
Conflicts []importConflict `json:"conflicts"`
// SameNameNewPersons 是「身份字段对不上 → 按同名的另一个人新建档案」的清单。
// 只作报告:不弹窗、不阻塞导入,但必须让用户看得见。
SameNameNewPersons []sameNameNewPerson `json:"sameNameNewPersons"`
}
// importConflict 是「命中了库里已有人员、但人员字段与库中不一致」的一行。
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
type importConflict struct {
Row int `json:"row"`
IDCard string `json:"idCard"`
Bib string `json:"bib"`
AthleteID int64 `json:"athleteId"`
// Duplicate 表示这不是人员字段不一致,而是「同一份表里同一证件号 + 同一个小项同一个组别」的真重复行:
// 两行字段可能完全一样,但要不要覆盖已经落库的那条报名必须由用户决定。
Duplicate bool `json:"duplicate"`
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
// importSheet 是解析后的一张待导入表。
//
// CSV 与「单表 xlsx」只有一张、名字为空串:这类文件的报错文案不加工作表前缀,
// 与升级前逐字一致(现场手里几百份老表的报错格式不该因为这次改动变样)。
type importSheet struct {
name string
rows [][]string
// hidden 表示这是 xlsx 里的隐藏工作表:不导入,但要在结果里说一句(见 prepareImportTables)。
hidden bool
}
// readImportForm 解析报名表上传(multipart file 字段),返回表格里的全部工作表。
func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([]importSheet, 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 readTableSheets(filepath.Ext(header.Filename), file)
}
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns,分工作表模板见 entries_sheet_template.go。
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
sheets, err := s.readImportForm(w, r)
if err != nil {
return nil, err
}
result, err := s.importEntriesWith(r.Context(), sheets, nil)
if err != nil {
return nil, err
}
// 审计动作一律以 entity_type 的单数族名打头:本族已有 entry.heat / entry.scratch /
// entry.restore / entry.remove,导入原先写成 entries.import 自成第二族,
// 现场按文档示例填 `entry.` 一筛会漏掉两条导入审计(清单 B01)。
s.audit(r.Context(), "entry.import", "entry", 0, nil, result)
return result, nil
}
// resolveImportHandler 按用户对冲突的逐条决策重跑导入:
// 已决策的冲突按决策落库,未决策的冲突继续留在 conflicts 里返回。
func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
sheets, 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(), sheets, byKey)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entry.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
}
// readTableSheets 读出表格里的全部工作表(xlsx 按工作簿顺序;CSV 只有一张、名字为空串)。
//
// 单表 CSV / 单表 xlsx 的解析结果与升级前完全一样(同样的一张表、同一套表头口径),
// 变化只在 xlsx 多读了几张表——分工作表模板要用得到,见 prepareImportTables。
func readTableSheets(ext string, file io.Reader) ([]importSheet, 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() }()
names := f.GetSheetList()
if len(names) == 0 {
return nil, badRequest("xlsx 没有工作表", nil)
}
out := make([]importSheet, 0, len(names))
hidden := 0
for _, name := range names {
// 隐藏工作表按约定不是给人填的数据(工具生成的辅助表常见),不参与导入;
// 但要在结果里说一句,免得「有一张表没导进去」变成静默丢数据。
sheetHidden := false
if visible, err := f.GetSheetVisible(name); err == nil && !visible {
sheetHidden = true
hidden++
}
rows, err := f.GetRows(name)
if err != nil {
return nil, badRequest("读取 xlsx 失败", err.Error())
}
out = append(out, importSheet{name: name, rows: rows, hidden: sheetHidden})
}
if hidden == len(out) {
return nil, badRequest("xlsx 里的工作表都是隐藏的,没有可导入的数据", nil)
}
return out, 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 {
// CSV 语法错误几乎都是格子里有没配对的双引号。message 给现场能照着做的中文提示,
// Go 的英文原文(parse error on line ...)留在 detail 里,排查时还要用。
return nil, badRequest("无法解析 CSV:表格里多半有没配对的双引号。请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入", err.Error())
}
return []importSheet{{rows: rows}}, nil
}
}
// 报名表文本可能带的字节序标记。
var (
tableUTF8BOM = []byte{0xEF, 0xBB, 0xBF}
tableUTF16LEBOM = []byte{0xFF, 0xFE}
tableUTF16BEBOM = []byte{0xFE, 0xFF}
)
// errTableReplacementRune 表示解码结果里出现了替换字符 U+FFFD。
// x/text 的解码器对非法字节、孤立代理对这类坏数据不返回 error,只把它们换成 U+FFFD,
// 靠这个哨兵错误把「解出一串 �」和「真的解开了」区分开。
var errTableReplacementRune = errors.New("decoded text contains U+FFFD replacement character")
// 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, false)
case bytes.HasPrefix(data, tableUTF16BEBOM):
return decodeUTF16Table(data[len(tableUTF16BEBOM):], textunicode.BigEndian, false)
}
// 丢了 BOM 的 UTF-16 必须排在 utf8.Valid 之前判断:UTF-16 里 0x00 是合法的 UTF-8 码点,
// utf8.Valid 会放它过去,表头随即被当成 "e\0v\0e\0n\0t...",最后报「缺少列 event_code」——
// 把现场引去查表头,而真正的问题是编码。
if order, ok := guessUTF16Order(data); ok {
return decodeUTF16Table(data, order, true)
}
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())
}
// 非法字节会被静默换成 U+FFFD:不拦住的话它一路往下走,现场看到的是
// 「第 2 行:组别 ��� 不存在」,跑去查组别配置——真正坏的是文件。
if strings.ContainsRune(text, utf8.RuneError) {
return "", badRequest("报名表编码无法识别:文件里混有无法解码的字节(多半是复制粘贴或转换工具弄坏的)。请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入", nil)
}
return text, nil
}
// guessUTF16Order 判断无 BOM 的字节流是不是 UTF-16,是的话顺带给出字节序。
//
// 判据刻意保守,因为猜错的代价是把好文件解成乱码:UTF-16 的每个 ASCII 字符
// (列名、逗号、数字、换行)都会贡献一个固定奇偶位的 0x00,而正常的 UTF-8/GB18030
// 报名表里一个 0x00 都没有。所以只认「NUL 足够多、且几乎只落在同一位」的字节流,
// 只是混进一两个坏字节的文本文件不会被误判。
func guessUTF16Order(data []byte) (textunicode.Endianness, bool) {
var even, odd int
for i, b := range data {
if b != 0 {
continue
}
if i%2 == 0 {
even++
} else {
odd++
}
}
zeros := even + odd
if zeros < 8 || zeros*4 < len(data) {
return false, false
}
switch {
case odd*20 >= zeros*19: // 小端:ASCII 的高位 0x00 落在奇数位
return textunicode.LittleEndian, true
case even*20 >= zeros*19: // 大端:0x00 落在偶数位
return textunicode.BigEndian, true
}
return false, false
}
// decodeUTF16Table 解 UTF-16(BOM 已由调用方摘掉)。missingBOM 决定失败时给现场哪句提示:
// 缺 BOM 的要教他「另存为 CSV UTF-8」,带 BOM 还解不动的按编码损坏处理。
func decodeUTF16Table(data []byte, order textunicode.Endianness, missingBOM bool) (string, error) {
text, _, err := transform.String(textunicode.UTF16(order, textunicode.IgnoreBOM).NewDecoder(), string(data))
if err == nil && strings.ContainsRune(text, utf8.RuneError) {
err = errTableReplacementRune
}
// 字节序猜反时整份文本会翻成另一堆合法码点(不会出现 U+FFFD),再拿表头必然有的
// 逗号兜一道底:认不出是 CSV 就宁可报编码问题,也不能把乱码当成功导进去。
if err == nil && missingBOM && !strings.ContainsRune(text, ',') {
err = errors.New("utf-16 decode produced no csv delimiter")
}
if err != nil {
message := "报名表编码无法识别,请另存为 UTF-8 编码的 CSV 后重试"
if missingBOM {
message = "报名表看起来是 UTF-16 编码但缺少 BOM,请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入"
}
return "", badRequest(message, err.Error())
}
return text, nil
}
// importColumn 是一列表头的全部可接受写法。
//
// name 是规范名(英文):取值、报错、纯英文模板都用它;aliases 是中文别名,供现场用中文表头填表。
// 一列同时认英文名与中文名,纯英文的老文件一字不改照常导入(别名只加不改)。
type importColumn struct {
name string
aliases []string
optional bool
}
// importColumns 是报名表的全部列,顺序即模板的列顺序(与前端 REQUIRED_COLUMNS、
// docs/报名名单导入规范.md 第二节的表格逐列一致)。
//
// 中文别名一律取导入规范文档里出现过的措辞;改这里必须同步改
// web/src/admin/components/baseData/entrySelection.ts 的 COLUMN_ALIASES,
// 否则会出现「示例数据模式能导入、连上服务端却报缺列」。
var importColumns = []importColumn{
{name: "event_code", aliases: []string{"项目编码", "小项编码"}},
{name: "category", aliases: []string{"组别", "组别名称"}},
{name: "team_code", aliases: []string{"单位编码", "参赛单位编码"}},
{name: "athlete_name", aliases: []string{"姓名", "运动员姓名"}},
// id_card、gender、age、heat 是可缺省的列:缺列不报错,按空值处理。
{name: "id_card", aliases: []string{"证件号", "身份证号"}, optional: true},
{name: "gender", aliases: []string{"性别"}, optional: true},
{name: "age", aliases: []string{"年龄"}, optional: true},
{name: "bib", aliases: []string{"号码布"}},
{name: "seed", aliases: []string{"种子", "道次"}},
{name: "heat", aliases: []string{"批次", "批次号"}, optional: true},
{name: "status", aliases: []string{"状态", "报名状态"}},
{name: "member_names", aliases: []string{"队员名单"}},
// squad(队伍)挂在**最后**:新列追加在表尾,现场手里那些少一列的旧表格一列都不会错位。
// 空串 = 不区分队伍,与老口径一致;同一个单位在同一个组别派两个队就靠它区分(迁移 0025)。
{name: "squad", aliases: []string{"队伍", "队名", "队伍名"}, optional: true},
}
// label 是报错时用的列名:英文规范名 + 首选中文别名。缺列提示中英并列,
// 手拿中文模板的人看到「缺少列 athlete_name(姓名)」能立刻对上自己表头那一格。
func (c importColumn) label() string {
if len(c.aliases) == 0 {
return c.name
}
return c.name + "(" + c.aliases[0] + ")"
}
// normalizeImportHeader 把一格表头归一成可比对的写法:去首尾空白(含全角空格)、
// 全角 ASCII / 全角空格转半角、U+FEFF 丢弃、转小写。
//
// 现场的表多半是中文 Excel 存出来的:列名可能带全角空格、被输入法写成全角字母,
// 或者粘贴时把字节序标记 U+FEFF 带进了第一格。这些都不该变成「缺少列 xxx」——
// 已有的编码处理(decodeTableText)只摘掉文件开头的 BOM,格子内部的还得在这里兜住。
func normalizeImportHeader(raw string) string {
var builder strings.Builder
builder.Grow(len(raw))
for _, r := range raw {
switch {
case r == '\uFEFF':
// BOM 粘在表头上正是「缺少列 event_code」的经典成因,直接丢掉。
case r == '\u3000':
builder.WriteRune(' ')
case r >= '\uFF01' && r <= '\uFF5E':
// 全角 ASCII 与半角一一对应,码点相差 0xFEE0。
builder.WriteRune(r - 0xFEE0)
default:
builder.WriteRune(r)
}
}
return strings.ToLower(strings.TrimSpace(builder.String()))
}
// lookupImportColumn 认一格表头,返回它的规范列名。
// 英文名与中文别名同等对待,认不出的(多余列、备注列)返回 false 由调用方忽略。
func lookupImportColumn(raw string) (string, bool) {
header := normalizeImportHeader(raw)
if header == "" {
return "", false
}
for _, column := range importColumns {
if header == column.name {
return column.name, true
}
for _, alias := range column.aliases {
if header == alias {
return column.name, true
}
}
}
return "", false
}
// importColumnIndex 把表头行转成「规范列名 → 下标」。
//
// 中英混用是允许的:每一列各自按名字认,互不影响(现场把一份中文表的一部分列换成英文名,
// 或者拿英文模板补上几列中文,都还导得动)。
//
// 但**同一列在一份表里出现两种写法**(既有 event_code 又有 项目编码,或同一格重复出现两次)
// 一律拦下:两列都可能有数据,系统无从判断该信哪一列,静默取其中一列等于把另一列的数据丢了。
// 报错点名这一列与两种写法,让人删掉多余的那一列——这比猜一个更安全。
func importColumnIndex(header []string) (map[string]int, error) {
index := make(map[string]int, len(importColumns))
spelled := make(map[string]string, len(importColumns))
for position, raw := range header {
name, ok := lookupImportColumn(raw)
if !ok {
continue
}
cell := strings.TrimSpace(raw)
if prev, duplicate := spelled[name]; duplicate {
return nil, badRequest(fmt.Sprintf(
"报名表表头重复:第 1 行里同一列出现了两种写法(%s、%s),请只保留一列再导入(列名中英文都可以,但不能一列写两遍)",
prev, cell), nil)
}
spelled[name] = cell
index[name] = position
}
return index, nil
}
// importTable 是一张进入导入流程的表。
//
// scope 为 nil 表示单表口径:范围由表内的「项目编码 / 组别」两列决定(CSV 与单表 xlsx 都走这条)。
// scope 非 nil 表示分工作表口径:范围由工作表名给定,表内那两列可以留空,填了就必须与工作表名一致。
type importTable struct {
sheet string
rows [][]string
scope *entrySheetScope
}
// bibOwner 汇总一个号码布在一份报名表里被哪些行用过,用来区分「同一个人报多个项目」与
// 「两个人在抢同一个号码布」。
//
// 判据是证件号:号码布是现场认人的依据,但它同时是一人多项目的合法重复值,只有证件号能证明
// 这是两个不同的人(两边都填了且对不上)。证件号相同、或有一方没填——后者是现场的常态,
// 证件号列常整列留空——都算同一个人,只在收尾用 notices 提示,绝不拦下这些行。
type bibOwner struct {
idCard string // 第一次见到的非空证件号;空表示这些行都没填
team bool // 这个号码布落在团体行上(团体行没有「人」,重复一律报错)
rows int // 用过这个号码布、且已放行的行数
scope entryScope // 第一行落在哪个 (小项, 组别)
scopes map[entryScope]bool // 第二行起记放行行落到过哪些 (小项, 组别)
unproven bool // 放行靠的是「证件号没填齐」,而不是证件号对得上
repeatScope bool // 有第二行落在同一个 (小项, 组别) 上 = 同一人同一组别重复报名
}
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
// 非 nil 时按决策处理命中人员的冲突,未出现在 decisions 里的冲突继续收集。
//
// sheets 是上传文件里的全部工作表:CSV 与单表 xlsx 只有一张(名字为空),分工作表模板则一张表一个
// (小项, 组别)。两种口径在这份代码里往下走同一条路,于是人员匹配、号码布查重、跨行去重这些判据
// (bibOwners / idCardCounts / seenIdentities)天然是**跨工作表**的:同一个号码布出现在两张表里,
// 照样按「报名表内重号」处理。
func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, decisions map[string]string) (entryImportResult, error) {
result := entryImportResult{
Errors: []string{}, Notices: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{},
}
tables, notices, err := s.prepareImportTables(ctx, sheets)
if err != nil {
return result, err
}
result.Notices = append(result.Notices, notices...)
// 号码布是现场认定的「人」:同一个人报多个项目时它会重复出现。判重只认证件号能不能
// 证明这是两个人(见 bibOwner),所以这里记的是号码布 -> 用过它的那些行。
bibOwners := make(map[string]bibOwner)
// 证件号才是唯一标识:同一份表里重复出现 = 同一个人报多个组别(不报错,收尾统一给 notices 提示);
// 但「同一证件号 + 同一个小项同一个组别」是真重复,只有它进冲突面板等决策。
idCardCounts := make(map[string]int)
seenIdentities := make(map[string]bool)
// 受影响的 (小项, 组别):只有「新建了报名项」或「可排名状态跨了界」的行才加进来。
// 名单里的姓名 / 号码布 / 批次这类不影响算分的字段改动不该触发重算,
// 否则几百行的常规导入会把每个组白算一遍(见 recomputeImportedScopes)。
affectedScopes := make([]entryScope, 0, 2)
// 「队员名单」列按号码布认出来、并挂到报名上的队员总数。收尾说一句:这些人会参与个人算分,
// 认不出的照旧只是文字名单——不报错,但现场得知道「这份表到底认上了几个人」。
linkedMembers := 0
for _, table := range tables {
index, err := importColumnIndex(table.rows[0])
if err != nil {
return result, withSheetPrefix(table.sheet, err)
}
for _, column := range importColumns {
if column.optional {
continue
}
// 分工作表口径下范围由工作表名承担:「项目编码 / 组别」两列可以整列不要。
if table.scope != nil && (column.name == "event_code" || column.name == "category") {
continue
}
if _, ok := index[column.name]; !ok {
// 表头类报错必须点到「表头行」本身:用户常在表头上方加一行标题,
// 只说「缺少列 X」会让他去翻数据行,找不到问题在哪。
// 列名中英并列:手上是中文模板的人一眼对得上,不会以为系统只认英文。
return result, withSheetPrefix(table.sheet, badRequest("报名表缺少列 "+column.label()+
":第 1 行应是表头(列名与导入模板一致,中文与英文表头都认),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil))
}
}
// 行级报错在工作表口径下带上表名:几张表各有自己的「第 3 行」,只说行号现场认不出是哪张表。
// 单表 / CSV 的 prefix 是空串,报错文案与升级前逐字一致。
prefix := ""
if table.sheet != "" {
prefix = "[工作表 " + table.sheet + "] "
}
for n, row := range table.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")
// 队伍是自由文本(一队 / 二队 / 青年队),不做任何归一:现场怎么写就怎么存,
// 「一队」与「一 队」是两个队(系统没有立场替现场合并)。
squad := cell("squad")
if teamCode == "" || (table.scope == nil && eventCode == "") {
result.Skipped++
if table.scope == nil {
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "event_code / team_code 必填"))
} else {
// 分工作表口径下小项由工作表名给定,缺的只可能是单位编码。
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "team_code(单位编码)必填"))
}
continue
}
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
status, err := normalizeImportStatus(cell("status"))
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
continue
}
// heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。
heat, ok := parseImportHeat(cell("heat"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
"heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat))
continue
}
// 范围:单表口径按表里的两列查;分工作表口径由工作表名给定,表里填了就必须与它一致。
// 不一致时**报错而不是挑一个用**:两处说法矛盾时系统无从判断该信哪个,
// 静默取工作表名会让填错的行悄悄落到别的小项上去。
var eventID, categoryID int64
if table.scope != nil {
eventID, categoryID = table.scope.EventID, table.scope.CategoryID
if eventCode != "" {
scoped, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "小项 %s 不存在", eventCode))
continue
}
if scoped != eventID {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
"本行项目编码 %s 不是工作表名里那个小项「%s」:分工作表模板里这两处必须说的是同一个小项。"+
"请删掉这一格(留空即按工作表名走),或改成本工作表对应的小项编码",
eventCode, table.scope.EventName))
continue
}
}
if categoryName != "" && !sameImportScopeName(categoryName, table.scope.CategoryName) {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
"本行组别「%s」与工作表名里的组别「%s」不一致:分工作表模板里这两处必须是同一个组别。"+
"请删掉这一格(留空即按工作表名走),或改成工作表名里的组别", categoryName, table.scope.CategoryName))
continue
}
} else {
eventID, err = s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "小项 %s 不存在", eventCode))
continue
}
}
// team_code 写 `A+B` 就是联队(devdocs/19):解析出多个成员单位,第一个是主单位,
// 分与奖牌按份额下发给各单位。成员单位的存在性、复活与报错都在里面,
// 报错文案与单单位口径逐字一致(现场几百份老表的提示格式不该变样)。
teamIDs, teamShares, err := s.lookupImportTeams(ctx, teamCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
continue
}
teamID := teamIDs[0]
if table.scope == nil {
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, prefix+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, prefix+s.interceptRow(line, "小项 %s 没有组别", eventCode))
continue
}
}
}
// 号码布查重对**所有行**生效(含团体行):团体行没有人员,但号码布照样是这张表的身份,
// 同一张表里两个团体行、或团体行与个人行共用一个号码布都必须报出来。
// 这段原先写在 athleteName != "" 里面,团体行填的号码布既不登记也不查重,重号会静默落库。
//
// 个人行之间的重复**只按证件号判**:证件号是人员的唯一标识,号码布不是——同一个人报多个
// 项目本来就会带着同一个号码布出现好几行。两边证件号都填了且对不上才算「两个人抢一个号」;
// 证件号相同、或有一方没填(现场常不填),都证明不了是两个人,一律当同一个人放行,
// 收尾用 notices 说清楚这个号码布出现在几行上,让操作员自己核对。
idCard := cell("id_card")
bib := cell("bib")
if bib != "" {
owner, seen := bibOwners[bib]
switch {
case seen && (owner.team || athleteName == ""):
// 团体行没有「人」可言,谈不上「视为同一个人」,一律按重号报错(升级前的行为)。
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
continue
case seen && owner.idCard != "" && idCard != "" && owner.idCard != idCard:
// 两个人共用一个号码布:保持升级前的行级报错口径。
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
continue
}
if seen {
// 复用放行:记下这次靠的是「证件号对得上」还是「有一方没填、证明不了」,
// 以及第二行起落在哪些 (小项, 组别) 上——同一组别上出现两次是真重复报名。
scope := entryScope{eventID, categoryID}
if owner.scopes == nil {
owner.scopes = map[entryScope]bool{owner.scope: true}
}
if owner.scopes[scope] {
owner.repeatScope = true
}
owner.scopes[scope] = true
owner.unproven = owner.unproven || owner.idCard == "" || idCard == ""
} else {
owner.scope = entryScope{eventID, categoryID}
owner.team = athleteName == ""
}
if owner.idCard == "" {
owner.idCard = idCard
}
owner.rows++
bibOwners[bib] = owner
}
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
var athleteID *int64
if athleteName != "" {
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
if idCard == "" && bib == "" {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
continue
}
age, ok := parseImportAge(cell("age"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "age 需要是 0-120 的整数"))
continue
}
// 真重复判据与服务端认人顺序一致:同一证件号 + 同一个小项同一个组别。
// 组别留空时上面已取该小项的第一个组别,这里用解析出的 categoryID 比。
duplicate := false
if idCard != "" {
identity := fmt.Sprintf("%s\x00%d\x00%d", idCard, eventID, categoryID)
duplicate = seenIdentities[identity]
seenIdentities[identity] = true
idCardCounts[idCard]++
}
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
Line: line,
Name: athleteName,
Gender: normalizeGender(cell("gender")),
TeamID: teamID,
TeamCode: teamCode,
IDCard: idCard,
Bib: bib,
Age: age,
Duplicate: duplicate,
}, decisions)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", importRowMessage(err)))
continue
}
if matched.Conflict != nil {
result.Conflicts = append(result.Conflicts, *matched.Conflict)
continue
}
if matched.Skip {
// 真重复行 + 用户选了「保留已有」:库里那条报名原样不动,本行不落库。
result.Skipped++
continue
}
if matched.SameNameNewPerson != nil {
result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson)
}
athleteID = &matched.AthleteID
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
teamID = matched.TeamID
}
// 份额必须跟着**最终**的主单位走:上面的人员匹配可能把报名改挂到库里那条档案的单位
// (keep-existing 的既有口径「报名跟随库里已有的单位」)。主单位掉到表格写的成员之外时
// 整组退回独占,与升级前一致——报名挂在哪个单位,分就记给哪个单位;
// 否则会出现「报名挂在 A 名下、分却记给了表格里写的 B」这种看不见的错。
if !sharesContainTeam(teamShares, teamID) {
teamShares = nil
}
// 队伍只对**纯团体报名**(这一行没填姓名)生效。个人报名按 (组别, 单位, 运动员) 唯一,
// squad 不参与那条判断——填了就写不进库,所以要说一句,不能悄悄丢掉这一格。
// 说清「改哪一格」比说清后果更要紧:现场看到「没写进库里」只会换个填法再试一遍
// (「团体报名到底能不能写姓名」就是这么来的)。
if athleteID != nil && squad != "" {
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行填了队伍「%s」,但这一行也填了运动员姓名,系统按个人报名处理:队伍只对团体报名生效,"+
"这一格没有写进库里。要报团体(一支队)请把姓名列留空、把队员的号码布填到「队员名单」列;"+
"这一行确实是个人报名,把「队伍」这一格删掉即可",
line, squad))
squad = ""
}
// 「队员名单」列既是显示用的文字,也能写**号码布**:一格里的每一段先按号码布去
// athletes 认人,认得上就把这条报名跟那个人关联起来(entry_athletes),
// 认不上的照旧只当文字(现状不变,不报错)。号码布是运动员的唯一标识(devdocs/20 §三)。
memberTokens := splitNames(cell("member_names"))
link, err := s.importMemberAthletes(ctx, memberTokens)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", importRowMessage(err)))
continue
}
for _, name := range link.Repeated {
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行:队员名单里的号码布 %s 写了两次,只关联一个人(同一条报名里一个人只占一个位置)", line, name))
}
// 队员只对**纯团体报名**生效:带运动员姓名的行按个人报名处理,那种报名自己就是那个人的
// 身份(见 PUT /api/entries/{id}/athletes 的同一条口径),所以这一格只留文字、不关联。
// 认得出人时才说一句——这一格在个人行上本来就只是备注,空着或只写姓名都不必打扰现场。
// 与上面队伍那条同一口径:给出「团体怎么填 / 个人怎么填」两条动作,不停在「这一格没关联」。
if athleteID != nil && len(link.Members) > 0 {
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行的队员名单里有 %d 个号码布认得出人,但这一行也填了运动员姓名,系统按个人报名处理:"+
"队员只对团体报名生效,这一格只作文字名单保留、没有关联到人。"+
"要报团体请把姓名列留空、队员的号码布就填在「队员名单」列;这一行确实是个人报名,"+
"把「队员名单」这一格删掉即可",
line, len(link.Members)))
link.Members = nil
}
// 一条报名的队员上限与 PUT /api/entries/{id}/athletes 共用:超过就整行拦下,不写一条
// 界面上改不动的报名(与联队「一个联队最多 5 个单位」同一条口径)。认不出人的段不计入,
// 所以只写姓名的老表一行都不会被它拦下。
if len(link.Members) > maxEntryAthletes {
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
"队员名单里认出了 %d 名运动员,超过一条报名最多 %d 人:请检查这一格是不是把号码布写错了位置",
len(link.Members), maxEntryAthletes))
continue
}
// 号码布认到别的单位的人:照号码布认(与迁移回填同一口径:号码布是唯一标识,不看单位),
// 但要点名说清 —— 团体报名挂在 A 单位、队员却是 B 单位的人,最常见的原因是号码布写错了。
// 联队(team_code 写 A+B)的成员单位都算「本行的单位」:队员本来就该来自其中任何一个,
// 不该报一个会让人去改正确号码布的假警报。
// 这里**不**进冲突面板:那是「要不要用表里的字段覆盖库里这条人员档案」的决策,
// 而队员关联一个字段都不改(与 PUT /api/entries/{id}/athletes 一样只认 athleteId)。
for _, member := range link.Members {
if member.TeamID == teamID || sharesContainTeam(teamShares, member.TeamID) {
continue
}
memberTeam := member.TeamCode
if memberTeam == "" {
memberTeam = "(无单位)"
}
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行:队员名单里的号码布 %s 是单位 %s 的 %s,本行报名挂在单位 %s 名下 —— "+
"号码布是运动员的唯一标识,已按它关联为队员,请核对号码布是否写错",
line, member.Bib, memberTeam, member.Name, teamCode))
}
members := make([]entryAthleteDTO, 0, len(link.Members))
for _, member := range link.Members {
members = append(members, entryAthleteDTO{AthleteID: member.AthleteID})
}
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
EventID: eventID,
CategoryID: categoryID,
TeamID: teamID,
TeamShares: teamShares,
Squad: squad,
AthleteID: athleteID,
MemberNames: memberTokens,
Athletes: members,
Bib: nilIfEmpty(bib),
Seed: nilIfEmpty(cell("seed")),
Heat: heat,
Status: status,
})
switch {
case err != nil:
result.Skipped++
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
case outcome.Created:
result.Created++
linkedMembers += len(members)
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
// 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员
// 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。
for _, moved := range outcome.MovedEntries {
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
"第 %d 行:%s 换了单位,原单位 %s 在本组别的报名已移出,已改在单位 %s 加入",
line, moved.Name, moved.OldTeamCode, moved.NewTeamCode))
}
default:
result.Updated++
linkedMembers += len(members)
// 队员关联与 status 跨界同一类:它们决定「谁拿到分」,改了就要重算这一组,
// 否则会出现「名单里认出了队员、榜上还没他的分」(与 PUT /api/entries/{id}/athletes 同口径)。
if outcome.StatusChanged || outcome.MembersChanged {
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
}
}
}
}
// 同一证件号在本表里出现多次 = 同一个人报多个组别:不报错,但要让现场看见。
// 按证件号排序,保证同一份表的提示顺序稳定(前端展示与用例断言都依赖它)。
// 分工作表模板下这条判据是**跨表**的:同一个人在两个组别各一行,同样只给一条提示。
idCards := make([]string, 0, len(idCardCounts))
for idCard := range idCardCounts {
idCards = append(idCards, idCard)
}
sort.Strings(idCards)
for _, idCard := range idCards {
if count := idCardCounts[idCard]; count > 1 {
result.Notices = append(result.Notices,
fmt.Sprintf("证件号 %s 在本表中出现 %d 次(同一人报名多个组别)", idCard, count))
}
}
// 号码布重复出现、但证件号证明不了是两个人(有一方或双方没填)时,行放行但要说清楚:
// 号码布是现场认人的依据,重复既可能是一人多项目,也可能是表格填错。
// 与证件号那条同一个 notices 通道,也按号码布排序,保证同一份表的提示顺序稳定。
bibs := make([]string, 0, len(bibOwners))
for bib := range bibOwners {
bibs = append(bibs, bib)
}
sort.Strings(bibs)
for _, bib := range bibs {
owner := bibOwners[bib]
if owner.rows < 2 || !owner.unproven {
// 证件号对得上(两边都填了同一个号)时不用这条:证件号那条提示已经说过这件事了。
continue
}
if owner.repeatScope {
result.Notices = append(result.Notices, fmt.Sprintf(
"号码布 %s 在本表中出现 %d 次,其中同一小项同一组别有重复行:同一个人的同一组别只保留一条报名(后一行覆盖前一行),请核对表格",
bib, owner.rows))
continue
}
result.Notices = append(result.Notices, fmt.Sprintf(
"号码布 %s 在本表中出现 %d 次,证件号没填齐,已视为同一人报名多个项目", bib, owner.rows))
}
// 队员名单认了多少人,用既有 notices 通道说一句:号码布认得出人时,这些人就从「文字名单」
// 变成了「真实的队员」,会各自拿到个人分与个人奖牌(配了个人通道的项目才上个人榜)。
// 认不出的一律照旧只是文字,不报错——人没录档案是常态,这正是降级形态存在的意义。
if linkedMembers > 0 {
result.Notices = append(result.Notices, fmt.Sprintf(
"队员名单列按号码布关联了 %d 名队员(号码布是运动员的唯一标识):配了个人通道的项目,他们会各自拿到个人分与个人奖牌;"+
"只写姓名、或号码布认不出人的照旧只作文字名单", linkedMembers))
}
// 导入动过「谁还算数」的组,按当前报名与成绩重算名次与流水并推榜。
// 与批量退赛/移出、作废成绩同一口径:延迟结算下也要当场算,不然名单改了榜不动。
if err := s.recomputeImportedScopes(ctx, affectedScopes, middleware.User(ctx).ID); err != nil {
return result, err
}
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
}
// importMember 是一格「队员名单」里按号码布认出来的一个人。
//
// 写进报名报的只有 AthleteID(与 PUT /api/entries/{id}/athletes 同一口径:认人是调用方的事);
// 姓名、号码布与单位只用于报告 —— 「认到的是谁、他属于哪个单位」。
type importMember struct {
AthleteID int64
Name string
Bib string
TeamID int64
TeamCode string
}
// importMemberLink 是一格「队员名单」认人的结果。
type importMemberLink struct {
// Members 按这一格里的书写顺序给出认出来的人(同一个号码布写多次只留一次),
// 写进报名的 sort_order 就用这个顺序(与迁移回填「按 member_names 书写顺序」同一口径)。
Members []importMember
// Repeated 是同一格里写了两次以上、且认得出人的号码布,按首次出现顺序。
Repeated []string
}
// importMemberAthletes 把「队员名单」一格里的每一段按**号码布**认成人。
//
// 口径(devdocs/20 §三,主人 2026-09-14 定的):
// - 号码布是运动员的唯一标识(0014 上有部分唯一索引):一段文字与某个运动员的号码布完全相同,
// 那就是他 —— **与单位无关**(迁移里的回填 1 同样不看单位),单位不同只作提示;
// - 一格里的内容同时能当成号码布与姓名时,以号码布为准:先认号码布,命中了就不再看姓名;
// - 认不出的段**保持原样当文字**(member_names 照常保留,现状不变):现场难免有没录档案的人。
// 姓名一律不做匹配——同名会认错人,而认错的分已经上了榜(老数据的姓名回填是迁移里的一次性动作,
// 且要求同单位唯一,导入不重复这条不确定性)。
//
// 一格一次查询:格子里的段数最多十几段,但一份表可能有几百行,逐段查会把导入变成几百次往返。
func (s *server) importMemberAthletes(ctx context.Context, tokens []string) (importMemberLink, error) {
if len(tokens) == 0 {
return importMemberLink{}, nil
}
seen := make(map[string]bool, len(tokens))
args := make([]any, 0, len(tokens))
for _, token := range tokens {
if seen[token] {
continue
}
seen[token] = true
args = append(args, token)
}
rows, err := s.ev.QueryContext(ctx,
`SELECT a.bib, a.id, a.name, a.team_id, COALESCE(t.code, '')
FROM athletes a LEFT JOIN teams t ON t.id = a.team_id
WHERE a.bib IN (`+placeholders(len(args))+`)`, args...)
if err != nil {
return importMemberLink{}, internal(err)
}
defer func() { _ = rows.Close() }()
byBib := make(map[string]importMember, len(args))
for rows.Next() {
var (
m importMember
bib sql.NullString
code string
)
if err := rows.Scan(&bib, &m.AthleteID, &m.Name, &m.TeamID, &code); err != nil {
return importMemberLink{}, internal(err)
}
m.Bib = bib.String
m.TeamCode = code
byBib[m.Bib] = m
}
if err := rows.Err(); err != nil {
return importMemberLink{}, internal(err)
}
var link importMemberLink
emitted := make(map[int64]bool)
for _, token := range tokens {
member, ok := byBib[token]
if !ok {
// 认不出人:照旧只当文字(member_names 已经把它留在库里了)。
continue
}
if emitted[member.AthleteID] {
// 同一个号码布写了两次:同一条报名里一个人只占一个位置(表的主键也这么定),
// 这里就地去掉并记下来,由调用方说一句。
if !slices.Contains(link.Repeated, member.Bib) {
link.Repeated = append(link.Repeated, member.Bib)
}
continue
}
emitted[member.AthleteID] = true
link.Members = append(link.Members, member)
}
return link, nil
}
// ---------- 图片上传与静态访问 ----------
// 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 "", 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 匿名提供上传目录里的图片:大屏与 <img> 不带会话,不能要求登录。
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
}
// ---------- 上传图片的删除与孤儿清理 ----------
// uploadRef 是一条「引用了某张上传图片」的配置:name 是配置值指向的文件名,label 是给操作员看的位置。
type uploadRef struct {
name string
label string
}
// uploadRefPoints 是赛事设置里的图片引用点。
// 加新的图片来源时必须同步加到这里(以及 uploadRefs 里的单位查询),
// 否则孤儿清单会把正在用的图片当成垃圾删掉。
var uploadRefPoints = []struct {
key string
label string
}{
{"event.logo_path", "赛事信息·赛事会标"},
{"display.logo", "赛事信息·大屏会标"},
{"display.background", "赛事信息·大屏背景图"},
}
// uploadRefs 扫一遍全部引用点,返回「哪张图被哪里引用」。
//
// 单位不按 deleted_at 过滤:被彻底删除(隐藏)的单位还能恢复,恢复后图标必须还在,
// 所以它的 logo 仍然算被引用——这里的方向是宁可漏删孤儿,也不能误删在用的图。
func (s *server) uploadRefs(ctx context.Context) ([]uploadRef, error) {
values, err := loadSettingsMap(ctx, s.ev)
if err != nil {
return nil, err
}
var refs []uploadRef
for _, point := range uploadRefPoints {
if name, ok := uploadNameInPath(values[point.key]); ok {
refs = append(refs, uploadRef{name: name, label: point.label})
}
}
rows, err := s.ev.QueryContext(ctx,
`SELECT COALESCE(code, ''), name, COALESCE(short_name, ''), COALESCE(logo_path, '')
FROM teams WHERE COALESCE(logo_path, '') <> ''`)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var code, teamName, shortName, logoPath string
if err := rows.Scan(&code, &teamName, &shortName, &logoPath); err != nil {
return nil, internal(err)
}
name, ok := uploadNameInPath(logoPath)
if !ok {
continue
}
label := firstNonBlank(teamName, shortName, code)
refs = append(refs, uploadRef{name: name, label: "单位「" + label + "」的单位图标"})
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
return refs, nil
}
// referencedUploads 把 uploadRefs 汇总成 文件名 → 引用位置列表。
// 孤儿清单与删除前的复核共用它,保证「列出来是孤儿」与「删的时候判定还是孤儿」口径一致。
func (s *server) referencedUploads(ctx context.Context) (map[string][]string, error) {
refs, err := s.uploadRefs(ctx)
if err != nil {
return nil, err
}
out := make(map[string][]string, len(refs))
for _, ref := range refs {
out[ref.name] = append(out[ref.name], ref.label)
}
return out, nil
}
// uploadNameInPath 从配置值里取出它指向的上传文件名。
//
// 管理端允许手填 `uploads/xxx.png` 这类相对路径,也可能粘一整条地址,
// 所以统一去掉查询串后取最后一段路径再比对。这种「宽松识别」的方向是有意的:
// 认错(把别处同名的图当成本地文件)最多漏删一个孤儿,不认则会删掉正在用的图。
func uploadNameInPath(value string) (string, bool) {
v := strings.TrimSpace(value)
if v == "" {
return "", false
}
// Windows 手填路径用反斜杠,先归一成 /。
v = strings.ReplaceAll(v, `\`, "/")
if i := strings.LastIndexAny(v, "?#"); i >= 0 {
v = v[:i]
}
v = strings.TrimSuffix(v, "/")
if i := strings.LastIndex(v, "/"); i >= 0 {
v = v[i+1:]
}
if v == "" {
return "", false
}
return v, true
}
func firstNonBlank(values ...string) string {
for _, v := range values {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
// uploadFileDTO 是孤儿清单里的一项。
type uploadFileDTO struct {
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
ModifiedAt string `json:"modifiedAt"`
}
type uploadOrphansDTO struct {
Items []uploadFileDTO `json:"items"`
Total int `json:"total"`
TotalBytes int64 `json:"totalBytes"`
}
// listUploadOrphansHandler 列出 uploads/ 下没有被任何引用点用到的图片(admin)。
//
// 只扫单层目录:uploads/fonts/ 是字体资源,有各自的删除接口,不归这里管。
// 目录不存在回空列表——「一张孤儿都没有」是正常状态,不是错误。
func (s *server) listUploadOrphansHandler(w http.ResponseWriter, r *http.Request) (any, error) {
dir, err := s.uploadsDir()
if err != nil {
return nil, err
}
referenced, err := s.referencedUploads(r.Context())
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return uploadOrphansDTO{Items: []uploadFileDTO{}}, nil
}
return nil, internal(err)
}
items := make([]uploadFileDTO, 0, len(entries))
var totalBytes int64
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// 复用静态访问的校验:清单里不该出现 /uploads/ 根本取不到的文件。
if _, ok := uploadContentType(name); !ok {
continue
}
if _, used := referenced[name]; used {
continue
}
// 单个条目读不到(权限 / 并发删除)只跳过它,不让整张清单失败。
info, err := entry.Info()
if err != nil {
continue
}
items = append(items, uploadFileDTO{
Name: name,
URL: "/uploads/" + name,
Size: info.Size(),
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
})
totalBytes += info.Size()
}
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
return uploadOrphansDTO{Items: items, Total: len(items), TotalBytes: totalBytes}, nil
}
// deleteUploadHandler 删除单张上传图片(admin)。
//
// 仍被赛事设置或单位引用时拒绝,并把「被谁引用」列出来:只回一句「正在使用」
// 会让现场不知道该去哪里改,而删掉在用的图会让大屏直接白图,比拒绝更难排查
// (与字体删除同一口径,见 fonts.go 的 ensureFontUnused)。
func (s *server) deleteUploadHandler(w http.ResponseWriter, r *http.Request) error {
name := r.PathValue("name")
// 文件名收敛与防穿越复用静态访问的校验,不另写一套。
if _, ok := uploadContentType(name); !ok {
return notFound("图片不存在")
}
dir, err := s.uploadsDir()
if err != nil {
return err
}
referenced, err := s.referencedUploads(r.Context())
if err != nil {
return err
}
if refs := referenced[name]; len(refs) > 0 {
return conflict(uploadInUseMessage(name, refs))
}
if err := os.Remove(filepath.Join(dir, filepath.Base(name))); err != nil {
if os.IsNotExist(err) {
return notFound("图片不存在")
}
return internal(err)
}
s.audit(r.Context(), "uploads.delete", "upload", 0, map[string]any{"file": name}, nil)
return nil
}
// uploadInUseMessage 把「被谁引用」拼成操作员看得懂的拒绝理由。
func uploadInUseMessage(name string, refs []string) string {
return "图片 " + name + " 正在被使用(" + strings.Join(refs, "、") +
"),请先在对应位置改掉或清除后再删除"
}
// maxUploadDeleteBatch 限制一次批量删除的条目数,超出的请求整单拒绝而不是部分执行。
const maxUploadDeleteBatch = 500
type deleteUploadsInput struct {
Names []string `json:"names"`
}
type uploadDeleteFailure struct {
Name string `json:"name"`
Reason string `json:"reason"`
}
type deleteUploadsResult struct {
Deleted []string `json:"deleted"`
Failed []uploadDeleteFailure `json:"failed"`
}
// deleteUploadsHandler 批量删除「请求里明确列出」的孤儿图片(admin),逐条回报结果。
//
// 红线:只动请求点名的文件,绝不「扫到没引用的就删」。清单在前端可能已经过期
// (期间有人把图配到了单位或背景上),所以删除前重新扫一遍引用,被引用的条目只回报不删。
func (s *server) deleteUploadsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in deleteUploadsInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if len(in.Names) == 0 {
return nil, badRequest("没有指定要删除的图片", nil)
}
if len(in.Names) > maxUploadDeleteBatch {
return nil, badRequest(fmt.Sprintf("一次最多删除 %d 张图片", maxUploadDeleteBatch), len(in.Names))
}
dir, err := s.uploadsDir()
if err != nil {
return nil, err
}
referenced, err := s.referencedUploads(r.Context())
if err != nil {
return nil, err
}
deleted := make([]string, 0, len(in.Names))
failed := make([]uploadDeleteFailure, 0)
seen := make(map[string]bool, len(in.Names))
for _, raw := range in.Names {
name := strings.TrimSpace(raw)
// 同一个名字重复出现只处理一次:第二条回报「文件不存在」会误导操作员。
if name == "" || seen[name] {
continue
}
seen[name] = true
if _, ok := uploadContentType(name); !ok {
failed = append(failed, uploadDeleteFailure{name, "文件名非法,已跳过"})
continue
}
if refs := referenced[name]; len(refs) > 0 {
failed = append(failed, uploadDeleteFailure{name, "仍被引用(" + strings.Join(refs, "、") + "),已跳过"})
continue
}
if err := os.Remove(filepath.Join(dir, filepath.Base(name))); err != nil {
if os.IsNotExist(err) {
failed = append(failed, uploadDeleteFailure{name, "文件不存在,已跳过"})
continue
}
failed = append(failed, uploadDeleteFailure{name, "删除失败:" + err.Error()})
continue
}
deleted = append(deleted, name)
}
if len(deleted) > 0 {
s.audit(r.Context(), "uploads.delete", "upload", 0, map[string]any{"files": deleted}, nil)
}
return deleteUploadsResult{Deleted: deleted, Failed: failed}, nil
}