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