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