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 }