437 lines
14 KiB
Go
437 lines
14 KiB
Go
package result
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/SamuelTariku/FortuneBet-Backend/internal/config"
|
|
"github.com/SamuelTariku/FortuneBet-Backend/internal/domain"
|
|
"github.com/SamuelTariku/FortuneBet-Backend/internal/repository"
|
|
)
|
|
|
|
type Service struct {
|
|
repo *repository.Store
|
|
config *config.Config
|
|
logger *slog.Logger
|
|
client *http.Client
|
|
}
|
|
|
|
func NewService(repo *repository.Store, cfg *config.Config, logger *slog.Logger) *Service {
|
|
return &Service{
|
|
repo: repo,
|
|
config: cfg,
|
|
logger: logger,
|
|
client: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
var supportedMarkets = map[string]domain.MarketConfig{
|
|
"football": {
|
|
Sport: "football",
|
|
MarketCategories: map[string]bool{
|
|
"main": true,
|
|
"asian_lines": true,
|
|
"goals": true,
|
|
"half": true,
|
|
},
|
|
MarketTypes: map[string]bool{
|
|
"full_time_result": true,
|
|
"double_chance": true,
|
|
"goals_over_under": true,
|
|
"correct_score": true,
|
|
"asian_handicap": true,
|
|
"goal_line": true,
|
|
"half_time_result": true,
|
|
"1st_half_asian_handicap": true,
|
|
"1st_half_goal_line": true,
|
|
"first_team_to_score": true,
|
|
"goals_odd_even": true,
|
|
"draw_no_bet": true,
|
|
},
|
|
},
|
|
}
|
|
|
|
func (s *Service) FetchAndProcessResults(ctx context.Context) error {
|
|
outcomes, err := s.repo.GetPendingBetOutcomes(ctx)
|
|
if err != nil {
|
|
s.logger.Error("Failed to get pending bet outcomes", "error", err)
|
|
return err
|
|
}
|
|
|
|
for _, outcome := range outcomes {
|
|
if outcome.Expires.After(time.Now()) {
|
|
continue
|
|
}
|
|
|
|
result, err := s.fetchResult(ctx, outcome.EventID, outcome.OddID, outcome.MarketID, outcome)
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch result", "event_id", outcome.EventID, "error", err)
|
|
continue
|
|
}
|
|
|
|
_, err = s.repo.CreateResult(ctx, domain.CreateResult{
|
|
BetOutcomeID: outcome.ID,
|
|
EventID: outcome.EventID,
|
|
OddID: outcome.OddID,
|
|
MarketID: outcome.MarketID,
|
|
Status: result.Status,
|
|
Score: result.Score,
|
|
})
|
|
if err != nil {
|
|
s.logger.Error("Failed to store result", "bet_outcome_id", outcome.ID, "error", err)
|
|
continue
|
|
}
|
|
|
|
err = s.repo.UpdateBetOutcomeStatus(ctx, outcome.ID, result.Status)
|
|
if err != nil {
|
|
s.logger.Error("Failed to update bet outcome status", "bet_outcome_id", outcome.ID, "error", err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) FetchAndStoreResult(ctx context.Context, eventID string) error {
|
|
url := fmt.Sprintf("https://api.b365api.com/v1/bet365/result?token=%s&event_id=%s", s.config.Bet365Token, eventID)
|
|
|
|
res, err := s.client.Get(url)
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch result", "event_id", eventID, "error", err)
|
|
return fmt.Errorf("failed to fetch result: %w", err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK {
|
|
s.logger.Error("Unexpected status code", "event_id", eventID, "status_code", res.StatusCode)
|
|
return fmt.Errorf("unexpected status code: %d", res.StatusCode)
|
|
}
|
|
|
|
var apiResp domain.ResultResponse
|
|
if err := json.NewDecoder(res.Body).Decode(&apiResp); err != nil {
|
|
s.logger.Error("Failed to decode result", "event_id", eventID, "error", err)
|
|
return fmt.Errorf("failed to decode result: %w", err)
|
|
}
|
|
|
|
if apiResp.Success != 1 || len(apiResp.Results) == 0 {
|
|
s.logger.Error("Invalid API response", "event_id", eventID)
|
|
return fmt.Errorf("no result returned from API")
|
|
}
|
|
|
|
r := apiResp.Results[0]
|
|
if r.TimeStatus != "3" {
|
|
s.logger.Warn("Match not yet completed", "event_id", eventID)
|
|
return fmt.Errorf("match not yet completed")
|
|
}
|
|
|
|
eventIDInt, err := strconv.ParseInt(eventID, 10, 64)
|
|
if err != nil {
|
|
s.logger.Error("Failed to parse event_id", "event_id", eventID, "error", err)
|
|
return fmt.Errorf("failed to parse event_id: %w", err)
|
|
}
|
|
|
|
halfScore := ""
|
|
if r.Scores.FirstHalf.Home != "" {
|
|
halfScore = fmt.Sprintf("%s-%s", r.Scores.FirstHalf.Home, r.Scores.FirstHalf.Away)
|
|
}
|
|
|
|
result := domain.Result{
|
|
EventID: eventIDInt,
|
|
Status: domain.OUTCOME_STATUS_PENDING,
|
|
Score: r.SS,
|
|
FullTimeScore: r.SS,
|
|
HalfTimeScore: halfScore,
|
|
SS: r.SS,
|
|
Scores: make(map[string]domain.Score),
|
|
}
|
|
for k, v := range map[string]domain.Score{
|
|
"1": r.Scores.FirstHalf,
|
|
"2": r.Scores.SecondHalf,
|
|
} {
|
|
result.Scores[k] = domain.Score{
|
|
Home: v.Home,
|
|
Away: v.Away,
|
|
}
|
|
}
|
|
|
|
return s.repo.InsertResult(ctx, result)
|
|
}
|
|
|
|
func (s *Service) fetchResult(ctx context.Context, eventID, oddID, marketID int64, outcome domain.BetOutcome) (domain.CreateResult, error) {
|
|
url := fmt.Sprintf("https://api.b365api.com/v1/bet365/result?token=%s&event_id=%d", s.config.Bet365Token, eventID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
s.logger.Error("Failed to create request", "event_id", eventID, "error", err)
|
|
return domain.CreateResult{}, err
|
|
}
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
s.logger.Error("Failed to fetch result", "event_id", eventID, "error", err)
|
|
return domain.CreateResult{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
s.logger.Error("Unexpected status code", "event_id", eventID, "status_code", resp.StatusCode)
|
|
return domain.CreateResult{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
}
|
|
|
|
var resultResp domain.ResultResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&resultResp); err != nil {
|
|
s.logger.Error("Failed to decode result", "event_id", eventID, "error", err)
|
|
return domain.CreateResult{}, err
|
|
}
|
|
|
|
if resultResp.Success != 1 || len(resultResp.Results) == 0 {
|
|
s.logger.Error("Invalid API response", "event_id", eventID)
|
|
return domain.CreateResult{}, fmt.Errorf("invalid API response")
|
|
}
|
|
|
|
result := resultResp.Results[0]
|
|
if result.TimeStatus != "3" {
|
|
s.logger.Warn("Match not yet completed", "event_id", eventID)
|
|
return domain.CreateResult{}, fmt.Errorf("match not yet completed")
|
|
}
|
|
|
|
finalScore := parseScore(result.SS)
|
|
firstHalfScore := parseScore(fmt.Sprintf("%s-%s", result.Scores.FirstHalf.Home, result.Scores.FirstHalf.Away))
|
|
|
|
corners := parseStats(result.Stats.Corners)
|
|
status, err := s.evaluateOutcome(outcome, finalScore, firstHalfScore, corners, result.Events)
|
|
if err != nil {
|
|
s.logger.Error("Failed to evaluate outcome", "event_id", eventID, "market_id", marketID, "error", err)
|
|
return domain.CreateResult{}, err
|
|
}
|
|
|
|
return domain.CreateResult{
|
|
BetOutcomeID: 0,
|
|
EventID: eventID,
|
|
OddID: oddID,
|
|
MarketID: marketID,
|
|
Status: status,
|
|
Score: result.SS,
|
|
}, nil
|
|
}
|
|
|
|
func parseScore(scoreStr string) struct{ Home, Away int } {
|
|
parts := strings.Split(scoreStr, "-")
|
|
if len(parts) != 2 {
|
|
return struct{ Home, Away int }{0, 0}
|
|
}
|
|
home, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
|
|
away, _ := strconv.Atoi(strings.TrimSpace(parts[1]))
|
|
return struct{ Home, Away int }{Home: home, Away: away}
|
|
}
|
|
|
|
func parseStats(stats []string) struct{ Home, Away int } {
|
|
if len(stats) != 2 {
|
|
return struct{ Home, Away int }{0, 0}
|
|
}
|
|
home, _ := strconv.Atoi(stats[0])
|
|
away, _ := strconv.Atoi(stats[1])
|
|
return struct{ Home, Away int }{Home: home, Away: away}
|
|
}
|
|
|
|
// evaluateOutcome determines the outcome status based on market type and odd
|
|
func (s *Service) evaluateOutcome(outcome domain.BetOutcome, finalScore, firstHalfScore struct{ Home, Away int }, corners struct{ Home, Away int }, events []map[string]string) (domain.OutcomeStatus, error) {
|
|
marketConfig := supportedMarkets["football"]
|
|
if !marketConfig.MarketTypes[outcome.MarketName] {
|
|
s.logger.Warn("Unsupported market type", "market_name", outcome.MarketName)
|
|
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("unsupported market type: %s", outcome.MarketName)
|
|
}
|
|
|
|
switch outcome.MarketName {
|
|
case "full_time_result":
|
|
return evaluateFullTimeResult(outcome, finalScore)
|
|
case "goals_over_under":
|
|
return evaluateGoalsOverUnder(outcome, finalScore)
|
|
case "correct_score":
|
|
return evaluateCorrectScore(outcome, finalScore)
|
|
case "half_time_result":
|
|
return evaluateHalfTimeResult(outcome, firstHalfScore)
|
|
case "asian_handicap":
|
|
return evaluateAsianHandicap(outcome, finalScore)
|
|
case "goal_line":
|
|
return evaluateGoalLine(outcome, finalScore)
|
|
case "1st_half_asian_handicap":
|
|
return evaluateAsianHandicap(outcome, firstHalfScore)
|
|
case "1st_half_goal_line":
|
|
return evaluateGoalLine(outcome, firstHalfScore)
|
|
case "first_team_to_score":
|
|
return evaluateFirstTeamToScore(outcome, events)
|
|
case "goals_odd_even":
|
|
return evaluateGoalsOddEven(outcome, finalScore)
|
|
case "double_chance":
|
|
return evaluateDoubleChance(outcome, finalScore)
|
|
case "draw_no_bet":
|
|
return evaluateDrawNoBet(outcome, finalScore)
|
|
default:
|
|
s.logger.Warn("Market type not implemented", "market_name", outcome.MarketName)
|
|
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("market type not implemented: %s", outcome.MarketName)
|
|
}
|
|
}
|
|
|
|
func evaluateFullTimeResult(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
|
|
switch outcome.OddName {
|
|
case "1": // Home win
|
|
if score.Home > score.Away {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
case "Draw":
|
|
if score.Home == score.Away {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
case "2": // Away win
|
|
if score.Away > score.Home {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
default:
|
|
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName)
|
|
}
|
|
}
|
|
|
|
func evaluateGoalsOverUnder(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
|
|
totalGoals := float64(score.Home + score.Away)
|
|
threshold, err := strconv.ParseFloat(outcome.OddName, 64)
|
|
if err != nil {
|
|
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid threshold: %s", outcome.OddName)
|
|
}
|
|
|
|
if outcome.OddHeader == "Over" {
|
|
if totalGoals > threshold {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
} else if totalGoals == threshold {
|
|
return domain.OUTCOME_STATUS_VOID, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
} else if outcome.OddHeader == "Under" {
|
|
if totalGoals < threshold {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
} else if totalGoals == threshold {
|
|
return domain.OUTCOME_STATUS_VOID, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
|
|
}
|
|
|
|
func evaluateCorrectScore(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
|
|
expectedScore := fmt.Sprintf("%d-%d", score.Home, score.Away)
|
|
if outcome.OddName == expectedScore {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
}
|
|
|
|
func evaluateHalfTimeResult(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
|
|
return evaluateFullTimeResult(outcome, score)
|
|
}
|
|
|
|
func evaluateAsianHandicap(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
|
|
handicap, err := strconv.ParseFloat(outcome.OddHandicap, 64)
|
|
if err != nil {
|
|
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid handicap: %s", outcome.OddHandicap)
|
|
}
|
|
|
|
adjustedHomeScore := float64(score.Home)
|
|
adjustedAwayScore := float64(score.Away)
|
|
|
|
if outcome.OddHeader == "1" { // Home team
|
|
adjustedHomeScore += handicap
|
|
} else if outcome.OddHeader == "2" { // Away team
|
|
adjustedAwayScore += handicap
|
|
} else {
|
|
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd header: %s", outcome.OddHeader)
|
|
}
|
|
|
|
if adjustedHomeScore > adjustedAwayScore {
|
|
if outcome.OddHeader == "1" {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
} else if adjustedHomeScore < adjustedAwayScore {
|
|
if outcome.OddHeader == "2" {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_VOID, nil
|
|
}
|
|
|
|
func evaluateGoalLine(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
|
|
return evaluateGoalsOverUnder(outcome, score)
|
|
}
|
|
|
|
func evaluateFirstTeamToScore(outcome domain.BetOutcome, events []map[string]string) (domain.OutcomeStatus, error) {
|
|
for _, event := range events {
|
|
if strings.Contains(event["text"], "1st Goal") {
|
|
if strings.Contains(event["text"], outcome.HomeTeamName) && outcome.OddName == "1" {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
} else if strings.Contains(event["text"], outcome.AwayTeamName) && outcome.OddName == "2" {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
}
|
|
}
|
|
return domain.OUTCOME_STATUS_VOID, nil // No goals scored
|
|
}
|
|
|
|
func evaluateGoalsOddEven(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
|
|
totalGoals := score.Home + score.Away
|
|
isOdd := totalGoals%2 == 1
|
|
if outcome.OddName == "Odd" && isOdd {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
} else if outcome.OddName == "Even" && !isOdd {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
}
|
|
|
|
func evaluateDoubleChance(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
|
|
isHomeWin := score.Home > score.Away
|
|
isDraw := score.Home == score.Away
|
|
isAwayWin := score.Away > score.Home
|
|
|
|
switch outcome.OddName {
|
|
case "1 or Draw":
|
|
if isHomeWin || isDraw {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
case "Draw or 2":
|
|
if isDraw || isAwayWin {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
case "1 or 2":
|
|
if isHomeWin || isAwayWin {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
default:
|
|
return domain.OUTCOME_STATUS_PENDING, fmt.Errorf("invalid odd name: %s", outcome.OddName)
|
|
}
|
|
}
|
|
|
|
func evaluateDrawNoBet(outcome domain.BetOutcome, score struct{ Home, Away int }) (domain.OutcomeStatus, error) {
|
|
if score.Home == score.Away {
|
|
return domain.OUTCOME_STATUS_VOID, nil
|
|
}
|
|
if outcome.OddName == "1" && score.Home > score.Away {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
} else if outcome.OddName == "2" && score.Away > score.Home {
|
|
return domain.OUTCOME_STATUS_WIN, nil
|
|
}
|
|
return domain.OUTCOME_STATUS_LOSS, nil
|
|
}
|