Files
Scoreboard/server/internal/httpapi/export_handlers.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

1707 lines
62 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
}
// 起手值一律取库里现值:**列缺席 = 不动该字段**(布尔列也一样,见下面的解析块),
// 列在但格子空才落到「取默认值」:排序空值不改起手值、启用空值由解析取「是」。
// 新建时 p.teams 取不到,给与建表默认一致的起手值:启用、排序 0。
existing, ok := p.teams[code]
if !ok {
existing = baseDataExistingTeam{active: true}
}
plan := baseDataTeamPlan{line: line, code: code, name: name, active: existing.active, sortOrder: existing.sortOrder}
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
}
}
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
}
// 与单位同口径:起手取库里现值,列缺席 = 不动该字段(启用也一样);
// 列在(row.Has 为真)才走下面的解析块,格子空由它取默认「是」。新建才落默认:启用、排序 0。
existing, ok := p.sports[code]
if !ok {
existing = baseDataExistingSport{active: true}
}
plan := baseDataSportPlan{line: line, code: code, name: name, active: existing.active, sortOrder: existing.sortOrder}
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
}
}
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
}
// backupRestoreResult 是恢复请求被接受后的回执。
//
// SafetyCopy 是「恢复前安全副本」的文件名,必须回报给操作员:
// 应用这次恢复的理由是数据被改乱了,那恢复本身错了(选错时间点 / 选错库)的概率就不低,
// 现场要能立刻拿副本退回。
type backupRestoreResult struct {
// Kind 是这次用的文件的性质:system / event / safety(安全副本也能拿来退回)。
Kind string `json:"kind"`
// DBKind 是被恢复的库:system / event。
DBKind string `json:"dbKind"`
KindLabel string `json:"kindLabel"`
File string `json:"file"`
Target string `json:"target"`
// SafetyCopy 与本策略同名文件在备份目录里的完整路径;PrunedSafetyCopies 是这次顺带清掉的旧副本份数。
SafetyCopy string `json:"safetyCopy"`
SafetyCopyPath string `json:"safetyCopyPath"`
PrunedSafetyCopies int `json:"prunedSafetyCopies"`
Restarting bool `json:"restarting"`
Message string `json:"message"`
Warning string `json:"warning,omitempty"`
}
// restoreBackupHandler 用备份文件恢复当前库(仅 admin)。
//
// 形态与「切换赛事」同路:先做完全部校验与留底,再动库文件,最后优雅退出让 Console 拉起。
// 不在运行中热替换——Windows 上替换不了正被打开的文件,硬写会把库写坏;
// 所以这里先把目标库的连接关掉(服务端从此不再持有它),整文件替换,再触发重启。
//
// 顺序是有讲究的,任何一步失败都不能留下「半截」状态:
//
// 校验备份(坏文件直接 400,当前库毫发无伤)
// → 给当前库拍安全副本(失败就放弃,什么都没动)
// → 落恢复标记(死了也有线索)
// → 关连接、替换文件
// → 复核新库 + 写审计 + 回执(复核不过自动退回安全副本)
func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
var in restoreBackupPayload
if err := decodeJSON(r, &in); err != nil {
return nil, err
}
kind, ok := export.ParseRestoreKind(in.Kind)
if !ok {
return nil, badRequest("请指定要恢复的库:kind 只能是 system(系统库)或 event(赛事库)", nil)
}
name := strings.TrimSpace(in.File)
if name == "" {
return nil, badRequest("请选择要恢复的备份文件", nil)
}
// 只收裸文件名:带分隔符(或 ..)的输入会把恢复变成「任意文件覆盖任意路径」。
if name != filepath.Base(name) || strings.ContainsAny(name, `/\`) {
return nil, badRequest("备份文件名非法,只能填备份目录里的文件名", nil)
}
fileKind, fileDBKind, ok := export.KindOfBackupName(name)
if !ok {
return nil, badRequest("无法从这个文件名判断它属于哪个库,请从备份列表里选择", nil)
}
// 比的是「它对应的库」:安全副本的性质是 safety,但覆盖的仍是它那一份库,
// 拿安全副本退回上一个时间点是正当操作(列表接口会给出 dbKind)。
if fileDBKind != kind {
return nil, badRequest(fmt.Sprintf("%s 是%s的备份,不能恢复成%s;请确认要恢复的库",
name, fileDBKind.Label(), kind.Label()), nil)
}
backupsDir := filepath.Join(s.dataDir, "backups")
backupPath := filepath.Join(backupsDir, name)
if err := export.ValidateBackup(backupPath, kind); err != nil {
return nil, badRequest(trimExportPrefix(err.Error()), nil)
}
target, db, apiErr := s.restoreTarget(kind)
if apiErr != nil {
return nil, apiErr
}
// 上一次恢复(或任何原因)已经把连接关掉了:这时再往下走,拍安全副本只会撞上
// 「sql: database is closed」,报出来是一句看不懂的 500。直接说清「先重启服务端」。
if err := db.PingContext(r.Context()); err != nil && dbClosed(err) {
return nil, serviceUnavailable("INTERNAL", "数据库连接已关闭(上一次恢复后尚未重启),请先重启服务端再恢复")
}
safety, err := export.SafetySnapshot(db, backupsDir, kind, time.Now())
if err != nil {
return nil, internalMsg("生成恢复前安全副本失败,当前库未做任何改动", err)
}
// 安全副本自己那份保留策略(每类库最近 5 份,最新的永不删),与普通备份的 20 份滚动互不干扰。
// 清不掉旧副本不算恢复失败:磁盘上多留一份整库文件,比因此拒绝一次现场恢复划算得多。
pruned := 0
if removed, pruneErr := export.PruneSafetyCopies(backupsDir, kind, export.DefaultSafetyCopyKeep); pruneErr != nil {
s.logger.Warn("清理旧安全副本失败", "kind", string(kind), "err", pruneErr)
} else {
pruned = len(removed)
if pruned > 0 {
s.logger.Info("已清理旧安全副本", "kind", string(kind),
"removed", pruned, "keep", export.DefaultSafetyCopyKeep)
}
}
operator := "guest"
if u := middleware.User(r.Context()); u != nil && u.ID != 0 {
operator = fmt.Sprintf("%s#%d", u.Username, u.ID)
}
markerPath, err := export.WritePendingRestore(s.dataDir, export.PendingRestore{
Kind: kind,
Backup: backupPath,
BackupName: name,
Target: target,
SafetyCopy: safety.Path,
Operator: operator,
RequestedAt: time.Now(),
})
if err != nil {
return nil, internalMsg("写入恢复标记失败,当前库未做任何改动", err)
}
// 停机第一步:断开目标库的全部连接。此后服务端不再持有这个库,
// 直到本次请求触发优雅退出、Console 把服务端重新拉起来。
if err := db.Close(); err != nil {
s.logger.Warn("关闭目标库连接失败,继续恢复", "kind", string(kind), "err", err)
}
if err := export.ApplyRestore(backupPath, target); err != nil {
// 连接已经断开,即使文件没换成,也得让服务端重启一次把库重新打开。
s.triggerRestart(kind)
return nil, internalMsg("替换数据库失败,当前库文件未被改动;恢复标记留在 "+markerPath+
";请用恢复前安全副本 "+safety.Path+" 退回", err)
}
// 备份在替换前校验过一次,这里是兜底复核:万一新库打不开,立刻退回安全副本,
// 绝不能让一次恢复留下一个打不开的库。
if err := export.ValidateBackup(target, kind); err != nil {
if rbErr := export.ApplyRestore(safety.Path, target); rbErr != nil {
s.triggerRestart(kind)
return nil, internalMsg("恢复后校验失败,且自动回滚也失败;请立即用安全副本 "+
safety.Path+" 手工恢复", err)
}
_ = export.ClearPendingRestore(s.dataDir)
s.triggerRestart(kind)
return nil, internalMsg("恢复后校验失败,已自动回滚到恢复前状态(安全副本 "+safety.Name+")", err)
}
_ = export.ClearPendingRestore(s.dataDir)
// 审计直接写给「恢复之后还在的那份库」(审计表在赛事库);
// 恢复赛事库时旧连接已关、旧文件已换,走中间件的审计只会留在被换掉的文件上。
warning := ""
if err := s.recordRestoreAudit(r, kind, name, target, safety.Name); err != nil {
s.logger.Error("恢复审计写入失败", "kind", string(kind), "err", err)
warning = "恢复已完成,但审计写入失败:" + err.Error()
}
base := fmt.Sprintf("已用 %s 恢复%s", name, kind.Label())
if s.shutdown == nil {
// 裸服务端(没接 Console)没有可触发的优雅重启,库文件已经换好但进程还抱着旧连接,
// 只能由操作员手工重启——回执必须说实话,不能让人以为它会自己回来。
base += "。服务端未接入自动重启,请手工重启服务后刷新页面"
} else {
base += ";服务端正在重启,请稍候刷新页面"
}
notes := []string{"恢复前安全副本:" + safety.Name}
if pruned > 0 {
notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本(每类库保留最近 %d 份)",
pruned, export.DefaultSafetyCopyKeep))
}
if kind == export.RestoreKindEvent {
// 备份文件名里没有项目名,从别的项目备下来的 event-…db 长得一模一样。
notes = append(notes, "赛事库备份的文件名里不含来源项目,恢复前请确认来源")
}
result := backupRestoreResult{
Kind: fileKind,
DBKind: string(kind),
KindLabel: kind.Label(),
File: name,
Target: target,
SafetyCopy: safety.Name,
SafetyCopyPath: safety.Path,
PrunedSafetyCopies: pruned,
Restarting: s.shutdown != nil,
Message: base + "。" + strings.Join(notes, ";"),
Warning: warning,
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": result})
s.triggerRestart(kind)
return nil, errHandled
}
// triggerRestart 走「切换赛事」同一条收尾:优雅退出,由 Console 拉起服务端。
//
// 恢复一旦执行到「关掉目标库连接」这一步,无论后面成没成,都必须重启一次——
// 连接已经断开,只有新进程会把库重新打开。调用点在响应写完之后:
// srv.Shutdown 会等本次请求(含响应与审计落库)结束,不会截断回执。
func (s *server) triggerRestart(kind export.RestoreKind) {
if s.shutdown == nil {
// 与切换赛事一致的降级:没接入优雅关闭时文件已经换好,下次重启即生效。
s.logger.Warn("服务端未接入优雅关闭,恢复将在下次重启后生效", "kind", string(kind))
return
}
go s.shutdown()
}
// restoreTarget 返回要恢复的库文件路径与当前连接。
// 赛事库的路径取自服务端实际打开的赛事目录,而不是从记忆文件重新推一遍:
// 恢复必须覆盖「当前正在用的那份」,退役项目里的同名文件不是目标。
func (s *server) restoreTarget(kind export.RestoreKind) (string, *sql.DB, error) {
switch kind {
case export.RestoreKindSystem:
if strings.TrimSpace(s.dataDir) == "" || s.sys == nil {
return "", nil, serviceUnavailable("INTERNAL", "服务端未配置数据目录,无法恢复系统库")
}
return filepath.Join(s.dataDir, "system.db"), s.sys, nil
case export.RestoreKindEvent:
if strings.TrimSpace(s.projectDir) == "" || s.ev == nil {
return "", nil, serviceUnavailable("INTERNAL", "服务端未配置赛事项目目录,无法恢复赛事库")
}
return filepath.Join(s.projectDir, "event.db"), s.ev, nil
default:
return "", nil, badRequest("要恢复的库只能是 system 或 event", nil)
}
}
// recordRestoreAudit 把这次恢复写进赛事库的 audit_logs(audit 表在赛事库,系统库没有)。
func (s *server) recordRestoreAudit(r *http.Request, kind export.RestoreKind, file, target, safetyName string) error {
if strings.TrimSpace(s.projectDir) == "" {
return errors.New("未配置赛事项目目录,没有可写审计的库")
}
detail := map[string]any{
"kind": string(kind),
"file": file,
"target": target,
"safetyCopy": safetyName,
}
if project := currentProjectName(s.projectDir); project != "" {
detail["project"] = project
}
return export.RecordRestoreAudit(filepath.Join(s.projectDir, "event.db"), export.RestoreAuditRow{
Action: "system.restore",
EntityType: "backup",
ActorID: middlewareUserID(r),
IP: middleware.ClientIP(r),
RequestID: middleware.GetRequestID(r.Context()),
Detail: detail,
})
}
// trimExportPrefix 去掉 export 包错误里的包名前缀,让提示读起来像人话。
func trimExportPrefix(message string) string {
return strings.TrimPrefix(message, "export: ")
}