package service import ( "encoding/json" "errors" "fmt" "io" "log" "math" "net/http" "time" "accounting-app/internal/models" "accounting-app/internal/repository" ) // YunAPIClient handles fetching exchange rates from yunapi.cn type YunAPIClient struct { apiURL string apiKey string httpClient *http.Client exchangeRateRepo *repository.ExchangeRateRepository maxRetries int } // YunAPIResponse represents the response from yunapi.cn // The API returns rates relative to CNY (e.g., USD: 6.9756 means 1 USD = 6.9756 CNY) type YunAPIResponse map[string]float64 // Common errors var ( ErrAPIRequestFailed = errors.New("API request failed") ErrInvalidResponse = errors.New("invalid API response") ) // NewYunAPIClient creates a new YunAPIClient instance func NewYunAPIClient(apiURL string, exchangeRateRepo *repository.ExchangeRateRepository) *YunAPIClient { return &YunAPIClient{ apiURL: apiURL, httpClient: &http.Client{ Timeout: 30 * time.Second, }, exchangeRateRepo: exchangeRateRepo, maxRetries: 3, } } // NewYunAPIClientWithConfig creates a new YunAPIClient with full configuration func NewYunAPIClientWithConfig(apiURL, apiKey string, maxRetries int, exchangeRateRepo *repository.ExchangeRateRepository) *YunAPIClient { client := NewYunAPIClient(apiURL, exchangeRateRepo) client.apiKey = apiKey if maxRetries > 0 { client.maxRetries = maxRetries } return client } // FetchAndSaveRates fetches exchange rates from yunapi.cn and saves them to the database func (c *YunAPIClient) FetchAndSaveRates() error { log.Println("[YunAPI] Fetching exchange rates...") // Fetch rates with retry rates, err := c.fetchRatesWithRetry() if err != nil { return err } // Save rates to database return c.saveRates(rates) } // ForceRefresh forces an immediate refresh of exchange rates // Returns the number of rates saved and any error func (c *YunAPIClient) ForceRefresh() (int, error) { log.Println("[YunAPI] Force refreshing exchange rates...") rates, err := c.fetchRatesWithRetry() if err != nil { return 0, err } savedCount, err := c.saveRatesWithCount(rates) if err != nil { return 0, err } log.Printf("[YunAPI] Force refresh completed: saved %d exchange rates", savedCount) return savedCount, nil } // FetchRates fetches exchange rates from the API with retry logic // Returns a map of currency code to rate (1 Currency = Rate CNY) // This method does not save to database - use FetchAndSaveRates for that func (c *YunAPIClient) FetchRates() (YunAPIResponse, error) { return c.fetchRatesWithRetry() } // fetchRatesWithRetry fetches rates from the API with exponential backoff retry func (c *YunAPIClient) fetchRatesWithRetry() (YunAPIResponse, error) { var lastErr error for attempt := 0; attempt < c.maxRetries; attempt++ { if attempt > 0 { // Exponential backoff: 1s, 2s, 4s, ... backoff := time.Duration(math.Pow(2, float64(attempt-1))) * time.Second log.Printf("[YunAPI] Retry attempt %d/%d after %v", attempt+1, c.maxRetries, backoff) time.Sleep(backoff) } rates, err := c.fetchRates() if err == nil { return rates, nil } lastErr = err log.Printf("[YunAPI] Attempt %d failed: %v", attempt+1, err) } return nil, fmt.Errorf("failed after %d retries: %w", c.maxRetries, lastErr) } // fetchRates makes a single API request to fetch exchange rates func (c *YunAPIClient) fetchRates() (YunAPIResponse, error) { // Build request URL url := c.apiURL if c.apiKey != "" { url = fmt.Sprintf("%s?key=%s", c.apiURL, c.apiKey) } // Make HTTP GET request resp, err := c.httpClient.Get(url) if err != nil { return nil, fmt.Errorf("%w: %v", ErrAPIRequestFailed, err) } defer resp.Body.Close() // Check status code if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("%w: status %d, body: %s", ErrAPIRequestFailed, resp.StatusCode, string(body)) } // Read response body body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } // Parse JSON response var rates YunAPIResponse if err := json.Unmarshal(body, &rates); err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidResponse, err) } if len(rates) == 0 { return nil, fmt.Errorf("%w: empty response", ErrInvalidResponse) } log.Printf("[YunAPI] Successfully fetched %d currency rates", len(rates)) return rates, nil } // saveRates saves exchange rates to the database func (c *YunAPIClient) saveRates(rates YunAPIResponse) error { now := time.Now() var exchangeRates []models.ExchangeRate // The API returns rates as: 1 [Currency] = X CNY // We store them as: FromCurrency -> ToCurrency with Rate for currencyCode, rateToCNY := range rates { // Map API currency codes to our supported currencies currency := mapToCurrency(currencyCode) if currency == "" { continue // Skip unsupported currencies } // Skip CNY itself if currency == models.CurrencyCNY { continue } // Create exchange rate: Currency -> CNY // e.g., USD -> CNY = 6.9756 (meaning 1 USD = 6.9756 CNY) exchangeRate := models.ExchangeRate{ FromCurrency: currency, ToCurrency: models.CurrencyCNY, Rate: rateToCNY, EffectiveDate: now, } exchangeRates = append(exchangeRates, exchangeRate) } if len(exchangeRates) == 0 { return nil } // Use synchronized batch insert if err := c.exchangeRateRepo.BatchUpsertOptimized(exchangeRates); err != nil { return fmt.Errorf("failed to batch save exchange rates: %w", err) } log.Printf("[YunAPI] Successfully batch saved %d exchange rates", len(exchangeRates)) return nil } // saveRatesWithCount saves exchange rates and returns the count of saved rates func (c *YunAPIClient) saveRatesWithCount(rates YunAPIResponse) (int, error) { now := time.Now() var exchangeRates []models.ExchangeRate for currencyCode, rateToCNY := range rates { currency := mapToCurrency(currencyCode) if currency == "" { continue } if currency == models.CurrencyCNY { continue } exchangeRate := models.ExchangeRate{ FromCurrency: currency, ToCurrency: models.CurrencyCNY, Rate: rateToCNY, EffectiveDate: now, } exchangeRates = append(exchangeRates, exchangeRate) } if len(exchangeRates) == 0 { return 0, nil } // Use synchronized batch insert if err := c.exchangeRateRepo.BatchUpsertOptimized(exchangeRates); err != nil { return 0, fmt.Errorf("failed to batch save exchange rates: %w", err) } return len(exchangeRates), nil } // GetAPIURL returns the configured API URL func (c *YunAPIClient) GetAPIURL() string { return c.apiURL } // mapToCurrency maps API currency codes to our supported Currency type // Supports all 37 currencies from YunAPI func mapToCurrency(code string) models.Currency { switch code { // Major currencies case "CNY": return models.CurrencyCNY case "USD": return models.CurrencyUSD case "EUR": return models.CurrencyEUR case "JPY": return models.CurrencyJPY case "GBP": return models.CurrencyGBP case "HKD": return models.CurrencyHKD // Asia Pacific case "AUD": return models.CurrencyAUD case "NZD": return models.CurrencyNZD case "SGD": return models.CurrencySGD case "KRW": return models.CurrencyKRW case "THB": return models.CurrencyTHB case "TWD": return models.CurrencyTWD case "MOP": return models.CurrencyMOP case "PHP": return models.CurrencyPHP case "IDR": return models.CurrencyIDR case "INR": return models.CurrencyINR case "VND": return models.CurrencyVND case "MNT": return models.CurrencyMNT case "KHR": return models.CurrencyKHR case "NPR": return models.CurrencyNPR case "PKR": return models.CurrencyPKR case "BND": return models.CurrencyBND // Europe case "CHF": return models.CurrencyCHF case "SEK": return models.CurrencySEK case "NOK": return models.CurrencyNOK case "DKK": return models.CurrencyDKK case "CZK": return models.CurrencyCZK case "HUF": return models.CurrencyHUF case "RUB": return models.CurrencyRUB case "TRY": return models.CurrencyTRY // Americas case "CAD": return models.CurrencyCAD case "MXN": return models.CurrencyMXN case "BRL": return models.CurrencyBRL // Middle East & Africa case "AED": return models.CurrencyAED case "SAR": return models.CurrencySAR case "QAR": return models.CurrencyQAR case "KWD": return models.CurrencyKWD case "ILS": return models.CurrencyILS case "ZAR": return models.CurrencyZAR default: return "" // Unsupported currency } }