This commit is contained in:
2026-01-25 21:59:00 +08:00
parent 7fd537bef3
commit 4cad3f0250
118 changed files with 30473 additions and 0 deletions

View 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
}
}