Files
Scoreboard/server/internal/httpapi/handlers_config.go
T
AskaEth 53a0cb225c feat: 备份恢复 / 图片清理 / 单位恢复 / 审计过滤 / 模板引擎 M1 + 两个导入缺陷修复
用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:

**新增能力**
- 备份恢复:`POST /api/backup/restore`(admin)。先把校验做在拍快照之前(只读打开 + integrity_check +
  关键表校验 + 异类库标志表互斥),再用 VACUUM INTO 拍「恢复前安全副本」,然后关连接、整文件替换、
  触发优雅重启;`-wal` 还在时直接拒绝(宁可失败也不让旧库 WAL 套到新库上);恢复后审计直写"恢复之后
  还在的那份库"(不走通用 Auditor,否则记录只会落在被替换掉的旧文件上)。顺带修掉:裸服务端连续恢复
  第二次会在拍副本处崩出一句看不懂的 500,现在回 503「上次恢复后尚未重启,请先重启服务端」。
- 安全副本保留策略:每类库保留最近 5 份,独立于 .db 的 20 份滚动清理。
- 图片管理:`DELETE /api/uploads/{name}`(被引用 409 并列出引用点)、`GET /api/uploads/orphans`、
  `POST /api/uploads/orphans/delete`(只删列出的孤儿,宁漏勿误),全部 admin(与字体删除同档);
  `deploy/backup.bat` 补上 uploads/ 归档(logs 不归档:可再生的诊断且会无上限增长)。
- 已彻底删除单位的恢复:`POST /api/teams/{id}/restore`(与 hideTeam 同档)。幂等;恢复不会把「停用」
  变成「启用」;真机确认恢复前后榜单逐字节一致、change_log 最大值不变(数据一行没动,不需要重算)。
  界面加「显示已彻底删除的单位」(仅 admin)与恢复按钮;非 admin 的删除确认框里写明"本账号无法自行恢复"。
- 审计日志按 `action` 过滤(子串模糊,输入里的 %/_/\ 转义成字面量)。
- 删除死表 recent_projects(新增 system 迁移 0006,未动已应用的迁移)。
- points_override 不再"设了没效果":写入侧 400 + 引擎侧守卫,提示改用「积分调整」。
- 联队份额空壳清理(UnionAttribution/UnionSplit/Entry.TeamShares/resolvedShares 等;TeamShare 类型保留,
  因为 export/realtime/奖牌流水三处在生产路径上构造它)。
- 自定义大屏模板 M1:插值引擎(parseTemplate/renderTemplate),任何情况都不抛异常、未定义→空串+警告、
  警告按 (code,path) 合并计数、无 eval/innerHTML,57 个新用例。

**两个真缺陷(前两个已随 0.1.6 出包)**
- 基础数据包导入「排序」列口径不一致:小项/组别留空保持原值,单位/大项留空被写成 0(plan 没从库里
  现值起手)→ 四张表统一为「留空保持原值」。
- 同类第二处:布尔列「整列被删掉」被当成"取默认值",会把库里所有停用的单位/大项静默改回启用 →
  改为「缺列 = 不动」(写侧与解析侧都收口,补了"缺列保持/空格子=是/显式否=停用"三组对照用例)。

**删死代码**
- createEntry / updateEntry / deleteEntry / entryEventID / checkCategoryInEvent / getEntry /
  normalizeEntryStatus / ExportBaseDataXLSX(全仓 grep 零引用)。⚠️ 随之记入文档:「组别必须属于该小项」
  这道校验现在只由导入路径"按小项+组别名解析"天然保证,将来若新增从请求体接收 categoryId 的接口,
  必须自己补校验(库层没有复合外键)。

**文档**
- devdocs 全面同步(01/02/03/04/05/06/07/09/11/12/13/README;含身份口径、延迟结算、大屏单栏新版式、
  新增路由与设置键、R18/R19 风险);docs 复核 + 第二轮(身份口径三个易写反的分支、图片删除、备份覆盖、
  审计过滤、单位恢复、去掉硬编码版本号、上线检查清单 +5 项);debug/使用说明 6 份逐份 diff 一致。

测试:服务端 13 包(连跑两遍)、前端 406 例、Console 28 例 + 18 文件语法检查 —— 全绿。
版本号 dev_0.1.6 → dev_0.1.7。
2026-09-11 19:26:36 +08:00

1587 lines
52 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
}
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
}
updated, err := s.updateEvent(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.update", "event", id, before, updated)
return updated, nil
}
func (s *server) deleteEventHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getEvent(r.Context(), id)
if err != nil {
return err
}
if err := s.deleteEvent(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "event.delete", "event", id, before, nil)
return nil
}
func (s *server) listEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listEntriesWithResults(r.Context(), id)
}
func (s *server) listCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listCategories(r.Context(), id)
}
func (s *server) replaceCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in categoriesPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.listCategories(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.replaceCategories(r.Context(), id, in.Categories)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.categories.replace", "event", id, before, updated)
return updated, nil
}
func (s *server) listChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listChannels(r.Context(), id)
}
func (s *server) replaceChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in channelsPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.listChannels(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.replaceChannels(r.Context(), id, in.Channels)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.channels.replace", "event", id, before, updated)
return updated, nil
}
// ---------- scoring profiles ----------
func (s *server) listProfilesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.listProfiles(r.Context())
}
func (s *server) getProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getProfile(r.Context(), id)
}
func (s *server) createProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in profileDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createProfile(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "profile.create", "scoring_profile", created.ID, nil, created)
return created, nil
}
func (s *server) updateProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in profileDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getProfile(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateProfile(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "profile.update", "scoring_profile", id, before, updated)
return updated, nil
}
// ---------- athletes ----------
func (s *server) listAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
teamID, _ := int64Query(r, "teamId")
return s.listAthletes(r.Context(), teamID)
}
func (s *server) getAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getAthlete(r.Context(), id)
}
func (s *server) createAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in athleteDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createAthlete(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "athlete.create", "athlete", created.ID, nil, created)
return created, nil
}
func (s *server) updateAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in athleteDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getAthlete(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateAthlete(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "athlete.update", "athlete", id, before, updated)
return updated, nil
}
// deleteAthleteHandler 默认软删除(停用);带 purge=1 时物理删除人员、报名、成绩并重算受影响的积分。
func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
before, err := s.getAthlete(r.Context(), id)
if err != nil {
return nil, err
}
if !isQueryTrue(r, "purge") {
if err := s.deleteAthlete(r.Context(), id); err != nil {
return nil, err
}
s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil)
return nil, nil
}
purged, err := s.purgeAthlete(r.Context(), id)
if err != nil {
return nil, err
}
// 审计带上删除规模与重算过的小项,事后能对上「榜为什么变了」。
s.audit(r.Context(), "athlete.purge", "athlete", id, before, purged)
return purged, nil
}
// isQueryTrue 解析开关型查询参数:只认 1 / true(大小写不敏感),其余值一律当 false。
// 写错的值不会误开开关——purge=0、purge=false 都还是软删除。
func isQueryTrue(r *http.Request, key string) bool {
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key))) {
case "1", "true":
return true
}
return false
}
// ---------- 报名表导入 ----------
type entryImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
Conflicts []importConflict `json:"conflicts"`
// 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"`
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{}, 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)
}
}
// 号码布是人员唯一标识,同一份表里重复出现仍无法判断是不是同一个人,照旧按行报错;
// 证件号只是可选档案信息,重复不再拦截(同一份表内、跨行、跨表都不因它拦截)。
seenBibs := make(map[string]bool)
for n, row := range rows[1:] {
line := n + 2
cell := func(name string) string {
i, ok := index[name]
if !ok || i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
eventCode := cell("event_code")
categoryName := cell("category")
teamCode := cell("team_code")
athleteName := cell("athlete_name")
if eventCode == "" || teamCode == "" {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "event_code / team_code 必填"))
continue
}
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
status, err := normalizeImportStatus(cell("status"))
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
// heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。
heat, ok := parseImportHeat(cell("heat"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line,
"heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat))
continue
}
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 不存在", eventCode))
continue
}
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "单位 %s 不存在", teamCode))
continue
}
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
if err := s.reviveTeam(ctx, teamID); err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
var categoryID int64
if categoryName != "" {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "组别 %s 不存在", categoryName))
continue
}
} else {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 没有组别", eventCode))
continue
}
}
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
var athleteID *int64
if athleteName != "" {
idCard := cell("id_card")
bib := cell("bib")
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
if idCard == "" && bib == "" {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
continue
}
age, ok := parseImportAge(cell("age"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
continue
}
if bib != "" {
if seenBibs[bib] {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
continue
}
seenBibs[bib] = true
}
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
Line: line,
Name: athleteName,
Gender: normalizeGender(cell("gender")),
TeamID: teamID,
TeamCode: teamCode,
IDCard: idCard,
Bib: bib,
Age: age,
}, decisions)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", importRowMessage(err)))
continue
}
if matched.Conflict != nil {
result.Conflicts = append(result.Conflicts, *matched.Conflict)
continue
}
if matched.SameNameNewPerson != nil {
result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson)
}
athleteID = &matched.AthleteID
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
teamID = matched.TeamID
}
created, err := s.upsertImportedEntry(ctx, entryDTO{
EventID: eventID,
CategoryID: categoryID,
TeamID: teamID,
AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(cell("bib")),
Seed: nilIfEmpty(cell("seed")),
Heat: heat,
Status: status,
})
switch {
case err != nil:
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
case created:
result.Created++
default:
result.Updated++
}
}
return result, nil
}
// parseImportAge 解析可选的 age 列:空值合法(返回 nil),非整数或超出 0-120 视为非法。
func parseImportAge(raw string) (*int, bool) {
if raw == "" {
return nil, true
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 || n > 120 {
return nil, false
}
return &n, true
}
// parseImportHeat 解析可选的 heat 列:留空表示未分批(nil),非整数或超出 1-maxEntryHeat 视为非法。
func parseImportHeat(raw string) (*int64, bool) {
if raw == "" {
return nil, true
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 1 || n > maxEntryHeat {
return nil, false
}
return &n, true
}
// normalizeGender 只认 男/女,其它值(含空)一律忽略。
func normalizeGender(raw string) string {
switch raw {
case "男", "女":
return raw
}
return ""
}
func splitNames(raw string) []string {
if raw == "" {
return nil
}
parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ',' || r == '/' })
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
}
// ---------- 图片上传与静态访问 ----------
// maxUploadBytes 是单张图片的硬上限;multipart 边界与表头还占少量字节,
// 解析上限相应放宽,真实大小由 uploadHandler 里的 LimitReader 兜住。
const (
maxUploadBytes = 5 << 20
maxUploadFormBytes = maxUploadBytes + 1<<20
)
// allowedUploadImageTypes 是允许上传的图片扩展名与对应 Content-Type。
// 收 SVG 会让匿名可访问的同源静态资源变成存储型 XSS 入口,故白名单里没有 .svg。
var allowedUploadImageTypes = map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
}
// uploadsCacheControl 命中随机文件名即可长缓存:同名文件内容不会变。
const uploadsCacheControl = "public, max-age=86400"
// uploadResult 是上传成功后的访问路径;path 与 url 同值,前端可直接拿去当 <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
}