Files
Scoreboard/server/internal/httpapi/export_handlers.go
T
AskaEth 1b8a37ff8c feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘
三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开):

一、分工作表报名模板(新增能力)
- 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板,
  一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。
- 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。
- 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。
- 单表 CSV/XLSX 导入路径不回归。

二、号码布判重(修误判)
- 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。
- 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行;
  号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。
- 团体行的号码布一并纳入查重。

三、自动备份的关停与落盘(修数据安全隐患)
- export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限,
  超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。
- vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件;
  Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。
- 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件,
  避免半截 .db 被列进备份列表、进而被「一键恢复」选中。

文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
2026-09-14 01:10:16 +08:00

1798 lines
68 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"
"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))
// 基础数据包(单位 / 大项 / 小项 / 组别):导出为多 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: ")
}