From 1d6a533f7ee83610308ec53530982b760ce9d9bc Mon Sep 17 00:00:00 2001 From: OneTap Technologies Date: Fri, 11 Apr 2025 13:57:32 +0300 Subject: [PATCH] addign odd --- cmd/main.go | 10 +- db/migrations/000001_fortune.up.sql | 42 ++++-- db/query/odds.sql | 39 ++++- gen/db/models.go | 32 ++-- gen/db/odds.sql.go | 69 ++++++--- go.mod | 2 +- internal/domain/odds.go | 27 ++-- internal/repository/odds.go | 94 +++++++----- internal/services/odds/service.go | 221 ++++++++++++++-------------- 9 files changed, 318 insertions(+), 218 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index ac6ca85..6b945d5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,6 +1,7 @@ package main import ( + // "context" "fmt" "log/slog" "os" @@ -35,39 +36,34 @@ import ( // @name Authorization // @BasePath / func main() { - // Load config cfg, err := config.NewConfig() if err != nil { slog.Error("❌ Config error:", "err", err) os.Exit(1) } - // Connect to database db, _, err := repository.OpenDB(cfg.DbUrl) if err != nil { fmt.Println("❌ Database error:", err) os.Exit(1) } - // Init core services logger := customlogger.NewLogger(cfg.Env, cfg.LogLevel) store := repository.NewStore(db) v := customvalidator.NewCustomValidator(validator.New()) - // Auth and user services authSvc := authentication.NewService(store, store, cfg.RefreshExpiry) mockSms := mocksms.NewMockSMS() mockEmail := mockemail.NewMockEmail() userSvc := user.NewService(store, store, mockSms, mockEmail) - // 🎯 Event & Odds fetching services eventSvc := event.New(cfg.Bet365Token, store) oddsSvc := odds.New(cfg.Bet365Token, store) - // 🕒 Start scheduled cron jobs + + httpserver.StartDataFetchingCrons(eventSvc, oddsSvc) - // 🚀 Start HTTP server app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ JwtAccessKey: cfg.JwtKey, JwtAccessExpiry: cfg.AccessExpiry, diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index 6d965b4..ba45316 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -88,17 +88,33 @@ CREATE TABLE events ( CREATE TABLE odds ( id SERIAL PRIMARY KEY, - event_id TEXT, -- Parsed from "FI" (Bet365 Event ID). Nullable in case of failures. - market_type TEXT NOT NULL, -- E.g., 'asian_handicap', 'goal_line', 'both_teams_to_score' - header TEXT, -- E.g., '1', '2', 'Over', 'Under', 'Draw', 'Yes', 'No' - name TEXT, -- Bet name like "2.5", "Over 2.5 & Yes", etc. - odds_value DOUBLE PRECISION, -- The numeric odds (e.g., 1.920) - handicap TEXT, -- Handicap value like "-0.5", "0.0, +0.5" - section TEXT NOT NULL, -- Odds section: 'asian_lines', 'goals', etc. - category TEXT, -- Market category (e.g., 'sp') - market_id TEXT, -- Market ID from the API (e.g., "938", "50138") - fetched_at TIMESTAMP DEFAULT now(), -- When this record was fetched - source TEXT DEFAULT 'b365api', -- Source identifier - is_active BOOLEAN DEFAULT true, -- Optional deactivation flag - raw_event_id TEXT -- Original/failed event ID if event_id is nil or invalid + + -- Core IDs + event_id TEXT, + fi TEXT, -- ✅ from Market.FI + raw_event_id TEXT, -- Original event ID if different + + -- Market info + market_type TEXT NOT NULL, -- e.g., "asian_handicap" + market_name TEXT, -- ✅ from Market.MarketName + market_category TEXT, -- ✅ from Market.marketcatagory (like "asian_lines") + market_id TEXT, -- e.g., "938" + + -- Odds detail + header TEXT, + name TEXT, + handicap TEXT, + odds_value DOUBLE PRECISION, + + -- Meta + section TEXT NOT NULL, + category TEXT, + raw_odds JSONB, -- ✅ store full odds array here + fetched_at TIMESTAMP DEFAULT now(), + source TEXT DEFAULT 'b365api', + is_active BOOLEAN DEFAULT true, + + -- Conflict resolution key + UNIQUE (event_id, market_id, header, name, handicap) ); + diff --git a/db/query/odds.sql b/db/query/odds.sql index b041a85..c1ca21c 100644 --- a/db/query/odds.sql +++ b/db/query/odds.sql @@ -1,13 +1,38 @@ - -- name: InsertNonLiveOdd :exec INSERT INTO odds ( - event_id, market_type, header, name, odds_value, handicap, - section, category, market_id, is_active, source, fetched_at, raw_event_id + event_id, + fi, + raw_event_id, + market_type, + market_name, + market_category, + market_id, + header, + name, + handicap, + odds_value, + section, + category, + raw_odds, + is_active, + source, + fetched_at ) VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, true, 'b365api', now(), $10 -); - + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, $14, + true, 'b365api', now() +) +ON CONFLICT (event_id, market_id, header, name, handicap) DO UPDATE SET + odds_value = EXCLUDED.odds_value, + raw_odds = EXCLUDED.raw_odds, + market_type = EXCLUDED.market_type, + market_name = EXCLUDED.market_name, + market_category = EXCLUDED.market_category, + fetched_at = now(), + is_active = true, + source = 'b365api', + fi = EXCLUDED.fi, + raw_event_id = EXCLUDED.raw_event_id; -- name: GetUpcomingEventIDs :many SELECT id FROM events diff --git a/gen/db/models.go b/gen/db/models.go index 24be0bd..071fe68 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -33,20 +33,24 @@ type Event struct { } type Odd struct { - ID int32 - EventID pgtype.Text - MarketType string - Header pgtype.Text - Name pgtype.Text - OddsValue pgtype.Float8 - Handicap pgtype.Text - Section string - Category pgtype.Text - MarketID pgtype.Text - FetchedAt pgtype.Timestamp - Source pgtype.Text - IsActive pgtype.Bool - RawEventID pgtype.Text + ID int32 + EventID pgtype.Text + Fi pgtype.Text + RawEventID pgtype.Text + MarketType string + MarketName pgtype.Text + MarketCategory pgtype.Text + MarketID pgtype.Text + Header pgtype.Text + Name pgtype.Text + Handicap pgtype.Text + OddsValue pgtype.Float8 + Section string + Category pgtype.Text + RawOdds []byte + FetchedAt pgtype.Timestamp + Source pgtype.Text + IsActive pgtype.Bool } type Otp struct { diff --git a/gen/db/odds.sql.go b/gen/db/odds.sql.go index fe33a5e..f3e0029 100644 --- a/gen/db/odds.sql.go +++ b/gen/db/odds.sql.go @@ -38,39 +38,74 @@ func (q *Queries) GetUpcomingEventIDs(ctx context.Context) ([]string, error) { const InsertNonLiveOdd = `-- name: InsertNonLiveOdd :exec INSERT INTO odds ( - event_id, market_type, header, name, odds_value, handicap, - section, category, market_id, is_active, source, fetched_at, raw_event_id + event_id, + fi, + raw_event_id, + market_type, + market_name, + market_category, + market_id, + header, + name, + handicap, + odds_value, + section, + category, + raw_odds, + is_active, + source, + fetched_at ) VALUES ( - $1, $2, $3, $4, $5, $6, - $7, $8, $9, true, 'b365api', now(), $10 + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, $14, + true, 'b365api', now() ) +ON CONFLICT (event_id, market_id, header, name, handicap) DO UPDATE SET + odds_value = EXCLUDED.odds_value, + raw_odds = EXCLUDED.raw_odds, + market_type = EXCLUDED.market_type, + market_name = EXCLUDED.market_name, + market_category = EXCLUDED.market_category, + fetched_at = now(), + is_active = true, + source = 'b365api', + fi = EXCLUDED.fi, + raw_event_id = EXCLUDED.raw_event_id ` type InsertNonLiveOddParams struct { - EventID pgtype.Text - MarketType string - Header pgtype.Text - Name pgtype.Text - OddsValue pgtype.Float8 - Handicap pgtype.Text - Section string - Category pgtype.Text - MarketID pgtype.Text - RawEventID pgtype.Text + EventID pgtype.Text + Fi pgtype.Text + RawEventID pgtype.Text + MarketType string + MarketName pgtype.Text + MarketCategory pgtype.Text + MarketID pgtype.Text + Header pgtype.Text + Name pgtype.Text + Handicap pgtype.Text + OddsValue pgtype.Float8 + Section string + Category pgtype.Text + RawOdds []byte } func (q *Queries) InsertNonLiveOdd(ctx context.Context, arg InsertNonLiveOddParams) error { _, err := q.db.Exec(ctx, InsertNonLiveOdd, arg.EventID, + arg.Fi, + arg.RawEventID, arg.MarketType, + arg.MarketName, + arg.MarketCategory, + arg.MarketID, arg.Header, arg.Name, - arg.OddsValue, arg.Handicap, + arg.OddsValue, arg.Section, arg.Category, - arg.MarketID, - arg.RawEventID, + arg.RawOdds, ) return err } diff --git a/go.mod b/go.mod index 02a28b2..7988b31 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.2 github.com/jackc/pgx/v5 v5.7.4 github.com/joho/godotenv v1.5.1 + github.com/robfig/cron/v3 v3.0.1 github.com/swaggo/fiber-swagger v1.3.0 github.com/swaggo/swag v1.16.4 golang.org/x/crypto v0.36.0 @@ -39,7 +40,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/internal/domain/odds.go b/internal/domain/odds.go index 0af5dbd..18652f5 100644 --- a/internal/domain/odds.go +++ b/internal/domain/odds.go @@ -1,14 +1,17 @@ package domain -type OddsRecord struct { - EventID string - MarketType string - Header string - Name string - OddsValue float64 - Handicap string - Section string - Category string - MarketID string - RawEventID string -} +import ( + "encoding/json" + "time" +) + +type Market struct { + EventID string // 7549892 + FI string // 147543881 + MarketCategory string // Corrected spelling and casing + MarketType string // e.g., "asian_handicap", "goal_line" + MarketName string // e.g., "Asian Handicap" + MarketID string // e.g., "938" + UpdatedAt time.Time // parsed from "updated_at" + Odds []json.RawMessage // oddd is sometimes null +} \ No newline at end of file diff --git a/internal/repository/odds.go b/internal/repository/odds.go index c2ea64f..af20bbb 100644 --- a/internal/repository/odds.go +++ b/internal/repository/odds.go @@ -2,63 +2,79 @@ package repository import ( "context" + "encoding/json" "fmt" + "os" + "time" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/jackc/pgx/v5/pgtype" ) -func (s *Store) SaveNonLiveOdd(ctx context.Context, o domain.OddsRecord) error { +func (s *Store) SaveNonLiveOdd(ctx context.Context, m domain.Market) error { + rawOddsBytes, _ := json.Marshal(m.Odds) + params := dbgen.InsertNonLiveOddParams{ - EventID: pgtype.Text{ - String: o.EventID, - Valid: o.EventID != "", - }, - MarketType: o.MarketType, - Header: pgtype.Text{ - String: o.Header, - Valid: o.Header != "", - }, - Name: pgtype.Text{ - String: o.Name, - Valid: o.Name != "", - }, - OddsValue: pgtype.Float8{ - Float64: o.OddsValue, - Valid: true, - }, - Handicap: pgtype.Text{ - String: o.Handicap, - Valid: o.Handicap != "", - }, - Section: o.Section, - Category: pgtype.Text{ - String: o.Category, - Valid: o.Category != "", - }, - MarketID: pgtype.Text{ - String: o.MarketID, - Valid: o.MarketID != "", - }, - RawEventID: pgtype.Text{ - String: o.RawEventID, - Valid: o.RawEventID != "", - }, + EventID: pgtype.Text{String: m.EventID, Valid: m.EventID != ""}, + Fi: pgtype.Text{String: m.FI, Valid: m.FI != ""}, + RawEventID: pgtype.Text{String: m.EventID, Valid: m.EventID != ""}, + MarketType: m.MarketType, + MarketName: pgtype.Text{String: m.MarketName, Valid: m.MarketName != ""}, + MarketCategory: pgtype.Text{String: m.MarketCategory, Valid: m.MarketCategory != ""}, + MarketID: pgtype.Text{String: m.MarketID, Valid: m.MarketID != ""}, + Header: pgtype.Text{String: "", Valid: false}, + Name: pgtype.Text{String: "", Valid: false}, + Handicap: pgtype.Text{String: "", Valid: false}, + OddsValue: pgtype.Float8{Float64: 0, Valid: false}, + Section: m.MarketCategory, + Category: pgtype.Text{String: "", Valid: false}, + RawOdds: rawOddsBytes, } err := s.queries.InsertNonLiveOdd(ctx, params) - if err != nil { - fmt.Printf("❌ Failed to insert odd: event_id=%s | market=%s | odds=%.3f | error=%v\n", - o.EventID, o.MarketType, o.OddsValue, err) + fmt.Printf("❌ Failed to insert/upsert market: event_id=%s | market_type=%s | err=%v\n", + m.EventID, m.MarketType, err) + _ = writeFailedMarketLog(m, err) } else { - fmt.Printf("✅ Stored: event_id=%s | market=%s | odds=%.3f\n", o.EventID, o.MarketType, o.OddsValue) + fmt.Printf("✅ Upserted market: event_id=%s | market_type=%s\n", m.EventID, m.MarketType) } return err } +func writeFailedMarketLog(m domain.Market, err error) error { + logDir := "logs" + logFile := logDir + "/failed_markets.log" + + if mkErr := os.MkdirAll(logDir, 0755); mkErr != nil { + return mkErr + } + + f, fileErr := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if fileErr != nil { + return fileErr + } + defer f.Close() + + entry := struct { + Time string `json:"time"` + Error string `json:"error"` + Record domain.Market `json:"record"` + }{ + Time: time.Now().Format(time.RFC3339), + Error: err.Error(), + Record: m, + } + + jsonData, _ := json.MarshalIndent(entry, "", " ") + _, writeErr := f.WriteString(string(jsonData) + "\n\n") + return writeErr +} + + + func (s *Store) GetUpcomingEventIDs(ctx context.Context) ([]string, error) { return s.queries.GetUpcomingEventIDs(ctx) } diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go index 090add4..7562ff0 100644 --- a/internal/services/odds/service.go +++ b/internal/services/odds/service.go @@ -7,7 +7,7 @@ import ( "io" "net/http" "strconv" - "sync" + "time" "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" @@ -21,132 +21,137 @@ type ServiceImpl struct { func New(token string, store *repository.Store) *ServiceImpl { return &ServiceImpl{token: token, store: store} } + func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { - eventIDs, err := s.store.GetUpcomingEventIDs(ctx) - if err != nil { - return fmt.Errorf("fetch upcoming event IDs: %w", err) - } + sportIDs := []int{1, 13, 78, 18, 91, 16, 17} - type OddsMarket struct { - ID string `json:"id"` - Name string `json:"name"` - Odds []struct { - ID string `json:"id"` - Odds string `json:"odds"` - Header string `json:"header,omitempty"` - Name string `json:"name,omitempty"` - Handicap string `json:"handicap,omitempty"` - } `json:"odds"` - } + for _, sportID := range sportIDs { + upcomingURL := fmt.Sprintf("https://api.b365api.com/v1/bet365/upcoming?sport_id=%d&token=%s", sportID, s.token) + resp, err := http.Get(upcomingURL) + if err != nil { + fmt.Printf("❌ Failed to fetch upcoming for sport_id=%d: %v\n", sportID, err) + continue + } + defer resp.Body.Close() - type OddsSection struct { - UpdatedAt string `json:"updated_at"` - Sp map[string]OddsMarket `json:"sp"` - } - - type StructuredOddsResponse struct { - Success int `json:"success"` - Results []struct { - EventID string `json:"event_id"` - FI string `json:"FI"` - AsianLines OddsSection `json:"asian_lines"` - Goals OddsSection `json:"goals"` - } `json:"results"` - } - - var wg sync.WaitGroup - sem := make(chan struct{}, 5) - - for _, eventID := range eventIDs { - if eventID == "" || len(eventID) < 5 { + body, _ := io.ReadAll(resp.Body) + var data struct { + Success int `json:"success"` + Results []map[string]interface{} `json:"results"` + } + if err := json.Unmarshal(body, &data); err != nil || data.Success != 1 { + fmt.Printf("❌ Failed to decode upcoming for sport_id=%d\nRaw: %s\n", sportID, string(body)) continue } - wg.Add(1) - go func(originalID string) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() + for _, ev := range data.Results { + if getString(ev["type"]) != "EV" { + continue + } + eventID := getString(ev["ID"]) + if eventID == "" { + continue + } - url := fmt.Sprintf("https://api.b365api.com/v1/bet365/odds?token=%s&event_id=%s", s.token, originalID) - resp, err := http.Get(url) + prematchURL := fmt.Sprintf("https://api.b365api.com/v3/bet365/prematch?token=%s&FI=%s", s.token, eventID) + oddsResp, err := http.Get(prematchURL) if err != nil { - fmt.Printf(" Failed HTTP request for event_id=%s: %v\n", originalID, err) - return + fmt.Printf("❌ Odds fetch failed for event_id=%s: %v\n", eventID, err) + continue } - defer resp.Body.Close() + defer oddsResp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Printf(" Failed to read response body for event_id=%s: %v\n", originalID, err) - return + oddsBody, _ := io.ReadAll(oddsResp.Body) + var oddsData struct { + Success int `json:"success"` + Results []struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + AsianLines OddsSection `json:"asian_lines"` + Goals OddsSection `json:"goals"` + } `json:"results"` + } + if err := json.Unmarshal(oddsBody, &oddsData); err != nil || oddsData.Success != 1 || len(oddsData.Results) == 0 { + fmt.Printf("❌ Failed odds decode for event_id=%s\nRaw: %s\n", eventID, string(oddsBody)) + continue } - var data StructuredOddsResponse - if err := json.Unmarshal(body, &data); err != nil { - fmt.Printf(" JSON unmarshal failed for event_id=%s. Response: %s\n", originalID, string(body)) - return + result := oddsData.Results[0] + finalID := result.EventID + if finalID == "" { + finalID = result.FI + } + if finalID == "" { + fmt.Println("⚠️ Skipping event with missing final ID.") + continue } - if data.Success != 1 || len(data.Results) == 0 { - fmt.Printf(" API response error or no results for event_id=%s\nBody: %s\n", originalID, string(body)) - return - } - - result := data.Results[0] - finalEventID := result.EventID - if finalEventID == "" { - finalEventID = result.FI - } - if finalEventID == "" { - fmt.Printf(" Skipping event_id=%s due to missing both event_id and FI\n", originalID) - return - } - - saveOdds := func(sectionName string, section OddsSection) { - for marketType, market := range section.Sp { - for _, odd := range market.Odds { - val, err := strconv.ParseFloat(odd.Odds, 64) - if err != nil { - fmt.Printf(" Skipping invalid odds for market=%s, event_id=%s\n", marketType, finalEventID) - continue - } - - record := domain.OddsRecord{ - EventID: finalEventID, - MarketType: marketType, - Header: odd.Header, - Name: odd.Name, - Handicap: odd.Handicap, - OddsValue: val, - Section: sectionName, - Category: market.ID, - MarketID: odd.ID, - RawEventID: originalID, - } - - fmt.Printf("🟡 Preparing to store: event_id=%s | market=%s | header=%s | name=%s | odds=%.3f\n", - finalEventID, marketType, odd.Header, odd.Name, val) - - err = s.store.SaveNonLiveOdd(ctx, record) - if err != nil { - fmt.Printf("❌ DB save error for market=%s, event_id=%s: %v\nRecord: %+v\n", marketType, finalEventID, err, record) - } else { - fmt.Printf("✅ Stored odd: event_id=%s | market=%s | odds=%.3f\n", finalEventID, marketType, val) - } - } + saveOdds := func(sectionName string, section OddsSection) error { + if len(section.Sp) == 0 { + fmt.Printf("⚠️ No odds in section '%s' for event_id=%s\n", sectionName, finalID) + return nil } - } - - - saveOdds("asian_lines", result.AsianLines) - saveOdds("goals", result.Goals) - }(eventID) + updatedAtUnix, _ := strconv.ParseInt(section.UpdatedAt, 10, 64) + updatedAt := time.Unix(updatedAtUnix, 0) + + for marketType, market := range section.Sp { + if len(market.Odds) == 0 { + fmt.Printf("⚠️ No odds for marketType=%s in section=%s\n", marketType, sectionName) + continue + } + + marketRecord := domain.Market{ + EventID: finalID, + FI: result.FI, + MarketCategory: sectionName, + MarketType: marketType, + MarketName: market.Name, + MarketID: market.ID, + UpdatedAt: updatedAt, + Odds: market.Odds, + } + + s.store.SaveNonLiveOdd(ctx, marketRecord) + fmt.Printf("✅ STORED MARKET: event_id=%s | type=%s | name=%s\n", finalID, marketType, market.Name) + } + return nil + } + + if err := saveOdds("asian_lines", result.AsianLines); err != nil { + fmt.Printf("⚠️ Skipping event %s due to asian_lines error: %v\n", finalID, err) + continue + } + if err := saveOdds("goals", result.Goals); err != nil { + fmt.Printf("⚠️ Skipping event %s due to goals error: %v\n", finalID, err) + continue + } + + fmt.Printf("✅ Done storing all odds for event_id=%s\n", finalID) + } } - wg.Wait() fmt.Println("✅ All non-live odds fetched and stored.") return nil } +// Odds structures + +type OddsMarket struct { + ID string `json:"id"` + Name string `json:"name"` + Odds []json.RawMessage `json:"odds"` +} + +type OddsSection struct { + UpdatedAt string `json:"updated_at"` + Sp map[string]OddsMarket `json:"sp"` +} + +// Helper +func getString(v interface{}) string { + if str, ok := v.(string); ok { + return str + } + return "" +}