主人反馈:导出页「报名名单」卡片导出的表格与导入模板对不上,没法拿回去重导。
核对属实(旧导出是给人看的报表):列序与 importColumns 不同、缺「种子 / 队员名单 / 队伍」
三列、姓名列被拼成「姓名(号码布)」、队名拼在单位名后面、状态列写「已退赛」而
normalizeImportStatus 不认 —— 整体错位 + 状态列报错,重导必炸。
- 新增 GET /api/export/entries-import.xlsx?format=flat|sheets&lang=zh|en&<四个筛选>
· flat(缺省)= 所有行汇总在一张工作表;表名不含 @(导入侧见到带 @ 的表名就整份按
分工作表模板解析,汇总表定不出「小项@组别」会被拒)
· sheets = 按「小项@组别」分工作表,表名走 entrySheetName(与模板、导入解析三处同源,
31 字符截断与撞名去重都跟着一起走)
· 权限与筛选参数与 /api/export/entries.xlsx 完全一致(访客可读);空结果回 200 + 只有表头
· 列序不写常量:按 importColumns 逐列取值,表头走 entryTemplateHeader(lang),
加列/改序时测试当场红(TestEntryImportRecordCoversImportColumns)
- 取值口径(重导的关键):团体行的「姓名」留空、队员写「队员名单」列(中文逗号分隔;
以库里 token 为底,再补上 entry_athletes 里后加的队员号码布,只增不减)、
队伍独立成列、状态写导入器认的四个中文说法(正常参赛/退赛/取消资格/未出场)、
联队照旧写 A+B、未分批留空、证件号/性别/年龄来自运动员档案(团体行空)
- 新增 internal/export/entries_import.go(读库层,与报表导出同源 SQL、取值口径刻意不同)
与 internal/httpapi/entries_import_export.go(工作簿组装 + handler)
- 原有报表导出(/api/export/entries.xlsx 与 internal/export/entries.go)一个字没动;
导出页新增两个按钮与中文/英文表头选择(只影响这两个导入格式)
测试:往返测试把导出件直接喂回报名导入解析器,flat 与 sheets 各断言四条(解析零错误 /
行数与 13 列逐项一致 / 团体行没被变成个人报名 / 两种格式都跑);另做变异验证 —— 状态改回
「已退赛」、给团体行填姓名,守卫当场变红。四套自检 ×2 全绿(finalcheck_0514.log:
前端 996 例 / Console 96 例 / 服务端 16 包,EXIT=0)。
无数据库迁移;已有成绩、名次与榜单不变。
文档:使用手册 §11.1(并订正「不要拿导出件重导」的旧话)、报名名单导入规范 新增第九节、
升级说明 新增 dev_0.5.14、常见问题 新增 Q104。
1801 lines
69 KiB
Go
1801 lines
69 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"database/sql"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"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 是赛事包上传的硬上限。
|
||
//
|
||
// **故意与另外两个批量导入(名单 CSV 32MB、基础数据包 32MB)不同**:赛事包是唯一
|
||
// 带二进制资源的包——库里导出 + 赛事会标 / 大屏背景图 / 单位 logo,以及**字体**
|
||
// (字体上传的单文件上限本身就是 20MB,见 fonts.go 的 maxFontBytes,且允许传多个)。
|
||
// 按 32MB 卡会让「两个字体 + 一张背景图」的正常赛事包直接导不进来,属于生产事故。
|
||
//
|
||
// 真正防「整个包驻留内存」的是 ParseMultipartForm 的 maxMemory(见 multipartMemoryBytes):
|
||
// 超出部分 Go 会自动落到临时文件,所以上限放宽不等于内存放大。
|
||
//
|
||
// 它是 var 而不是 const,只为给测试留一个「临时调小再验拒绝路径」的口子
|
||
// (见 SetMaxProjectImportBytesForTest);生产代码没有任何改动它的入口。
|
||
var maxProjectImportBytes int64 = 512 << 20
|
||
|
||
// SetMaxProjectImportBytesForTest 临时调整赛事包上限,返回原值便于 defer 还原。
|
||
// 仅供测试使用;调用方不要在 t.Parallel() 的用例里用它。
|
||
func SetMaxProjectImportBytesForTest(v int64) int64 {
|
||
prev := maxProjectImportBytes
|
||
maxProjectImportBytes = v
|
||
return prev
|
||
}
|
||
|
||
// 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))
|
||
// 报名名单(导入格式):列序与列名与报名导入模板逐列一致,导出的文件可以直接重新导入。
|
||
// 筛选参数与上面那份报表完全一样;format=flat(一张表汇总,缺省)| sheets(按小项@组别分表)。
|
||
mux.HandleFunc("GET /api/export/entries-import.xlsx", s.read(auth.RoleGuest, s.exportEntriesImportXLSX))
|
||
// 基础数据包(单位 / 大项 / 小项 / 组别):导出为多 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
|
||
}
|
||
// 先确认小项存在:LoadEventWorkbook 对小项不存在只回一句裸错误串(没有哨兵错误),
|
||
// 落到统一错误处理里就是 500 + 原始文本,而导出接口承诺「资源不存在 → 404」。
|
||
// 这里复用读接口的同一句话,现场看到的与 GET /api/events/{id} 一致。
|
||
if _, err := eventByIDQ(r.Context(), s.ev, id); 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 {
|
||
// 单位不存在是「资源不存在」,按 404 报(与 GET /api/teams/{id} 同一句话);
|
||
// 其它错误(读库失败等)才是 500。全部走 internal 会让每次点空单位导出都记一条伪 5xx。
|
||
if errors.Is(err, export.ErrTeamNotFound) {
|
||
return nil, notFound("单位不存在")
|
||
}
|
||
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(multipartMemoryBytes); 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
|
||
// deleted 表示这行是「已彻底删除」(teams.deleted_at 非空)的单位:库里有、界面上没有。
|
||
deleted 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,它继续是隐藏状态——导入不该顺手把藏起来的条目翻出来。
|
||
// deleted_at 也一并读出来:命中隐藏单位时要把它跳掉并明确告诉用户(见 planTeams),
|
||
// 只按「更新」走的话现场改完名字界面上仍然没有它,看起来像导入没生效。
|
||
teamRows, err := p.s.ev.QueryContext(p.ctx, `
|
||
SELECT id, code, name, short_name, color, sort_order, active, deleted_at IS NOT NULL
|
||
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, deleted int
|
||
if err := teamRows.Scan(&t.id, &code, &t.name, &shortName, &color, &t.sortOrder, &active, &deleted); err != nil {
|
||
return internal(err)
|
||
}
|
||
t.shortName, t.color, t.active = strPtr(shortName), strPtr(color), active != 0
|
||
t.deleted = deleted != 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}
|
||
}
|
||
// 命中「已彻底删除」(deleted_at 非空)的单位:库里那行还在、界面上和导出件里都没有它。
|
||
// 旧行为是照常走「更新 / 无变化」并真的写库,于是现场会看到「更新 1 条」却怎么也找不到这个单位
|
||
// (它仍是隐藏状态),像是导入没生效。改成整行跳过 + 单独报一句原因与恢复入口。
|
||
// 文案只指界面路径:这句话会直接显示给现场操作员,内部接口路径对他们没有意义。
|
||
if ok && existing.deleted {
|
||
p.rowError(sheet, line,
|
||
"单位 %s 已被彻底删除,本行跳过(导入不会把隐藏的单位翻出来);要放回列表请先到「基础数据 → 参赛单位」勾选「显示已彻底删除的单位」后点「恢复」",
|
||
code)
|
||
continue
|
||
}
|
||
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
|
||
}
|
||
// 基础数据包是第三条能写进小项名的路:与 POST /api/events 同口径,名字里不能有 @。
|
||
if msg := nameSeparatorProblem("小项名称", name); msg != "" {
|
||
p.rowError(sheet, line, "%s", msg)
|
||
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,
|
||
}
|
||
// 枚举列的三态口径:列不在表里 / 整格留空都 = 不改动,只有显式填值才覆盖。
|
||
// 以前格子留空会落到「取系统默认」(类型→individual、状态→pending、平均口径→valid …),
|
||
// 一次误清格就能把已发布小项打回「待录入」、把团体项目的类型与均分口径改掉,
|
||
// 而预览里只是一条不显眼的差异。
|
||
if v := row.Cells["eventType"]; v != "" {
|
||
parsed, err := normalizeEventType(v)
|
||
if err != nil {
|
||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||
continue
|
||
}
|
||
plan.eventType = parsed
|
||
}
|
||
if v := row.Cells["resultMode"]; v != "" {
|
||
parsed, err := normalizeResultMode(v)
|
||
if err != nil {
|
||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||
continue
|
||
}
|
||
plan.resultMode = parsed
|
||
}
|
||
if v := row.Cells["resultType"]; v != "" {
|
||
parsed, err := normalizeResultType(v)
|
||
if err != nil {
|
||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||
continue
|
||
}
|
||
plan.resultType = parsed
|
||
}
|
||
if v := row.Cells["compare"]; v != "" {
|
||
parsed, err := normalizeCompare(v)
|
||
if err != nil {
|
||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||
continue
|
||
}
|
||
plan.compare = parsed
|
||
}
|
||
if v := row.Cells["status"]; v != "" {
|
||
parsed, err := normalizeEventStatus(v)
|
||
if err != nil {
|
||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||
continue
|
||
}
|
||
plan.status = parsed
|
||
}
|
||
if v := row.Cells["avgMode"]; v != "" {
|
||
parsed, err := normalizeAvgMode(v)
|
||
if err != nil {
|
||
p.rowError(sheet, line, "%s", rowMessage(err))
|
||
continue
|
||
}
|
||
plan.avgMode = parsed
|
||
}
|
||
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
|
||
}
|
||
// 与 PUT /api/events/{id}/categories 同口径:组别名里不能有 @(分工作表模板的分隔符)。
|
||
if msg := nameSeparatorProblem("组别名称", name); msg != "" {
|
||
p.rowError(sheet, line, "%s", msg)
|
||
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 v := strings.TrimSpace(row.Cells["gender"]); v != "" {
|
||
if v != "男" && v != "女" {
|
||
p.rowError(sheet, line, "性别只能填 男 / 女(留空表示不改动,新建时留空即不限)")
|
||
continue
|
||
}
|
||
plan.gender = baseDataStrPtr(v)
|
||
}
|
||
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
|
||
}
|
||
|
||
// multipartMemoryBytes 是所有 multipart 上传共用的**内存档位**(不是大小上限):
|
||
// 解析时最多在内存里留这么多,超出部分 Go 自动落到临时文件。
|
||
// 防「整个包驻留内存」靠的是它,所以各入口的大小上限可以按内容类型各不相同。
|
||
const multipartMemoryBytes = 16 << 20
|
||
|
||
func (s *server) importProjectZip(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
r.Body = http.MaxBytesReader(w, r.Body, maxProjectImportBytes)
|
||
// 内存里只留 multipartMemoryBytes,超出的部分由 multipart 落在系统临时目录。
|
||
// 注意别把 maxMemory 与上面的大小上限搞混:上限可以宽(赛事包带字体/图片),
|
||
// 内存档位必须窄。
|
||
if err := r.ParseMultipartForm(multipartMemoryBytes); 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"`
|
||
}
|
||
|
||
// restoreMu 串行化「一键恢复」。
|
||
//
|
||
// 恢复动的是数据目录里的库文件本身(关连接 → 整文件替换 → 触发重启),
|
||
// 同一次操作里没有任何一步能并发安全地跑两遍:两个管理员同时点(连点两次、两个窗口、
|
||
// 两个人各按一次)会让两次替换交错——一个刚拍完安全副本、另一个已经换了文件,
|
||
// 回执还会各说各话(一个说已用 A 恢复、另一个说已用 B 恢复),而现场只看到「恢复了」。
|
||
//
|
||
// 与备份侧 export.Service.mu 是同一套做法(同一时刻只让一个动库文件的操作在跑),
|
||
// 区别在这里是全局一把:要保护的是数据目录里的库文件,它不属于某一个 server 实例。
|
||
// 后来者不排队静默执行——排队会让请求一直挂着,页面上的「正在重启」会变成假象,
|
||
// 所以用 TryLock 当场返回「正在恢复,请稍后」。
|
||
var restoreMu sync.Mutex
|
||
|
||
// restoreBackupHandler 用备份文件恢复当前库(仅 admin)。
|
||
//
|
||
// 形态与「切换赛事」同路:先做完全部校验与留底,再动库文件,最后优雅退出让 Console 拉起。
|
||
// 不在运行中热替换——Windows 上替换不了正被打开的文件,硬写会把库写坏;
|
||
// 所以这里先把目标库的连接关掉(服务端从此不再持有它),整文件替换,再触发重启。
|
||
//
|
||
// 顺序是有讲究的,任何一步失败都不能留下「半截」状态:
|
||
//
|
||
// 校验备份(坏文件直接 400,当前库毫发无伤)
|
||
// → 给当前库拍安全副本(失败就放弃,什么都没动)
|
||
// → 落恢复标记(死了也有线索)
|
||
// → 关连接、替换文件
|
||
// → 复核新库 + 写审计 + 回执(复核不过自动退回安全副本)
|
||
func (s *server) restoreBackupHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
// 整段串行化(含参数校验):忙的时候直接回话,连文件都不碰。
|
||
if !restoreMu.TryLock() {
|
||
return nil, conflict("正在恢复,请稍后:上一次恢复还在进行,等它重启完成、页面恢复后再试")
|
||
}
|
||
defer restoreMu.Unlock()
|
||
|
||
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 += ";服务端正在重启,请稍候刷新页面"
|
||
}
|
||
// 安全副本自己那份保留策略要固定说清楚:用户文档只承诺「不参与 20 份滚动清理」,
|
||
// 现场很容易理解成「这些副本会一直在」。实际上每类库只留最近 DefaultSafetyCopyKeep 份,
|
||
// 第 N 次恢复会把最早的删掉——不写死这句话,想退回三周前那个时间点时会扑空。
|
||
// 但只在真清理了才说「已清理 N 份」:没删就说删了也是假话。
|
||
notes := []string{fmt.Sprintf("恢复前安全副本:%s(每类库保留最近 %d 份,更旧的会被后续恢复清理)",
|
||
safety.Name, export.DefaultSafetyCopyKeep)}
|
||
if pruned > 0 {
|
||
notes = append(notes, fmt.Sprintf("已清理 %d 份更旧的安全副本", pruned))
|
||
}
|
||
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: ")
|
||
}
|