init
This commit is contained in:
344
internal/service/yunapi_client.go
Normal file
344
internal/service/yunapi_client.go
Normal file
@@ -0,0 +1,344 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user