package httpapi import ( "context" "database/sql" "errors" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" ) // ---------- events ---------- const eventColumns = `id, sport_id, code, name, event_type, result_mode, result_type, compare, record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark` func scanEvent(row interface{ Scan(...any) error }) (eventDTO, error) { var ( e eventDTO scheduledAt, venue, note sql.NullString ) err := row.Scan(&e.ID, &e.SportID, &e.Code, &e.Name, &e.EventType, &e.ResultMode, &e.ResultType, &e.Compare, &e.RecordEligible, &e.Scored, &e.Multiplier, &e.Status, &scheduledAt, &venue, &e.SortOrder, ¬e) e.ScheduledAt = strPtr(scheduledAt) e.Venue = strPtr(venue) e.Remark = strPtr(note) return e, err } func (s *server) listEvents(ctx context.Context, sportID int64) ([]eventDTO, error) { query := `SELECT ` + eventColumns + ` FROM events` args := []any{} if sportID > 0 { query += ` WHERE sport_id = ?` args = append(args, sportID) } query += ` ORDER BY sort_order, code` rows, err := s.ev.QueryContext(ctx, query, args...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]eventDTO, 0) for rows.Next() { e, err := scanEvent(rows) if err != nil { return nil, internal(err) } out = append(out, e) } return out, rows.Err() } func (s *server) getEvent(ctx context.Context, id int64) (eventDTO, error) { e, err := eventByIDQ(ctx, s.ev, id) if err != nil { return eventDTO{}, err } cats, err := s.listCategories(ctx, id) if err != nil { return eventDTO{}, err } chs, err := s.listChannels(ctx, id) if err != nil { return eventDTO{}, err } e.Categories = cats e.Channels = chs return e, nil } // eventByIDQ 在给定连接/事务内读取小项(不含组别/通道),供写事务内构造 change_log 载荷复用。 func eventByIDQ(ctx context.Context, q queryer, id int64) (eventDTO, error) { e, err := scanEvent(q.QueryRowContext(ctx, `SELECT `+eventColumns+` FROM events WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return eventDTO{}, notFound("小项不存在") } if err != nil { return eventDTO{}, internal(err) } return e, nil } func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error) { if in.SportID == 0 || strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { return eventDTO{}, badRequest("sportId、code、name 必填", nil) } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return eventDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, record_eligible, scored, multiplier, status, scheduled_at, venue, sort_order, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, in.SportID, in.Code, in.Name, orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"), orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"), in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"), nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark)) if isUniqueViolation(err) { return eventDTO{}, conflict("小项编码已存在") } if isForeignKeyViolation(err) { return eventDTO{}, badRequest("sportId 不存在", nil) } if err != nil { return eventDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return eventDTO{}, internal(err) } // 项目增删改要推给大屏,否则底栏/分项页拿不到新项目。 if err := s.bumpEvent(ctx, tx, id); err != nil { return eventDTO{}, err } if err := tx.Commit(); err != nil { return eventDTO{}, internal(err) } return s.getEvent(ctx, id) } func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventDTO, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return eventDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `UPDATE events SET sport_id = ?, code = ?, name = ?, event_type = ?, result_mode = ?, result_type = ?, compare = ?, record_eligible = ?, scored = ?, multiplier = ?, status = ?, scheduled_at = ?, venue = ?, sort_order = ?, remark = ? WHERE id = ?`, in.SportID, in.Code, in.Name, orDefault(in.EventType, "individual"), orDefault(in.ResultMode, "auto"), orDefault(in.ResultType, "none"), orDefault(in.Compare, "none"), in.RecordEligible, in.Scored, multiplierOrOne(in.Multiplier), orDefault(in.Status, "pending"), nullStr(in.ScheduledAt), nullStr(in.Venue), in.SortOrder, nullStr(in.Remark), id) if isUniqueViolation(err) { return eventDTO{}, conflict("小项编码已存在") } if isForeignKeyViolation(err) { return eventDTO{}, badRequest("sportId 不存在", nil) } if err != nil { return eventDTO{}, internal(err) } if err := requireChanges(res, "小项不存在"); err != nil { return eventDTO{}, err } if err := s.bumpEvent(ctx, tx, id); err != nil { return eventDTO{}, err } if err := tx.Commit(); err != nil { return eventDTO{}, internal(err) } return s.getEvent(ctx, id) } // bumpEvent 在写事务内推送小项变更;组别/通道的改动同样以本小项为载体。 func (s *server) bumpEvent(ctx context.Context, tx *sql.Tx, eventID int64) error { ev, err := eventByIDQ(ctx, tx, eventID) if err != nil { return err } return bumpVersion(ctx, tx, "events", eventID, "upsert", ev, middleware.User(ctx).ID) } func (s *server) deleteEvent(ctx context.Context, id int64) error { has, err := exists(ctx, s.ev, `SELECT 1 FROM entries WHERE event_id = ? LIMIT 1`, id) if err != nil { return internal(err) } if has { return conflict("小项已有报名数据,不能删除") } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return internal(err) } defer func() { _ = tx.Rollback() }() for _, stmt := range []string{ `DELETE FROM event_score_channels WHERE event_id = ?`, `DELETE FROM event_categories WHERE event_id = ?`, `DELETE FROM events WHERE id = ?`, } { if _, err := tx.ExecContext(ctx, stmt, id); err != nil { return internal(err) } } if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil { return err } if err := tx.Commit(); err != nil { return internal(err) } return nil } func multiplierOrOne(v float64) float64 { if v <= 0 { return 1 } return v } // ---------- categories ---------- func (s *server) listCategories(ctx context.Context, eventID int64) ([]categoryDTO, error) { rows, err := s.ev.QueryContext(ctx, `SELECT id, event_id, name, multiplier, gender, age_min, age_max, sort_order FROM event_categories WHERE event_id = ? ORDER BY sort_order, id`, eventID) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]categoryDTO, 0) for rows.Next() { var ( c categoryDTO gender sql.NullString ageMin, ageMax sql.NullInt64 ) if err := rows.Scan(&c.ID, &c.EventID, &c.Name, &c.Multiplier, &gender, &ageMin, &ageMax, &c.SortOrder); err != nil { return nil, internal(err) } c.Gender = strPtr(gender) c.AgeMin = intPtr(ageMin) c.AgeMax = intPtr(ageMax) out = append(out, c) } return out, rows.Err() } // replaceCategories 整体替换某小项的组别(devdocs/05 约定)。已有报名数据时拒绝替换, // 否则外键会把历史报名项一并带走。 func (s *server) replaceCategories(ctx context.Context, eventID int64, in []categoryDTO) ([]categoryDTO, error) { has, err := exists(ctx, s.ev, `SELECT 1 FROM entries e JOIN event_categories c ON c.id = e.category_id WHERE c.event_id = ? LIMIT 1`, eventID) if err != nil { return nil, internal(err) } if has { return nil, conflict("该项目已有报名数据,不能整体替换组别") } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return nil, internal(err) } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, `DELETE FROM event_categories WHERE event_id = ?`, eventID); err != nil { return nil, internal(err) } for i, c := range in { if strings.TrimSpace(c.Name) == "" { return nil, badRequest("组别 name 必填", i) } if _, err := tx.ExecContext(ctx, `INSERT INTO event_categories (event_id, name, multiplier, gender, age_min, age_max, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`, eventID, c.Name, multiplierOrOne(c.Multiplier), nullStr(c.Gender), nullInt(c.AgeMin), nullInt(c.AgeMax), c.SortOrder); err != nil { if isUniqueViolation(err) { return nil, conflict("组别名称重复:" + c.Name) } return nil, internal(err) } } if err := s.bumpEvent(ctx, tx, eventID); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, internal(err) } return s.listCategories(ctx, eventID) } // ---------- score channels ---------- func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, error) { rows, err := s.ev.QueryContext(ctx, `SELECT id, event_id, scope, basis, profile_id, sort_order FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]channelDTO, 0) for rows.Next() { var c channelDTO if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder); err != nil { return nil, internal(err) } out = append(out, c) } return out, rows.Err() } func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return nil, internal(err) } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, `DELETE FROM event_score_channels WHERE event_id = ?`, eventID); err != nil { return nil, internal(err) } for i, c := range in { if c.Scope != "athlete" && c.Scope != "team" { return nil, badRequest("通道 scope 只能是 athlete/team", i) } switch c.Basis { case "result", "manual", "aggregate": default: return nil, badRequest("通道 basis 非法", i) } if c.ProfileID == 0 { return nil, badRequest("通道 profileId 必填", i) } if _, err := tx.ExecContext(ctx, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) VALUES (?, ?, ?, ?, ?)`, eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder); err != nil { if isUniqueViolation(err) { return nil, conflict("同一 scope+basis 只能配置一个通道") } if isForeignKeyViolation(err) { return nil, badRequest("profileId 不存在", i) } return nil, internal(err) } } if err := s.bumpEvent(ctx, tx, eventID); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, internal(err) } return s.listChannels(ctx, eventID) } // ---------- entries ---------- const entryColumns = `e.id, e.event_id, e.category_id, e.team_id, e.athlete_id, e.member_names, e.bib, e.seed, e.status, e.remark, a.name, t.code, t.name, t.short_name, c.name` func scanEntry(row interface{ Scan(...any) error }) (entryDTO, error) { var ( e entryDTO athleteID sql.NullInt64 memberNames sql.NullString bib, seed, rem sql.NullString athleteName sql.NullString teamCode sql.NullString teamName sql.NullString shortName sql.NullString categoryName sql.NullString ) if err := row.Scan(&e.ID, &e.EventID, &e.CategoryID, &e.TeamID, &athleteID, &memberNames, &bib, &seed, &e.Status, &rem, &athleteName, &teamCode, &teamName, &shortName, &categoryName); err != nil { return e, err } e.AthleteID = i64Ptr(athleteID) e.MemberNames = parseNames(memberNames) e.Bib = strPtr(bib) e.Seed = strPtr(seed) e.Remark = strPtr(rem) e.AthleteName = strPtr(athleteName) e.TeamCode = teamCode.String e.TeamName = teamName.String e.ShortName = strPtr(shortName) e.CategoryName = categoryName.String return e, nil } const entrySelect = `SELECT ` + entryColumns + ` FROM entries e LEFT JOIN athletes a ON a.id = e.athlete_id JOIN teams t ON t.id = e.team_id JOIN event_categories c ON c.id = e.category_id` func (s *server) listEntries(ctx context.Context, eventID int64) ([]entryDTO, error) { rows, err := s.ev.QueryContext(ctx, entrySelect+` WHERE e.event_id = ? ORDER BY c.sort_order, e.id`, eventID) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]entryDTO, 0) for rows.Next() { e, err := scanEntry(rows) if err != nil { return nil, internal(err) } out = append(out, e) } return out, rows.Err() } // listEntriesWithResults 返回报名项及其成绩(无成绩时 result=null)。 func (s *server) listEntriesWithResults(ctx context.Context, eventID int64) ([]entryWithResultDTO, error) { entries, err := s.listEntries(ctx, eventID) if err != nil { return nil, err } results, err := s.resultsByEvent(ctx, eventID) if err != nil { return nil, err } byEntry := make(map[int64]resultDTO, len(results)) for _, r := range results { byEntry[r.EntryID] = r } out := make([]entryWithResultDTO, 0, len(entries)) for _, e := range entries { item := entryWithResultDTO{entryDTO: e} if r, ok := byEntry[e.ID]; ok { cp := r item.Result = &cp } out = append(out, item) } return out, nil } func (s *server) getEntry(ctx context.Context, id int64) (entryDTO, error) { e, err := scanEntry(s.ev.QueryRowContext(ctx, entrySelect+` WHERE e.id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return entryDTO{}, notFound("报名项不存在") } if err != nil { return entryDTO{}, internal(err) } return e, nil } func (s *server) createEntry(ctx context.Context, in entryDTO) (entryDTO, error) { if in.EventID == 0 || in.CategoryID == 0 || in.TeamID == 0 { return entryDTO{}, badRequest("eventId、categoryId、teamId 必填", nil) } res, err := s.ev.ExecContext(ctx, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, member_names, bib, seed, status, remark) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, in.EventID, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark)) if isUniqueViolation(err) { return entryDTO{}, conflict("该报名项已存在") } if isForeignKeyViolation(err) { return entryDTO{}, badRequest("eventId/categoryId/teamId/athleteId 不存在", nil) } if err != nil { return entryDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return entryDTO{}, internal(err) } return s.getEntry(ctx, id) } func (s *server) updateEntry(ctx context.Context, id int64, in entryDTO) (entryDTO, error) { res, err := s.ev.ExecContext(ctx, `UPDATE entries SET category_id = ?, team_id = ?, athlete_id = ?, member_names = ?, bib = ?, seed = ?, status = ?, remark = ? WHERE id = ?`, in.CategoryID, in.TeamID, nullI64(in.AthleteID), marshalNames(in.MemberNames), nullStr(in.Bib), nullStr(in.Seed), orDefault(in.Status, "registered"), nullStr(in.Remark), id) if isUniqueViolation(err) { return entryDTO{}, conflict("该报名项已存在") } if isForeignKeyViolation(err) { return entryDTO{}, badRequest("categoryId/teamId/athleteId 不存在", nil) } if err != nil { return entryDTO{}, internal(err) } if err := requireChanges(res, "报名项不存在"); err != nil { return entryDTO{}, err } return s.getEntry(ctx, id) } func (s *server) deleteEntry(ctx context.Context, id int64) error { // 报名项被成绩引用时不允许删除;业务数据一律软删(status=scratched)。 res, err := s.ev.ExecContext(ctx, `UPDATE entries SET status = 'scratched' WHERE id = ?`, id) if err != nil { return internal(err) } return requireChanges(res, "报名项不存在") }