Yimaru-BackEnd/internal/services/result/service.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
}