addign odd

This commit is contained in:
OneTap Technologies 2025-04-11 13:57:32 +03:00
parent 92250d61a8
commit 1d6a533f7e
9 changed files with 318 additions and 218 deletions

View File

@ -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,

View File

@ -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)
);

View File

@ -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

View File

@ -35,18 +35,22 @@ type Event struct {
type Odd struct {
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
OddsValue pgtype.Float8
Handicap pgtype.Text
OddsValue pgtype.Float8
Section string
Category pgtype.Text
MarketID pgtype.Text
RawOdds []byte
FetchedAt pgtype.Timestamp
Source pgtype.Text
IsActive pgtype.Bool
RawEventID pgtype.Text
}
type Otp struct {

View File

@ -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
Fi pgtype.Text
RawEventID pgtype.Text
MarketType string
MarketName pgtype.Text
MarketCategory pgtype.Text
MarketID pgtype.Text
Header pgtype.Text
Name pgtype.Text
OddsValue pgtype.Float8
Handicap pgtype.Text
OddsValue pgtype.Float8
Section string
Category pgtype.Text
MarketID pgtype.Text
RawEventID 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
}

2
go.mod
View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -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,30 +21,48 @@ 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)
sportIDs := []int{1, 13, 78, 18, 91, 16, 17}
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 {
return fmt.Errorf("fetch upcoming event IDs: %w", err)
fmt.Printf("❌ Failed to fetch upcoming for sport_id=%d: %v\n", sportID, err)
continue
}
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
}
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 _, ev := range data.Results {
if getString(ev["type"]) != "EV" {
continue
}
eventID := getString(ev["ID"])
if eventID == "" {
continue
}
type OddsSection struct {
UpdatedAt string `json:"updated_at"`
Sp map[string]OddsMarket `json:"sp"`
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()
type StructuredOddsResponse struct {
oddsBody, _ := io.ReadAll(oddsResp.Body)
var oddsData struct {
Success int `json:"success"`
Results []struct {
EventID string `json:"event_id"`
@ -53,100 +71,87 @@ func (s *ServiceImpl) FetchNonLiveOdds(ctx context.Context) error {
Goals OddsSection `json:"goals"`
} `json:"results"`
}
var wg sync.WaitGroup
sem := make(chan struct{}, 5)
for _, eventID := range eventIDs {
if eventID == "" || len(eventID) < 5 {
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
}
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
result := oddsData.Results[0]
finalID := result.EventID
if finalID == "" {
finalID = result.FI
}
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
if finalID == "" {
fmt.Println("⚠️ Skipping event with missing final ID.")
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
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
}
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
}
updatedAtUnix, _ := strconv.ParseInt(section.UpdatedAt, 10, 64)
updatedAt := time.Unix(updatedAtUnix, 0)
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)
if len(market.Odds) == 0 {
fmt.Printf("⚠️ No odds for marketType=%s in section=%s\n", marketType, sectionName)
continue
}
record := domain.OddsRecord{
EventID: finalEventID,
marketRecord := domain.Market{
EventID: finalID,
FI: result.FI,
MarketCategory: sectionName,
MarketType: marketType,
Header: odd.Header,
Name: odd.Name,
Handicap: odd.Handicap,
OddsValue: val,
Section: sectionName,
Category: market.ID,
MarketID: odd.ID,
RawEventID: originalID,
MarketName: market.Name,
MarketID: market.ID,
UpdatedAt: updatedAt,
Odds: market.Odds,
}
fmt.Printf("🟡 Preparing to store: event_id=%s | market=%s | header=%s | name=%s | odds=%.3f\n",
finalEventID, marketType, odd.Header, odd.Name, val)
s.store.SaveNonLiveOdd(ctx, marketRecord)
fmt.Printf("✅ STORED MARKET: event_id=%s | type=%s | name=%s\n", finalID, marketType, market.Name)
}
return nil
}
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)
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)
}
}
saveOdds("asian_lines", result.AsianLines)
saveOdds("goals", result.Goals)
}(eventID)
}
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 ""
}