From 8e271559ae38755748e06e857aa481068e1657a0 Mon Sep 17 00:00:00 2001 From: Samuel Tariku Date: Thu, 8 May 2025 03:38:19 +0300 Subject: [PATCH] fix odd filtering --- cmd/main.go | 2 +- internal/domain/oddres.go | 50 ++++++ internal/domain/result.go | 176 -------------------- internal/domain/resultres.go | 152 +++++++++++++++++ internal/services/odds/service.go | 242 +++++++++++++++++++++++----- internal/services/result/service.go | 1 - 6 files changed, 402 insertions(+), 221 deletions(-) create mode 100644 internal/domain/oddres.go create mode 100644 internal/domain/resultres.go diff --git a/cmd/main.go b/cmd/main.go index f6fd907..d9b1089 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -69,7 +69,7 @@ func main() { userSvc := user.NewService(store, store, mockSms, mockEmail) eventSvc := event.New(cfg.Bet365Token, store) - oddsSvc := odds.New(cfg.Bet365Token, store) + oddsSvc := odds.New(store, cfg, logger) resultSvc := result.NewService(store, cfg, logger) ticketSvc := ticket.NewService(store) betSvc := bet.NewService(store) diff --git a/internal/domain/oddres.go b/internal/domain/oddres.go new file mode 100644 index 0000000..8c2707d --- /dev/null +++ b/internal/domain/oddres.go @@ -0,0 +1,50 @@ +package domain + +import "encoding/json" + +type BaseNonLiveOddResponse struct { + Success int `json:"success"` + Results []json.RawMessage `json:"results"` +} + +type OddsSection struct { + UpdatedAt string `json:"updated_at"` + Sp map[string]OddsMarket `json:"sp"` +} + +type OddsMarket struct { + ID json.Number `json:"id"` + Name string `json:"name"` + Odds []json.RawMessage `json:"odds"` + Header string `json:"header,omitempty"` + Handicap string `json:"handicap,omitempty"` + Open int64 `json:"open,omitempty"` +} + +type FootballOddsResponse struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + Main OddsSection `json:"main"` + AsianLines OddsSection `json:"asian_lines"` + Goals OddsSection `json:"goals"` + Half OddsSection `json:"half"` +} + +type BasketballOddsResponse struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + Main OddsSection `json:"main"` + HalfProps OddsSection `json:"half_props"` + QuarterProps OddsSection `json:"quarter_props"` + TeamProps OddsSection `json:"team_props"` + Others []OddsSection `json:"others"` +} + +type IceHockeyOddsResponse struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + Main OddsSection `json:"main"` + Main2 OddsSection `json:"main_2"` + FirstPeriod OddsSection `json:"1st_period"` + Others []OddsSection `json:"others"` +} diff --git a/internal/domain/result.go b/internal/domain/result.go index dacd634..44861d2 100644 --- a/internal/domain/result.go +++ b/internal/domain/result.go @@ -1,185 +1,9 @@ package domain import ( - "encoding/json" "time" ) -type BaseResultResponse struct { - Success int `json:"success"` - Results []json.RawMessage `json:"results"` -} -type FootballResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League struct { - ID string `json:"id"` - Name string `json:"name"` - CC string `json:"cc"` - } `json:"league"` - Home struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"home"` - Away struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"away"` - SS string `json:"ss"` - Scores struct { - FirstHalf Score `json:"1"` - SecondHalf Score `json:"2"` - } `json:"scores"` - Stats struct { - Attacks []string `json:"attacks"` - Corners []string `json:"corners"` - DangerousAttacks []string `json:"dangerous_attacks"` - Goals []string `json:"goals"` - OffTarget []string `json:"off_target"` - OnTarget []string `json:"on_target"` - Penalties []string `json:"penalties"` - PossessionRT []string `json:"possession_rt"` - RedCards []string `json:"redcards"` - Substitutions []string `json:"substitutions"` - YellowCards []string `json:"yellowcards"` - } `json:"stats"` - Extra struct { - HomePos string `json:"home_pos"` - AwayPos string `json:"away_pos"` - StadiumData map[string]string `json:"stadium_data"` - Round string `json:"round"` - } `json:"extra"` - Events []map[string]string `json:"events"` - HasLineup int `json:"has_lineup"` - ConfirmedAt string `json:"confirmed_at"` - Bet365ID string `json:"bet365_id"` -} - -type BasketballResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League struct { - ID string `json:"id"` - Name string `json:"name"` - CC string `json:"cc"` - } `json:"league"` - Home struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"home"` - Away struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"away"` - SS string `json:"ss"` - Scores struct { - FirstQuarter Score `json:"1"` - SecondQuarter Score `json:"2"` - FirstHalf Score `json:"3"` - ThirdQuarter Score `json:"4"` - FourthQuarter Score `json:"5"` - TotalScore Score `json:"7"` - } `json:"scores"` - Stats struct { - TwoPoints []string `json:"2points"` - ThreePoints []string `json:"3points"` - BiggestLead []string `json:"biggest_lead"` - Fouls []string `json:"fouls"` - FreeThrows []string `json:"free_throws"` - FreeThrowRate []string `json:"free_throws_rate"` - LeadChanges []string `json:"lead_changes"` - MaxpointsInarow []string `json:"maxpoints_inarow"` - Possession []string `json:"possession"` - SuccessAttempts []string `json:"success_attempts"` - TimeSpendInLead []string `json:"timespent_inlead"` - Timeuts []string `json:"time_outs"` - } `json:"stats"` - Extra struct { - HomePos string `json:"home_pos"` - AwayPos string `json:"away_pos"` - AwayManager map[string]string `json:"away_manager"` - HomeManager map[string]string `json:"home_manager"` - NumberOfPeriods string `json:"numberofperiods"` - PeriodLength string `json:"periodlength"` - StadiumData map[string]string `json:"stadium_data"` - Length string `json:"length"` - Round string `json:"round"` - } `json:"extra"` - Events []map[string]string `json:"events"` - HasLineup int `json:"has_lineup"` - ConfirmedAt string `json:"confirmed_at"` - Bet365ID string `json:"bet365_id"` -} -type IceHockeyResultResponse struct { - ID string `json:"id"` - SportID string `json:"sport_id"` - Time string `json:"time"` - TimeStatus string `json:"time_status"` - League struct { - ID string `json:"id"` - Name string `json:"name"` - CC string `json:"cc"` - } `json:"league"` - Home struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"home"` - Away struct { - ID string `json:"id"` - Name string `json:"name"` - ImageID string `json:"image_id"` - CC string `json:"cc"` - } `json:"away"` - SS string `json:"ss"` - Scores struct { - FirstPeriod Score `json:"1"` - SecondPeriod Score `json:"2"` - ThirdPeriod Score `json:"3"` - TotalScore Score `json:"5"` - } `json:"scores"` - - Stats struct { - Shots []string `json:"shots"` - Penalties []string `json:"penalties"` - GoalsOnPowerPlay []string `json:"goals_on_power_play"` - SSeven []string `json:"s7"` - } `json:"stats"` - Extra struct { - HomePos string `json:"home_pos"` - AwayPos string `json:"away_pos"` - AwayManager map[string]string `json:"away_manager"` - HomeManager map[string]string `json:"home_manager"` - NumberOfPeriods string `json:"numberofperiods"` - PeriodLength string `json:"periodlength"` - StadiumData map[string]string `json:"stadium_data"` - Length string `json:"length"` - Round string `json:"round"` - } `json:"extra"` - Events []map[string]string `json:"events"` - HasLineup int `json:"has_lineup"` - ConfirmedAt string `json:"confirmed_at"` - Bet365ID string `json:"bet365_id"` -} - -type Score struct { - Home string `json:"home"` - Away string `json:"away"` -} - type MarketConfig struct { Sport string MarketCategories map[string]bool diff --git a/internal/domain/resultres.go b/internal/domain/resultres.go new file mode 100644 index 0000000..b69a6a9 --- /dev/null +++ b/internal/domain/resultres.go @@ -0,0 +1,152 @@ +package domain + +import ( + "encoding/json" +) + +type BaseResultResponse struct { + Success int `json:"success"` + Results []json.RawMessage `json:"results"` +} + +type League struct { + ID string `json:"id"` + Name string `json:"name"` + CC string `json:"cc"` +} + +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + ImageID string `json:"image_id"` + CC string `json:"cc"` +} + +type Score struct { + Home string `json:"home"` + Away string `json:"away"` +} + +type FootballResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League League `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstHalf Score `json:"1"` + SecondHalf Score `json:"2"` + } `json:"scores"` + Stats struct { + Attacks []string `json:"attacks"` + Corners []string `json:"corners"` + DangerousAttacks []string `json:"dangerous_attacks"` + Goals []string `json:"goals"` + OffTarget []string `json:"off_target"` + OnTarget []string `json:"on_target"` + Penalties []string `json:"penalties"` + PossessionRT []string `json:"possession_rt"` + RedCards []string `json:"redcards"` + Substitutions []string `json:"substitutions"` + YellowCards []string `json:"yellowcards"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + StadiumData map[string]string `json:"stadium_data"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} + +type BasketballResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League League `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstQuarter Score `json:"1"` + SecondQuarter Score `json:"2"` + FirstHalf Score `json:"3"` + ThirdQuarter Score `json:"4"` + FourthQuarter Score `json:"5"` + TotalScore Score `json:"7"` + } `json:"scores"` + Stats struct { + TwoPoints []string `json:"2points"` + ThreePoints []string `json:"3points"` + BiggestLead []string `json:"biggest_lead"` + Fouls []string `json:"fouls"` + FreeThrows []string `json:"free_throws"` + FreeThrowRate []string `json:"free_throws_rate"` + LeadChanges []string `json:"lead_changes"` + MaxpointsInarow []string `json:"maxpoints_inarow"` + Possession []string `json:"possession"` + SuccessAttempts []string `json:"success_attempts"` + TimeSpendInLead []string `json:"timespent_inlead"` + Timeuts []string `json:"time_outs"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + AwayManager map[string]string `json:"away_manager"` + HomeManager map[string]string `json:"home_manager"` + NumberOfPeriods string `json:"numberofperiods"` + PeriodLength string `json:"periodlength"` + StadiumData map[string]string `json:"stadium_data"` + Length string `json:"length"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} +type IceHockeyResultResponse struct { + ID string `json:"id"` + SportID string `json:"sport_id"` + Time string `json:"time"` + TimeStatus string `json:"time_status"` + League League `json:"league"` + Home Team `json:"home"` + Away Team `json:"away"` + SS string `json:"ss"` + Scores struct { + FirstPeriod Score `json:"1"` + SecondPeriod Score `json:"2"` + ThirdPeriod Score `json:"3"` + TotalScore Score `json:"5"` + } `json:"scores"` + + Stats struct { + Shots []string `json:"shots"` + Penalties []string `json:"penalties"` + GoalsOnPowerPlay []string `json:"goals_on_power_play"` + SSeven []string `json:"s7"` + } `json:"stats"` + Extra struct { + HomePos string `json:"home_pos"` + AwayPos string `json:"away_pos"` + AwayManager map[string]string `json:"away_manager"` + HomeManager map[string]string `json:"home_manager"` + NumberOfPeriods string `json:"numberofperiods"` + PeriodLength string `json:"periodlength"` + StadiumData map[string]string `json:"stadium_data"` + Length string `json:"length"` + Round string `json:"round"` + } `json:"extra"` + Events []map[string]string `json:"events"` + HasLineup int `json:"has_lineup"` + ConfirmedAt string `json:"confirmed_at"` + Bet365ID string `json:"bet365_id"` +} diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go index c60e8c6..c42bab7 100644 --- a/internal/services/odds/service.go +++ b/internal/services/odds/service.go @@ -3,26 +3,37 @@ package odds import ( "context" "encoding/json" + "errors" + "fmt" "io" "log" + "log/slog" "net/http" "strconv" "time" + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" ) type ServiceImpl struct { - token string - store *repository.Store + store *repository.Store + config *config.Config + logger *slog.Logger + client *http.Client } -func New(token string, store *repository.Store) *ServiceImpl { - return &ServiceImpl{token: token, store: store} +func New(store *repository.Store, cfg *config.Config, logger *slog.Logger) *ServiceImpl { + return &ServiceImpl{ + store: store, + config: cfg, + logger: logger, + client: &http.Client{Timeout: 10 * time.Second}, + } } -// TODO this is only getting the main odds, this must be fixed +// TODO Add the optimization to get 10 events at the same time func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { eventIDs, err := s.store.GetAllUpcomingEvents(ctx) if err != nil { @@ -30,60 +41,208 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { return err } + var errs []error + for _, event := range eventIDs { // time.Sleep(3 * time.Second) //This will restrict the fetching to 1200 requests per hour - eventID := event.ID - prematchURL := "https://api.b365api.com/v3/bet365/prematch?token=" + s.token + "&FI=" + eventID - log.Printf("📡 Fetching prematch odds for event ID: %s", eventID) - resp, err := http.Get(prematchURL) + eventID, err := strconv.ParseInt(event.ID, 10, 64) if err != nil { - log.Printf("❌ Failed to fetch prematch odds for event %s: %v", eventID, err) + s.logger.Error("Failed to parse event id") + return err + } + + url := fmt.Sprintf("https://api.b365api.com/v3/bet365/prematch?token=%s&FI=%d", s.config.Bet365Token, eventID) + + log.Printf("📡 Fetching prematch odds for event ID: %d", eventID) + + resp, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + log.Printf("❌ Failed to fetch prematch odds for event %d: %v", eventID, err) continue } + defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) - var oddsData struct { - Success int `json:"success"` - Results []struct { - EventID string `json:"event_id"` - FI string `json:"FI"` - Main OddsSection `json:"main"` - } `json:"results"` - } + var oddsData domain.BaseNonLiveOddResponse + if err := json.Unmarshal(body, &oddsData); err != nil || oddsData.Success != 1 || len(oddsData.Results) == 0 { - log.Printf("❌ Invalid prematch data for event %s", eventID) + log.Printf("❌ Invalid prematch data for event %d", eventID) continue } - result := oddsData.Results[0] - finalID := result.EventID - if finalID == "" { - finalID = result.FI + sportID, err := strconv.ParseInt(event.SportID, 10, 64) + + switch sportID { + case domain.FOOTBALL: + if err := s.parseFootball(ctx, oddsData.Results[0]); err != nil { + s.logger.Error("Failed to insert football odd") + errs = append(errs, err) + } + case domain.BASKETBALL: + if err := s.parseBasketball(ctx, oddsData.Results[0]); err != nil { + s.logger.Error("Failed to insert basketball odd") + errs = append(errs, err) + } + case domain.ICE_HOCKEY: + if err := s.parseIceHockey(ctx, oddsData.Results[0]); err != nil { + s.logger.Error("Failed to insert ice hockey odd") + errs = append(errs, err) + } + } - if finalID == "" { - log.Printf("⚠️ Skipping event %s with no valid ID", eventID) - continue - } - s.storeSection(ctx, finalID, result.FI, "main", result.Main) + + // result := oddsData.Results[0] + } return nil } -func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName string, section OddsSection) { +func (s *ServiceImpl) parseFootball(ctx context.Context, res json.RawMessage) error { + var footballRes domain.FootballOddsResponse + if err := json.Unmarshal(res, &footballRes); err != nil { + s.logger.Error("Failed to unmarshal football result", "error", err) + return err + } + if footballRes.EventID == "" && footballRes.FI == "" { + s.logger.Error("Skipping result with no valid Event ID") + return fmt.Errorf("Skipping result with no valid Event ID") + } + sections := map[string]domain.OddsSection{ + "main": footballRes.Main, + "asian_lines": footballRes.AsianLines, + "goals": footballRes.Goals, + "half": footballRes.Half, + } + + var errs []error + + for oddCategory, section := range sections { + if err := s.storeSection(ctx, footballRes.EventID, footballRes.FI, oddCategory, section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (s *ServiceImpl) parseBasketball(ctx context.Context, res json.RawMessage) error { + var basketballRes domain.BasketballOddsResponse + if err := json.Unmarshal(res, &basketballRes); err != nil { + s.logger.Error("Failed to unmarshal football result", "error", err) + return err + } + if basketballRes.EventID == "" && basketballRes.FI == "" { + s.logger.Error("Skipping result with no valid Event ID") + return fmt.Errorf("Skipping result with no valid Event ID") + } + sections := map[string]domain.OddsSection{ + "main": basketballRes.Main, + "half_props": basketballRes.HalfProps, + "quarter_props": basketballRes.QuarterProps, + "team_props": basketballRes.TeamProps, + } + + var errs []error + + for oddCategory, section := range sections { + if err := s.storeSection(ctx, basketballRes.EventID, basketballRes.FI, oddCategory, section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + for _, section := range basketballRes.Others { + if err := s.storeSection(ctx, basketballRes.EventID, basketballRes.FI, "others", section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} +func (s *ServiceImpl) parseIceHockey(ctx context.Context, res json.RawMessage) error { + var iceHockeyRes domain.IceHockeyOddsResponse + if err := json.Unmarshal(res, &iceHockeyRes); err != nil { + s.logger.Error("Failed to unmarshal football result", "error", err) + return err + } + if iceHockeyRes.EventID == "" && iceHockeyRes.FI == "" { + s.logger.Error("Skipping result with no valid Event ID") + return fmt.Errorf("Skipping result with no valid Event ID") + } + sections := map[string]domain.OddsSection{ + "main": iceHockeyRes.Main, + "main_2": iceHockeyRes.Main2, + "1st_period": iceHockeyRes.FirstPeriod, + } + + var errs []error + + for oddCategory, section := range sections { + if err := s.storeSection(ctx, iceHockeyRes.EventID, iceHockeyRes.FI, oddCategory, section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + for _, section := range iceHockeyRes.Others { + if err := s.storeSection(ctx, iceHockeyRes.EventID, iceHockeyRes.FI, "others", section); err != nil { + s.logger.Error("Skipping result with no valid Event ID") + errs = append(errs, err) + continue + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName string, section domain.OddsSection) error { if len(section.Sp) == 0 { - return + return nil } updatedAtUnix, _ := strconv.ParseInt(section.UpdatedAt, 10, 64) updatedAt := time.Unix(updatedAtUnix, 0) + var errs []error for marketType, market := range section.Sp { if len(market.Odds) == 0 { continue } + + marketID, err := market.ID.Int64() + if err != nil { + s.logger.Error("Invalid market id", "marketID", marketID) + errs = append(errs, err) + continue + } + + isSupported, ok := domain.SupportedMarkets[marketID] + + if !ok || !isSupported { + s.logger.Info("Unsupported market_id", "marketID", marketID) + continue + } + marketRecord := domain.Market{ EventID: eventID, FI: fi, @@ -95,21 +254,18 @@ func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName Odds: market.Odds, } - _ = s.store.SaveNonLiveMarket(ctx, marketRecord) + err = s.store.SaveNonLiveMarket(ctx, marketRecord) + if err != nil { + s.logger.Error("failed to save market", "market_id", market.ID, "error", err) + errs = append(errs, fmt.Errorf("market %s: %w", market.ID, err)) + continue + } } -} -type OddsMarket struct { - ID json.Number `json:"id"` - Name string `json:"name"` - Odds []json.RawMessage `json:"odds"` - Header string `json:"header,omitempty"` - Handicap string `json:"handicap,omitempty"` -} - -type OddsSection struct { - UpdatedAt string `json:"updated_at"` - Sp map[string]OddsMarket `json:"sp"` + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil } func (s *ServiceImpl) GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) { diff --git a/internal/services/result/service.go b/internal/services/result/service.go index 74983cb..c959f00 100644 --- a/internal/services/result/service.go +++ b/internal/services/result/service.go @@ -66,7 +66,6 @@ func (s *Service) FetchAndProcessResults(ctx context.Context) error { s.logger.Error("Sport ID is invalid", "event_id", outcome.EventID, "error", err) continue } - result, err := s.fetchResult(ctx, outcome.EventID, outcome.OddID, outcome.MarketID, sportID, outcome) if err != nil { s.logger.Error("Failed to fetch result", "event_id", outcome.EventID, "error", err)