From 92250d61a825375d39d5093e80db87355d54718f Mon Sep 17 00:00:00 2001 From: OneTap Technologies Date: Thu, 10 Apr 2025 16:42:26 +0300 Subject: [PATCH 1/4] event and odd data --- cmd/main.go | 34 +++-- db/migrations/000001_fortune.down.sql | 5 + db/migrations/000001_fortune.up.sql | 45 ++++++- db/query/events.sql | 38 ++++++ db/query/odds.sql | 14 +++ gen/db/auth.sql.go | 2 +- gen/db/db.go | 2 +- gen/db/events.sql.go | 122 ++++++++++++++++++ gen/db/models.go | 43 ++++++- gen/db/odds.sql.go | 76 +++++++++++ gen/db/otp.sql.go | 2 +- gen/db/user.sql.go | 2 +- go.mod | 1 + go.sum | 2 + internal/config/config.go | 7 ++ internal/domain/event.go | 23 ++++ internal/domain/odds.go | 14 +++ internal/repository/event.go | 44 +++++++ internal/repository/odds.go | 64 ++++++++++ internal/services/event/port.go | 8 ++ internal/services/event/service.go | 173 ++++++++++++++++++++++++++ internal/services/odds/port.go | 8 ++ internal/services/odds/service.go | 152 ++++++++++++++++++++++ internal/web_server/cron.go | 54 ++++++++ 24 files changed, 921 insertions(+), 14 deletions(-) create mode 100644 db/query/events.sql create mode 100644 db/query/odds.sql create mode 100644 gen/db/events.sql.go create mode 100644 gen/db/odds.sql.go create mode 100644 internal/domain/event.go create mode 100644 internal/domain/odds.go create mode 100644 internal/repository/event.go create mode 100644 internal/repository/odds.go create mode 100644 internal/services/event/port.go create mode 100644 internal/services/event/service.go create mode 100644 internal/services/odds/port.go create mode 100644 internal/services/odds/service.go create mode 100644 internal/web_server/cron.go diff --git a/cmd/main.go b/cmd/main.go index b57aacc..ac6ca85 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,17 +5,20 @@ import ( "log/slog" "os" + "github.com/go-playground/validator/v10" + "github.com/SamuelTariku/FortuneBet-Backend/internal/config" customlogger "github.com/SamuelTariku/FortuneBet-Backend/internal/logger" mockemail "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_email" mocksms "github.com/SamuelTariku/FortuneBet-Backend/internal/mocks/mock_sms" "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" httpserver "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server" jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" - "github.com/go-playground/validator/v10" ) // @title FortuneBet API @@ -32,32 +35,47 @@ import ( // @name Authorization // @BasePath / func main() { + // Load config cfg, err := config.NewConfig() if err != nil { - slog.Error(err.Error()) + slog.Error("❌ Config error:", "err", err) os.Exit(1) } + + // Connect to database db, _, err := repository.OpenDB(cfg.DbUrl) if err != nil { - fmt.Print("db", err) + 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) + 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, - }, userSvc, - ) + }, userSvc) + logger.Info("Starting server", "port", cfg.Port) if err := app.Run(); err != nil { logger.Error("Failed to start server", "error", err) os.Exit(1) } - } diff --git a/db/migrations/000001_fortune.down.sql b/db/migrations/000001_fortune.down.sql index 489466f..180201e 100644 --- a/db/migrations/000001_fortune.down.sql +++ b/db/migrations/000001_fortune.down.sql @@ -72,3 +72,8 @@ DROP TABLE IF EXISTS ussd_account; DROP TYPE IF EXISTS ua_pin_status; DROP TYPE IF EXISTS ua_status; DROP TYPE IF EXISTS ua_registaration_type; + + +DROP TABLE IF EXISTS odds; +DROP TABLE IF EXISTS events; + diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index a4e3bd0..6d965b4 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -58,4 +58,47 @@ INSERT INTO users ( CURRENT_TIMESTAMP, NULL, FALSE -); \ No newline at end of file +); + +--------------------------------------------------Bet365 Data Fetching + Event Managment------------------------------------------------ +CREATE TABLE events ( + id TEXT PRIMARY KEY, + sport_id TEXT, + match_name TEXT, + home_team TEXT, + away_team TEXT, + home_team_id TEXT, + away_team_id TEXT, + home_kit_image TEXT, + away_kit_image TEXT, + league_id TEXT, + league_name TEXT, + league_cc TEXT, + start_time TIMESTAMP, + score TEXT, + match_minute INT, + timer_status TEXT, + added_time INT, + match_period INT, + is_live BOOLEAN, + status TEXT, + fetched_at TIMESTAMP DEFAULT now() +); + + +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 +); diff --git a/db/query/events.sql b/db/query/events.sql new file mode 100644 index 0000000..dddec63 --- /dev/null +++ b/db/query/events.sql @@ -0,0 +1,38 @@ +-- name: InsertEvent :exec +INSERT INTO events ( + id, sport_id, match_name, home_team, away_team, + home_team_id, away_team_id, home_kit_image, away_kit_image, + league_id, league_name, league_cc, start_time, score, + match_minute, timer_status, added_time, match_period, + is_live, status +) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + $10, $11, $12, $13, $14, + $15, $16, $17, $18, + $19, $20 +) +ON CONFLICT (id) DO UPDATE SET + sport_id = EXCLUDED.sport_id, + match_name = EXCLUDED.match_name, + home_team = EXCLUDED.home_team, + away_team = EXCLUDED.away_team, + home_team_id = EXCLUDED.home_team_id, + away_team_id = EXCLUDED.away_team_id, + home_kit_image = EXCLUDED.home_kit_image, + away_kit_image = EXCLUDED.away_kit_image, + league_id = EXCLUDED.league_id, + league_name = EXCLUDED.league_name, + league_cc = EXCLUDED.league_cc, + start_time = EXCLUDED.start_time, + score = EXCLUDED.score, + match_minute = EXCLUDED.match_minute, + timer_status = EXCLUDED.timer_status, + added_time = EXCLUDED.added_time, + match_period = EXCLUDED.match_period, + is_live = EXCLUDED.is_live, + status = EXCLUDED.status, + fetched_at = now(); + +-- name: ListLiveEvents :many +SELECT id FROM events WHERE is_live = true; \ No newline at end of file diff --git a/db/query/odds.sql b/db/query/odds.sql new file mode 100644 index 0000000..b041a85 --- /dev/null +++ b/db/query/odds.sql @@ -0,0 +1,14 @@ + +-- 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 +) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, true, 'b365api', now(), $10 +); + + +-- name: GetUpcomingEventIDs :many +SELECT id FROM events +WHERE is_live = false; diff --git a/gen/db/auth.sql.go b/gen/db/auth.sql.go index 27fb891..c826c36 100644 --- a/gen/db/auth.sql.go +++ b/gen/db/auth.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.28.0 // source: auth.sql package dbgen diff --git a/gen/db/db.go b/gen/db/db.go index fe4ae38..136f20a 100644 --- a/gen/db/db.go +++ b/gen/db/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.28.0 package dbgen diff --git a/gen/db/events.sql.go b/gen/db/events.sql.go new file mode 100644 index 0000000..e833d0e --- /dev/null +++ b/gen/db/events.sql.go @@ -0,0 +1,122 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: events.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const InsertEvent = `-- name: InsertEvent :exec +INSERT INTO events ( + id, sport_id, match_name, home_team, away_team, + home_team_id, away_team_id, home_kit_image, away_kit_image, + league_id, league_name, league_cc, start_time, score, + match_minute, timer_status, added_time, match_period, + is_live, status +) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + $10, $11, $12, $13, $14, + $15, $16, $17, $18, + $19, $20 +) +ON CONFLICT (id) DO UPDATE SET + sport_id = EXCLUDED.sport_id, + match_name = EXCLUDED.match_name, + home_team = EXCLUDED.home_team, + away_team = EXCLUDED.away_team, + home_team_id = EXCLUDED.home_team_id, + away_team_id = EXCLUDED.away_team_id, + home_kit_image = EXCLUDED.home_kit_image, + away_kit_image = EXCLUDED.away_kit_image, + league_id = EXCLUDED.league_id, + league_name = EXCLUDED.league_name, + league_cc = EXCLUDED.league_cc, + start_time = EXCLUDED.start_time, + score = EXCLUDED.score, + match_minute = EXCLUDED.match_minute, + timer_status = EXCLUDED.timer_status, + added_time = EXCLUDED.added_time, + match_period = EXCLUDED.match_period, + is_live = EXCLUDED.is_live, + status = EXCLUDED.status, + fetched_at = now() +` + +type InsertEventParams struct { + ID string + SportID pgtype.Text + MatchName pgtype.Text + HomeTeam pgtype.Text + AwayTeam pgtype.Text + HomeTeamID pgtype.Text + AwayTeamID pgtype.Text + HomeKitImage pgtype.Text + AwayKitImage pgtype.Text + LeagueID pgtype.Text + LeagueName pgtype.Text + LeagueCc pgtype.Text + StartTime pgtype.Timestamp + Score pgtype.Text + MatchMinute pgtype.Int4 + TimerStatus pgtype.Text + AddedTime pgtype.Int4 + MatchPeriod pgtype.Int4 + IsLive pgtype.Bool + Status pgtype.Text +} + +func (q *Queries) InsertEvent(ctx context.Context, arg InsertEventParams) error { + _, err := q.db.Exec(ctx, InsertEvent, + arg.ID, + arg.SportID, + arg.MatchName, + arg.HomeTeam, + arg.AwayTeam, + arg.HomeTeamID, + arg.AwayTeamID, + arg.HomeKitImage, + arg.AwayKitImage, + arg.LeagueID, + arg.LeagueName, + arg.LeagueCc, + arg.StartTime, + arg.Score, + arg.MatchMinute, + arg.TimerStatus, + arg.AddedTime, + arg.MatchPeriod, + arg.IsLive, + arg.Status, + ) + return err +} + +const ListLiveEvents = `-- name: ListLiveEvents :many +SELECT id FROM events WHERE is_live = true +` + +func (q *Queries) ListLiveEvents(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, ListLiveEvents) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/gen/db/models.go b/gen/db/models.go index a1465c2..24be0bd 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.28.0 package dbgen @@ -8,6 +8,47 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Event struct { + ID string + SportID pgtype.Text + MatchName pgtype.Text + HomeTeam pgtype.Text + AwayTeam pgtype.Text + HomeTeamID pgtype.Text + AwayTeamID pgtype.Text + HomeKitImage pgtype.Text + AwayKitImage pgtype.Text + LeagueID pgtype.Text + LeagueName pgtype.Text + LeagueCc pgtype.Text + StartTime pgtype.Timestamp + Score pgtype.Text + MatchMinute pgtype.Int4 + TimerStatus pgtype.Text + AddedTime pgtype.Int4 + MatchPeriod pgtype.Int4 + IsLive pgtype.Bool + Status pgtype.Text + FetchedAt pgtype.Timestamp +} + +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 +} + type Otp struct { ID int64 SentTo string diff --git a/gen/db/odds.sql.go b/gen/db/odds.sql.go new file mode 100644 index 0000000..fe33a5e --- /dev/null +++ b/gen/db/odds.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: odds.sql + +package dbgen + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const GetUpcomingEventIDs = `-- name: GetUpcomingEventIDs :many +SELECT id FROM events +WHERE is_live = false +` + +func (q *Queries) GetUpcomingEventIDs(ctx context.Context) ([]string, error) { + rows, err := q.db.Query(ctx, GetUpcomingEventIDs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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 +) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, true, 'b365api', now(), $10 +) +` + +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 +} + +func (q *Queries) InsertNonLiveOdd(ctx context.Context, arg InsertNonLiveOddParams) error { + _, err := q.db.Exec(ctx, InsertNonLiveOdd, + arg.EventID, + arg.MarketType, + arg.Header, + arg.Name, + arg.OddsValue, + arg.Handicap, + arg.Section, + arg.Category, + arg.MarketID, + arg.RawEventID, + ) + return err +} diff --git a/gen/db/otp.sql.go b/gen/db/otp.sql.go index 619bf92..e0b9806 100644 --- a/gen/db/otp.sql.go +++ b/gen/db/otp.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.28.0 // source: otp.sql package dbgen diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 88ee397..39f0a5c 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.26.0 +// sqlc v1.28.0 // source: user.sql package dbgen diff --git a/go.mod b/go.mod index 2fb3275..02a28b2 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ 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/go.sum b/go.sum index c86e5af..1cd6ef9 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/internal/config/config.go b/internal/config/config.go index 229bd47..98a6a12 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ var ( ErrLogLevel = errors.New("log level not set") ErrInvalidLevel = errors.New("invalid log level") ErrInvalidEnv = errors.New("env not set or invalid") + ErrMissingBetToken = errors.New("missing BET365_TOKEN in .env") ) type Config struct { @@ -29,6 +30,7 @@ type Config struct { JwtKey string LogLevel slog.Level Env string + Bet365Token string } func NewConfig() (*Config, error) { @@ -89,5 +91,10 @@ func (c *Config) loadEnv() error { return ErrInvalidLevel } c.LogLevel = lvl + betToken := os.Getenv("BET365_TOKEN") + if betToken == "" { + return ErrMissingBetToken + } + c.Bet365Token = betToken return nil } diff --git a/internal/domain/event.go b/internal/domain/event.go new file mode 100644 index 0000000..fb7edf4 --- /dev/null +++ b/internal/domain/event.go @@ -0,0 +1,23 @@ +package domain +type Event struct { + ID string + SportID string + MatchName string + HomeTeam string + AwayTeam string + HomeTeamID string + AwayTeamID string + HomeKitImage string + AwayKitImage string + LeagueID string + LeagueName string + LeagueCC string + StartTime string + Score string + MatchMinute int + TimerStatus string + AddedTime int + MatchPeriod int + IsLive bool + Status string +} \ No newline at end of file diff --git a/internal/domain/odds.go b/internal/domain/odds.go new file mode 100644 index 0000000..0af5dbd --- /dev/null +++ b/internal/domain/odds.go @@ -0,0 +1,14 @@ +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 +} diff --git a/internal/repository/event.go b/internal/repository/event.go new file mode 100644 index 0000000..1949a44 --- /dev/null +++ b/internal/repository/event.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + "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) SaveEvent(ctx context.Context, e domain.Event) error { + parsedTime, err := time.Parse(time.RFC3339, e.StartTime) + if err != nil { + return err + } + + return s.queries.InsertEvent(ctx, dbgen.InsertEventParams{ + ID: e.ID, + SportID: pgtype.Text{String: e.SportID, Valid: true}, + MatchName: pgtype.Text{String: e.MatchName, Valid: true}, + HomeTeam: pgtype.Text{String: e.HomeTeam, Valid: true}, + AwayTeam: pgtype.Text{String: e.AwayTeam, Valid: true}, + HomeTeamID: pgtype.Text{String: e.HomeTeamID, Valid: true}, + AwayTeamID: pgtype.Text{String: e.AwayTeamID, Valid: true}, + HomeKitImage: pgtype.Text{String: e.HomeKitImage, Valid: true}, + AwayKitImage: pgtype.Text{String: e.AwayKitImage, Valid: true}, + LeagueID: pgtype.Text{String: e.LeagueID, Valid: true}, + LeagueName: pgtype.Text{String: e.LeagueName, Valid: true}, + LeagueCc: pgtype.Text{String: e.LeagueCC, Valid: true}, + StartTime: pgtype.Timestamp{Time: parsedTime, Valid: true}, + Score: pgtype.Text{String: e.Score, Valid: true}, + MatchMinute: pgtype.Int4{Int32: int32(e.MatchMinute), Valid: true}, + TimerStatus: pgtype.Text{String: e.TimerStatus, Valid: true}, + AddedTime: pgtype.Int4{Int32: int32(e.AddedTime), Valid: true}, + MatchPeriod: pgtype.Int4{Int32: int32(e.MatchPeriod), Valid: true}, + IsLive: pgtype.Bool{Bool: e.IsLive, Valid: true}, + Status: pgtype.Text{String: e.Status, Valid: true}, + }) +} + +func (s *Store) GetLiveEventIDs(ctx context.Context) ([]string, error) { + return s.queries.ListLiveEvents(ctx) +} diff --git a/internal/repository/odds.go b/internal/repository/odds.go new file mode 100644 index 0000000..c2ea64f --- /dev/null +++ b/internal/repository/odds.go @@ -0,0 +1,64 @@ +package repository + +import ( + "context" + "fmt" + + 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 { + 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 != "", + }, + } + + 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) + } else { + fmt.Printf("✅ Stored: event_id=%s | market=%s | odds=%.3f\n", o.EventID, o.MarketType, o.OddsValue) + } + + return err +} + +func (s *Store) GetUpcomingEventIDs(ctx context.Context) ([]string, error) { + return s.queries.GetUpcomingEventIDs(ctx) +} diff --git a/internal/services/event/port.go b/internal/services/event/port.go new file mode 100644 index 0000000..b500ca4 --- /dev/null +++ b/internal/services/event/port.go @@ -0,0 +1,8 @@ +package event + +import "context" + +type Service interface { + FetchLiveEvents(ctx context.Context) error + FetchUpcomingEvents(ctx context.Context) error +} diff --git a/internal/services/event/service.go b/internal/services/event/service.go new file mode 100644 index 0000000..3d54d7a --- /dev/null +++ b/internal/services/event/service.go @@ -0,0 +1,173 @@ +package event + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" +) + +type service struct { + token string + store *repository.Store +} + +func New(token string, store *repository.Store) Service { + return &service{ + token: token, + store: store, + } +} + +func (s *service) FetchLiveEvents(ctx context.Context) error { + sportIDs := []int{1, 13, 78, 18, 91, 16, 17, 14, 12, 3, 2, 4, 83, 15, 92, 94, 8, 19, 36, 66, 9, 75, 90, 95, 110, 107, 151, 162, 148} + + var wg sync.WaitGroup + + for _, sportID := range sportIDs { + wg.Add(1) + go func(sportID int) { + defer wg.Done() + + url := fmt.Sprintf("https://api.b365api.com/v1/bet365/inplay?sport_id=%d&token=%s", sportID, s.token) + resp, err := http.Get(url) + if err != nil { + fmt.Printf(" Failed request for sport_id=%d: %v\n", sportID, err) + return + } + defer resp.Body.Close() + + 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(" Decode failed for sport_id=%d\nRaw: %s\n", sportID, string(body)) + return + } + + for _, group := range data.Results { + for _, ev := range group { + if getString(ev["type"]) != "EV" { + continue + } + + event := domain.Event{ + ID: getString(ev["ID"]), + SportID: fmt.Sprintf("%d", sportID), + MatchName: getString(ev["NA"]), + Score: getString(ev["SS"]), + MatchMinute: getInt(ev["TM"]), + TimerStatus: getString(ev["TT"]), + HomeTeamID: getString(ev["HT"]), + AwayTeamID: getString(ev["AT"]), + HomeKitImage: getString(ev["K1"]), + AwayKitImage: getString(ev["K2"]), + LeagueName: getString(ev["CT"]), + LeagueID: getString(ev["C2"]), + LeagueCC: getString(ev["CB"]), + StartTime: time.Now().UTC().Format(time.RFC3339), + IsLive: true, + Status: "live", + MatchPeriod: getInt(ev["MD"]), + AddedTime: getInt(ev["TA"]), + } + + if err := s.store.SaveEvent(ctx, event); err != nil { + fmt.Printf("Could not store live event [id=%s]: %v\n", event.ID, err) + } + } + } + }(sportID) + } + + wg.Wait() + fmt.Println("All live events fetched and stored.") + return nil +} + +func (s *service) FetchUpcomingEvents(ctx context.Context) error { + sportIDs := []int{1, 13, 78, 18, 91, 16, 17, 14, 12, 3, 2, 4, 83, 15, 92, 94, 8, 19, 36, 66, 9, 75, 90, 95, 110, 107, 151, 162, 148} + + var wg sync.WaitGroup + + for _, sportID := range sportIDs { + wg.Add(1) + go func(sportID int) { + defer wg.Done() + + url := fmt.Sprintf("https://api.b365api.com/v1/bet365/upcoming?sport_id=%d&token=%s", sportID, s.token) + resp, err := http.Get(url) + if err != nil { + fmt.Printf(" Failed request for upcoming sport_id=%d: %v\n", sportID, err) + return + } + defer resp.Body.Close() + + 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(" Decode failed for upcoming sport_id=%d\nRaw: %s\n", sportID, string(body)) + return + } + + for _, group := range data.Results { + for _, ev := range group { + if getString(ev["type"]) != "EV" { + continue + } + + event := domain.Event{ + ID: getString(ev["ID"]), + SportID: fmt.Sprintf("%d", sportID), + MatchName: getString(ev["NA"]), + HomeTeamID: getString(ev["HT"]), + AwayTeamID: getString(ev["AT"]), + HomeKitImage: getString(ev["K1"]), + AwayKitImage: getString(ev["K2"]), + LeagueID: getString(ev["C2"]), + LeagueName: getString(ev["CT"]), + LeagueCC: getString(ev["CB"]), + StartTime: time.Now().UTC().Format(time.RFC3339), + IsLive: false, + Status: "upcoming", + } + + if err := s.store.SaveEvent(ctx, event); err != nil { + fmt.Printf(" Could not store upcoming event [id=%s]: %v\n", event.ID, err) + } + } + } + }(sportID) + } + + wg.Wait() + fmt.Println(" All upcoming events fetched and stored.") + return nil +} + +func getString(v interface{}) string { + if str, ok := v.(string); ok { + return str + } + return "" +} + +func getInt(v interface{}) int { + if f, ok := v.(float64); ok { + return int(f) + } + return 0 +} diff --git a/internal/services/odds/port.go b/internal/services/odds/port.go new file mode 100644 index 0000000..d95367e --- /dev/null +++ b/internal/services/odds/port.go @@ -0,0 +1,8 @@ +package odds + +import "context" + +type Service interface { + FetchNonLiveOdds(ctx context.Context) error + +} diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go new file mode 100644 index 0000000..090add4 --- /dev/null +++ b/internal/services/odds/service.go @@ -0,0 +1,152 @@ +package odds + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "sync" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" + "github.com/SamuelTariku/FortuneBet-Backend/internal/repository" +) + +type ServiceImpl struct { + token string + store *repository.Store +} + +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) + } + + 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"` + } + + 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 { + continue + } + + wg.Add(1) + go func(originalID string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + url := fmt.Sprintf("https://api.b365api.com/v1/bet365/odds?token=%s&event_id=%s", s.token, originalID) + resp, err := http.Get(url) + if err != nil { + fmt.Printf(" Failed HTTP request for event_id=%s: %v\n", originalID, err) + return + } + defer resp.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 + } + + 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 + } + + 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("asian_lines", result.AsianLines) + saveOdds("goals", result.Goals) + + }(eventID) + } + + wg.Wait() + fmt.Println("✅ All non-live odds fetched and stored.") + return nil +} + diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go new file mode 100644 index 0000000..e895b6e --- /dev/null +++ b/internal/web_server/cron.go @@ -0,0 +1,54 @@ +package httpserver + +import ( + "context" + "log" + + eventsvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/event" + oddssvc "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" + "github.com/robfig/cron/v3" +) + +func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.Service) { + c := cron.New(cron.WithSeconds()) + + schedule := []struct { + spec string + task func() + }{ + { + spec: "0 0 * * * *", // Every hour + task: func() { + if err := eventService.FetchUpcomingEvents(context.Background()); err != nil { + log.Printf(" FetchUpcomingEvents error: %v", err) + } + }, + }, + // { + // spec: "*/5 * * * * *", // Every 5 seconds + // task: func() { + // if err := eventService.FetchLiveEvents(context.Background()); err != nil { + // log.Printf(" FetchLiveEvents error: %v", err) + // } + // }, + // }, + { + spec: "*/5 * * * * *", // Every 5 seconds + task: func() { + if err := oddsService.FetchNonLiveOdds(context.Background()); err != nil { + log.Printf(" FetchNonLiveOdds error: %v", err) + } + }, + }, + + } + + for _, job := range schedule { + if _, err := c.AddFunc(job.spec, job.task); err != nil { + log.Fatalf(" Failed to schedule cron job: %v", err) + } + } + + c.Start() + log.Println(" Cron jobs started for event and odds services") +} From 1d6a533f7ee83610308ec53530982b760ce9d9bc Mon Sep 17 00:00:00 2001 From: OneTap Technologies Date: Fri, 11 Apr 2025 13:57:32 +0300 Subject: [PATCH 2/4] 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 "" +} From a2820801331363ebcb907de7edd16ac406d3369f Mon Sep 17 00:00:00 2001 From: OneTap Technologies Date: Fri, 11 Apr 2025 15:12:55 +0300 Subject: [PATCH 3/4] addign odd data --- db/migrations/000001_fortune.up.sql | 25 +--- db/query/odds.sql | 1 + internal/domain/odds.go | 24 +-- internal/repository/odds.go | 89 ++++++++---- internal/services/odds/service.go | 217 ++++++++++++++-------------- 5 files changed, 193 insertions(+), 163 deletions(-) diff --git a/db/migrations/000001_fortune.up.sql b/db/migrations/000001_fortune.up.sql index ba45316..602a6ae 100644 --- a/db/migrations/000001_fortune.up.sql +++ b/db/migrations/000001_fortune.up.sql @@ -88,33 +88,24 @@ CREATE TABLE events ( CREATE TABLE odds ( id SERIAL PRIMARY KEY, - - -- 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 + fi TEXT, + raw_event_id TEXT, + market_type TEXT NOT NULL, + market_name TEXT, + market_category TEXT, + market_id TEXT, 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 + raw_odds JSONB, 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 c1ca21c..acb69d6 100644 --- a/db/query/odds.sql +++ b/db/query/odds.sql @@ -34,6 +34,7 @@ ON CONFLICT (event_id, market_id, header, name, handicap) DO UPDATE SET fi = EXCLUDED.fi, raw_event_id = EXCLUDED.raw_event_id; + -- name: GetUpcomingEventIDs :many SELECT id FROM events WHERE is_live = false; diff --git a/internal/domain/odds.go b/internal/domain/odds.go index 18652f5..521fdb5 100644 --- a/internal/domain/odds.go +++ b/internal/domain/odds.go @@ -6,12 +6,18 @@ import ( ) 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 + EventID string + FI string + MarketCategory string + MarketType string + MarketName string + MarketID string + UpdatedAt time.Time + Odds []json.RawMessage + + // Optional breakdown (extracted from odds) + Header string // only if processing one odd at a time + Name string + Handicap string + OddsVal float64 +} diff --git a/internal/repository/odds.go b/internal/repository/odds.go index af20bbb..399c13b 100644 --- a/internal/repository/odds.go +++ b/internal/repository/odds.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "time" dbgen "github.com/SamuelTariku/FortuneBet-Backend/gen/db" @@ -12,38 +13,57 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -func (s *Store) SaveNonLiveOdd(ctx context.Context, m domain.Market) error { - rawOddsBytes, _ := json.Marshal(m.Odds) - - params := dbgen.InsertNonLiveOddParams{ - 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, +func (s *Store) SaveNonLiveMarket(ctx context.Context, m domain.Market) error { + if len(m.Odds) == 0 { + fmt.Printf("⚠️ Market has no odds: %s (%s)\n", m.MarketType, m.EventID) + return nil } - err := s.queries.InsertNonLiveOdd(ctx, params) - if err != nil { - 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("✅ Upserted market: event_id=%s | market_type=%s\n", m.EventID, m.MarketType) - } + for _, raw := range m.Odds { + var item map[string]interface{} + if err := json.Unmarshal(raw, &item); err != nil { + fmt.Printf("❌ Invalid odd JSON for %s (%s): %v\n", m.MarketType, m.EventID, err) + continue + } - return err + header := getString(item["header"]) + name := getString(item["name"]) + handicap := getString(item["handicap"]) + oddsVal := getFloat(item["odds"]) + + // Marshal the full list of odds for reference (if needed) + rawOddsBytes, _ := json.Marshal(m.Odds) + + params := dbgen.InsertNonLiveOddParams{ + 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: header, Valid: header != ""}, + Name: pgtype.Text{String: name, Valid: name != ""}, + Handicap: pgtype.Text{String: handicap, Valid: handicap != ""}, + OddsValue: pgtype.Float8{Float64: oddsVal, Valid: oddsVal != 0}, + Section: m.MarketCategory, + Category: pgtype.Text{Valid: false}, + RawOdds: rawOddsBytes, + } + + err := s.queries.InsertNonLiveOdd(ctx, params) + if err != nil { + fmt.Printf("❌ Failed to insert odd for market %s (%s): %v\n", m.MarketType, m.EventID, err) + _ = writeFailedMarketLog(m, err) + continue + } + + fmt.Printf("✅ Inserted odd: %s | type=%s | header=%s | name=%s\n", m.EventID, m.MarketType, header, name) + } + return nil } + func writeFailedMarketLog(m domain.Market, err error) error { logDir := "logs" logFile := logDir + "/failed_markets.log" @@ -73,7 +93,22 @@ func writeFailedMarketLog(m domain.Market, err error) error { return writeErr } +func getString(v interface{}) string { + if s, ok := v.(string); ok { + return s + } + return "" +} +func getFloat(v interface{}) float64 { + if s, ok := v.(string); ok { + f, err := strconv.ParseFloat(s, 64) + if err == nil { + return f + } + } + return 0 +} 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 7562ff0..b45152c 100644 --- a/internal/services/odds/service.go +++ b/internal/services/odds/service.go @@ -23,124 +23,121 @@ func New(token string, store *repository.Store) *ServiceImpl { } func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { - sportIDs := []int{1, 13, 78, 18, 91, 16, 17} + fmt.Println("🔄 Starting FetchNonLiveOdds...") - 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() + sportID := 1 + 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: %v\n", err) + return err + } + defer resp.Body.Close() - 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 - } - - for _, ev := range data.Results { - if getString(ev["type"]) != "EV" { - continue - } - eventID := getString(ev["ID"]) - if eventID == "" { - continue - } - - 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("❌ Odds fetch failed for event_id=%s: %v\n", eventID, err) - continue - } - defer oddsResp.Body.Close() - - 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 - } - - result := oddsData.Results[0] - finalID := result.EventID - if finalID == "" { - finalID = result.FI - } - if finalID == "" { - fmt.Println("⚠️ Skipping event with missing final ID.") - continue - } - - 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 - } - - 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) - } + body, _ := io.ReadAll(resp.Body) + var upcomingData struct { + Success int `json:"success"` + Results []struct { + ID string `json:"id"` + } `json:"results"` + } + if err := json.Unmarshal(body, &upcomingData); err != nil || upcomingData.Success != 1 { + fmt.Printf("❌ Failed to decode upcoming response\nRaw: %s\n", string(body)) + return err } - fmt.Println("✅ All non-live odds fetched and stored.") + for _, ev := range upcomingData.Results { + eventID := ev.ID + fmt.Printf("📦 Fetching prematch odds for event_id=%s\n", eventID) + 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("❌ Odds fetch failed for event_id=%s: %v\n", eventID, err) + continue + } + defer oddsResp.Body.Close() + + oddsBody, _ := io.ReadAll(oddsResp.Body) + fmt.Printf("📩 Raw odds response for event_id=%s: %.300s...\n", eventID, string(oddsBody)) + + var oddsData struct { + Success int `json:"success"` + Results []struct { + EventID string `json:"event_id"` + FI string `json:"FI"` + Main OddsSection `json:"main"` + } `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 + } + + result := oddsData.Results[0] + finalID := result.EventID + if finalID == "" { + finalID = result.FI + } + if finalID == "" { + fmt.Println("⚠️ Skipping event with missing final ID.") + continue + } + + fmt.Printf("🗂 Saving prematch odds for event_id=%s\n", finalID) + s.storeSection(ctx, finalID, result.FI, "main", result.Main) + fmt.Printf("✅ Finished storing prematch odds for event_id=%s\n", finalID) + } + + fmt.Println("✅ All prematch odds fetched and stored.") return nil } +func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName string, section OddsSection) { + fmt.Printf("📂 Processing section '%s' for event_id=%s\n", sectionName, eventID) + if len(section.Sp) == 0 { + fmt.Printf("⚠️ No odds in section '%s' for event_id=%s\n", sectionName, eventID) + return + } + + updatedAtUnix, _ := strconv.ParseInt(section.UpdatedAt, 10, 64) + updatedAt := time.Unix(updatedAtUnix, 0) + + for marketType, market := range section.Sp { + fmt.Printf("🔍 Processing market: %s (%s)\n", marketType, market.ID) + if len(market.Odds) == 0 { + fmt.Printf("⚠️ Empty odds for marketType=%s in section=%s\n", marketType, sectionName) + continue + } + + marketRecord := domain.Market{ + EventID: eventID, + FI: fi, + MarketCategory: sectionName, + MarketType: marketType, + MarketName: market.Name, + MarketID: market.ID, + UpdatedAt: updatedAt, + Odds: market.Odds, + } + + fmt.Printf("📦 Saving market to DB: %s (%s)\n", marketType, market.ID) + err := s.store.SaveNonLiveMarket(ctx, marketRecord) + if err != nil { + fmt.Printf("❌ Save failed for market %s (%s): %v\n", marketType, eventID, err) + } else { + fmt.Printf("✅ Successfully stored market: %s (%s)\n", marketType, eventID) + } + } +} + // Odds structures type OddsMarket struct { - ID string `json:"id"` - Name string `json:"name"` - Odds []json.RawMessage `json:"odds"` + ID string `json:"id"` + Name string `json:"name"` + Odds []json.RawMessage `json:"odds"` + Header string `json:"header,omitempty"` + Handicap string `json:"handicap,omitempty"` } type OddsSection struct { @@ -148,10 +145,10 @@ type OddsSection struct { Sp map[string]OddsMarket `json:"sp"` } -// Helper +// Utility func getString(v interface{}) string { if str, ok := v.(string); ok { return str } return "" -} +} \ No newline at end of file From b90fd84aba3765e3cc2fa88aba1559637fb4437a Mon Sep 17 00:00:00 2001 From: OneTap Technologies Date: Fri, 11 Apr 2025 17:04:25 +0300 Subject: [PATCH 4/4] adding prematchodd --- cmd/main.go | 2 +- db/query/odds.sql | 25 ++++- docs/docs.go | 115 ++++++++++++++++++++++- docs/swagger.json | 115 ++++++++++++++++++++++- docs/swagger.yaml | 79 +++++++++++++++- gen/db/odds.sql.go | 56 +++++++++-- internal/domain/odds.go | 26 ++++- internal/repository/odds.go | 52 ++++++++-- internal/services/odds/port.go | 8 +- internal/services/odds/service.go | 51 ++++++---- internal/services/user/service.go | 1 + internal/web_server/app.go | 83 ++++++++-------- internal/web_server/cron.go | 16 ++-- internal/web_server/handlers/prematch.go | 37 ++++++++ internal/web_server/routes.go | 3 + 15 files changed, 566 insertions(+), 103 deletions(-) create mode 100644 internal/web_server/handlers/prematch.go diff --git a/cmd/main.go b/cmd/main.go index 6b945d5..584e821 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -67,7 +67,7 @@ func main() { app := httpserver.NewApp(cfg.Port, v, authSvc, logger, jwtutil.JwtConfig{ JwtAccessKey: cfg.JwtKey, JwtAccessExpiry: cfg.AccessExpiry, - }, userSvc) + }, userSvc, oddsSvc) logger.Info("Starting server", "port", cfg.Port) if err := app.Run(); err != nil { diff --git a/db/query/odds.sql b/db/query/odds.sql index acb69d6..07e1c99 100644 --- a/db/query/odds.sql +++ b/db/query/odds.sql @@ -35,6 +35,25 @@ ON CONFLICT (event_id, market_id, header, name, handicap) DO UPDATE SET raw_event_id = EXCLUDED.raw_event_id; --- name: GetUpcomingEventIDs :many -SELECT id FROM events -WHERE is_live = false; +-- name: GetPrematchOdds :many +SELECT + id, + event_id, + fi, + raw_event_id, + market_type, + market_name, + market_category, + market_id, + header, + name, + handicap, + odds_value, + section, + category, + raw_odds, + fetched_at, + source, + is_active +FROM odds +WHERE event_id = $1 AND is_active = true AND source = 'b365api'; \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 6625028..6448575 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -180,6 +180,53 @@ const docTemplate = `{ } } }, + "/prematch/odds/{event_id}": { + "get": { + "description": "Retrieve prematch odds for a specific event by event ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "prematch" + ], + "summary": "Retrieve prematch odds for an event", + "parameters": [ + { + "type": "string", + "description": "Event ID", + "name": "event_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Odd" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/user/checkPhoneEmailExist": { "post": { "description": "Check if phone number or email exist", @@ -452,20 +499,80 @@ const docTemplate = `{ } }, "definitions": { + "domain.Odd": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "fetched_at": { + "type": "string" + }, + "fi": { + "type": "string" + }, + "handicap": { + "type": "string" + }, + "header": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "market_category": { + "type": "string" + }, + "market_id": { + "type": "string" + }, + "market_name": { + "type": "string" + }, + "market_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "odds_value": { + "type": "number" + }, + "raw_event_id": { + "type": "string" + }, + "raw_odds": { + "type": "array", + "items": {} + }, + "section": { + "type": "string" + }, + "source": { + "type": "string" + } + } + }, "domain.Role": { "type": "string", "enum": [ - "admin", - "customer", "super_admin", + "admin", "branch_manager", + "customer", "cashier" ], "x-enum-varnames": [ - "RoleAdmin", - "RoleCustomer", "RoleSuperAdmin", + "RoleAdmin", "RoleBranchManager", + "RoleCustomer", "RoleCashier" ] }, diff --git a/docs/swagger.json b/docs/swagger.json index 76ae6c5..414256f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -172,6 +172,53 @@ } } }, + "/prematch/odds/{event_id}": { + "get": { + "description": "Retrieve prematch odds for a specific event by event ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "prematch" + ], + "summary": "Retrieve prematch odds for an event", + "parameters": [ + { + "type": "string", + "description": "Event ID", + "name": "event_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/domain.Odd" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/user/checkPhoneEmailExist": { "post": { "description": "Check if phone number or email exist", @@ -444,20 +491,80 @@ } }, "definitions": { + "domain.Odd": { + "type": "object", + "properties": { + "category": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "fetched_at": { + "type": "string" + }, + "fi": { + "type": "string" + }, + "handicap": { + "type": "string" + }, + "header": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "market_category": { + "type": "string" + }, + "market_id": { + "type": "string" + }, + "market_name": { + "type": "string" + }, + "market_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "odds_value": { + "type": "number" + }, + "raw_event_id": { + "type": "string" + }, + "raw_odds": { + "type": "array", + "items": {} + }, + "section": { + "type": "string" + }, + "source": { + "type": "string" + } + } + }, "domain.Role": { "type": "string", "enum": [ - "admin", - "customer", "super_admin", + "admin", "branch_manager", + "customer", "cashier" ], "x-enum-varnames": [ - "RoleAdmin", - "RoleCustomer", "RoleSuperAdmin", + "RoleAdmin", "RoleBranchManager", + "RoleCustomer", "RoleCashier" ] }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 166d41d..00333d0 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,17 +1,57 @@ definitions: + domain.Odd: + properties: + category: + type: string + event_id: + type: string + fetched_at: + type: string + fi: + type: string + handicap: + type: string + header: + type: string + id: + type: integer + is_active: + type: boolean + market_category: + type: string + market_id: + type: string + market_name: + type: string + market_type: + type: string + name: + type: string + odds_value: + type: number + raw_event_id: + type: string + raw_odds: + items: {} + type: array + section: + type: string + source: + type: string + type: object domain.Role: enum: - - admin - - customer - super_admin + - admin - branch_manager + - customer - cashier type: string x-enum-varnames: - - RoleAdmin - - RoleCustomer - RoleSuperAdmin + - RoleAdmin - RoleBranchManager + - RoleCustomer - RoleCashier handlers.CheckPhoneEmailExistReq: properties: @@ -275,6 +315,37 @@ paths: summary: Refresh token tags: - auth + /prematch/odds/{event_id}: + get: + consumes: + - application/json + description: Retrieve prematch odds for a specific event by event ID + parameters: + - description: Event ID + in: path + name: event_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/domain.Odd' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Retrieve prematch odds for an event + tags: + - prematch /user/checkPhoneEmailExist: post: consumes: diff --git a/gen/db/odds.sql.go b/gen/db/odds.sql.go index f3e0029..003be80 100644 --- a/gen/db/odds.sql.go +++ b/gen/db/odds.sql.go @@ -11,24 +11,62 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) -const GetUpcomingEventIDs = `-- name: GetUpcomingEventIDs :many -SELECT id FROM events -WHERE is_live = false +const GetPrematchOdds = `-- name: GetPrematchOdds :many +SELECT + id, + event_id, + fi, + raw_event_id, + market_type, + market_name, + market_category, + market_id, + header, + name, + handicap, + odds_value, + section, + category, + raw_odds, + fetched_at, + source, + is_active +FROM odds +WHERE event_id = $1 AND is_active = true AND source = 'b365api' ` -func (q *Queries) GetUpcomingEventIDs(ctx context.Context) ([]string, error) { - rows, err := q.db.Query(ctx, GetUpcomingEventIDs) +func (q *Queries) GetPrematchOdds(ctx context.Context, eventID pgtype.Text) ([]Odd, error) { + rows, err := q.db.Query(ctx, GetPrematchOdds, eventID) if err != nil { return nil, err } defer rows.Close() - var items []string + var items []Odd for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { + var i Odd + if err := rows.Scan( + &i.ID, + &i.EventID, + &i.Fi, + &i.RawEventID, + &i.MarketType, + &i.MarketName, + &i.MarketCategory, + &i.MarketID, + &i.Header, + &i.Name, + &i.Handicap, + &i.OddsValue, + &i.Section, + &i.Category, + &i.RawOdds, + &i.FetchedAt, + &i.Source, + &i.IsActive, + ); err != nil { return nil, err } - items = append(items, id) + items = append(items, i) } if err := rows.Err(); err != nil { return nil, err diff --git a/internal/domain/odds.go b/internal/domain/odds.go index 521fdb5..418b33d 100644 --- a/internal/domain/odds.go +++ b/internal/domain/odds.go @@ -3,7 +3,9 @@ package domain import ( "encoding/json" "time" + ) +type RawMessage interface{} // Change from json.RawMessage to interface{} type Market struct { EventID string @@ -15,9 +17,29 @@ type Market struct { UpdatedAt time.Time Odds []json.RawMessage - // Optional breakdown (extracted from odds) - Header string // only if processing one odd at a time + Header string Name string Handicap string OddsVal float64 } + +type Odd struct { + ID int64 `json:"id"` + EventID string `json:"event_id"` + Fi string `json:"fi"` + RawEventID string `json:"raw_event_id"` + MarketType string `json:"market_type"` + MarketName string `json:"market_name"` + MarketCategory string `json:"market_category"` + MarketID string `json:"market_id"` + Header string `json:"header"` + Name string `json:"name"` + Handicap string `json:"handicap"` + OddsValue float64 `json:"odds_value"` + Section string `json:"section"` + Category string `json:"category"` + RawOdds []RawMessage `json:"raw_odds"` + FetchedAt time.Time `json:"fetched_at"` + Source string `json:"source"` + IsActive bool `json:"is_active"` +} \ No newline at end of file diff --git a/internal/repository/odds.go b/internal/repository/odds.go index 399c13b..a21c729 100644 --- a/internal/repository/odds.go +++ b/internal/repository/odds.go @@ -15,14 +15,14 @@ import ( func (s *Store) SaveNonLiveMarket(ctx context.Context, m domain.Market) error { if len(m.Odds) == 0 { - fmt.Printf("⚠️ Market has no odds: %s (%s)\n", m.MarketType, m.EventID) + fmt.Printf(" Market has no odds: %s (%s)\n", m.MarketType, m.EventID) return nil } for _, raw := range m.Odds { var item map[string]interface{} if err := json.Unmarshal(raw, &item); err != nil { - fmt.Printf("❌ Invalid odd JSON for %s (%s): %v\n", m.MarketType, m.EventID, err) + fmt.Printf(" Invalid odd JSON for %s (%s): %v\n", m.MarketType, m.EventID, err) continue } @@ -31,7 +31,6 @@ func (s *Store) SaveNonLiveMarket(ctx context.Context, m domain.Market) error { handicap := getString(item["handicap"]) oddsVal := getFloat(item["odds"]) - // Marshal the full list of odds for reference (if needed) rawOddsBytes, _ := json.Marshal(m.Odds) params := dbgen.InsertNonLiveOddParams{ @@ -53,12 +52,12 @@ func (s *Store) SaveNonLiveMarket(ctx context.Context, m domain.Market) error { err := s.queries.InsertNonLiveOdd(ctx, params) if err != nil { - fmt.Printf("❌ Failed to insert odd for market %s (%s): %v\n", m.MarketType, m.EventID, err) + fmt.Printf(" Failed to insert odd for market %s (%s): %v\n", m.MarketType, m.EventID, err) _ = writeFailedMarketLog(m, err) continue } - fmt.Printf("✅ Inserted odd: %s | type=%s | header=%s | name=%s\n", m.EventID, m.MarketType, header, name) + fmt.Printf("Inserted odd: %s | type=%s | header=%s | name=%s\n", m.EventID, m.MarketType, header, name) } return nil } @@ -110,6 +109,43 @@ func getFloat(v interface{}) float64 { return 0 } -func (s *Store) GetUpcomingEventIDs(ctx context.Context) ([]string, error) { - return s.queries.GetUpcomingEventIDs(ctx) -} +func (s *Store) GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) { + eventIDParam := pgtype.Text{String: eventID, Valid: eventID != ""} + + odds, err := s.queries.GetPrematchOdds(ctx, eventIDParam) + if err != nil { + return nil, err + } + + domainOdds := make([]domain.Odd, len(odds)) + for i, odd := range odds { + domainOdds[i] = domain.Odd{ + ID: int64(odd.ID), // Cast int32 to int64 + EventID: odd.EventID.String, // Extract the String value + Fi: odd.Fi.String, // Extract the String value + RawEventID: odd.RawEventID.String, // Extract the String value + MarketType: odd.MarketType, // Direct assignment + MarketName: odd.MarketName.String, // Extract the String value + MarketCategory: odd.MarketCategory.String, // Extract the String value + MarketID: odd.MarketID.String, // Extract the String value + Header: odd.Header.String, // Extract the String value + Name: odd.Name.String, // Extract the String value + Handicap: odd.Handicap.String, // Extract the String value + OddsValue: odd.OddsValue.Float64, // Extract the Float64 value + Section: odd.Section, // Direct assignment + Category: odd.Category.String, // Extract the String value + RawOdds: func() []domain.RawMessage { + var rawOdds []domain.RawMessage + if err := json.Unmarshal(odd.RawOdds, &rawOdds); err != nil { + rawOdds = nil + } + return rawOdds + }(), + FetchedAt: odd.FetchedAt.Time, // Extract the Time value + Source: odd.Source.String, // Extract the String value + IsActive: odd.IsActive.Bool, // Extract the Bool value + } + } + + return domainOdds, nil +} \ No newline at end of file diff --git a/internal/services/odds/port.go b/internal/services/odds/port.go index d95367e..d50a8af 100644 --- a/internal/services/odds/port.go +++ b/internal/services/odds/port.go @@ -1,8 +1,14 @@ package odds -import "context" +import ( + "context" + + "github.com/SamuelTariku/FortuneBet-Backend/internal/domain" +) type Service interface { FetchNonLiveOdds(ctx context.Context) error + GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) + } diff --git a/internal/services/odds/service.go b/internal/services/odds/service.go index b45152c..2f3245d 100644 --- a/internal/services/odds/service.go +++ b/internal/services/odds/service.go @@ -23,13 +23,13 @@ func New(token string, store *repository.Store) *ServiceImpl { } func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { - fmt.Println("🔄 Starting FetchNonLiveOdds...") + fmt.Println("Starting FetchNonLiveOdds...") sportID := 1 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: %v\n", err) + fmt.Printf("Failed to fetch upcoming: %v\n", err) return err } defer resp.Body.Close() @@ -42,23 +42,23 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { } `json:"results"` } if err := json.Unmarshal(body, &upcomingData); err != nil || upcomingData.Success != 1 { - fmt.Printf("❌ Failed to decode upcoming response\nRaw: %s\n", string(body)) + fmt.Printf("Failed to decode upcoming response\nRaw: %s\n", string(body)) return err } for _, ev := range upcomingData.Results { eventID := ev.ID - fmt.Printf("📦 Fetching prematch odds for event_id=%s\n", eventID) + fmt.Printf("Fetching prematch odds for event_id=%s\n", eventID) 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("❌ Odds fetch failed for event_id=%s: %v\n", eventID, err) + fmt.Printf(" Odds fetch failed for event_id=%s: %v\n", eventID, err) continue } defer oddsResp.Body.Close() oddsBody, _ := io.ReadAll(oddsResp.Body) - fmt.Printf("📩 Raw odds response for event_id=%s: %.300s...\n", eventID, string(oddsBody)) + fmt.Printf(" Raw odds response for event_id=%s: %.300s...\n", eventID, string(oddsBody)) var oddsData struct { Success int `json:"success"` @@ -69,7 +69,7 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { } `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)) + fmt.Printf(" Failed odds decode for event_id=%s\nRaw: %s\n", eventID, string(oddsBody)) continue } @@ -79,23 +79,23 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error { finalID = result.FI } if finalID == "" { - fmt.Println("⚠️ Skipping event with missing final ID.") + fmt.Println(" Skipping event with missing final ID.") continue } fmt.Printf("🗂 Saving prematch odds for event_id=%s\n", finalID) s.storeSection(ctx, finalID, result.FI, "main", result.Main) - fmt.Printf("✅ Finished storing prematch odds for event_id=%s\n", finalID) + fmt.Printf(" Finished storing prematch odds for event_id=%s\n", finalID) } - fmt.Println("✅ All prematch odds fetched and stored.") + fmt.Println(" All prematch odds fetched and stored.") return nil } func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName string, section OddsSection) { - fmt.Printf("📂 Processing section '%s' for event_id=%s\n", sectionName, eventID) + fmt.Printf(" Processing section '%s' for event_id=%s\n", sectionName, eventID) if len(section.Sp) == 0 { - fmt.Printf("⚠️ No odds in section '%s' for event_id=%s\n", sectionName, eventID) + fmt.Printf(" No odds in section '%s' for event_id=%s\n", sectionName, eventID) return } @@ -103,9 +103,9 @@ func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName updatedAt := time.Unix(updatedAtUnix, 0) for marketType, market := range section.Sp { - fmt.Printf("🔍 Processing market: %s (%s)\n", marketType, market.ID) + fmt.Printf(" Processing market: %s (%s)\n", marketType, market.ID) if len(market.Odds) == 0 { - fmt.Printf("⚠️ Empty odds for marketType=%s in section=%s\n", marketType, sectionName) + fmt.Printf(" Empty odds for marketType=%s in section=%s\n", marketType, sectionName) continue } @@ -120,17 +120,16 @@ func (s *ServiceImpl) storeSection(ctx context.Context, eventID, fi, sectionName Odds: market.Odds, } - fmt.Printf("📦 Saving market to DB: %s (%s)\n", marketType, market.ID) + fmt.Printf(" Saving market to DB: %s (%s)\n", marketType, market.ID) err := s.store.SaveNonLiveMarket(ctx, marketRecord) if err != nil { - fmt.Printf("❌ Save failed for market %s (%s): %v\n", marketType, eventID, err) + fmt.Printf(" Save failed for market %s (%s): %v\n", marketType, eventID, err) } else { - fmt.Printf("✅ Successfully stored market: %s (%s)\n", marketType, eventID) + fmt.Printf(" Successfully stored market: %s (%s)\n", marketType, eventID) } } } -// Odds structures type OddsMarket struct { ID string `json:"id"` @@ -145,10 +144,22 @@ type OddsSection struct { Sp map[string]OddsMarket `json:"sp"` } -// Utility func getString(v interface{}) string { if str, ok := v.(string); ok { return str } return "" -} \ No newline at end of file +} + +func (s *ServiceImpl) GetPrematchOdds(ctx context.Context, eventID string) ([]domain.Odd, error) { + fmt.Printf("Retrieving prematch odds for event_id=%s\n", eventID) + + odds, err := s.store.GetPrematchOdds(ctx, eventID) + if err != nil { + fmt.Printf(" Failed to retrieve odds for event_id=%s: %v\n", eventID, err) + return nil, err + } + + fmt.Printf(" Retrieved %d odds entries for event_id=%s\n", len(odds), eventID) + return odds, nil +} diff --git a/internal/services/user/service.go b/internal/services/user/service.go index cfa93fd..17a7820 100644 --- a/internal/services/user/service.go +++ b/internal/services/user/service.go @@ -13,6 +13,7 @@ type Service struct { otpStore OtpStore smsGateway SmsGateway emailGateway EmailGateway + } func NewService( diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 2ebd22e..4149f6b 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -1,55 +1,60 @@ package httpserver import ( - "fmt" - "log/slog" + "fmt" + "log/slog" - "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" - "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" - jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" - customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" - "github.com/bytedance/sonic" - "github.com/gofiber/fiber/v2" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/authentication" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/user" + jwtutil "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/jwt" + customvalidator "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/validator" + + "github.com/bytedance/sonic" + "github.com/gofiber/fiber/v2" ) type App struct { - fiber *fiber.App - logger *slog.Logger - port int - authSvc *authentication.Service - userSvc *user.Service - validator *customvalidator.CustomValidator - JwtConfig jwtutil.JwtConfig + fiber *fiber.App + logger *slog.Logger + port int + authSvc *authentication.Service + userSvc *user.Service + validator *customvalidator.CustomValidator + JwtConfig jwtutil.JwtConfig + prematchSvc *odds.ServiceImpl } func NewApp( - port int, validator *customvalidator.CustomValidator, - authSvc *authentication.Service, - logger *slog.Logger, - JwtConfig jwtutil.JwtConfig, - userSvc *user.Service, + port int, validator *customvalidator.CustomValidator, + authSvc *authentication.Service, + logger *slog.Logger, + JwtConfig jwtutil.JwtConfig, + userSvc *user.Service, + prematchSvc *odds.ServiceImpl, ) *App { - app := fiber.New(fiber.Config{ - CaseSensitive: true, - DisableHeaderNormalizing: true, - JSONEncoder: sonic.Marshal, - JSONDecoder: sonic.Unmarshal, - }) - s := &App{ - fiber: app, - port: port, - authSvc: authSvc, - validator: validator, - logger: logger, - JwtConfig: JwtConfig, - userSvc: userSvc, - } + app := fiber.New(fiber.Config{ + CaseSensitive: true, + DisableHeaderNormalizing: true, + JSONEncoder: sonic.Marshal, + JSONDecoder: sonic.Unmarshal, + }) + s := &App{ + fiber: app, + port: port, + authSvc: authSvc, + validator: validator, + logger: logger, + JwtConfig: JwtConfig, + userSvc: userSvc, + prematchSvc: prematchSvc, + } - s.initAppRoutes() + s.initAppRoutes() - return s + return s } func (a *App) Run() error { - return a.fiber.Listen(fmt.Sprintf(":%d", a.port)) -} + return a.fiber.Listen(fmt.Sprintf(":%d", a.port)) +} \ No newline at end of file diff --git a/internal/web_server/cron.go b/internal/web_server/cron.go index e895b6e..68ddcf2 100644 --- a/internal/web_server/cron.go +++ b/internal/web_server/cron.go @@ -24,14 +24,14 @@ func StartDataFetchingCrons(eventService eventsvc.Service, oddsService oddssvc.S } }, }, - // { - // spec: "*/5 * * * * *", // Every 5 seconds - // task: func() { - // if err := eventService.FetchLiveEvents(context.Background()); err != nil { - // log.Printf(" FetchLiveEvents error: %v", err) - // } - // }, - // }, + { + spec: "*/5 * * * * *", // Every 5 seconds + task: func() { + if err := eventService.FetchLiveEvents(context.Background()); err != nil { + log.Printf(" FetchLiveEvents error: %v", err) + } + }, + }, { spec: "*/5 * * * * *", // Every 5 seconds task: func() { diff --git a/internal/web_server/handlers/prematch.go b/internal/web_server/handlers/prematch.go new file mode 100644 index 0000000..cc48b10 --- /dev/null +++ b/internal/web_server/handlers/prematch.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v2" + "github.com/SamuelTariku/FortuneBet-Backend/internal/services/odds" + "github.com/SamuelTariku/FortuneBet-Backend/internal/web_server/response" + "log/slog" +) + +// GetPrematchOdds godoc +// @Summary Retrieve prematch odds for an event +// @Description Retrieve prematch odds for a specific event by event ID +// @Tags prematch +// @Accept json +// @Produce json +// @Param event_id path string true "Event ID" +// @Success 200 {array} domain.Odd +// @Failure 400 {object} response.APIResponse +// @Failure 500 {object} response.APIResponse +// @Router /prematch/odds/{event_id} [get] +func GetPrematchOdds(logger *slog.Logger, prematchSvc *odds.ServiceImpl) fiber.Handler { + return func(c *fiber.Ctx) error { + eventID := c.Params("event_id") + if eventID == "" { + logger.Error("GetPrematchOdds failed: missing event_id") + return response.WriteJSON(c, fiber.StatusBadRequest, "Missing event_id", nil, nil) + } + + odds, err := prematchSvc.GetPrematchOdds(c.Context(), eventID) + if err != nil { + logger.Error("GetPrematchOdds failed", "error", err) + return response.WriteJSON(c, fiber.StatusInternalServerError, "Failed to retrieve odds", nil, nil) + } + + return response.WriteJSON(c, fiber.StatusOK, "Prematch odds retrieved successfully", odds, nil) + } +} \ No newline at end of file diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index c30622d..22ebb93 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -26,6 +26,9 @@ func (a *App) initAppRoutes() { a.fiber.Post("/user/sendRegisterCode", handlers.SendRegisterCode(a.logger, a.userSvc, a.validator)) a.fiber.Post("/user/checkPhoneEmailExist", handlers.CheckPhoneEmailExist(a.logger, a.userSvc, a.validator)) a.fiber.Get("/user/profile", a.authMiddleware, handlers.UserProfile(a.logger, a.userSvc)) + + a.fiber.Get("/prematch/odds/:event_id", handlers.GetPrematchOdds(a.logger, a.prematchSvc)) + // Swagger a.fiber.Get("/swagger/*", fiberSwagger.WrapHandler) }