Files
Scoreboard/server/internal/httpapi/handlers_config.go
T
AskaEth 45edfbeb71 fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛)
results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」,
团体手工通道把个人名次当团体名次又按团体表发一遍分。
- 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建)
  与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键,
  避免规则包重导时级联删掉录入数据)
- results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回;
  团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底,
  个人报名项的名次绝不进团体通道
- writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked /
  realtime.loadRanked 三份同源 SQL 一起改读新表
- 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证)

## 奖牌结算开关与奖牌通道(P1(5),新功能)
- events.award_medals + 通道 count_to_medals / count_to_team_medals
  (迁移默认 1,升级不改变既有榜单)
- 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位)
- 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算
- 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、
  录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks)

## 身份口径改回证件号(P1(15),推翻第三十六轮)
- 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard
- 同表重复证件号 = 同一人报多个组别(notices 提示,不报错);
  只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate)
- 补齐后端缺失但前端/文档已在依赖的三处契约

## 其余 P1
- 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径,
  被限流的请求挂起并由心跳兜底补发,不丢刷新)
- 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 /
  积分模板 awardRanks → 400 或 404,不再 500
- 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、
  报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价)
- 配置改动触发重算:通道 / 小项参数 / 积分模板
- 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算
  (服务对外监听之前完成,避免升级后奖牌榜全 0)
- 示例数据模式下破坏性操作被阻断,不再假成功
- Console:修复「只剩一块屏」后屏幕监听器被永久摘掉
- 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常

## 文档
- docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、
  常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、
  部署指南、上线检查清单
- 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步)

自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build;
Console 35 例 + 语法检查。
2026-09-11 21:47:07 +08:00

1661 lines
56 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"
"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) {
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())
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
}
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
}
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)
}
// 会话被盗后不能直接改密,必须再验一次原密码。
// 这里不能用 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
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
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
// readImportForm 解析报名表上传(multipart file 字段),返回表格行。
func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([][]string, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
if err := r.ParseMultipartForm(16 << 20); err != nil {
return nil, badRequest("上传文件解析失败", err.Error())
}
file, header, err := r.FormFile("file")
if err != nil {
return nil, badRequest("缺少 file 字段", err.Error())
}
defer func() { _ = file.Close() }()
return readTable(filepath.Ext(header.Filename), file)
}
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
rows, err := s.readImportForm(w, r)
if err != nil {
return nil, err
}
result, err := s.importEntries(r.Context(), rows)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entries.import", "entry", 0, nil, result)
return result, nil
}
// resolveImportHandler 按用户对冲突的逐条决策重跑导入:
// 已决策的冲突按决策落库,未决策的冲突继续留在 conflicts 里返回。
func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
rows, err := s.readImportForm(w, r)
if err != nil {
return nil, err
}
raw := strings.TrimSpace(r.FormValue("decisions"))
if raw == "" {
return nil, badRequest("缺少 decisions 字段", nil)
}
var decisions []importDecision
if err := json.Unmarshal([]byte(raw), &decisions); err != nil {
return nil, badRequest("decisions 不是合法 JSON", err.Error())
}
byKey := make(map[string]string, len(decisions))
for _, d := range decisions {
if d.Action != decisionKeepExisting && d.Action != decisionKeepNew {
return nil, badRequest("action 只能是 keep-existing 或 keep-new", d.Action)
}
key := strings.TrimSpace(d.IDCard)
if key == "" {
key = strings.TrimSpace(d.Bib)
}
byKey[key] = d.Action
}
result, err := s.importEntriesWith(r.Context(), rows, byKey)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entries.import.resolve", "entry", 0, nil, result)
return result, nil
}
// setEntriesHeatHandler 批量设置 / 清除一批报名项的批次(PUT /api/entries/heat)。
func (s *server) setEntriesHeatHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in entryHeatPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
result, err := s.setEntriesHeat(r.Context(), in.EntryIDs, in.Heat)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entry.heat", "entry", 0, nil,
map[string]any{"entryIds": result.EntryIDs, "heat": result.Heat, "updated": result.Updated})
return result, nil
}
func readTable(ext string, file io.Reader) ([][]string, error) {
switch strings.ToLower(ext) {
case ".xlsx":
f, err := excelize.OpenReader(file)
if err != nil {
return nil, badRequest("无法解析 xlsx", err.Error())
}
defer func() { _ = f.Close() }()
sheets := f.GetSheetList()
if len(sheets) == 0 {
return nil, badRequest("xlsx 没有工作表", nil)
}
rows, err := f.GetRows(sheets[0])
if err != nil {
return nil, badRequest("读取 xlsx 失败", err.Error())
}
return rows, nil
default:
text, err := decodeTableText(file)
if err != nil {
return nil, err
}
reader := csv.NewReader(strings.NewReader(text))
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
rows, err := reader.ReadAll()
if err != nil {
// CSV 语法错误几乎都是格子里有没配对的双引号。message 给现场能照着做的中文提示,
// Go 的英文原文(parse error on line ...)留在 detail 里,排查时还要用。
return nil, badRequest("无法解析 CSV:表格里多半有没配对的双引号。请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入", err.Error())
}
return 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
}
// 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{}, Notices: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{},
}
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 {
// 表头类报错必须点到「表头行」本身:用户常在表头上方加一行标题,
// 只说「缺少列 X」会让他去翻数据行,找不到问题在哪。
return result, badRequest("报名表缺少列 "+col+":第 1 行应是表头(列名与导入模板一致),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil)
}
}
// 号码布不再是人员的唯一标识:同一个人报多个组别时可以重复使用,只有「两个不同的人共用一个号码布」
// 才按行报错,所以这里记的是号码布 -> 第一次出现的那个人的证件号。
bibOwners := make(map[string]string)
// 证件号才是唯一标识:同一份表里重复出现 = 同一个人报多个组别(不报错,收尾统一给 notices 提示);
// 但「同一证件号 + 同一个小项同一个组别」是真重复,只有它进冲突面板等决策。
idCardCounts := make(map[string]int)
seenIdentities := make(map[string]bool)
// 受影响的 (小项, 组别):只有「新建了报名项」或「可排名状态跨了界」的行才加进来。
// 名单里的姓名 / 号码布 / 批次这类不影响算分的字段改动不该触发重算,
// 否则几百行的常规导入会把每个组白算一遍(见 recomputeImportedScopes)。
affectedScopes := make([]entryScope, 0, 2)
for n, row := range rows[1:] {
line := n + 2
cell := func(name string) string {
i, ok := index[name]
if !ok || i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
eventCode := cell("event_code")
categoryName := cell("category")
teamCode := cell("team_code")
athleteName := cell("athlete_name")
if eventCode == "" || teamCode == "" {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "event_code / team_code 必填"))
continue
}
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
status, err := normalizeImportStatus(cell("status"))
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
// heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。
heat, ok := parseImportHeat(cell("heat"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line,
"heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat))
continue
}
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 不存在", eventCode))
continue
}
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "单位 %s 不存在", teamCode))
continue
}
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
if err := s.reviveTeam(ctx, teamID); err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
var categoryID int64
if categoryName != "" {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "组别 %s 不存在", categoryName))
continue
}
} else {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 没有组别", eventCode))
continue
}
}
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
var athleteID *int64
if athleteName != "" {
idCard := cell("id_card")
bib := cell("bib")
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
if idCard == "" && bib == "" {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
continue
}
age, ok := parseImportAge(cell("age"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
continue
}
if bib != "" {
// 同一个人(证件号相同且都填了)可以共用号码布;其余重复一律按行报错。
if owner, seen := bibOwners[bib]; seen && (idCard == "" || owner != idCard) {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
continue
}
bibOwners[bib] = idCard
}
// 真重复判据与服务端认人顺序一致:同一证件号 + 同一个小项同一个组别。
// 组别留空时上面已取该小项的第一个组别,这里用解析出的 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, 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
}
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
EventID: eventID,
CategoryID: categoryID,
TeamID: teamID,
AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(cell("bib")),
Seed: nilIfEmpty(cell("seed")),
Heat: heat,
Status: status,
})
switch {
case err != nil:
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
case outcome.Created:
result.Created++
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
default:
result.Updated++
if outcome.StatusChanged {
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))
}
}
// 导入动过「谁还算数」的组,按当前报名与成绩重算名次与流水并推榜。
// 与批量退赛/移出、作废成绩同一口径:延迟结算下也要当场算,不然名单改了榜不动。
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
}
// ---------- 图片上传与静态访问 ----------
// 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
}