Files
Scoreboard/server/internal/httpapi/export_handlers.go
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

1450 lines
50 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 (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/rules"
)
const xlsxMIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
// maxProjectImportBytes 是赛事包上传的硬上限,避免超大请求把内存和磁盘撑爆。
const maxProjectImportBytes = 512 << 20
// exportRoutes 挂载 xlsx 导出、赛事项目包与备份查询。
func (s *server) exportRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/export/standings.xlsx", s.read(auth.RoleGuest, s.exportStandingsXLSX))
mux.HandleFunc("GET /api/export/all.xlsx", s.read(auth.RoleGuest, s.exportAllXLSX))
mux.HandleFunc("GET /api/export/all-flat.xlsx", s.read(auth.RoleGuest, s.exportAllFlatXLSX))
// Go 1.22 ServeMux 的通配段必须独占一段,所以分项/单位导出用 /{id},文件名由响应头给出。
mux.HandleFunc("GET /api/export/events/{id}", s.read(auth.RoleGuest, s.exportEventXLSX))
// 团体奖牌明细 / 个人得分明细:与总积分榜同一份官方名次与流水。
mux.HandleFunc("GET /api/export/medals.xlsx", s.read(auth.RoleGuest, s.exportMedalsXLSX))
mux.HandleFunc("GET /api/export/athletes.xlsx", s.read(auth.RoleGuest, s.exportAthletesXLSX))
// teams.xlsx 与 teams/{id} 段数不同,ServeMux 不会冲突。
mux.HandleFunc("GET /api/export/teams.xlsx", s.read(auth.RoleGuest, s.exportAllTeamsXLSX))
mux.HandleFunc("GET /api/export/teams/{id}", s.read(auth.RoleGuest, s.exportTeamXLSX))
// 报名名单:按小项 / 组别 / 单位 / 批次筛选,四者可单传也可全不传(= 全部)。
mux.HandleFunc("GET /api/export/entries.xlsx", s.read(auth.RoleGuest, s.exportEntriesXLSX))
// 基础数据包(单位 / 大项 / 小项 / 组别):导出为多 sheet 工作簿,导入按编码 upsert。
// 导出与其它导出同档(基础数据本身在 /api/teams、/api/sports 等接口就是公开可读的);
// 导入是批量写、影响面比录入大,权限收到 admin。
mux.HandleFunc("GET /api/export/basedata.xlsx", s.read(auth.RoleGuest, s.exportBaseDataXLSX))
mux.HandleFunc("POST /api/export/basedata/import", s.write(auth.RoleAdmin, s.importBaseDataXLSX))
mux.HandleFunc("GET /api/export/project.zip", s.read(auth.RoleAdmin, s.exportProjectZip))
mux.HandleFunc("POST /api/export/project/import", s.write(auth.RoleAdmin, s.importProjectZip))
mux.HandleFunc("GET /api/export/backups", s.read(auth.RoleAdmin, s.listBackups))
}
// contentDisposition 组装下载头:filename* 给支持 RFC 5987 的浏览器(保留中文名),
// filename 给不认 filename* 的老客户端兜底——只有 filename* 时它们会退化成按 URL 末段
// 命名,teams/1 这种连扩展名都没有。asciiName 是兜底段的全部内容,必须能原样放进引号,
// 所以这里再过滤一遍非 ASCII、引号、反斜杠与控制字符;过滤后为空就只留 filename*。
func contentDisposition(asciiName, filename string) string {
var ascii strings.Builder
ascii.Grow(len(asciiName))
for _, r := range asciiName {
if r < 0x20 || r > 0x7e || r == '"' || r == '\\' {
continue
}
ascii.WriteRune(r)
}
disposition := "attachment"
if ascii.Len() > 0 {
disposition += `; filename="` + ascii.String() + `"`
}
return disposition + "; filename*=UTF-8''" + url.PathEscape(filename)
}
// streamDownload 直接写响应体;返回 errHandled 让统一响应层不再追加 JSON。
func (s *server) streamDownload(w http.ResponseWriter, asciiName, filename, contentType string, write func(io.Writer) error) (any, error) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", contentDisposition(asciiName, filename))
if err := write(w); err != nil {
s.logger.Error("导出写出失败", "file", filename, "err", err)
}
return nil, errHandled
}
func (s *server) exportStandingsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadStandingsWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "standings.xlsx", "总积分榜.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteStandingsXLSX(out, wb)
})
}
func (s *server) exportEventXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
wb, err := export.LoadEventWorkbook(s.ev, id, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "event.xlsx", "分项成绩表.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteEventXLSX(out, wb)
})
}
func (s *server) exportAllXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadAllWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "all.xlsx", "赛事成绩总表.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteAllXLSX(out, wb)
})
}
// exportAllFlatXLSX 导出全部成绩的单页平铺版:一个工作簿只含一张表,
// 每行 = 某个单位某个人的某个项目。与「全部」(all.xlsx,多 sheet)内容同源。
func (s *server) exportAllFlatXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadFlatWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "all-flat.xlsx", "赛事成绩总表(单页).xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteFlatXLSX(out, wb)
})
}
// exportMedalsXLSX 导出团体奖牌明细:每块奖牌逐项来源,总表 + 每个有奖牌的单位一张分表。
func (s *server) exportMedalsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadMedalDetailWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "medals.xlsx", "团体奖牌明细.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteMedalDetailXLSX(out, wb)
})
}
// exportAthletesXLSX 导出个人得分明细:记到个人的流水逐条列出,总表 + 每个有得分的单位一张分表。
func (s *server) exportAthletesXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadAthleteDetailWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "athletes.xlsx", "个人积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteAthleteDetailXLSX(out, wb)
})
}
// exportAllTeamsXLSX 导出所有单位的积分明细:第一张总表 + 每个单位一张分表。
func (s *server) exportAllTeamsXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadAllTeamDetailsWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "teams.xlsx", "单位积分总明细.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteAllTeamDetailsXLSX(out, wb)
})
}
func (s *server) exportTeamXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
wb, err := export.LoadTeamDetailWorkbook(s.ev, id, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "team.xlsx", "单位积分明细.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteTeamDetailXLSX(out, wb)
})
}
// exportEntriesXLSX 导出报名名单(xlsx)。筛选参数都可省,省 = 不筛。
// 批次是用户点名要的一列,它属于报名数据,所以这里读的是 entries 而不是成绩表。
func (s *server) exportEntriesXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
filter := export.EntriesFilter{}
for _, f := range []struct {
key string
dst *int64
what string
}{
{"eventId", &filter.EventID, "小项"},
{"categoryId", &filter.CategoryID, "组别"},
{"teamId", &filter.TeamID, "单位"},
{"heat", &filter.Heat, "批次"},
} {
id, err := filterIDQuery(r, f.key, f.what)
if err != nil {
return nil, err
}
*f.dst = id
}
wb, err := export.LoadEntriesWorkbook(s.ev, filter, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "entries.xlsx", "报名名单.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteEntriesXLSX(out, wb)
})
}
// ---------- 基础数据包(单位 / 大项 / 小项 / 组别) ----------
// maxBaseDataImportBytes 是基础数据包上传的硬上限(16MB 文件 + multipart 开销)。
const maxBaseDataImportBytes = 32 << 20
func (s *server) exportBaseDataXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
wb, err := export.LoadBaseDataWorkbook(s.ev, export.Options{})
if err != nil {
return nil, internal(err)
}
return s.streamDownload(w, "basedata.xlsx", "基础数据包.xlsx", xlsxMIME, func(out io.Writer) error {
return export.WriteBaseDataXLSX(out, wb)
})
}
// baseDataFieldDiff 是一条字段级差异:哪个字段、从什么变成什么。
type baseDataFieldDiff struct {
Field string `json:"field"`
From string `json:"from"`
To string `json:"to"`
}
// baseDataChange 是一行的差异;Action 为 added / updated。
type baseDataChange struct {
Sheet string `json:"sheet"`
Line int `json:"line"`
Action string `json:"action"`
Key string `json:"key"`
Fields []baseDataFieldDiff `json:"fields"`
}
// baseDataImportResult 是差异预览 / 导入落地的结果。
// 三类计数互不重叠:added + updated + unchanged = 表里有效数据行数;
// errorCount 是另计的被拦下的行(它们不写库)。
type baseDataImportResult struct {
DryRun bool `json:"dryRun"`
Added int `json:"added"`
Updated int `json:"updated"`
Unchanged int `json:"unchanged"`
ErrorCount int `json:"errorCount"`
Errors []string `json:"errors"`
Changes []baseDataChange `json:"changes"`
}
// importBaseDataXLSX 导入基础数据包:一个端点 + dryRun 标志。
//
// 与 rules/preview + rules/import 那种双端点相比,这里干的是同一件事、同一份文件、
// 同一份解析结果,只差「写不写库」——用标志位表达,避免预览与实际落地各解析一遍而分叉。
// 两段式由调用方保证:先 ?dryRun=1 预览,用户确认后再不带标志提交。
//
// dryRun 下绝不写库;落地时按编码 upsert(存在就更新、不存在就新建),
// 表里没有的行一律不动——导入永远不做删除。
func (s *server) importBaseDataXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxBaseDataImportBytes)
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() }()
if ext := strings.ToLower(filepath.Ext(header.Filename)); ext != ".xlsx" {
return nil, badRequest("基础数据包必须是 .xlsx 文件:单位 / 大项 / 小项 / 组别分在四张工作表里,CSV 装不下", ext)
}
dryRun := isTruthy(r.FormValue("dryRun"))
tables, err := export.ReadBaseDataPackage(file)
if err != nil {
return nil, badRequest(err.Error(), nil)
}
plan, err := s.planBaseDataImport(r.Context(), tables)
if err != nil {
return nil, err
}
plan.result.DryRun = dryRun
if dryRun {
return plan.result, nil
}
if err := s.applyBaseDataPlan(r.Context(), plan); err != nil {
return nil, err
}
s.audit(r.Context(), "basedata.import", "basedata", 0, nil, map[string]any{
"added": plan.result.Added,
"updated": plan.result.Updated,
"errorCount": plan.result.ErrorCount,
})
return plan.result, nil
}
// isTruthy 解析开关型表单/查询值:只认 1 / true,写错的值不会误开开关。
func isTruthy(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true":
return true
}
return false
}
// ---------- 导入:先算差异(dryRun 与落地共用这一段) ----------
// 现值快照类型:更新时以它们为基准做「列不存在就不改」的合并与逐字段比对。
type baseDataExistingTeam struct {
id int64
name string
shortName *string
color *string
sortOrder int
active bool
}
type baseDataExistingSport struct {
id int64
name string
shortName *string
sortOrder int
active bool
}
type baseDataExistingEvent struct {
id int64
sportCode string
name string
eventType string
resultMode string
resultType string
compare string
scored bool
recordEligible bool
multiplier float64
status string
avgMode string
sortOrder int
venue *string
remark *string
}
type baseDataExistingCategory struct {
id int64
eventID int64
name string
gender *string
ageMin *int
ageMax *int
sortOrder int
multiplier float64
}
// 待写计划:落地阶段只认这些值,不再回头解析表。
type baseDataTeamPlan struct {
line int
action string
code string
name string
shortName *string
color *string
sortOrder int
active bool
}
type baseDataSportPlan struct {
line int
action string
code string
name string
shortName *string
sortOrder int
active bool
}
type baseDataEventPlan struct {
line int
action string
code string
sportCode string
name string
eventType string
resultMode string
resultType string
compare string
scored bool
recordEligible bool
multiplier float64
status string
avgMode string
sortOrder int
venue *string
remark *string
}
type baseDataCategoryPlan struct {
line int
action string
eventCode string
name string
multiplier float64
gender *string
ageMin *int
ageMax *int
sortOrder int
}
// baseDataPlan 是一次导入的全部待写内容与差异报告。
type baseDataPlan struct {
teams []baseDataTeamPlan
sports []baseDataSportPlan
events []baseDataEventPlan
categories []baseDataCategoryPlan
result baseDataImportResult
}
// baseDataPlanner 在解析期间维护「库里现值 + 本次会新建的编码」两张视图:
// 后一张表引用前一张表刚建的编码时,同一次导入里也要认得出来(哪怕还在 dryRun)。
type baseDataPlanner struct {
s *server
ctx context.Context
specs map[string]export.BaseDataSheetSpec
teams map[string]baseDataExistingTeam
sports map[string]baseDataExistingSport
events map[string]baseDataExistingEvent
cats map[string]baseDataExistingCategory
plan baseDataPlan
}
func (p *baseDataPlanner) rowError(sheet string, line int, format string, args ...any) {
// 行号之外再带上工作表名:四张表各有一条「第 3 行」,只说行号现场认不出是哪张表。
p.plan.result.Errors = append(p.plan.result.Errors, sheet+" "+p.s.interceptRow(line, format, args...))
p.plan.result.ErrorCount++
}
func (p *baseDataPlanner) record(action, sheet string, line int, key string, fields []baseDataFieldDiff) {
switch action {
case "added":
p.plan.result.Added++
case "updated":
p.plan.result.Updated++
default:
p.plan.result.Unchanged++
return
}
p.plan.result.Changes = append(p.plan.result.Changes, baseDataChange{
Sheet: sheet, Line: line, Action: action, Key: key, Fields: fields,
})
}
// planBaseDataImport 读库取现值,逐表逐行算差异;行级错误只拦该行,其余行照常进计划。
func (s *server) planBaseDataImport(ctx context.Context, tables []export.BaseDataTable) (baseDataPlan, error) {
p := &baseDataPlanner{
s: s,
ctx: ctx,
specs: make(map[string]export.BaseDataSheetSpec, 4),
teams: make(map[string]baseDataExistingTeam),
sports: make(map[string]baseDataExistingSport),
events: make(map[string]baseDataExistingEvent),
cats: make(map[string]baseDataExistingCategory),
plan: baseDataPlan{result: baseDataImportResult{Errors: []string{}, Changes: []baseDataChange{}}},
}
for _, spec := range export.BaseDataSpecs() {
p.specs[spec.Sheet] = spec
}
if err := p.loadExisting(); err != nil {
return baseDataPlan{}, err
}
rowsBySheet := make(map[string][]export.BaseDataRow, len(tables))
for _, t := range tables {
rowsBySheet[t.Sheet] = t.Rows
}
// 固定按 单位 → 大项 → 小项 → 组别 处理:后者引用前者的编码,顺序反了会误报「不存在」。
p.planTeams(rowsBySheet[export.BaseDataSheetTeams])
p.planSports(rowsBySheet[export.BaseDataSheetSports])
p.planEvents(rowsBySheet[export.BaseDataSheetEvents])
p.planCategories(rowsBySheet[export.BaseDataSheetCategories])
return p.plan, nil
}
func (p *baseDataPlanner) loadExisting() error {
// 单位连「彻底删除」(deleted_at 非空)的一起读:编码有唯一索引,不认它就会在 INSERT 时撞索引。
// 更新不会碰 deleted_at,它继续是隐藏状态——导入不该顺手把藏起来的条目翻出来。
teamRows, err := p.s.ev.QueryContext(p.ctx, `
SELECT id, code, name, short_name, color, sort_order, active
FROM teams ORDER BY sort_order, code`)
if err != nil {
return internal(err)
}
defer func() { _ = teamRows.Close() }()
for teamRows.Next() {
var (
t baseDataExistingTeam
code string
shortName, color sql.NullString
)
var active int
if err := teamRows.Scan(&t.id, &code, &t.name, &shortName, &color, &t.sortOrder, &active); err != nil {
return internal(err)
}
t.shortName, t.color, t.active = strPtr(shortName), strPtr(color), active != 0
p.teams[code] = t
}
if err := teamRows.Err(); err != nil {
return internal(err)
}
sportRows, err := p.s.ev.QueryContext(p.ctx, `
SELECT id, code, name, short_name, sort_order, active FROM sports ORDER BY sort_order, code`)
if err != nil {
return internal(err)
}
defer func() { _ = sportRows.Close() }()
for sportRows.Next() {
var (
sp baseDataExistingSport
code string
short sql.NullString
active int
)
if err := sportRows.Scan(&sp.id, &code, &sp.name, &short, &sp.sortOrder, &active); err != nil {
return internal(err)
}
sp.shortName, sp.active = strPtr(short), active != 0
p.sports[code] = sp
}
if err := sportRows.Err(); err != nil {
return internal(err)
}
eventRows, err := p.s.ev.QueryContext(p.ctx, `
SELECT e.id, COALESCE(s.code, ''), e.code, e.name, e.event_type, e.result_mode, e.result_type,
e.compare, e.scored, e.record_eligible, e.multiplier, e.status, e.avg_mode,
e.sort_order, e.venue, e.remark
FROM events e LEFT JOIN sports s ON s.id = e.sport_id`)
if err != nil {
return internal(err)
}
defer func() { _ = eventRows.Close() }()
for eventRows.Next() {
var (
e baseDataExistingEvent
code string
scored, recordEligible int
venue, remark sql.NullString
)
if err := eventRows.Scan(&e.id, &e.sportCode, &code, &e.name, &e.eventType, &e.resultMode,
&e.resultType, &e.compare, &scored, &recordEligible, &e.multiplier, &e.status,
&e.avgMode, &e.sortOrder, &venue, &remark); err != nil {
return internal(err)
}
e.scored, e.recordEligible = scored != 0, recordEligible != 0
e.venue, e.remark = strPtr(venue), strPtr(remark)
p.events[code] = e
}
if err := eventRows.Err(); err != nil {
return internal(err)
}
catRows, err := p.s.ev.QueryContext(p.ctx, `
SELECT c.id, c.event_id, COALESCE(e.code, ''), c.name, c.multiplier,
c.gender, c.age_min, c.age_max, c.sort_order
FROM event_categories c LEFT JOIN events e ON e.id = c.event_id`)
if err != nil {
return internal(err)
}
defer func() { _ = catRows.Close() }()
for catRows.Next() {
var (
c baseDataExistingCategory
eventCode string
gender sql.NullString
ageMin, ageMax sql.NullInt64
)
if err := catRows.Scan(&c.id, &c.eventID, &eventCode, &c.name, &c.multiplier,
&gender, &ageMin, &ageMax, &c.sortOrder); err != nil {
return internal(err)
}
c.gender = strPtr(gender)
c.ageMin, c.ageMax = intPtr(ageMin), intPtr(ageMax)
p.cats[baseDataCategoryKey(eventCode, c.name)] = c
}
return catRows.Err()
}
// baseDataCategoryKey 用 NUL 拼小项编码与组别名:两者都不含控制字符,拼出来不会撞键。
func baseDataCategoryKey(eventCode, name string) string {
return eventCode + "\x00" + name
}
func (p *baseDataPlanner) planTeams(rows []export.BaseDataRow) {
spec := p.specs[export.BaseDataSheetTeams]
for _, row := range rows {
sheet, line := export.BaseDataSheetTeams, row.Line
code := row.Cells["code"]
if code == "" {
p.rowError(sheet, line, "单位编码必填")
continue
}
name := row.Cells["name"]
if name == "" {
p.rowError(sheet, line, "单位名称必填")
continue
}
plan := baseDataTeamPlan{line: line, code: code, name: name, active: true}
if row.Has["active"] {
active, ok := export.ParseBaseDataBool(row.Cells["active"], true)
if !ok {
p.rowError(sheet, line, "启用只能填 是 / 否")
continue
}
plan.active = active
}
if row.Has["sortOrder"] {
n, ok := export.ParseBaseDataInt(row.Cells["sortOrder"])
if !ok {
p.rowError(sheet, line, "排序需要是整数")
continue
}
if n != nil {
plan.sortOrder = *n
}
}
existing, ok := p.teams[code]
if !ok {
plan.action = "added"
if row.Has["shortName"] {
plan.shortName = baseDataStrPtr(row.Cells["shortName"])
}
if row.Has["color"] {
plan.color = baseDataStrPtr(row.Cells["color"])
}
p.plan.teams = append(p.plan.teams, plan)
p.teams[code] = baseDataExistingTeam{
name: name, shortName: plan.shortName, color: plan.color,
sortOrder: plan.sortOrder, active: plan.active,
}
p.record("added", sheet, line, code, addedFields(spec, row))
continue
}
if row.Has["shortName"] {
plan.shortName = baseDataStrPtr(row.Cells["shortName"])
} else {
plan.shortName = existing.shortName
}
if row.Has["color"] {
plan.color = baseDataStrPtr(row.Cells["color"])
} else {
plan.color = existing.color
}
diffs := make([]baseDataFieldDiff, 0, 6)
addDiff(&diffs, "单位名称", existing.name, plan.name)
addDiff(&diffs, "简称", ptrText(existing.shortName), ptrText(plan.shortName))
addDiff(&diffs, "颜色", ptrText(existing.color), ptrText(plan.color))
addDiff(&diffs, "排序", strconv.Itoa(existing.sortOrder), strconv.Itoa(plan.sortOrder))
addBoolDiff(&diffs, "启用", existing.active, plan.active)
if len(diffs) == 0 {
p.record("unchanged", sheet, line, code, nil)
continue
}
plan.action = "updated"
p.plan.teams = append(p.plan.teams, plan)
p.teams[code] = baseDataExistingTeam{
id: existing.id, name: name, shortName: plan.shortName, color: plan.color,
sortOrder: plan.sortOrder, active: plan.active,
}
p.record("updated", sheet, line, code, diffs)
}
}
func (p *baseDataPlanner) planSports(rows []export.BaseDataRow) {
spec := p.specs[export.BaseDataSheetSports]
for _, row := range rows {
sheet, line := export.BaseDataSheetSports, row.Line
code := row.Cells["code"]
if code == "" {
p.rowError(sheet, line, "大项编码必填")
continue
}
name := row.Cells["name"]
if name == "" {
p.rowError(sheet, line, "大项名称必填")
continue
}
plan := baseDataSportPlan{line: line, code: code, name: name, active: true}
if row.Has["active"] {
active, ok := export.ParseBaseDataBool(row.Cells["active"], true)
if !ok {
p.rowError(sheet, line, "启用只能填 是 / 否")
continue
}
plan.active = active
}
if row.Has["sortOrder"] {
n, ok := export.ParseBaseDataInt(row.Cells["sortOrder"])
if !ok {
p.rowError(sheet, line, "排序需要是整数")
continue
}
if n != nil {
plan.sortOrder = *n
}
}
existing, ok := p.sports[code]
if !ok {
plan.action = "added"
if row.Has["shortName"] {
plan.shortName = baseDataStrPtr(row.Cells["shortName"])
}
p.plan.sports = append(p.plan.sports, plan)
p.sports[code] = baseDataExistingSport{
name: name, shortName: plan.shortName,
sortOrder: plan.sortOrder, active: plan.active,
}
p.record("added", sheet, line, code, addedFields(spec, row))
continue
}
if row.Has["shortName"] {
plan.shortName = baseDataStrPtr(row.Cells["shortName"])
} else {
plan.shortName = existing.shortName
}
diffs := make([]baseDataFieldDiff, 0, 3)
addDiff(&diffs, "大项名称", existing.name, plan.name)
addDiff(&diffs, "简称", ptrText(existing.shortName), ptrText(plan.shortName))
addDiff(&diffs, "排序", strconv.Itoa(existing.sortOrder), strconv.Itoa(plan.sortOrder))
addBoolDiff(&diffs, "启用", existing.active, plan.active)
if len(diffs) == 0 {
p.record("unchanged", sheet, line, code, nil)
continue
}
plan.action = "updated"
p.plan.sports = append(p.plan.sports, plan)
p.sports[code] = baseDataExistingSport{
id: existing.id, name: name, shortName: plan.shortName,
sortOrder: plan.sortOrder, active: plan.active,
}
p.record("updated", sheet, line, code, diffs)
}
}
func (p *baseDataPlanner) planEvents(rows []export.BaseDataRow) {
spec := p.specs[export.BaseDataSheetEvents]
for _, row := range rows {
sheet, line := export.BaseDataSheetEvents, row.Line
sportCode := row.Cells["sportCode"]
code := row.Cells["code"]
name := row.Cells["name"]
if sportCode == "" {
p.rowError(sheet, line, "大项编码必填")
continue
}
if code == "" {
p.rowError(sheet, line, "小项编码必填")
continue
}
if name == "" {
p.rowError(sheet, line, "小项名称必填")
continue
}
if _, ok := p.sports[sportCode]; !ok {
p.rowError(sheet, line, "大项编码 %s 不存在", sportCode)
continue
}
existing, exists := p.events[code]
if !exists {
// 新建时的起手值:与建表默认值 / 创建接口的空值口径一致。
existing = baseDataExistingEvent{
eventType: "individual", resultMode: "auto", resultType: "none",
compare: "none", status: "pending", avgMode: "valid",
scored: true, recordEligible: true, multiplier: 1,
}
}
plan := baseDataEventPlan{
line: line, code: code, sportCode: sportCode, name: name,
eventType: existing.eventType, resultMode: existing.resultMode,
resultType: existing.resultType, compare: existing.compare,
scored: existing.scored, recordEligible: existing.recordEligible,
multiplier: existing.multiplier, status: existing.status,
avgMode: existing.avgMode, sortOrder: existing.sortOrder,
venue: existing.venue, remark: existing.remark,
}
if row.Has["eventType"] {
v, err := normalizeEventType(row.Cells["eventType"])
if err != nil {
p.rowError(sheet, line, "%s", rowMessage(err))
continue
}
plan.eventType = v
}
if row.Has["resultMode"] {
v, err := normalizeResultMode(row.Cells["resultMode"])
if err != nil {
p.rowError(sheet, line, "%s", rowMessage(err))
continue
}
plan.resultMode = v
}
if row.Has["resultType"] {
v, err := normalizeResultType(row.Cells["resultType"])
if err != nil {
p.rowError(sheet, line, "%s", rowMessage(err))
continue
}
plan.resultType = v
}
if row.Has["compare"] {
v, err := normalizeCompare(row.Cells["compare"])
if err != nil {
p.rowError(sheet, line, "%s", rowMessage(err))
continue
}
plan.compare = v
}
if row.Has["status"] {
v, err := normalizeEventStatus(row.Cells["status"])
if err != nil {
p.rowError(sheet, line, "%s", rowMessage(err))
continue
}
plan.status = v
}
if row.Has["avgMode"] {
v, err := normalizeAvgMode(row.Cells["avgMode"])
if err != nil {
p.rowError(sheet, line, "%s", rowMessage(err))
continue
}
plan.avgMode = v
}
if row.Has["scored"] {
v, ok := export.ParseBaseDataBool(row.Cells["scored"], true)
if !ok {
p.rowError(sheet, line, "计分只能填 是 / 否")
continue
}
plan.scored = v
}
if row.Has["recordEligible"] {
v, ok := export.ParseBaseDataBool(row.Cells["recordEligible"], true)
if !ok {
p.rowError(sheet, line, "计破纪录只能填 是 / 否")
continue
}
plan.recordEligible = v
}
if row.Has["multiplier"] {
v, ok := export.ParseBaseDataFloat(row.Cells["multiplier"], 1)
if !ok || v < 0 {
p.rowError(sheet, line, "权重需要是不小于 0 的数字")
continue
}
plan.multiplier = v
}
if row.Has["sortOrder"] {
n, ok := export.ParseBaseDataInt(row.Cells["sortOrder"])
if !ok {
p.rowError(sheet, line, "排序需要是整数")
continue
}
if n != nil {
plan.sortOrder = *n
}
}
if row.Has["venue"] {
plan.venue = baseDataStrPtr(row.Cells["venue"])
}
if row.Has["remark"] {
plan.remark = baseDataStrPtr(row.Cells["remark"])
}
if !exists {
plan.action = "added"
p.plan.events = append(p.plan.events, plan)
p.events[code] = baseDataExistingEvent{
sportCode: sportCode, name: name, eventType: plan.eventType,
resultMode: plan.resultMode, resultType: plan.resultType, compare: plan.compare,
scored: plan.scored, recordEligible: plan.recordEligible, multiplier: plan.multiplier,
status: plan.status, avgMode: plan.avgMode, sortOrder: plan.sortOrder,
venue: plan.venue, remark: plan.remark,
}
p.record("added", sheet, line, code, addedFields(spec, row))
continue
}
diffs := make([]baseDataFieldDiff, 0, 16)
addDiff(&diffs, "大项编码", existing.sportCode, plan.sportCode)
addDiff(&diffs, "小项名称", existing.name, plan.name)
addDiff(&diffs, "类型", existing.eventType, plan.eventType)
addDiff(&diffs, "结果模式", existing.resultMode, plan.resultMode)
addDiff(&diffs, "结果类型", existing.resultType, plan.resultType)
addDiff(&diffs, "比较", existing.compare, plan.compare)
addBoolDiff(&diffs, "计分", existing.scored, plan.scored)
addBoolDiff(&diffs, "计破纪录", existing.recordEligible, plan.recordEligible)
addDiff(&diffs, "权重", formatFloat(existing.multiplier), formatFloat(plan.multiplier))
addDiff(&diffs, "状态", existing.status, plan.status)
addDiff(&diffs, "平均口径", existing.avgMode, plan.avgMode)
addDiff(&diffs, "排序", strconv.Itoa(existing.sortOrder), strconv.Itoa(plan.sortOrder))
addDiff(&diffs, "场地", ptrText(existing.venue), ptrText(plan.venue))
addDiff(&diffs, "备注", ptrText(existing.remark), ptrText(plan.remark))
if len(diffs) == 0 {
p.record("unchanged", sheet, line, code, nil)
continue
}
plan.action = "updated"
p.plan.events = append(p.plan.events, plan)
existing.sportCode, existing.name = plan.sportCode, plan.name
existing.eventType, existing.resultMode, existing.resultType, existing.compare = plan.eventType, plan.resultMode, plan.resultType, plan.compare
existing.scored, existing.recordEligible = plan.scored, plan.recordEligible
existing.multiplier, existing.status, existing.avgMode = plan.multiplier, plan.status, plan.avgMode
existing.sortOrder, existing.venue, existing.remark = plan.sortOrder, plan.venue, plan.remark
p.events[code] = existing
p.record("updated", sheet, line, code, diffs)
}
}
func (p *baseDataPlanner) planCategories(rows []export.BaseDataRow) {
spec := p.specs[export.BaseDataSheetCategories]
for _, row := range rows {
sheet, line := export.BaseDataSheetCategories, row.Line
eventCode := row.Cells["eventCode"]
name := row.Cells["name"]
if eventCode == "" {
p.rowError(sheet, line, "小项编码必填")
continue
}
if name == "" {
p.rowError(sheet, line, "组别名必填")
continue
}
if _, ok := p.events[eventCode]; !ok {
p.rowError(sheet, line, "小项编码 %s 不存在", eventCode)
continue
}
key := baseDataCategoryKey(eventCode, name)
existing, exists := p.cats[key]
if !exists {
existing = baseDataExistingCategory{name: name, multiplier: 1}
}
plan := baseDataCategoryPlan{
line: line, eventCode: eventCode, name: name,
multiplier: existing.multiplier, gender: existing.gender,
ageMin: existing.ageMin, ageMax: existing.ageMax, sortOrder: existing.sortOrder,
}
if row.Has["multiplier"] {
v, ok := export.ParseBaseDataFloat(row.Cells["multiplier"], 1)
if !ok || v < 0 {
p.rowError(sheet, line, "权重需要是不小于 0 的数字")
continue
}
plan.multiplier = v
}
if row.Has["gender"] {
gender := strings.TrimSpace(row.Cells["gender"])
if gender != "" && gender != "男" && gender != "女" {
p.rowError(sheet, line, "性别只能填 男 / 女(留空表示不限)")
continue
}
plan.gender = baseDataStrPtr(gender)
}
if row.Has["ageMin"] {
n, ok := export.ParseBaseDataInt(row.Cells["ageMin"])
if !ok {
p.rowError(sheet, line, "年龄下限需要是整数(留空表示不限)")
continue
}
plan.ageMin = n
}
if row.Has["ageMax"] {
n, ok := export.ParseBaseDataInt(row.Cells["ageMax"])
if !ok {
p.rowError(sheet, line, "年龄上限需要是整数(留空表示不限)")
continue
}
plan.ageMax = n
}
if err := validateCategoryAges(plan.ageMin, plan.ageMax); err != nil {
p.rowError(sheet, line, "%s", rowMessage(err))
continue
}
if row.Has["sortOrder"] {
n, ok := export.ParseBaseDataInt(row.Cells["sortOrder"])
if !ok {
p.rowError(sheet, line, "排序需要是整数")
continue
}
if n != nil {
plan.sortOrder = *n
}
}
if !exists {
plan.action = "added"
p.plan.categories = append(p.plan.categories, plan)
p.cats[key] = baseDataExistingCategory{
name: name, multiplier: plan.multiplier, gender: plan.gender,
ageMin: plan.ageMin, ageMax: plan.ageMax, sortOrder: plan.sortOrder,
}
p.record("added", sheet, line, eventCode+" / "+name, addedFields(spec, row))
continue
}
diffs := make([]baseDataFieldDiff, 0, 6)
addDiff(&diffs, "权重", formatFloat(existing.multiplier), formatFloat(plan.multiplier))
addDiff(&diffs, "性别", ptrText(existing.gender), ptrText(plan.gender))
addDiff(&diffs, "年龄下限", intText(existing.ageMin), intText(plan.ageMin))
addDiff(&diffs, "年龄上限", intText(existing.ageMax), intText(plan.ageMax))
addDiff(&diffs, "排序", strconv.Itoa(existing.sortOrder), strconv.Itoa(plan.sortOrder))
if len(diffs) == 0 {
p.record("unchanged", sheet, line, eventCode+" / "+name, nil)
continue
}
plan.action = "updated"
p.plan.categories = append(p.plan.categories, plan)
existing.multiplier, existing.gender = plan.multiplier, plan.gender
existing.ageMin, existing.ageMax, existing.sortOrder = plan.ageMin, plan.ageMax, plan.sortOrder
p.cats[key] = existing
p.record("updated", sheet, line, eventCode+" / "+name, diffs)
}
}
// ---------- 导入:落地 ----------
// applyBaseDataPlan 在一个事务里把计划写完:中途任何一步失败都不会留下半份数据。
func (s *server) applyBaseDataPlan(ctx context.Context, plan baseDataPlan) error {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return internal(err)
}
defer func() { _ = tx.Rollback() }()
actorID := middleware.User(ctx).ID
for _, t := range plan.teams {
var id int64
if t.action == "added" {
res, err := tx.ExecContext(ctx,
`INSERT INTO teams (code, name, short_name, color, sort_order, active) VALUES (?, ?, ?, ?, ?, ?)`,
t.code, t.name, nullStr(t.shortName), nullStr(t.color), t.sortOrder, t.active)
if err != nil {
return baseDataWriteError("单位", t.code, err)
}
if id, err = lastInsertID(res); err != nil {
return internal(err)
}
} else {
if _, err := tx.ExecContext(ctx,
`UPDATE teams SET name = ?, short_name = ?, color = ?, sort_order = ?, active = ? WHERE code = ?`,
t.name, nullStr(t.shortName), nullStr(t.color), t.sortOrder, t.active, t.code); err != nil {
return baseDataWriteError("单位", t.code, err)
}
if id, err = lookupCodeID(ctx, tx, `SELECT id FROM teams WHERE code = ?`, t.code); err != nil {
return err
}
}
// 与 POST /api/teams 同口径地推一次变更:单位改名要能传到大屏。
team, err := teamByIDQ(ctx, tx, id)
if err != nil {
return err
}
if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, actorID); err != nil {
return err
}
}
for _, sp := range plan.sports {
if sp.action == "added" {
if _, err := tx.ExecContext(ctx,
`INSERT INTO sports (code, name, short_name, sort_order, active) VALUES (?, ?, ?, ?, ?)`,
sp.code, sp.name, nullStr(sp.shortName), sp.sortOrder, sp.active); err != nil {
return baseDataWriteError("大项", sp.code, err)
}
continue
}
// 大项没有独立的变更推送(与 POST /api/sports 现状一致),只更新名字与启用状态。
if _, err := tx.ExecContext(ctx,
`UPDATE sports SET name = ?, short_name = ?, sort_order = ?, active = ? WHERE code = ?`,
sp.name, nullStr(sp.shortName), sp.sortOrder, sp.active, sp.code); err != nil {
return baseDataWriteError("大项", sp.code, err)
}
}
for _, e := range plan.events {
sportID, err := lookupCodeID(ctx, tx, `SELECT id FROM sports WHERE code = ?`, e.sportCode)
if err != nil {
return err
}
var id int64
if e.action == "added" {
res, err := tx.ExecContext(ctx,
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare,
record_eligible, scored, multiplier, status, sort_order, venue, remark, avg_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
sportID, e.code, e.name, e.eventType, e.resultMode, e.resultType, e.compare,
e.recordEligible, e.scored, e.multiplier, e.status, e.sortOrder,
nullStr(e.venue), nullStr(e.remark), e.avgMode)
if err != nil {
return baseDataWriteError("小项", e.code, err)
}
if id, err = lastInsertID(res); err != nil {
return internal(err)
}
} else {
if _, err := tx.ExecContext(ctx,
`UPDATE events SET sport_id = ?, name = ?, event_type = ?, result_mode = ?, result_type = ?,
compare = ?, record_eligible = ?, scored = ?, multiplier = ?, status = ?,
sort_order = ?, venue = ?, remark = ?, avg_mode = ?
WHERE code = ?`,
sportID, e.name, e.eventType, e.resultMode, e.resultType, e.compare,
e.recordEligible, e.scored, e.multiplier, e.status, e.sortOrder,
nullStr(e.venue), nullStr(e.remark), e.avgMode, e.code); err != nil {
return baseDataWriteError("小项", e.code, err)
}
if id, err = lookupCodeID(ctx, tx, `SELECT id FROM events WHERE code = ?`, e.code); err != nil {
return err
}
}
// 小项与它的组别都以本小项为载体推送(与组别整表替换同口径)。
if err := s.bumpEvent(ctx, tx, id); err != nil {
return err
}
}
for _, c := range plan.categories {
eventID, err := lookupCodeID(ctx, tx, `SELECT id FROM events WHERE code = ?`, c.eventCode)
if err != nil {
return err
}
if c.action == "added" {
if _, err := tx.ExecContext(ctx,
`INSERT INTO event_categories (event_id, name, multiplier, gender, age_min, age_max, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
eventID, c.name, c.multiplier, nullStr(c.gender), nullInt(c.ageMin), nullInt(c.ageMax), c.sortOrder); err != nil {
return baseDataWriteError("组别", c.eventCode+" / "+c.name, err)
}
continue
}
if _, err := tx.ExecContext(ctx,
`UPDATE event_categories SET multiplier = ?, gender = ?, age_min = ?, age_max = ?, sort_order = ?
WHERE event_id = ? AND name = ?`,
c.multiplier, nullStr(c.gender), nullInt(c.ageMin), nullInt(c.ageMax),
c.sortOrder, eventID, c.name); err != nil {
return baseDataWriteError("组别", c.eventCode+" / "+c.name, err)
}
}
return tx.Commit()
}
// baseDataWriteError 把写库失败翻成现场能照着改的话:唯一索引撞了就说明编码已存在
// (理论上计划阶段已经拦下,这里兜住并发导入这类竞态),其余仍走 5xx。
func baseDataWriteError(sheet, key string, err error) error {
if isUniqueViolation(err) {
return conflict(sheet + " " + key + " 已存在,请先合并编码再重新导入")
}
if isForeignKeyViolation(err) {
return badRequest(sheet+" "+key+" 引用的上级编码不存在,请检查大项编码 / 小项编码", nil)
}
return internal(err)
}
// lookupCodeID 按编码取主键;编码在计划阶段已校验存在,这里取不到说明有人并发改了库。
func lookupCodeID(ctx context.Context, q queryer, query, code string) (int64, error) {
var id int64
if err := q.QueryRowContext(ctx, query, code).Scan(&id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, conflict("编码 " + code + " 在写入过程中被改动,请重新预览后再导入")
}
return 0, internal(err)
}
return id, nil
}
// ---------- 小工具 ----------
func baseDataStrPtr(v string) *string {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return &v
}
func ptrText(v *string) string {
if v == nil {
return ""
}
return *v
}
func intText(v *int) string {
if v == nil {
return ""
}
return strconv.Itoa(*v)
}
func formatFloat(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
func addDiff(diffs *[]baseDataFieldDiff, header, before, after string) {
if before == after {
return
}
*diffs = append(*diffs, baseDataFieldDiff{Field: header, From: before, To: after})
}
func addBoolDiff(diffs *[]baseDataFieldDiff, header string, before, after bool) {
addDiff(diffs, header, boolText(before), boolText(after))
}
func boolText(v bool) string {
if v {
return "是"
}
return "否"
}
// addedFields 用表里写的原文列出一行新增数据的字段变化(从无到有);
// 只列文件里真有、且填了值的列:空单元格对新增行没有信息量,
// 全列出来会让预览里每行挂一串「简称:(空) → (空)」。
func addedFields(spec export.BaseDataSheetSpec, row export.BaseDataRow) []baseDataFieldDiff {
out := make([]baseDataFieldDiff, 0, len(spec.Headers))
for _, h := range spec.Headers {
if !row.Has[h.Field] || row.Cells[h.Field] == "" {
continue
}
out = append(out, baseDataFieldDiff{Field: h.Header, From: "", To: row.Cells[h.Field]})
}
return out
}
// rowMessage 把 badRequest 系列的错误取成一句中文行级提示。
// 枚举校验(normalize*)返回的 apiError 里带着给现场看的 message,直接用它;
// 这样导入的非法枚举值不会掉到 CHECK 约束上去报 500。
func rowMessage(err error) string {
var api *apiError
if errors.As(err, &api) {
return api.message
}
return err.Error()
}
// filterIDQuery 解析筛选型查询参数:留空表示不筛(返回 0);填了就必须是正整数。
// 刻意不学 int64Query 的「解析失败当没填」——那样会把「筛某个单位」静默变成
// 「导出全部」,现场拿到一份全量名单还以为筛过了。
func filterIDQuery(r *http.Request, key, what string) (int64, error) {
raw := strings.TrimSpace(r.URL.Query().Get(key))
if raw == "" {
return 0, nil
}
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n <= 0 {
return 0, badRequest(fmt.Sprintf("%s筛选参数 %s 需要是正整数(留空表示不筛选)", what, key), raw)
}
return n, nil
}
func (s *server) exportProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
dir := filepath.Join(s.dataDir, "exports")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, internal(err)
}
// 项目名取赛事设置里的 event.name;为空会触发 SafeProjectName 校验失败,回退到固定名
name := "scoreboard-project"
var raw string
if err := s.ev.QueryRowContext(r.Context(),
`SELECT value FROM settings WHERE key = 'event.name'`).Scan(&raw); err == nil && raw != "" {
name = raw
}
opts := export.ProjectExportOptions{Name: name, AppVersion: s.version}
// uploads 与图片上传共用同一路径约定;目录可能还没建过,只在存在时打包,否则 copyTree 会报错。
if uploads, err := s.uploadsDir(); err == nil {
if info, err := os.Stat(uploads); err == nil && info.IsDir() {
opts.UploadsDir = uploads
}
}
// 上传字体单独再接一次:它就在 uploads 目录里,整目录打包本来就会带上,
// 但字体是大屏渲染的硬依赖,这里显式再兜一层,避免以后 uploads 打包条件变动时静默漏掉。
if fonts, err := s.fontsDir(); err == nil {
if info, err := os.Stat(fonts); err == nil && info.IsDir() {
opts.FontsDir = fonts
opts.IncludeFonts = true
}
}
rulesPath, cleanup, err := s.writeRulesTempFile(r.Context())
if err != nil {
return nil, err
}
defer cleanup()
opts.RulesPath = rulesPath
dst, err := projectExportPath(dir, time.Now())
if err != nil {
return nil, internal(err)
}
if _, err := export.ExportProjectZip(s.ev, dst, opts); err != nil {
// 赛事名(设置里的 event.name)非法会让整包永远导不出来;它是入参问题,
// 必须按 400 回一句「怎么改」,不能笼统地报「服务端异常」把现场卡死。
// 其他 export 错误(磁盘、SQLite 等)仍走 5xx。
if errors.Is(err, export.ErrUnsafeName) {
return nil, badRequest(exportProjectNameMessage(name), err.Error())
}
return nil, internal(err)
}
defer func() { _ = os.Remove(dst) }()
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", contentDisposition("project.zip", "赛事项目包.zip"))
http.ServeFile(w, r, dst)
return nil, errHandled
}
// exportProjectNameMessage 把 export.SafeProjectName 的拒绝原因翻成可操作的中文提示。
// 现场只会看到这一句:必须点出是哪些字符、或名字过长,否则只看到「服务端异常」无从下手。
func exportProjectNameMessage(name string) string {
trimmed := strings.TrimSpace(name)
if n := len([]rune(trimmed)); n > 64 {
return fmt.Sprintf("赛事名称超过 64 个字符(当前 %d 个),请先改短赛事名称再导出", n)
}
// 去重后列出非法字符,避免同一个字符按出现次数刷屏。
bad := make([]string, 0, 4)
seen := make(map[rune]bool, 4)
for _, r := range name {
if r < 0x20 || strings.ContainsRune(`/\:*?"<>|`, r) {
if !seen[r] {
seen[r] = true
bad = append(bad, fmt.Sprintf("%q", r))
}
}
}
if len(bad) > 0 {
return fmt.Sprintf("赛事名称 %q 含有不能用于文件名的字符:%s;请先改赛事名称再导出",
trimmed, strings.Join(bad, "、"))
}
return fmt.Sprintf("赛事名称 %q 不能用作项目目录名(不能以点或空格结尾,也不能是系统保留名),请先改赛事名称再导出", trimmed)
}
// projectExportPath 生成带随机后缀的导出文件名:精度到秒的时间戳在并发导出时会撞名,
// 后写的会截断先写的文件,导致先返回的响应内容损坏。
func projectExportPath(dir string, now time.Time) (string, error) {
suffix := make([]byte, 4)
if _, err := rand.Read(suffix); err != nil {
return "", err
}
return filepath.Join(dir, "project-"+now.Format("20060102-150405")+"-"+hex.EncodeToString(suffix)+".zip"), nil
}
// writeRulesTempFile 把当前规则包导出成临时 YAML,供赛事项目包内嵌 rules.yaml。
// 返回的清理函数由调用方 defer,避免临时文件泄漏。
func (s *server) writeRulesTempFile(ctx context.Context) (string, func(), error) {
pack, err := rules.Export(s.ev, rules.Meta{})
if err != nil {
return "", func() {}, internal(err)
}
body, err := rules.Marshal(pack)
if err != nil {
return "", func() {}, internal(err)
}
f, err := os.CreateTemp("", "scoreboard-rules-*.yaml")
if err != nil {
return "", func() {}, internal(err)
}
if _, err := f.Write(body); err != nil {
_ = f.Close()
_ = os.Remove(f.Name())
return "", func() {}, internal(err)
}
if err := f.Close(); err != nil {
_ = os.Remove(f.Name())
return "", func() {}, internal(err)
}
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
}
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxProjectImportBytes)
if err := r.ParseMultipartForm(512 << 20); err != nil {
return nil, badRequest("解析上传失败", nil)
}
file, _, err := r.FormFile("file")
if err != nil {
return nil, badRequest("缺少上传字段 file", nil)
}
defer func() { _ = file.Close() }()
tmpDir, err := os.MkdirTemp("", "scoreboard-import-")
if err != nil {
return nil, internal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmp := filepath.Join(tmpDir, "project.zip")
out, err := os.Create(tmp)
if err != nil {
return nil, internal(err)
}
if _, err := io.Copy(out, file); err != nil {
_ = out.Close()
return nil, internal(err)
}
if err := out.Close(); err != nil {
return nil, internal(err)
}
// 覆盖正在运行的赛事项目会让运行中的 event.db 被 unlink(Linux)或 rename 失败(Windows),
// 现场继续写进已删除的 inode,重启即丢数据,必须在落盘前直接拒绝。
if cur := currentProjectName(s.projectDir); cur != "" {
meta, err := export.InspectProjectZip(tmp)
if err != nil {
return nil, badRequest(err.Error(), nil)
}
name := meta.Name
if v := strings.TrimSpace(r.FormValue("rename")); v != "" {
name = v
}
if safe, err := export.SafeProjectName(name); err == nil && strings.EqualFold(safe, cur) {
return nil, badRequest("不能覆盖正在运行的赛事项目 "+cur, nil)
}
}
res, err := export.ImportProjectZip(tmp, filepath.Join(s.dataDir, "event_projs"), export.ImportOptions{
Overwrite: r.FormValue("overwrite") == "1",
Rename: r.FormValue("rename"),
})
if err != nil {
return nil, badRequest(err.Error(), nil)
}
s.audit(r.Context(), "project.import", "project", 0, nil, res)
return res, nil
}
// currentProjectName 从项目目录反推当前运行的赛事项目名;未配置目录时返回空串。
func currentProjectName(projectDir string) string {
cleaned := filepath.Clean(strings.TrimSpace(projectDir))
if cleaned == "" || cleaned == "." || cleaned == string(filepath.Separator) {
return ""
}
return filepath.Base(cleaned)
}
func (s *server) listBackups(w http.ResponseWriter, r *http.Request) (any, error) {
items, err := export.ListBackups(filepath.Join(s.dataDir, "backups"), "")
if err != nil {
return nil, internal(err)
}
return items, nil
}