503 lines
15 KiB
Go
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
|
|
}
|