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,298 @@
package handler
import (
"context"
"errors"
"strings"
"accounting-app/internal/cache"
"accounting-app/internal/service"
"accounting-app/pkg/api"
"github.com/gin-gonic/gin"
)
// ExchangeRateHandlerV2 handles HTTP requests for the redesigned exchange rate API
// Uses ExchangeRateServiceV2 with Redis caching and SyncScheduler
type ExchangeRateHandlerV2 struct {
service *service.ExchangeRateServiceV2
scheduler *service.SyncScheduler
}
// NewExchangeRateHandlerV2 creates a new ExchangeRateHandlerV2 instance
func NewExchangeRateHandlerV2(service *service.ExchangeRateServiceV2, scheduler *service.SyncScheduler) *ExchangeRateHandlerV2 {
return &ExchangeRateHandlerV2{
service: service,
scheduler: scheduler,
}
}
// ConvertInput represents the input for currency conversion
type ConvertInput struct {
Amount float64 `json:"amount" binding:"required"`
FromCurrency string `json:"from_currency" binding:"required"`
ToCurrency string `json:"to_currency" binding:"required"`
}
// AllRatesResponse represents the response for GET /api/exchange-rates
type AllRatesResponse struct {
Rates []service.ExchangeRateDTO `json:"rates"`
BaseCurrency string `json:"base_currency"`
SyncStatus *cache.SyncStatus `json:"sync_status,omitempty"`
}
// GetAllRates handles GET /api/exchange-rates
// Returns all exchange rates relative to CNY with sync status
// Supports query parameter: currencies=USD,EUR,JPY for batch query
// Requirements: 2.1
func (h *ExchangeRateHandlerV2) GetAllRates(c *gin.Context) {
ctx := c.Request.Context()
// Check if specific currencies are requested
currenciesParam := c.Query("currencies")
var rates []service.ExchangeRateDTO
var err error
if currenciesParam != "" {
// Batch query for specific currencies
currencies := strings.Split(currenciesParam, ",")
// Trim spaces
for i := range currencies {
currencies[i] = strings.TrimSpace(currencies[i])
}
rates, err = h.service.GetRatesBatch(ctx, currencies)
} else {
// Get all rates
rates, err = h.service.GetAllRates(ctx)
}
if err != nil {
if errors.Is(err, service.ErrAPIUnavailable) {
api.BadGateway(c, "汇率服务暂时不可用")
return
}
if errors.Is(err, service.ErrCurrencyNotSupported) {
api.Error(c, 400, "CURRENCY_NOT_SUPPORTED", err.Error())
return
}
if errors.Is(err, service.ErrRateNotFound) {
api.NotFound(c, err.Error())
return
}
api.InternalError(c, "获取汇率失败: "+err.Error())
return
}
// Get sync status
syncStatus, _ := h.service.GetSyncStatus(ctx)
response := AllRatesResponse{
Rates: rates,
BaseCurrency: "CNY",
SyncStatus: syncStatus,
}
api.Success(c, response)
}
// GetRate handles GET /api/exchange-rates/:currency
// Returns a single currency's exchange rate relative to CNY
// Requirements: 2.2
func (h *ExchangeRateHandlerV2) GetRate(c *gin.Context) {
currency := c.Param("currency")
if currency == "" {
api.BadRequest(c, "货币代码不能为空")
return
}
ctx := c.Request.Context()
rate, err := h.service.GetRate(ctx, currency)
if err != nil {
if errors.Is(err, service.ErrCurrencyNotSupported) {
api.Error(c, 400, "CURRENCY_NOT_SUPPORTED", "货币 "+currency+" 不支持")
return
}
if errors.Is(err, service.ErrRateNotFound) {
api.NotFound(c, "未找到货币 "+currency+" 的汇率")
return
}
if errors.Is(err, service.ErrAPIUnavailable) {
api.BadGateway(c, "汇率服务暂时不可用")
return
}
api.InternalError(c, "获取汇率失败: "+err.Error())
return
}
api.Success(c, rate)
}
// Convert handles POST /api/exchange-rates/convert
// Converts an amount from one currency to another using CNY as intermediate
// Requirements: 2.3
func (h *ExchangeRateHandlerV2) Convert(c *gin.Context) {
var input ConvertInput
if err := c.ShouldBindJSON(&input); err != nil {
api.ValidationError(c, "无效的请求参数: "+err.Error())
return
}
ctx := c.Request.Context()
result, err := h.service.ConvertCurrency(ctx, input.Amount, input.FromCurrency, input.ToCurrency)
if err != nil {
if errors.Is(err, service.ErrCurrencyNotSupported) {
api.Error(c, 400, "CURRENCY_NOT_SUPPORTED", "不支持的货币: "+err.Error())
return
}
if errors.Is(err, service.ErrRateNotFound) {
api.NotFound(c, "未找到所需货币的汇率")
return
}
if errors.Is(err, service.ErrInvalidConversionAmount) {
api.BadRequest(c, "无效的转换金额")
return
}
if errors.Is(err, service.ErrAPIUnavailable) {
api.BadGateway(c, "汇率服务暂时不可用")
return
}
api.InternalError(c, "货币转换失败: "+err.Error())
return
}
api.Success(c, result)
}
// Refresh handles POST /api/exchange-rates/refresh
// Manually triggers a refresh of exchange rates from YunAPI
// Requirements: 2.5
func (h *ExchangeRateHandlerV2) Refresh(c *gin.Context) {
if h.scheduler == nil {
api.InternalError(c, "汇率同步服务未配置")
return
}
ctx := c.Request.Context()
// Trigger force sync
err := h.scheduler.ForceSync(ctx)
if err != nil {
api.InternalError(c, "刷新汇率失败: "+err.Error())
return
}
// Get updated sync status
syncStatus, _ := h.service.GetSyncStatus(ctx)
result := service.SyncResultDTO{
Message: "汇率同步成功",
RatesUpdated: 0,
}
if syncStatus != nil {
result.RatesUpdated = syncStatus.RatesCount
result.SyncTime = syncStatus.LastSyncTime
}
api.Success(c, result)
}
// GetSyncStatus handles GET /api/exchange-rates/sync-status
// Returns the current synchronization status
func (h *ExchangeRateHandlerV2) GetSyncStatus(c *gin.Context) {
ctx := c.Request.Context()
status, err := h.service.GetSyncStatus(ctx)
if err != nil {
api.InternalError(c, "获取同步状态失败: "+err.Error())
return
}
api.Success(c, status)
}
// HealthCheck handles GET /api/exchange-rates/health
// Returns the health status of the exchange rate service
func (h *ExchangeRateHandlerV2) HealthCheck(c *gin.Context) {
ctx := c.Request.Context()
health := map[string]interface{}{
"status": "healthy",
}
// Check cache connectivity
cacheExists, err := h.service.GetCache().Exists(ctx)
if err != nil {
health["cache_status"] = "error"
health["cache_error"] = err.Error()
health["status"] = "degraded"
} else if cacheExists {
health["cache_status"] = "connected"
} else {
health["cache_status"] = "empty"
}
// Get sync status
syncStatus, err := h.service.GetSyncStatus(ctx)
if err != nil {
health["sync_status"] = "unknown"
} else if syncStatus != nil {
health["sync_status"] = syncStatus.LastSyncStatus
health["last_sync"] = syncStatus.LastSyncTime
health["rates_count"] = syncStatus.RatesCount
if syncStatus.LastSyncStatus == "failed" {
health["status"] = "degraded"
health["sync_error"] = syncStatus.ErrorMessage
}
}
// Check API availability (optional - don't fail health check on this)
apiURL := h.service.GetClient().GetAPIURL()
health["api_url"] = apiURL
// Determine overall status
if health["status"] == "healthy" {
api.Success(c, health)
} else {
c.JSON(200, gin.H{
"success": true,
"data": health,
})
}
}
// RegisterRoutes registers all exchange rate v2 routes to the given router group
// This registers the new simplified API endpoints
func (h *ExchangeRateHandlerV2) RegisterRoutes(rg *gin.RouterGroup) {
exchangeRates := rg.Group("/exchange-rates")
{
// GET /api/exchange-rates - Get all rates with sync status
// Supports query param: ?currencies=USD,EUR,JPY for batch query
exchangeRates.GET("", h.GetAllRates)
// POST /api/exchange-rates/convert - Currency conversion
exchangeRates.POST("/convert", h.Convert)
// POST /api/exchange-rates/refresh - Manual refresh
exchangeRates.POST("/refresh", h.Refresh)
// GET /api/exchange-rates/sync-status - Get sync status
exchangeRates.GET("/sync-status", h.GetSyncStatus)
// GET /api/exchange-rates/health - Health check
exchangeRates.GET("/health", h.HealthCheck)
// GET /api/exchange-rates/:currency - Get single currency rate
// Note: This must be registered last to avoid conflicts with other routes
exchangeRates.GET("/:currency", h.GetRate)
}
}
// RegisterRoutesWithContext is an alternative registration method that accepts a context
// for dependency injection in tests
func (h *ExchangeRateHandlerV2) RegisterRoutesWithContext(rg *gin.RouterGroup, ctx context.Context) {
h.RegisterRoutes(rg)
}