package httpapi import ( "context" "database/sql" "errors" "fmt" "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" ) // 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移, // 仓储查询按实体分文件放在 httpapi 内,避免改动 store 的公开签名。 func strPtr(ns sql.NullString) *string { if !ns.Valid { return nil } v := ns.String return &v } func i64Ptr(ni sql.NullInt64) *int64 { if !ni.Valid { return nil } v := ni.Int64 return &v } func intPtr(ni sql.NullInt64) *int { if !ni.Valid { return nil } v := int(ni.Int64) return &v } func f64Ptr(nf sql.NullFloat64) *float64 { if !nf.Valid { return nil } v := nf.Float64 return &v } func nullStr(p *string) any { if p == nil || *p == "" { return nil } return *p } func nullI64(p *int64) any { if p == nil { return nil } return *p } func nullInt(p *int) any { if p == nil { return nil } return *p } func nullF64(p *float64) any { if p == nil { return nil } return *p } func lastInsertID(res sql.Result) (int64, error) { id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("获取自增 id: %w", err) } return id, nil } // requireChanges 把「影响行数为 0」翻译成 404。 func requireChanges(res sql.Result, message string) error { n, err := res.RowsAffected() if err != nil { return internal(err) } if n == 0 { return notFound(message) } return nil } func isUniqueViolation(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "unique") || strings.Contains(msg, "constraint failed") } func isForeignKeyViolation(err error) bool { if err == nil { return false } return strings.Contains(strings.ToLower(err.Error()), "foreign key") } func exists(ctx context.Context, db *sql.DB, query string, args ...any) (bool, error) { var one int err := db.QueryRowContext(ctx, query, args...).Scan(&one) switch { case errors.Is(err, sql.ErrNoRows): return false, nil case err != nil: return false, err } return true, nil } // ---------- teams ---------- const teamColumns = `id, code, name, short_name, logo_path, color, sort_order, active` func scanTeam(row interface{ Scan(...any) error }) (teamDTO, error) { var ( t teamDTO shortName, logoPath, color sql.NullString ) err := row.Scan(&t.ID, &t.Code, &t.Name, &shortName, &logoPath, &color, &t.SortOrder, &t.Active) t.ShortName = strPtr(shortName) t.LogoPath = strPtr(logoPath) t.Color = strPtr(color) return t, err } func (s *server) listTeams(ctx context.Context) ([]teamDTO, error) { rows, err := s.ev.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]teamDTO, 0) for rows.Next() { t, err := scanTeam(rows) if err != nil { return nil, internal(err) } out = append(out, t) } return out, rows.Err() } func (s *server) teamMap(ctx context.Context) (map[int64]teamDTO, error) { return teamMapQ(ctx, s.ev) } // teamMapQ 与 teamMap 同义,但可在事务内使用,保证榜单快照与写操作看到同一状态。 func teamMapQ(ctx context.Context, q queryer) (map[int64]teamDTO, error) { rows, err := q.QueryContext(ctx, `SELECT `+teamColumns+` FROM teams ORDER BY sort_order, code`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make(map[int64]teamDTO) for rows.Next() { t, err := scanTeam(rows) if err != nil { return nil, internal(err) } out[t.ID] = t } return out, rows.Err() } func (s *server) getTeam(ctx context.Context, id int64) (teamDTO, error) { return teamByIDQ(ctx, s.ev, id) } // teamByIDQ 在给定连接/事务内读取单位,供写事务内构造 change_log 载荷复用。 func teamByIDQ(ctx context.Context, q queryer, id int64) (teamDTO, error) { t, err := scanTeam(q.QueryRowContext(ctx, `SELECT `+teamColumns+` FROM teams WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return teamDTO{}, notFound("单位不存在") } if err != nil { return teamDTO{}, internal(err) } return t, nil } func (s *server) createTeam(ctx context.Context, in teamDTO) (teamDTO, error) { if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { return teamDTO{}, badRequest("code 与 name 必填", nil) } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return teamDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `INSERT INTO teams (code, name, short_name, logo_path, color, sort_order, active) VALUES (?, ?, ?, ?, ?, ?, ?)`, in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active) if isUniqueViolation(err) { return teamDTO{}, conflict("单位编码已存在") } if err != nil { return teamDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return teamDTO{}, internal(err) } team, err := teamByIDQ(ctx, tx, id) if err != nil { return teamDTO{}, err } // 单位改名/新建要推给大屏,否则榜单里的单位名不会刷新。 if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil { return teamDTO{}, err } if err := tx.Commit(); err != nil { return teamDTO{}, internal(err) } return team, nil } func (s *server) updateTeam(ctx context.Context, id int64, in teamDTO) (teamDTO, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return teamDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `UPDATE teams SET code = ?, name = ?, short_name = ?, logo_path = ?, color = ?, sort_order = ?, active = ? WHERE id = ?`, in.Code, in.Name, nullStr(in.ShortName), nullStr(in.LogoPath), nullStr(in.Color), in.SortOrder, in.Active, id) if isUniqueViolation(err) { return teamDTO{}, conflict("单位编码已存在") } if err != nil { return teamDTO{}, internal(err) } if err := requireChanges(res, "单位不存在"); err != nil { return teamDTO{}, err } team, err := teamByIDQ(ctx, tx, id) if err != nil { return teamDTO{}, err } if err := bumpVersion(ctx, tx, "teams", id, "upsert", team, middleware.User(ctx).ID); err != nil { return teamDTO{}, err } if err := tx.Commit(); err != nil { return teamDTO{}, internal(err) } return team, nil } func (s *server) deleteTeam(ctx context.Context, id int64) error { // 业务数据软删除:置 active=0,保留历史成绩与流水。 tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `UPDATE teams SET active = 0 WHERE id = ?`, id) if err != nil { return internal(err) } if err := requireChanges(res, "单位不存在"); err != nil { return err } if err := bumpVersion(ctx, tx, "teams", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil { return err } return tx.Commit() } // ---------- athletes ---------- const athleteColumns = `id, name, gender, team_id, employee_no, active` func scanAthlete(row interface{ Scan(...any) error }) (athleteDTO, error) { var ( a athleteDTO gender, empNo sql.NullString teamID sql.NullInt64 ) err := row.Scan(&a.ID, &a.Name, &gender, &teamID, &empNo, &a.Active) a.Gender = strPtr(gender) a.EmployeeNo = strPtr(empNo) if teamID.Valid { a.TeamID = teamID.Int64 } return a, err } func (s *server) listAthletes(ctx context.Context, teamID int64) ([]athleteDTO, error) { query := `SELECT ` + athleteColumns + ` FROM athletes` args := []any{} if teamID > 0 { query += ` WHERE team_id = ?` args = append(args, teamID) } query += ` ORDER BY name` rows, err := s.ev.QueryContext(ctx, query, args...) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]athleteDTO, 0) for rows.Next() { a, err := scanAthlete(rows) if err != nil { return nil, internal(err) } out = append(out, a) } return out, rows.Err() } func (s *server) getAthlete(ctx context.Context, id int64) (athleteDTO, error) { a, err := scanAthlete(s.ev.QueryRowContext(ctx, `SELECT `+athleteColumns+` FROM athletes WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return athleteDTO{}, notFound("运动员不存在") } if err != nil { return athleteDTO{}, internal(err) } return a, nil } func (s *server) createAthlete(ctx context.Context, in athleteDTO) (athleteDTO, error) { if strings.TrimSpace(in.Name) == "" || in.TeamID == 0 { return athleteDTO{}, badRequest("name 与 teamId 必填", nil) } res, err := s.ev.ExecContext(ctx, `INSERT INTO athletes (name, gender, team_id, employee_no, active) VALUES (?, ?, ?, ?, ?)`, in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active) if isForeignKeyViolation(err) { return athleteDTO{}, badRequest("teamId 不存在", nil) } if err != nil { return athleteDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return athleteDTO{}, internal(err) } return s.getAthlete(ctx, id) } func (s *server) updateAthlete(ctx context.Context, id int64, in athleteDTO) (athleteDTO, error) { res, err := s.ev.ExecContext(ctx, `UPDATE athletes SET name = ?, gender = ?, team_id = ?, employee_no = ?, active = ? WHERE id = ?`, in.Name, nullStr(in.Gender), in.TeamID, nullStr(in.EmployeeNo), in.Active, id) if isForeignKeyViolation(err) { return athleteDTO{}, badRequest("teamId 不存在", nil) } if err != nil { return athleteDTO{}, internal(err) } if err := requireChanges(res, "运动员不存在"); err != nil { return athleteDTO{}, err } return s.getAthlete(ctx, id) } func (s *server) deleteAthlete(ctx context.Context, id int64) error { res, err := s.ev.ExecContext(ctx, `UPDATE athletes SET active = 0 WHERE id = ?`, id) if err != nil { return internal(err) } return requireChanges(res, "运动员不存在") } // ---------- sports ---------- const sportColumns = `id, code, name, short_name, default_profile_id, sort_order, active` func scanSport(row interface{ Scan(...any) error }) (sportDTO, error) { var ( sp sportDTO shortName sql.NullString defaultProfileID sql.NullInt64 ) err := row.Scan(&sp.ID, &sp.Code, &sp.Name, &shortName, &defaultProfileID, &sp.SortOrder, &sp.Active) sp.ShortName = strPtr(shortName) sp.DefaultProfileID = i64Ptr(defaultProfileID) return sp, err } func (s *server) listSports(ctx context.Context) ([]sportDTO, error) { rows, err := s.ev.QueryContext(ctx, `SELECT `+sportColumns+` FROM sports ORDER BY sort_order, code`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]sportDTO, 0) for rows.Next() { sp, err := scanSport(rows) if err != nil { return nil, internal(err) } out = append(out, sp) } return out, rows.Err() } func (s *server) getSport(ctx context.Context, id int64) (sportDTO, error) { sp, err := scanSport(s.ev.QueryRowContext(ctx, `SELECT `+sportColumns+` FROM sports WHERE id = ?`, id)) if errors.Is(err, sql.ErrNoRows) { return sportDTO{}, notFound("大项不存在") } if err != nil { return sportDTO{}, internal(err) } return sp, nil } func (s *server) createSport(ctx context.Context, in sportDTO) (sportDTO, error) { if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { return sportDTO{}, badRequest("code 与 name 必填", nil) } res, err := s.ev.ExecContext(ctx, `INSERT INTO sports (code, name, short_name, default_profile_id, sort_order, active) VALUES (?, ?, ?, ?, ?, ?)`, in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active) if isUniqueViolation(err) { return sportDTO{}, conflict("大项编码已存在") } if err != nil { return sportDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return sportDTO{}, internal(err) } return s.getSport(ctx, id) } func (s *server) updateSport(ctx context.Context, id int64, in sportDTO) (sportDTO, error) { res, err := s.ev.ExecContext(ctx, `UPDATE sports SET code = ?, name = ?, short_name = ?, default_profile_id = ?, sort_order = ?, active = ? WHERE id = ?`, in.Code, in.Name, nullStr(in.ShortName), nullI64(in.DefaultProfileID), in.SortOrder, in.Active, id) if isUniqueViolation(err) { return sportDTO{}, conflict("大项编码已存在") } if err != nil { return sportDTO{}, internal(err) } if err := requireChanges(res, "大项不存在"); err != nil { return sportDTO{}, err } return s.getSport(ctx, id) } func (s *server) deleteSport(ctx context.Context, id int64) error { // 大项有子项目时不允许软删,避免项目挂在不可见的大项下。 has, err := exists(ctx, s.ev, `SELECT 1 FROM events WHERE sport_id = ? LIMIT 1`, id) if err != nil { return internal(err) } if has { return conflict("大项下仍有小项,不能删除") } res, err := s.ev.ExecContext(ctx, `UPDATE sports SET active = 0 WHERE id = ?`, id) if err != nil { return internal(err) } return requireChanges(res, "大项不存在") } // ---------- scoring profiles ---------- func (s *server) listProfiles(ctx context.Context) ([]profileDTO, error) { rows, err := s.ev.QueryContext(ctx, `SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero FROM scoring_profiles ORDER BY code`) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]profileDTO, 0) for rows.Next() { var p profileDTO if err := rows.Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero); err != nil { return nil, internal(err) } out = append(out, p) } if err := rows.Err(); err != nil { return nil, internal(err) } for i := range out { rules, err := s.profileRules(ctx, out[i].ID) if err != nil { return nil, err } out[i].Rules = rules } return out, nil } func (s *server) profileRules(ctx context.Context, profileID int64) ([]profileRuleDTO, error) { rows, err := s.ev.QueryContext(ctx, `SELECT rank, points FROM scoring_profile_rules WHERE profile_id = ? ORDER BY rank`, profileID) if err != nil { return nil, internal(err) } defer func() { _ = rows.Close() }() out := make([]profileRuleDTO, 0) for rows.Next() { var r profileRuleDTO if err := rows.Scan(&r.Rank, &r.Points); err != nil { return nil, internal(err) } out = append(out, r) } return out, rows.Err() } func (s *server) getProfile(ctx context.Context, id int64) (profileDTO, error) { var p profileDTO err := s.ev.QueryRowContext(ctx, `SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero FROM scoring_profiles WHERE id = ?`, id). Scan(&p.ID, &p.Code, &p.Name, &p.TieMode, &p.RecordBonusMode, &p.AwardRanks, &p.LastPlaceZero) if errors.Is(err, sql.ErrNoRows) { return profileDTO{}, notFound("积分模板不存在") } if err != nil { return profileDTO{}, internal(err) } rules, err := s.profileRules(ctx, p.ID) if err != nil { return profileDTO{}, err } p.Rules = rules return p, nil } func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) { if strings.TrimSpace(in.Code) == "" || strings.TrimSpace(in.Name) == "" { return profileDTO{}, badRequest("code 与 name 必填", nil) } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return profileDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `INSERT INTO scoring_profiles (code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero) VALUES (?, ?, ?, ?, ?, ?)`, in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero) if isUniqueViolation(err) { return profileDTO{}, conflict("积分模板编码已存在") } if err != nil { return profileDTO{}, internal(err) } id, err := lastInsertID(res) if err != nil { return profileDTO{}, internal(err) } if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil { return profileDTO{}, err } if err := tx.Commit(); err != nil { return profileDTO{}, internal(err) } return s.getProfile(ctx, id) } func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (profileDTO, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return profileDTO{}, internal(err) } defer func() { _ = tx.Rollback() }() res, err := tx.ExecContext(ctx, `UPDATE scoring_profiles SET code = ?, name = ?, tie_mode = ?, record_bonus_mode = ?, award_ranks = ?, last_place_zero = ?, updated_at = datetime('now') WHERE id = ?`, in.Code, in.Name, orDefault(in.TieMode, "skip"), orDefault(in.RecordBonusMode, "max"), in.AwardRanks, in.LastPlaceZero, id) if isUniqueViolation(err) { return profileDTO{}, conflict("积分模板编码已存在") } if err != nil { return profileDTO{}, internal(err) } if err := requireChanges(res, "积分模板不存在"); err != nil { return profileDTO{}, err } if in.Rules != nil { if err := replaceProfileRules(ctx, tx, id, in.Rules); err != nil { return profileDTO{}, err } } if err := tx.Commit(); err != nil { return profileDTO{}, internal(err) } return s.getProfile(ctx, id) } func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules []profileRuleDTO) error { if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil { return internal(err) } for _, r := range rules { if r.Rank < 1 { return badRequest("积分名次必须 >= 1", r) } if _, err := tx.ExecContext(ctx, `INSERT INTO scoring_profile_rules (profile_id, rank, points) VALUES (?, ?, ?)`, profileID, r.Rank, r.Points); err != nil { return internal(err) } } return nil } func orDefault(v, def string) string { if strings.TrimSpace(v) == "" { return def } return v }