345 lines
8.4 KiB
Go
345 lines
8.4 KiB
Go
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
|
|
}
|
|
}
|