Files
Novault-backend/internal/service/exchange_rate_service_v2.go
2026-01-25 21:59:00 +08:00

503 lines
15 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"log"
"time"
"accounting-app/internal/cache"
"accounting-app/pkg/utils"
)
// Error definitions for exchange rate service v2
var (
ErrCurrencyNotSupported = errors.New("currency not supported")
ErrRateNotFound = errors.New("exchange rate not found")
ErrAPIUnavailable = errors.New("external API unavailable")
ErrInvalidConversionAmount = errors.New("invalid conversion amount")
ErrSyncFailed = errors.New("sync failed")
)
// ExchangeRateDTO represents exchange rate data transfer object
type ExchangeRateDTO struct {
Currency string `json:"currency"`
CurrencyName string `json:"currency_name"`
Symbol string `json:"symbol"`
Rate float64 `json:"rate"`
UpdatedAt time.Time `json:"updated_at"`
}
// ConversionResultDTO represents currency conversion result
type ConversionResultDTO struct {
OriginalAmount float64 `json:"original_amount"`
FromCurrency string `json:"from_currency"`
ToCurrency string `json:"to_currency"`
ConvertedAmount float64 `json:"converted_amount"`
RateUsed float64 `json:"rate_used"`
ConvertedAt time.Time `json:"converted_at"`
}
// SyncResultDTO represents sync operation result
type SyncResultDTO struct {
Message string `json:"message"`
RatesUpdated int `json:"rates_updated"`
SyncTime time.Time `json:"sync_time"`
}
// CurrencyMetadata contains display information for a currency
type CurrencyMetadata struct {
Name string
Symbol string
}
// currencyMetadataMap maps currency codes to their metadata
// Extended to support all currencies from YunAPI
var currencyMetadataMap = map[string]CurrencyMetadata{
"CNY": {Name: "人民币", Symbol: "¥"},
"USD": {Name: "美元", Symbol: "$"},
"EUR": {Name: "欧元", Symbol: "€"},
"JPY": {Name: "日元", Symbol: "¥"},
"GBP": {Name: "英镑", Symbol: "£"},
"HKD": {Name: "港币", Symbol: "HK$"},
"AUD": {Name: "澳元", Symbol: "A$"},
"CAD": {Name: "加元", Symbol: "C$"},
"CHF": {Name: "瑞士法郎", Symbol: "CHF"},
"SGD": {Name: "新加坡元", Symbol: "S$"},
"THB": {Name: "泰铢", Symbol: "฿"},
"KRW": {Name: "韩元", Symbol: "₩"},
"AED": {Name: "阿联酋迪拉姆", Symbol: "د.إ"},
"BND": {Name: "文莱元", Symbol: "B$"},
"BRL": {Name: "巴西雷亚尔", Symbol: "R$"},
"CZK": {Name: "捷克克朗", Symbol: "Kč"},
"DKK": {Name: "丹麦克朗", Symbol: "kr"},
"HUF": {Name: "匈牙利福林", Symbol: "Ft"},
"IDR": {Name: "印尼盾", Symbol: "Rp"},
"ILS": {Name: "以色列新谢克尔", Symbol: "₪"},
"INR": {Name: "印度卢比", Symbol: "₹"},
"KHR": {Name: "柬埔寨瑞尔", Symbol: "៛"},
"KWD": {Name: "科威特第纳尔", Symbol: "د.ك"},
"MNT": {Name: "蒙古图格里克", Symbol: "₮"},
"MOP": {Name: "澳门元", Symbol: "MOP$"},
"MXN": {Name: "墨西哥比索", Symbol: "Mex$"},
"NOK": {Name: "挪威克朗", Symbol: "kr"},
"NPR": {Name: "尼泊尔卢比", Symbol: "₨"},
"NZD": {Name: "新西兰元", Symbol: "NZ$"},
"PHP": {Name: "菲律宾比索", Symbol: "₱"},
"PKR": {Name: "巴基斯坦卢比", Symbol: "₨"},
"QAR": {Name: "卡塔尔里亚尔", Symbol: "﷼"},
"RUB": {Name: "俄罗斯卢布", Symbol: "₽"},
"SAR": {Name: "沙特里亚尔", Symbol: "﷼"},
"SEK": {Name: "瑞典克朗", Symbol: "kr"},
"TRY": {Name: "土耳其里拉", Symbol: "₺"},
"TWD": {Name: "新台币", Symbol: "NT$"},
"VND": {Name: "越南盾", Symbol: "₫"},
"ZAR": {Name: "南非兰特", Symbol: "R"},
}
// GetCurrencyMetadata returns metadata for a currency code
func GetCurrencyMetadata(currency string) CurrencyMetadata {
if meta, ok := currencyMetadataMap[currency]; ok {
return meta
}
// Return default metadata for unknown currencies
return CurrencyMetadata{Name: currency, Symbol: currency}
}
// IsCurrencySupported checks if a currency is supported
func IsCurrencySupported(currency string) bool {
_, ok := currencyMetadataMap[currency]
return ok
}
// ExchangeRateServiceV2 provides exchange rate business logic with Redis caching
type ExchangeRateServiceV2 struct {
cache *cache.ExchangeRateCache
client *YunAPIClient
}
// NewExchangeRateServiceV2 creates a new ExchangeRateServiceV2 instance
func NewExchangeRateServiceV2(cache *cache.ExchangeRateCache, client *YunAPIClient) *ExchangeRateServiceV2 {
return &ExchangeRateServiceV2{
cache: cache,
client: client,
}
}
// GetAllRates retrieves all exchange rates with cache-first strategy
// Returns rates for all currencies relative to CNY
func (s *ExchangeRateServiceV2) GetAllRates(ctx context.Context) ([]ExchangeRateDTO, error) {
// Try to get from cache first
rates, err := s.cache.GetAll(ctx)
if err != nil {
log.Printf("[ExchangeRateServiceV2] Cache error: %v, falling back to API", err)
}
// If cache hit, convert to DTOs
if rates != nil && len(rates) > 0 {
log.Printf("[ExchangeRateServiceV2] Cache hit: found %d rates", len(rates))
return s.ratesToDTOs(rates), nil
}
// Cache miss - fetch from API
log.Println("[ExchangeRateServiceV2] Cache miss, fetching from API")
rates, err = s.fetchAndCacheRates(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get rates: %w", err)
}
return s.ratesToDTOs(rates), nil
}
// GetRatesBatch retrieves multiple currencies' exchange rates in one call
// More efficient than calling GetRate multiple times
func (s *ExchangeRateServiceV2) GetRatesBatch(ctx context.Context, currencies []string) ([]ExchangeRateDTO, error) {
if len(currencies) == 0 {
return []ExchangeRateDTO{}, nil
}
// Validate all currencies first
for _, currency := range currencies {
if !IsCurrencySupported(currency) {
return nil, fmt.Errorf("%w: %s", ErrCurrencyNotSupported, currency)
}
}
// Try to get all from cache
allRates, err := s.cache.GetAll(ctx)
if err != nil || allRates == nil {
// Cache miss - fetch from API
allRates, err = s.fetchAndCacheRates(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get rates: %w", err)
}
}
// Filter requested currencies
result := make([]ExchangeRateDTO, 0, len(currencies))
for _, currency := range currencies {
if currency == "CNY" {
result = append(result, ExchangeRateDTO{
Currency: "CNY",
CurrencyName: "人民币",
Symbol: "¥",
Rate: 1.0,
UpdatedAt: time.Now(),
})
continue
}
if rate, ok := allRates[currency]; ok {
meta := GetCurrencyMetadata(currency)
result = append(result, ExchangeRateDTO{
Currency: currency,
CurrencyName: meta.Name,
Symbol: meta.Symbol,
Rate: rate,
UpdatedAt: time.Now(),
})
} else {
return nil, fmt.Errorf("%w: %s", ErrRateNotFound, currency)
}
}
return result, nil
}
// GetRate retrieves a single currency's exchange rate with cache-first strategy
// Returns the rate for the specified currency relative to CNY
func (s *ExchangeRateServiceV2) GetRate(ctx context.Context, currency string) (*ExchangeRateDTO, error) {
// Validate currency
if !IsCurrencySupported(currency) {
return nil, fmt.Errorf("%w: %s", ErrCurrencyNotSupported, currency)
}
// CNY to CNY is always 1
if currency == "CNY" {
return &ExchangeRateDTO{
Currency: "CNY",
CurrencyName: "人民币",
Symbol: "¥",
Rate: 1.0,
UpdatedAt: time.Now(),
}, nil
}
// Try to get from cache first
rate, err := s.cache.Get(ctx, currency)
if err == nil {
log.Printf("[ExchangeRateServiceV2] Cache hit for %s: %f", currency, rate)
meta := GetCurrencyMetadata(currency)
return &ExchangeRateDTO{
Currency: currency,
CurrencyName: meta.Name,
Symbol: meta.Symbol,
Rate: rate,
UpdatedAt: time.Now(),
}, nil
}
// Cache miss - try to fetch all rates from API and cache them
log.Printf("[ExchangeRateServiceV2] Cache miss for %s, fetching from API", currency)
rates, err := s.fetchAndCacheRates(ctx)
if err != nil {
// API failed - this is a critical error
return nil, fmt.Errorf("failed to get rate for %s: %w", currency, err)
}
// Check if the requested currency exists in the fetched rates
if rate, ok := rates[currency]; ok {
meta := GetCurrencyMetadata(currency)
return &ExchangeRateDTO{
Currency: currency,
CurrencyName: meta.Name,
Symbol: meta.Symbol,
Rate: rate,
UpdatedAt: time.Now(),
}, nil
}
return nil, fmt.Errorf("%w: %s", ErrRateNotFound, currency)
}
// GetRateWithFallback retrieves a rate with multiple fallback strategies
// This provides better reliability when cache or API fails
func (s *ExchangeRateServiceV2) GetRateWithFallback(ctx context.Context, currency string) (*ExchangeRateDTO, error) {
// Validate currency
if !IsCurrencySupported(currency) {
return nil, fmt.Errorf("%w: %s", ErrCurrencyNotSupported, currency)
}
// CNY to CNY is always 1
if currency == "CNY" {
return &ExchangeRateDTO{
Currency: "CNY",
CurrencyName: "人民币",
Symbol: "¥",
Rate: 1.0,
UpdatedAt: time.Now(),
}, nil
}
// Strategy 1: Try cache
rate, err := s.cache.Get(ctx, currency)
if err == nil {
log.Printf("[ExchangeRateServiceV2] Cache hit for %s", currency)
meta := GetCurrencyMetadata(currency)
return &ExchangeRateDTO{
Currency: currency,
CurrencyName: meta.Name,
Symbol: meta.Symbol,
Rate: rate,
UpdatedAt: time.Now(),
}, nil
}
// Strategy 2: Try API
log.Printf("[ExchangeRateServiceV2] Cache miss for %s, trying API", currency)
rates, err := s.client.FetchRates()
if err == nil {
// Cache the fetched rates
if cacheErr := s.cache.SetAll(ctx, rates); cacheErr != nil {
log.Printf("[ExchangeRateServiceV2] Warning: failed to cache rates: %v", cacheErr)
}
if rate, ok := rates[currency]; ok {
meta := GetCurrencyMetadata(currency)
return &ExchangeRateDTO{
Currency: currency,
CurrencyName: meta.Name,
Symbol: meta.Symbol,
Rate: rate,
UpdatedAt: time.Now(),
}, nil
}
}
// All strategies failed
return nil, fmt.Errorf("%w: all fallback strategies failed for %s", ErrRateNotFound, currency)
}
// GetSyncStatus retrieves the current synchronization status
func (s *ExchangeRateServiceV2) GetSyncStatus(ctx context.Context) (*cache.SyncStatus, error) {
status, err := s.cache.GetSyncStatus(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get sync status: %w", err)
}
// Return default status if none exists
if status == nil {
return &cache.SyncStatus{
LastSyncTime: time.Time{},
LastSyncStatus: "unknown",
NextSyncTime: time.Time{},
RatesCount: 0,
}, nil
}
return status, nil
}
// fetchAndCacheRates fetches rates from API and caches them
func (s *ExchangeRateServiceV2) fetchAndCacheRates(ctx context.Context) (map[string]float64, error) {
// Fetch from API using the existing client's retry logic
rates, err := s.client.FetchRates()
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrAPIUnavailable, err)
}
// Cache the rates
if err := s.cache.SetAll(ctx, rates); err != nil {
log.Printf("[ExchangeRateServiceV2] Warning: failed to cache rates: %v", err)
// Don't fail the request if caching fails
}
// Update sync status
syncStatus := &cache.SyncStatus{
LastSyncTime: time.Now(),
LastSyncStatus: "success",
NextSyncTime: time.Now().Add(s.cache.GetExpiration()),
RatesCount: len(rates),
}
if err := s.cache.SetSyncStatus(ctx, syncStatus); err != nil {
log.Printf("[ExchangeRateServiceV2] Warning: failed to update sync status: %v", err)
}
return rates, nil
}
// ratesToDTOs converts a map of rates to a slice of ExchangeRateDTO
func (s *ExchangeRateServiceV2) ratesToDTOs(rates map[string]float64) []ExchangeRateDTO {
dtos := make([]ExchangeRateDTO, 0, len(rates))
for currency, rate := range rates {
meta := GetCurrencyMetadata(currency)
dtos = append(dtos, ExchangeRateDTO{
Currency: currency,
CurrencyName: meta.Name,
Symbol: meta.Symbol,
Rate: rate,
UpdatedAt: time.Now(), // We don't store individual timestamps in cache
})
}
return dtos
}
// ConvertCurrency converts an amount from one currency to another
// Uses CNY as the intermediate currency for conversions
// Returns the conversion result with two decimal places precision
func (s *ExchangeRateServiceV2) ConvertCurrency(ctx context.Context, amount float64, from, to string) (*ConversionResultDTO, error) {
// Validate amount
if amount < 0 {
return nil, fmt.Errorf("%w: amount cannot be negative", ErrInvalidConversionAmount)
}
// Validate currencies
if !IsCurrencySupported(from) {
return nil, fmt.Errorf("%w: %s", ErrCurrencyNotSupported, from)
}
if !IsCurrencySupported(to) {
return nil, fmt.Errorf("%w: %s", ErrCurrencyNotSupported, to)
}
// Handle same currency conversion (Requirement 6.3)
if from == to {
return &ConversionResultDTO{
OriginalAmount: amount,
FromCurrency: from,
ToCurrency: to,
ConvertedAmount: utils.RoundToTwoDecimals(amount),
RateUsed: 1.0,
ConvertedAt: time.Now(),
}, nil
}
// Get rates for conversion - try cache first, fallback to API
var fromRate, toRate float64
var err error
// Get all rates at once for better performance
allRates, err := s.cache.GetAll(ctx)
if err != nil || allRates == nil || len(allRates) == 0 {
// Cache miss - fetch from API
log.Println("[ExchangeRateServiceV2] Cache miss in conversion, fetching from API")
allRates, err = s.fetchAndCacheRates(ctx)
if err != nil {
return nil, fmt.Errorf("failed to fetch rates for conversion: %w", err)
}
}
// Get from rate
if from == "CNY" {
fromRate = 1.0
} else {
rate, ok := allRates[from]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrRateNotFound, from)
}
fromRate = rate
}
// Get to rate
if to == "CNY" {
toRate = 1.0
} else {
rate, ok := allRates[to]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrRateNotFound, to)
}
toRate = rate
}
// Calculate conversion using CNY as intermediate currency (Requirement 6.4)
// Rate represents: 1 Currency = Rate CNY
// Conversion formula:
// - from CNY: result = amount / to_rate (CNY to target currency)
// - to CNY: result = amount * from_rate (source currency to CNY)
// - otherwise: result = amount * from_rate / to_rate (via CNY)
var convertedAmount float64
var rateUsed float64
if from == "CNY" {
// Converting from CNY to target currency
// amount CNY / to_rate = result in target currency
convertedAmount = amount / toRate
rateUsed = 1.0 / toRate
} else if to == "CNY" {
// Converting from source currency to CNY
// amount * from_rate = result in CNY
convertedAmount = amount * fromRate
rateUsed = fromRate
} else {
// Converting between two non-CNY currencies via CNY
// amount * from_rate / to_rate = result
convertedAmount = amount * fromRate / toRate
rateUsed = fromRate / toRate
}
// Round to two decimal places (Requirement 6.5)
convertedAmount = utils.RoundToTwoDecimals(convertedAmount)
rateUsed = utils.RoundToTwoDecimals(rateUsed)
return &ConversionResultDTO{
OriginalAmount: amount,
FromCurrency: from,
ToCurrency: to,
ConvertedAmount: convertedAmount,
RateUsed: rateUsed,
ConvertedAt: time.Now(),
}, nil
}
// GetCache returns the cache instance (for use by scheduler)
func (s *ExchangeRateServiceV2) GetCache() *cache.ExchangeRateCache {
return s.cache
}
// GetClient returns the API client instance (for use by scheduler)
func (s *ExchangeRateServiceV2) GetClient() *YunAPIClient {
return s.client
}