Files
Scoreboard/server/internal/httpapi/handlers_config.go
T
AskaEth 08a5740fb2 feat: 分批发布 + 实时赛况页 + 显式批次层 + 成绩框实时提示 + 窄屏适配 + 版本号单一真相
## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发)
- 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、
  手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。
- 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错
  (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。

## 实时赛况(新大屏页 + 定向接口)
- 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩
  (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。
  设置键 display.live_events / display.live_source。
- 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面
  (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。
- 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only),
  故对非管理员整组置灰 + 提示条(不新增角色)。

## 显式批次层(组别之下)
- 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果
  完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 +
  一键均分(按 N 人切片,已有批次号接着往后排)。

## 成绩输入框实时悬浮提示
- 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻;
  合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文;
  与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。

## 窄屏 / 手机端布局
- 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针,
  四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框
  塌成 22px 这类真问题。

## 版本号单一真相
- 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json
  的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。

测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
2026-09-11 04:05:02 +08:00

1161 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"bytes"
"context"
"crypto/rand"
"encoding/csv"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"unicode/utf8"
"github.com/xuri/excelize/v2"
"golang.org/x/text/encoding/simplifiedchinese"
textunicode "golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// ---------- 认证 ----------
func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
var in loginInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if strings.TrimSpace(in.Username) == "" || in.Password == "" {
return nil, badRequest("username 与 password 必填", nil)
}
ip := middleware.ClientIP(r)
u, token, err := s.auth.Authenticate(r.Context(), in.Username, in.Password, ip, r.UserAgent())
switch {
case err == auth.ErrRateLimited:
return nil, rateLimited("登录尝试过于频繁,请稍后再试")
case err == auth.ErrInvalidCredentials, err == auth.ErrUserInactive:
return nil, unauthorized("AUTH_FAILED", err.Error())
case err != nil:
return nil, internal(err)
}
s.auth.SetSessionCookie(w, token)
csrf, err := auth.NewCSRFToken()
if err != nil {
return nil, internal(err)
}
s.auth.SetCSRFCookie(w, csrf)
// 登录时请求 ctx 里还是 guest,Auditor 取不到用户,需在审计条目里显式带上 actorID。
middleware.Record(r.Context(), middleware.AuditEntry{
Action: "auth.login", EntityType: "user", EntityID: u.ID, ActorID: u.ID,
After: map[string]any{"username": u.Username},
})
return userDTO(u), nil
}
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
if c, err := r.Cookie(auth.CookieSession); err == nil {
if err := s.auth.Logout(r.Context(), c.Value); err != nil {
return nil, internal(err)
}
}
s.auth.ClearSessionCookie(w)
s.auth.ClearCSRFCookie(w)
return nil, nil
}
func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
u := middleware.User(r.Context())
return userDTO(u), nil
}
type changePasswordInput struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
// changePassword 修改当前登录用户的密码:先验原密码,再改密并作废全部会话。
func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, error) {
u := middleware.User(r.Context())
if u.Role == auth.RoleGuest {
return nil, unauthorized("AUTH_REQUIRED", "请先登录")
}
var in changePasswordInput
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
if len(in.NewPassword) < 8 {
return nil, badRequest("新密码至少 8 位", nil)
}
if len(in.NewPassword) > auth.MaxPasswordBytes {
return nil, badRequest(fmt.Sprintf("新密码不能超过 %d 字节", auth.MaxPasswordBytes), nil)
}
if in.NewPassword == in.CurrentPassword {
return nil, badRequest("新密码不能与原密码相同", nil)
}
// 会话被盗后不能直接改密,必须再验一次原密码
if err := s.auth.VerifyPassword(r.Context(), u.ID, in.CurrentPassword); err != nil {
return nil, unauthorized("AUTH_FAILED", "原密码不正确")
}
if err := s.auth.SetPassword(r.Context(), u.Username, in.NewPassword, false); err != nil {
return nil, internal(err)
}
// SetPassword 已作废全部会话,这里清掉 Cookie 让前端引导重新登录
s.auth.ClearSessionCookie(w)
s.auth.ClearCSRFCookie(w)
s.audit(r.Context(), "auth.change_password", "user", u.ID, nil, map[string]any{"username": u.Username})
return map[string]any{"relogin": true}, nil
}
func userDTO(u *auth.User) currentUserDTO {
dto := currentUserDTO{
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: u.Role.String(),
MustChangePassword: u.MustChangePassword,
}
if u.Role == auth.RoleGuest {
dto.Username = "guest"
dto.DisplayName = "访客"
}
if u.LastLoginAt != "" {
v := u.LastLoginAt
dto.LastLoginAt = &v
}
return dto
}
// ---------- settings ----------
func (s *server) getSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.getSettings(r.Context())
}
func (s *server) updateSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in settingsPatch
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
before, err := s.getSettings(r.Context())
if err != nil {
return nil, err
}
updated, err := s.updateSettings(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "settings.update", "settings", 0, before, updated)
return updated, nil
})
}
// ---------- teams ----------
func (s *server) listTeamsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
includeDeleted := isQueryTrue(r, "includeDeleted")
// 已彻底删除的单位不进任何列表,只有 admin 能用显式开关把它们翻出来排查。
if includeDeleted && !middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
return nil, forbidden("只有管理员可以查看已彻底删除的单位")
}
return s.listTeams(r.Context(), includeDeleted)
}
func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
t, err := s.getTeam(r.Context(), id)
if err != nil {
return nil, err
}
// 彻底删除的单位对非 admin 按不存在处理;admin 仍查得到,便于排查。
if t.DeletedAt != nil && !middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
return nil, notFound("单位不存在")
}
return t, nil
}
func (s *server) createTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in teamDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createTeam(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "team.create", "team", created.ID, nil, created)
return created, nil
}
func (s *server) updateTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in teamDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getTeam(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateTeam(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "team.update", "team", id, before, updated)
return updated, nil
}
// deleteTeamHandler 默认软删除(停用);带 purge=1 时彻底删除(对界面不可见,行仍在库里)。
func (s *server) deleteTeamHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getTeam(r.Context(), id)
if err != nil {
return err
}
if !isQueryTrue(r, "purge") {
if err := s.deleteTeam(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "team.delete", "team", id, before, nil)
return nil
}
after, err := s.hideTeam(r.Context(), id)
if err != nil {
return err
}
// 记清楚这是「隐藏」而不是物理删除:报名、成绩、流水一行没动,审计要能自证。
s.audit(r.Context(), "team.purge", "team", id, before, map[string]any{
"hidden": true, "deletedAt": after.DeletedAt,
})
return nil
}
// ---------- sports ----------
func (s *server) listSportsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.listSports(r.Context())
}
func (s *server) getSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getSport(r.Context(), id)
}
func (s *server) createSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in sportDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createSport(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "sport.create", "sport", created.ID, nil, created)
return created, nil
}
func (s *server) updateSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in sportDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getSport(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateSport(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "sport.update", "sport", id, before, updated)
return updated, nil
}
func (s *server) deleteSportHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getSport(r.Context(), id)
if err != nil {
return err
}
if err := s.deleteSport(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "sport.delete", "sport", id, before, nil)
return nil
}
// ---------- events ----------
func (s *server) listEventsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
sportID, _ := int64Query(r, "sportId")
return s.listEvents(r.Context(), sportID)
}
func (s *server) getEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getEvent(r.Context(), id)
}
func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in eventDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createEvent(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.create", "event", created.ID, nil, created)
return created, nil
}
func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in eventDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getEvent(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateEvent(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.update", "event", id, before, updated)
return updated, nil
}
func (s *server) deleteEventHandler(w http.ResponseWriter, r *http.Request) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
before, err := s.getEvent(r.Context(), id)
if err != nil {
return err
}
if err := s.deleteEvent(r.Context(), id); err != nil {
return err
}
s.audit(r.Context(), "event.delete", "event", id, before, nil)
return nil
}
func (s *server) listEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listEntriesWithResults(r.Context(), id)
}
func (s *server) listCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listCategories(r.Context(), id)
}
func (s *server) replaceCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in categoriesPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.listCategories(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.replaceCategories(r.Context(), id, in.Categories)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.categories.replace", "event", id, before, updated)
return updated, nil
}
func (s *server) listChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.listChannels(r.Context(), id)
}
func (s *server) replaceChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in channelsPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.listChannels(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.replaceChannels(r.Context(), id, in.Channels)
if err != nil {
return nil, err
}
s.audit(r.Context(), "event.channels.replace", "event", id, before, updated)
return updated, nil
}
// ---------- scoring profiles ----------
func (s *server) listProfilesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
return s.listProfiles(r.Context())
}
func (s *server) getProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getProfile(r.Context(), id)
}
func (s *server) createProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in profileDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createProfile(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "profile.create", "scoring_profile", created.ID, nil, created)
return created, nil
}
func (s *server) updateProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in profileDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getProfile(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateProfile(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "profile.update", "scoring_profile", id, before, updated)
return updated, nil
}
// ---------- athletes ----------
func (s *server) listAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
teamID, _ := int64Query(r, "teamId")
return s.listAthletes(r.Context(), teamID)
}
func (s *server) getAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
return s.getAthlete(r.Context(), id)
}
func (s *server) createAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in athleteDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
created, err := s.createAthlete(r.Context(), in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "athlete.create", "athlete", created.ID, nil, created)
return created, nil
}
func (s *server) updateAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
var in athleteDTO
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
before, err := s.getAthlete(r.Context(), id)
if err != nil {
return nil, err
}
updated, err := s.updateAthlete(r.Context(), id, in)
if err != nil {
return nil, err
}
s.audit(r.Context(), "athlete.update", "athlete", id, before, updated)
return updated, nil
}
// deleteAthleteHandler 默认软删除(停用);带 purge=1 时物理删除人员、报名、成绩并重算受影响的积分。
func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
before, err := s.getAthlete(r.Context(), id)
if err != nil {
return nil, err
}
if !isQueryTrue(r, "purge") {
if err := s.deleteAthlete(r.Context(), id); err != nil {
return nil, err
}
s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil)
return nil, nil
}
purged, err := s.purgeAthlete(r.Context(), id)
if err != nil {
return nil, err
}
// 审计带上删除规模与重算过的小项,事后能对上「榜为什么变了」。
s.audit(r.Context(), "athlete.purge", "athlete", id, before, purged)
return purged, nil
}
// isQueryTrue 解析开关型查询参数:只认 1 / true(大小写不敏感),其余值一律当 false。
// 写错的值不会误开开关——purge=0、purge=false 都还是软删除。
func isQueryTrue(r *http.Request, key string) bool {
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key))) {
case "1", "true":
return true
}
return false
}
// ---------- 报名表导入 ----------
type entryImportResult struct {
Created int `json:"created"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Errors []string `json:"errors"`
Conflicts []importConflict `json:"conflicts"`
}
// importConflict 是「证件号或号码布已存在、但人员字段与库中不一致」的一行。
// 该行本轮不落库,等用户逐条决策后走 /api/entries/import/resolve。
type importConflict struct {
Row int `json:"row"`
IDCard string `json:"idCard"`
Bib string `json:"bib"`
AthleteID int64 `json:"athleteId"`
Existing importAthleteView `json:"existing"`
Incoming importAthleteView `json:"incoming"`
ChangedFields []string `json:"changedFields"`
}
// importAthleteView 是冲突两端可比的字段快照(单位用编码,前端好展示)。
type importAthleteView struct {
Name string `json:"name"`
Gender *string `json:"gender"`
TeamCode string `json:"teamCode"`
EmployeeNo *string `json:"employeeNo"`
IDCard *string `json:"idCard"`
Bib *string `json:"bib"`
Age *int `json:"age"`
}
// importDecision 是冲突决策:keep-existing 保留库中人员字段,keep-new 用表里的字段覆盖。
// 证件号与号码布二选一作为决策键,证件号优先。
type importDecision struct {
IDCard string `json:"idCard"`
Bib string `json:"bib"`
Action string `json:"action"`
}
const (
decisionKeepExisting = "keep-existing"
decisionKeepNew = "keep-new"
)
// maxEntryImportBytes 是报名表上传的硬上限(16MB 文件 + multipart 开销)。
const maxEntryImportBytes = 32 << 20
// readImportForm 解析报名表上传(multipart file 字段),返回表格行。
func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([][]string, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
if err := r.ParseMultipartForm(16 << 20); err != nil {
return nil, badRequest("上传文件解析失败", err.Error())
}
file, header, err := r.FormFile("file")
if err != nil {
return nil, badRequest("缺少 file 字段", err.Error())
}
defer func() { _ = file.Close() }()
return readTable(filepath.Ext(header.Filename), file)
}
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
rows, err := s.readImportForm(w, r)
if err != nil {
return nil, err
}
result, err := s.importEntries(r.Context(), rows)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entries.import", "entry", 0, nil, result)
return result, nil
}
// resolveImportHandler 按用户对冲突的逐条决策重跑导入:
// 已决策的冲突按决策落库,未决策的冲突继续留在 conflicts 里返回。
func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
rows, err := s.readImportForm(w, r)
if err != nil {
return nil, err
}
raw := strings.TrimSpace(r.FormValue("decisions"))
if raw == "" {
return nil, badRequest("缺少 decisions 字段", nil)
}
var decisions []importDecision
if err := json.Unmarshal([]byte(raw), &decisions); err != nil {
return nil, badRequest("decisions 不是合法 JSON", err.Error())
}
byKey := make(map[string]string, len(decisions))
for _, d := range decisions {
if d.Action != decisionKeepExisting && d.Action != decisionKeepNew {
return nil, badRequest("action 只能是 keep-existing 或 keep-new", d.Action)
}
key := strings.TrimSpace(d.IDCard)
if key == "" {
key = strings.TrimSpace(d.Bib)
}
byKey[key] = d.Action
}
result, err := s.importEntriesWith(r.Context(), rows, byKey)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entries.import.resolve", "entry", 0, nil, result)
return result, nil
}
// setEntriesHeatHandler 批量设置 / 清除一批报名项的批次(PUT /api/entries/heat)。
func (s *server) setEntriesHeatHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in entryHeatPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
result, err := s.setEntriesHeat(r.Context(), in.EntryIDs, in.Heat)
if err != nil {
return nil, err
}
s.audit(r.Context(), "entry.heat", "entry", 0, nil,
map[string]any{"entryIds": result.EntryIDs, "heat": result.Heat, "updated": result.Updated})
return result, nil
}
func readTable(ext string, file io.Reader) ([][]string, error) {
switch strings.ToLower(ext) {
case ".xlsx":
f, err := excelize.OpenReader(file)
if err != nil {
return nil, badRequest("无法解析 xlsx", err.Error())
}
defer func() { _ = f.Close() }()
sheets := f.GetSheetList()
if len(sheets) == 0 {
return nil, badRequest("xlsx 没有工作表", nil)
}
rows, err := f.GetRows(sheets[0])
if err != nil {
return nil, badRequest("读取 xlsx 失败", err.Error())
}
return rows, nil
default:
text, err := decodeTableText(file)
if err != nil {
return nil, err
}
reader := csv.NewReader(strings.NewReader(text))
reader.FieldsPerRecord = -1
reader.TrimLeadingSpace = true
rows, err := reader.ReadAll()
if err != nil {
return nil, badRequest("无法解析 CSV", err.Error())
}
return rows, nil
}
}
// 报名表文本可能带的字节序标记。
var (
tableUTF8BOM = []byte{0xEF, 0xBB, 0xBF}
tableUTF16LEBOM = []byte{0xFF, 0xFE}
tableUTF16BEBOM = []byte{0xFE, 0xFF}
)
// decodeTableText 把报名表的字节流解码成 UTF-8 文本。
//
// 现场拿到的 CSV 基本都是直接从 Excel 存的,而 Excel 默认不写 UTF-8:中文 Windows 上
// 「另存为 CSV」出的是 GB18030,「CSV UTF-8」会带 BOM,PowerShell 的 Export-Csv 出 UTF-16LE。
// 这三种原样丢给 csv.Reader 都会炸,而且症状很误导——
// 中文单位名、运动员名变乱码;BOM 会粘在第一列表头上("\ufeffevent_code"),
// 于是报「报名表缺少列 event_code」,让人以为表头写错了。
func decodeTableText(file io.Reader) (string, error) {
// 上游 readImportForm 已用 MaxBytesReader 限住 body,整读不会失控。
data, err := io.ReadAll(file)
if err != nil {
return "", badRequest("读取报名表失败", err.Error())
}
switch {
case bytes.HasPrefix(data, tableUTF8BOM):
data = data[len(tableUTF8BOM):]
case bytes.HasPrefix(data, tableUTF16LEBOM):
return decodeUTF16Table(data[len(tableUTF16LEBOM):], textunicode.LittleEndian)
case bytes.HasPrefix(data, tableUTF16BEBOM):
return decodeUTF16Table(data[len(tableUTF16BEBOM):], textunicode.BigEndian)
}
if utf8.Valid(data) {
return string(data), nil
}
// GBK 是 GB18030 的子集,按 GB18030 解能一并覆盖。
text, _, err := transform.String(simplifiedchinese.GB18030.NewDecoder(), string(data))
if err != nil {
return "", badRequest("报名表编码无法识别,请另存为 UTF-8 或 GB18030 编码的 CSV 后重试", err.Error())
}
return text, nil
}
// decodeUTF16Table 解 UTF-16(BOM 已由调用方摘掉)。
func decodeUTF16Table(data []byte, order textunicode.Endianness) (string, error) {
text, _, err := transform.String(textunicode.UTF16(order, textunicode.IgnoreBOM).NewDecoder(), string(data))
if err != nil {
return "", badRequest("报名表编码无法识别,请另存为 UTF-8 编码的 CSV 后重试", err.Error())
}
return text, nil
}
// importColumns 是报名表必须包含的表头(大小写不敏感);
// id_card、gender、age 为可选列,缺列不报错,按空值处理。
var importColumns = []string{
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
}
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
return s.importEntriesWith(ctx, rows, nil)
}
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
// 非 nil 时按决策处理证件号/号码布命中的冲突,未出现在 decisions 里的冲突继续收集。
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
result := entryImportResult{Errors: []string{}, Conflicts: []importConflict{}}
if len(rows) < 2 {
return result, badRequest("报名表为空", nil)
}
index := make(map[string]int, len(rows[0]))
for i, h := range rows[0] {
index[strings.ToLower(strings.TrimSpace(h))] = i
}
for _, col := range importColumns {
if _, ok := index[col]; !ok {
return result, badRequest("报名表缺少列 "+col, nil)
}
}
// 同一文件里证件号、号码布都只能出现一次,否则无法判断是不是同一个人。
seenIDCards := make(map[string]bool)
seenBibs := make(map[string]bool)
for n, row := range rows[1:] {
line := n + 2
cell := func(name string) string {
i, ok := index[name]
if !ok || i >= len(row) {
return ""
}
return strings.TrimSpace(row[i])
}
eventCode := cell("event_code")
categoryName := cell("category")
teamCode := cell("team_code")
athleteName := cell("athlete_name")
if eventCode == "" || teamCode == "" {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "event_code / team_code 必填"))
continue
}
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
status, err := normalizeImportStatus(cell("status"))
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
// heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。
heat, ok := parseImportHeat(cell("heat"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line,
"heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat))
continue
}
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 不存在", eventCode))
continue
}
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "单位 %s 不存在", teamCode))
continue
}
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
if err := s.reviveTeam(ctx, teamID); err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
var categoryID int64
if categoryName != "" {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "组别 %s 不存在", categoryName))
continue
}
} else {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 没有组别", eventCode))
continue
}
}
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
var athleteID *int64
if athleteName != "" {
idCard := cell("id_card")
bib := cell("bib")
// 证件号与号码布二选一即可,都没有就无法区分同名的两个人。
if idCard == "" && bib == "" {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
continue
}
age, ok := parseImportAge(cell("age"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
continue
}
if idCard != "" {
if seenIDCards[idCard] {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "证件号 %s 在报名表内重复出现", idCard))
continue
}
seenIDCards[idCard] = true
}
if bib != "" {
if seenBibs[bib] {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
continue
}
seenBibs[bib] = true
}
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
Line: line,
Name: athleteName,
Gender: normalizeGender(cell("gender")),
TeamID: teamID,
TeamCode: teamCode,
IDCard: idCard,
Bib: bib,
Age: age,
}, decisions)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
if matched.Conflict != nil {
result.Conflicts = append(result.Conflicts, *matched.Conflict)
continue
}
athleteID = &matched.AthleteID
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
teamID = matched.TeamID
}
created, err := s.upsertImportedEntry(ctx, entryDTO{
EventID: eventID,
CategoryID: categoryID,
TeamID: teamID,
AthleteID: athleteID,
MemberNames: splitNames(cell("member_names")),
Bib: nilIfEmpty(cell("bib")),
Seed: nilIfEmpty(cell("seed")),
Heat: heat,
Status: status,
})
switch {
case err != nil:
result.Skipped++
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
case created:
result.Created++
default:
result.Updated++
}
}
return result, nil
}
// parseImportAge 解析可选的 age 列:空值合法(返回 nil),非整数或超出 0-120 视为非法。
func parseImportAge(raw string) (*int, bool) {
if raw == "" {
return nil, true
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 || n > 120 {
return nil, false
}
return &n, true
}
// parseImportHeat 解析可选的 heat 列:留空表示未分批(nil),非整数或超出 1-maxEntryHeat 视为非法。
func parseImportHeat(raw string) (*int64, bool) {
if raw == "" {
return nil, true
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 1 || n > maxEntryHeat {
return nil, false
}
return &n, true
}
// normalizeGender 只认 男/女,其它值(含空)一律忽略。
func normalizeGender(raw string) string {
switch raw {
case "男", "女":
return raw
}
return ""
}
func splitNames(raw string) []string {
if raw == "" {
return nil
}
parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ',' || r == '/' })
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
}
// ---------- 图片上传与静态访问 ----------
// maxUploadBytes 是单张图片的硬上限;multipart 边界与表头还占少量字节,
// 解析上限相应放宽,真实大小由 uploadHandler 里的 LimitReader 兜住。
const (
maxUploadBytes = 5 << 20
maxUploadFormBytes = maxUploadBytes + 1<<20
)
// allowedUploadImageTypes 是允许上传的图片扩展名与对应 Content-Type。
// 收 SVG 会让匿名可访问的同源静态资源变成存储型 XSS 入口,故白名单里没有 .svg。
var allowedUploadImageTypes = map[string]string{
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
}
// uploadsCacheControl 命中随机文件名即可长缓存:同名文件内容不会变。
const uploadsCacheControl = "public, max-age=86400"
// uploadResult 是上传成功后的访问路径;path 与 url 同值,前端可直接拿去当 <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
}