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 }