feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
This commit is contained in:
1 parent
d0e3b93e76
commit
bc8b1ee6c0
145 files changed
+25159
-321
No files matched your search
@@ -0,0 +1,652 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||||
)
|
||||
|
||||
// ---------- 认证 ----------
|
||||
|
||||
func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in loginInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(in.Username) == "" || in.Password == "" {
|
||||
return nil, badRequest("username 与 password 必填", nil)
|
||||
}
|
||||
|
||||
ip := middleware.ClientIP(r)
|
||||
u, token, err := s.auth.Authenticate(r.Context(), in.Username, in.Password, ip, r.UserAgent())
|
||||
switch {
|
||||
case err == auth.ErrRateLimited:
|
||||
return nil, &apiError{status: http.StatusTooManyRequests, code: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试"}
|
||||
case err == auth.ErrInvalidCredentials, err == auth.ErrUserInactive:
|
||||
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: err.Error()}
|
||||
case err != nil:
|
||||
return nil, internal(err)
|
||||
}
|
||||
|
||||
s.auth.SetSessionCookie(w, token)
|
||||
csrf, err := auth.NewCSRFToken()
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
s.auth.SetCSRFCookie(w, csrf)
|
||||
s.audit(r.Context(), "auth.login", "user", u.ID, nil, map[string]any{"username": u.Username})
|
||||
return userDTO(u), nil
|
||||
}
|
||||
|
||||
func (s *server) logout(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
if c, err := r.Cookie(auth.CookieSession); err == nil {
|
||||
if err := s.auth.Logout(r.Context(), c.Value); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
}
|
||||
s.auth.ClearSessionCookie(w)
|
||||
s.auth.ClearCSRFCookie(w)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *server) me(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
u := middleware.User(r.Context())
|
||||
return userDTO(u), nil
|
||||
}
|
||||
|
||||
func userDTO(u *auth.User) currentUserDTO {
|
||||
dto := currentUserDTO{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
DisplayName: u.DisplayName,
|
||||
Role: u.Role.String(),
|
||||
MustChangePassword: u.MustChangePassword,
|
||||
}
|
||||
if u.Role == auth.RoleGuest {
|
||||
dto.Username = "guest"
|
||||
dto.DisplayName = "访客"
|
||||
}
|
||||
if u.LastLoginAt != "" {
|
||||
v := u.LastLoginAt
|
||||
dto.LastLoginAt = &v
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// ---------- settings ----------
|
||||
|
||||
func (s *server) getSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
return s.getSettings(r.Context())
|
||||
}
|
||||
|
||||
func (s *server) updateSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in settingsPatch
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||||
before, err := s.getSettings(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.updateSettings(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "settings.update", "settings", 0, before, updated)
|
||||
return updated, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- teams ----------
|
||||
|
||||
func (s *server) listTeamsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
return s.listTeams(r.Context())
|
||||
}
|
||||
|
||||
func (s *server) getTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getTeam(r.Context(), id)
|
||||
}
|
||||
|
||||
func (s *server) createTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in teamDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created, err := s.createTeam(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "team.create", "team", created.ID, nil, created)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *server) updateTeamHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in teamDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getTeam(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.updateTeam(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "team.update", "team", id, before, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *server) deleteTeamHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := s.getTeam(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.deleteTeam(r.Context(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(r.Context(), "team.delete", "team", id, before, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- sports ----------
|
||||
|
||||
func (s *server) listSportsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
return s.listSports(r.Context())
|
||||
}
|
||||
|
||||
func (s *server) getSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getSport(r.Context(), id)
|
||||
}
|
||||
|
||||
func (s *server) createSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in sportDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created, err := s.createSport(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "sport.create", "sport", created.ID, nil, created)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *server) updateSportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in sportDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getSport(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.updateSport(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "sport.update", "sport", id, before, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *server) deleteSportHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := s.getSport(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.deleteSport(r.Context(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(r.Context(), "sport.delete", "sport", id, before, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- events ----------
|
||||
|
||||
func (s *server) listEventsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
sportID, _ := int64Query(r, "sportId")
|
||||
return s.listEvents(r.Context(), sportID)
|
||||
}
|
||||
|
||||
func (s *server) getEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getEvent(r.Context(), id)
|
||||
}
|
||||
|
||||
func (s *server) createEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in eventDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created, err := s.createEvent(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "event.create", "event", created.ID, nil, created)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *server) updateEventHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in eventDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getEvent(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.updateEvent(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "event.update", "event", id, before, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *server) deleteEventHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := s.getEvent(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.deleteEvent(r.Context(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(r.Context(), "event.delete", "event", id, before, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) listEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.listEntriesWithResults(r.Context(), id)
|
||||
}
|
||||
|
||||
func (s *server) listCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.listCategories(r.Context(), id)
|
||||
}
|
||||
|
||||
func (s *server) replaceCategoriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in categoriesPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.listCategories(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.replaceCategories(r.Context(), id, in.Categories)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "event.categories.replace", "event", id, before, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *server) listChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.listChannels(r.Context(), id)
|
||||
}
|
||||
|
||||
func (s *server) replaceChannelsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in channelsPayload
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.listChannels(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.replaceChannels(r.Context(), id, in.Channels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "event.channels.replace", "event", id, before, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// ---------- scoring profiles ----------
|
||||
|
||||
func (s *server) listProfilesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
return s.listProfiles(r.Context())
|
||||
}
|
||||
|
||||
func (s *server) getProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getProfile(r.Context(), id)
|
||||
}
|
||||
|
||||
func (s *server) createProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in profileDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created, err := s.createProfile(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "profile.create", "scoring_profile", created.ID, nil, created)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *server) updateProfileHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in profileDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getProfile(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.updateProfile(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "profile.update", "scoring_profile", id, before, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// ---------- athletes ----------
|
||||
|
||||
func (s *server) listAthletesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
teamID, _ := int64Query(r, "teamId")
|
||||
return s.listAthletes(r.Context(), teamID)
|
||||
}
|
||||
|
||||
func (s *server) getAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getAthlete(r.Context(), id)
|
||||
}
|
||||
|
||||
func (s *server) createAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
var in athleteDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
created, err := s.createAthlete(r.Context(), in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "athlete.create", "athlete", created.ID, nil, created)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *server) updateAthleteHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var in athleteDTO
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := s.getAthlete(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := s.updateAthlete(r.Context(), id, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "athlete.update", "athlete", id, before, updated)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *server) deleteAthleteHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := s.getAthlete(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.deleteAthlete(r.Context(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.audit(r.Context(), "athlete.delete", "athlete", id, before, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- 报名表导入 ----------
|
||||
|
||||
type entryImportResult struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
|
||||
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
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() }()
|
||||
|
||||
rows, err := readTable(filepath.Ext(header.Filename), file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.importEntries(r.Context(), rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(r.Context(), "entries.import", "entry", 0, nil, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".xlsx":
|
||||
f, err := excelize.OpenReader(file)
|
||||
if err != nil {
|
||||
return nil, badRequest("无法解析 xlsx", err.Error())
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
sheets := f.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
return nil, badRequest("xlsx 没有工作表", nil)
|
||||
}
|
||||
rows, err := f.GetRows(sheets[0])
|
||||
if err != nil {
|
||||
return nil, badRequest("读取 xlsx 失败", err.Error())
|
||||
}
|
||||
return rows, nil
|
||||
default:
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.TrimLeadingSpace = true
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, badRequest("无法解析 CSV", err.Error())
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
}
|
||||
|
||||
// importColumns 是报名表必须包含的表头(大小写不敏感)。
|
||||
var importColumns = []string{
|
||||
"event_code", "category", "team_code", "athlete_name", "bib", "seed", "status", "member_names",
|
||||
}
|
||||
|
||||
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
|
||||
result := entryImportResult{Errors: []string{}}
|
||||
if len(rows) < 2 {
|
||||
return result, badRequest("报名表为空", nil)
|
||||
}
|
||||
index := make(map[string]int, len(rows[0]))
|
||||
for i, h := range rows[0] {
|
||||
index[strings.ToLower(strings.TrimSpace(h))] = i
|
||||
}
|
||||
for _, col := range importColumns {
|
||||
if _, ok := index[col]; !ok {
|
||||
return result, badRequest("报名表缺少列 "+col, nil)
|
||||
}
|
||||
}
|
||||
|
||||
for n, row := range rows[1:] {
|
||||
line := n + 2
|
||||
cell := func(name string) string {
|
||||
i := index[name]
|
||||
if i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[i])
|
||||
}
|
||||
eventCode := cell("event_code")
|
||||
categoryName := cell("category")
|
||||
teamCode := cell("team_code")
|
||||
athleteName := cell("athlete_name")
|
||||
if eventCode == "" || teamCode == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:event_code / team_code 必填", line))
|
||||
continue
|
||||
}
|
||||
|
||||
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 不存在", line, eventCode))
|
||||
continue
|
||||
}
|
||||
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:单位 %s 不存在", line, teamCode))
|
||||
continue
|
||||
}
|
||||
var categoryID int64
|
||||
if categoryName != "" {
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:组别 %s 不存在", line, categoryName))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 没有组别", line, eventCode))
|
||||
continue
|
||||
}
|
||||
}
|
||||
var athleteID *int64
|
||||
if athleteName != "" {
|
||||
if id, err := s.lookupID(ctx, `SELECT id FROM athletes WHERE name = ? AND team_id = ? LIMIT 1`, athleteName, teamID); err == nil {
|
||||
athleteID = &id
|
||||
}
|
||||
}
|
||||
|
||||
created, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
EventID: eventID,
|
||||
CategoryID: categoryID,
|
||||
TeamID: teamID,
|
||||
AthleteID: athleteID,
|
||||
MemberNames: splitNames(cell("member_names")),
|
||||
Bib: nilIfEmpty(cell("bib")),
|
||||
Seed: nilIfEmpty(cell("seed")),
|
||||
Status: orDefault(cell("status"), "registered"),
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
|
||||
case created:
|
||||
result.Created++
|
||||
default:
|
||||
result.Updated++
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func splitNames(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ',' || r == '/' })
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user