diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index c288a289..0a0cc84b 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -50,7 +50,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { refreshTokenCache := repository.NewRefreshTokenCache(redisClient) settingRepository := repository.NewSettingRepository(client) groupRepository := repository.NewGroupRepository(client, db) - channelRepository := repository.NewChannelRepository(db) settingService := service.ProvideSettingService(settingRepository, groupRepository, configConfig) emailCache := repository.NewEmailCache(redisClient) emailService := service.NewEmailService(settingRepository, emailCache) @@ -65,7 +64,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userGroupRateRepository := repository.NewUserGroupRateRepository(db) apiKeyCache := repository.NewAPIKeyCache(redisClient) apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig) - apiKeyService.SetRateLimitCacheInvalidator(billingCache) apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService) promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator) subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig) @@ -73,15 +71,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userService := service.NewUserService(userRepository, apiKeyAuthCacheInvalidator, billingCache) redeemCache := repository.NewRedeemCache(redisClient) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator) - registry := payment.ProvideRegistry() - encryptionKey, err := payment.ProvideEncryptionKey(configConfig) - if err != nil { - return nil, err - } - defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey) - paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey) - paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository) - paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService) secretEncryptor, err := repository.NewAESEncryptor(configConfig) if err != nil { return nil, err @@ -92,7 +81,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { userHandler := handler.NewUserHandler(userService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) - usageBillingRepository := repository.NewUsageBillingRepository(client, db) usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) redeemHandler := handler.NewRedeemHandler(redeemService) @@ -110,7 +98,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { } dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig) dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService) - schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig) + schedulerCache := repository.NewSchedulerCache(redisClient) accountRepository := repository.NewAccountRepository(client, db, schedulerCache) proxyRepository := repository.NewProxyRepository(client, db) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) @@ -120,11 +108,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { concurrencyCache := repository.ProvideConcurrencyCache(redisClient, configConfig) concurrencyService := service.ProvideConcurrencyService(concurrencyCache, accountRepository, configConfig) adminUserHandler := admin.NewUserHandler(adminService, concurrencyService) + sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig) + rpmCache := repository.NewRPMCache(redisClient) + groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) + groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) claudeOAuthClient := repository.NewClaudeOAuthClient() oAuthService := service.NewOAuthService(proxyRepository, claudeOAuthClient) openAIOAuthClient := repository.NewOpenAIOAuthClient() openAIOAuthService := service.NewOpenAIOAuthService(proxyRepository, openAIOAuthClient) - openAIOAuthService.SetPrivacyClientFactory(privacyClientFactory) geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig) geminiCliCodeAssistClient := repository.NewGeminiCliCodeAssistClient() driveClient := repository.NewGeminiDriveClient() @@ -134,7 +125,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { tempUnschedCache := repository.NewTempUnschedCache(redisClient) timeoutCounterCache := repository.NewTimeoutCounterCache(redisClient) geminiTokenCache := repository.NewGeminiTokenCache(redisClient) - oauthRefreshAPI := service.NewOAuthRefreshAPI(accountRepository, geminiTokenCache) compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache) rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, settingService, compositeTokenCacheInvalidator) httpUpstream := repository.NewHTTPUpstream(configConfig) @@ -142,23 +132,20 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository) usageCache := service.NewUsageCache() identityCache := repository.NewIdentityCache(redisClient) - geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oauthRefreshAPI) - gatewayCache := repository.NewGatewayCache(redisClient) - schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db) - schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig) - antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oauthRefreshAPI, tempUnschedCache) - internal500CounterCache := repository.NewInternal500CounterCache(redisClient) tlsFingerprintProfileRepository := repository.NewTLSFingerprintProfileRepository(client) tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient) tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache) accountUsageService := service.NewAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, usageCache, identityCache, tlsFingerprintProfileService) - antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache) + oAuthRefreshAPI := service.NewOAuthRefreshAPI(accountRepository, geminiTokenCache) + geminiTokenProvider := service.ProvideGeminiTokenProvider(accountRepository, geminiTokenCache, geminiOAuthService, oAuthRefreshAPI) + gatewayCache := repository.NewGatewayCache(redisClient) + schedulerOutboxRepository := repository.NewSchedulerOutboxRepository(db) + schedulerSnapshotService := service.ProvideSchedulerSnapshotService(schedulerCache, schedulerOutboxRepository, accountRepository, groupRepository, configConfig) + antigravityTokenProvider := service.ProvideAntigravityTokenProvider(accountRepository, geminiTokenCache, antigravityOAuthService, oAuthRefreshAPI, tempUnschedCache) + internal500CounterCache := repository.NewInternal500CounterCache(redisClient) + antigravityGatewayService := service.NewAntigravityGatewayService(accountRepository, gatewayCache, schedulerSnapshotService, antigravityTokenProvider, rateLimitService, httpUpstream, settingService, internal500CounterCache, accountUsageService) accountTestService := service.NewAccountTestService(accountRepository, geminiTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) - sessionLimitCache := repository.ProvideSessionLimitCache(redisClient, configConfig) - rpmCache := repository.NewRPMCache(redisClient) - groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) - groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator) adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService) dataManagementService := service.NewDataManagementService() @@ -175,6 +162,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService) promoHandler := admin.NewPromoHandler(promoService) opsRepository := repository.NewOpsRepository(db) + usageBillingRepository := repository.NewUsageBillingRepository(client, db) pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig) pricingService, err := service.ProvidePricingService(configConfig, pricingRemoteClient) if err != nil { @@ -183,17 +171,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { billingService := service.NewBillingService(configConfig, pricingService) identityService := service.NewIdentityService(identityCache) deferredService := service.ProvideDeferredService(accountRepository, timingWheelService) - claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oauthRefreshAPI) + claudeTokenProvider := service.ProvideClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService, oAuthRefreshAPI) digestSessionStore := service.NewDigestSessionStore() + channelRepository := repository.NewChannelRepository(db) channelService := service.NewChannelService(channelRepository, apiKeyAuthCacheInvalidator) modelPricingResolver := service.NewModelPricingResolver(channelService, billingService) gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver) - openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oauthRefreshAPI) + openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI) openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService) geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig) opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository) opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink) - settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService) opsHandler := admin.NewOpsHandler(opsService) updateCache := repository.NewUpdateCache(redisClient) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) @@ -221,8 +209,18 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository) scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService) channelHandler := admin.NewChannelHandler(channelService, billingService) - adminPaymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, adminPaymentHandler) + registry := payment.ProvideRegistry() + encryptionKey, err := payment.ProvideEncryptionKey(configConfig) + if err != nil { + return nil, err + } + defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey) + paymentConfigService := service.ProvidePaymentConfigService(client, settingRepository, encryptionKey) + settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService) + paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository) + paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService) + paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, paymentHandler) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) @@ -245,7 +243,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig) opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig) opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig) - tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oauthRefreshAPI) + tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI) accountExpiryService := service.ProvideAccountExpiryService(accountRepository) subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository) scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go index 8a83bfeb..bf404118 100644 --- a/backend/internal/handler/payment_webhook_handler.go +++ b/backend/internal/handler/payment_webhook_handler.go @@ -4,7 +4,6 @@ import ( "io" "log/slog" "net/http" - "net/url" "strings" "github.com/Wei-Shaw/sub2api/internal/payment" @@ -73,13 +72,9 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) rawBody = string(body) } - // Extract out_trade_no to look up the order's specific provider instance. - // This is needed when multiple instances of the same provider exist (e.g. multiple EasyPay accounts). - outTradeNo := extractOutTradeNo(rawBody, providerKey) - - provider, err := h.paymentService.GetWebhookProvider(c.Request.Context(), providerKey, outTradeNo) + provider, err := h.registry.GetProviderByKey(providerKey) if err != nil { - slog.Warn("[Payment Webhook] provider not found", "provider", providerKey, "outTradeNo", outTradeNo, "error", err) + slog.Warn("[Payment Webhook] provider not registered", "provider", providerKey, "error", err) writeSuccessResponse(c, providerKey) return } @@ -116,40 +111,19 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) writeSuccessResponse(c, providerKey) } -// extractOutTradeNo parses the webhook body to find the out_trade_no. -// This allows looking up the correct provider instance before verification. -func extractOutTradeNo(rawBody, providerKey string) string { - switch providerKey { - case payment.TypeEasyPay: - values, err := url.ParseQuery(rawBody) - if err == nil { - return values.Get("out_trade_no") - } - } - // For other providers (Stripe, Alipay direct, WxPay direct), the registry - // typically has only one instance, so no instance lookup is needed. - return "" -} - // wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook. type wxpaySuccessResponse struct { Code string `json:"code"` Message string `json:"message"` } -// WeChat Pay webhook success response constants. -const ( - wxpaySuccessCode = "SUCCESS" - wxpaySuccessMessage = "成功" -) - // writeSuccessResponse sends the provider-specific success response. // WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"}; // Stripe expects an empty 200; others accept plain text "success". func writeSuccessResponse(c *gin.Context, providerKey string) { switch providerKey { case payment.TypeWxpay: - c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage}) + c.JSON(http.StatusOK, wxpaySuccessResponse{Code: "SUCCESS", Message: "成功"}) case payment.TypeStripe: c.String(http.StatusOK, "") default: diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index dafe9afd..9042c3ab 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -2,16 +2,13 @@ package service import ( "context" - "encoding/json" "fmt" "strconv" "strings" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance" - "github.com/Wei-Shaw/sub2api/ent/subscriptionplan" "github.com/Wei-Shaw/sub2api/internal/payment" - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) const ( @@ -26,6 +23,8 @@ const ( SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED" SettingProductNamePrefix = "PRODUCT_NAME_PREFIX" SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX" + SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL" + SettingHelpText = "PAYMENT_HELP_TEXT" SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED" SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX" SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW" @@ -33,91 +32,126 @@ const ( SettingCancelWindowMode = "CANCEL_RATE_LIMIT_WINDOW_MODE" ) +// Default values for payment configuration settings. +const ( + defaultOrderTimeoutMin = 30 + defaultMaxPendingOrders = 3 +) + // PaymentConfig holds the payment system configuration. type PaymentConfig struct { - Enabled bool `json:"enabled"` - MinAmount float64 `json:"minAmount"` - MaxAmount float64 `json:"maxAmount"` - DailyLimit float64 `json:"dailyLimit"` - OrderTimeoutMin int `json:"orderTimeoutMinutes"` - MaxPendingOrders int `json:"maxPendingOrders"` - EnabledTypes []string `json:"enabledTypes"` - BalanceDisabled bool `json:"balanceDisabled"` - LoadBalanceStrategy string `json:"loadBalanceStrategy"` - ProductNamePrefix string `json:"productNamePrefix"` - ProductNameSuffix string `json:"productNameSuffix"` + Enabled bool `json:"enabled"` + MinAmount float64 `json:"min_amount"` + MaxAmount float64 `json:"max_amount"` + DailyLimit float64 `json:"daily_limit"` + OrderTimeoutMin int `json:"order_timeout_minutes"` + MaxPendingOrders int `json:"max_pending_orders"` + EnabledTypes []string `json:"enabled_payment_types"` + BalanceDisabled bool `json:"balance_disabled"` + LoadBalanceStrategy string `json:"load_balance_strategy"` + ProductNamePrefix string `json:"product_name_prefix"` + ProductNameSuffix string `json:"product_name_suffix"` + HelpImageURL string `json:"help_image_url"` + HelpText string `json:"help_text"` + StripePublishableKey string `json:"stripe_publishable_key,omitempty"` + + // Cancel rate limit settings + CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"` + CancelRateLimitMax int `json:"cancel_rate_limit_max"` + CancelRateLimitWindow int `json:"cancel_rate_limit_window"` + CancelRateLimitUnit string `json:"cancel_rate_limit_unit"` + CancelRateLimitMode string `json:"cancel_rate_limit_window_mode"` } // UpdatePaymentConfigRequest contains fields to update payment configuration. type UpdatePaymentConfigRequest struct { Enabled *bool `json:"enabled"` - MinAmount *float64 `json:"minAmount"` - MaxAmount *float64 `json:"maxAmount"` - DailyLimit *float64 `json:"dailyLimit"` - OrderTimeoutMin *int `json:"orderTimeoutMinutes"` - MaxPendingOrders *int `json:"maxPendingOrders"` - EnabledTypes []string `json:"enabledTypes"` - BalanceDisabled *bool `json:"balanceDisabled"` - LoadBalanceStrategy *string `json:"loadBalanceStrategy"` - ProductNamePrefix *string `json:"productNamePrefix"` - ProductNameSuffix *string `json:"productNameSuffix"` + MinAmount *float64 `json:"min_amount"` + MaxAmount *float64 `json:"max_amount"` + DailyLimit *float64 `json:"daily_limit"` + OrderTimeoutMin *int `json:"order_timeout_minutes"` + MaxPendingOrders *int `json:"max_pending_orders"` + EnabledTypes []string `json:"enabled_payment_types"` + BalanceDisabled *bool `json:"balance_disabled"` + LoadBalanceStrategy *string `json:"load_balance_strategy"` + ProductNamePrefix *string `json:"product_name_prefix"` + ProductNameSuffix *string `json:"product_name_suffix"` + HelpImageURL *string `json:"help_image_url"` + HelpText *string `json:"help_text"` + + // Cancel rate limit settings + CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"` + CancelRateLimitMax *int `json:"cancel_rate_limit_max"` + CancelRateLimitWindow *int `json:"cancel_rate_limit_window"` + CancelRateLimitUnit *string `json:"cancel_rate_limit_unit"` + CancelRateLimitMode *string `json:"cancel_rate_limit_window_mode"` } // MethodLimits holds per-payment-type limits. type MethodLimits struct { - PaymentType string `json:"paymentType"` - FeeRate float64 `json:"feeRate"` - DailyLimit float64 `json:"dailyLimit"` - SingleMin float64 `json:"singleMin"` - SingleMax float64 `json:"singleMax"` + PaymentType string `json:"payment_type"` + FeeRate float64 `json:"fee_rate"` + DailyLimit float64 `json:"daily_limit"` + SingleMin float64 `json:"single_min"` + SingleMax float64 `json:"single_max"` +} + +// MethodLimitsResponse is the full response for the user-facing /limits API. +// It includes per-method limits and the global widest range (union of all methods). +type MethodLimitsResponse struct { + Methods map[string]MethodLimits `json:"methods"` + GlobalMin float64 `json:"global_min"` // 0 = no minimum + GlobalMax float64 `json:"global_max"` // 0 = no maximum } type CreateProviderInstanceRequest struct { - ProviderKey string `json:"providerKey"` + ProviderKey string `json:"provider_key"` Name string `json:"name"` Config map[string]string `json:"config"` - SupportedTypes string `json:"supportedTypes"` + SupportedTypes []string `json:"supported_types"` Enabled bool `json:"enabled"` - SortOrder int `json:"sortOrder"` + PaymentMode string `json:"payment_mode"` + SortOrder int `json:"sort_order"` Limits string `json:"limits"` - RefundEnabled bool `json:"refundEnabled"` + RefundEnabled bool `json:"refund_enabled"` } type UpdateProviderInstanceRequest struct { Name *string `json:"name"` Config map[string]string `json:"config"` - SupportedTypes *string `json:"supportedTypes"` + SupportedTypes []string `json:"supported_types"` Enabled *bool `json:"enabled"` - SortOrder *int `json:"sortOrder"` + PaymentMode *string `json:"payment_mode"` + SortOrder *int `json:"sort_order"` Limits *string `json:"limits"` - RefundEnabled *bool `json:"refundEnabled"` + RefundEnabled *bool `json:"refund_enabled"` } type CreatePlanRequest struct { - GroupID int64 `json:"groupId"` + GroupID int64 `json:"group_id"` Name string `json:"name"` Description string `json:"description"` Price float64 `json:"price"` - OriginalPrice *float64 `json:"originalPrice"` - ValidityDays int `json:"validityDays"` - ValidityUnit string `json:"validityUnit"` + OriginalPrice *float64 `json:"original_price"` + ValidityDays int `json:"validity_days"` + ValidityUnit string `json:"validity_unit"` Features string `json:"features"` - ProductName string `json:"productName"` - ForSale bool `json:"forSale"` - SortOrder int `json:"sortOrder"` + ProductName string `json:"product_name"` + ForSale bool `json:"for_sale"` + SortOrder int `json:"sort_order"` } type UpdatePlanRequest struct { - GroupID *int64 `json:"groupId"` + GroupID *int64 `json:"group_id"` Name *string `json:"name"` Description *string `json:"description"` Price *float64 `json:"price"` - OriginalPrice *float64 `json:"originalPrice"` - ValidityDays *int `json:"validityDays"` - ValidityUnit *string `json:"validityUnit"` + OriginalPrice *float64 `json:"original_price"` + ValidityDays *int `json:"validity_days"` + ValidityUnit *string `json:"validity_unit"` Features *string `json:"features"` - ProductName *string `json:"productName"` - ForSale *bool `json:"forSale"` - SortOrder *int `json:"sortOrder"` + ProductName *string `json:"product_name"` + ForSale *bool `json:"for_sale"` + SortOrder *int `json:"sort_order"` } // PaymentConfigService manages payment configuration and CRUD for @@ -149,29 +183,43 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders, SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy, SettingProductNamePrefix, SettingProductNameSuffix, + SettingHelpImageURL, SettingHelpText, + SettingCancelRateLimitOn, SettingCancelRateLimitMax, + SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode, } vals, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return nil, fmt.Errorf("get payment config settings: %w", err) } - return s.parsePaymentConfig(vals), nil + cfg := s.parsePaymentConfig(vals) + // Load Stripe publishable key from the first enabled Stripe provider instance + cfg.StripePublishableKey = s.getStripePublishableKey(ctx) + return cfg, nil } func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *PaymentConfig { cfg := &PaymentConfig{ Enabled: vals[SettingPaymentEnabled] == "true", MinAmount: pcParseFloat(vals[SettingMinRechargeAmount], 1), - MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 99999999.99), + MaxAmount: pcParseFloat(vals[SettingMaxRechargeAmount], 0), DailyLimit: pcParseFloat(vals[SettingDailyRechargeLimit], 0), - OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], 30), - MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], 3), + OrderTimeoutMin: pcParseInt(vals[SettingOrderTimeoutMinutes], defaultOrderTimeoutMin), + MaxPendingOrders: pcParseInt(vals[SettingMaxPendingOrders], defaultMaxPendingOrders), BalanceDisabled: vals[SettingBalancePayDisabled] == "true", LoadBalanceStrategy: vals[SettingLoadBalanceStrategy], ProductNamePrefix: vals[SettingProductNamePrefix], ProductNameSuffix: vals[SettingProductNameSuffix], + HelpImageURL: vals[SettingHelpImageURL], + HelpText: vals[SettingHelpText], + + CancelRateLimitEnabled: vals[SettingCancelRateLimitOn] == "true", + CancelRateLimitMax: pcParseInt(vals[SettingCancelRateLimitMax], 10), + CancelRateLimitWindow: pcParseInt(vals[SettingCancelWindowSize], 1), + CancelRateLimitUnit: vals[SettingCancelWindowUnit], + CancelRateLimitMode: vals[SettingCancelWindowMode], } if cfg.LoadBalanceStrategy == "" { - cfg.LoadBalanceStrategy = "round-robin" + cfg.LoadBalanceStrategy = payment.DefaultLoadBalanceStrategy } if raw := vals[SettingEnabledPaymentTypes]; raw != "" { for _, t := range strings.Split(raw, ",") { @@ -184,242 +232,100 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme return cfg } +// getStripePublishableKey finds the publishable key from the first enabled Stripe provider instance. +func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) string { + instances, err := s.entClient.PaymentProviderInstance.Query(). + Where( + paymentproviderinstance.EnabledEQ(true), + paymentproviderinstance.ProviderKeyEQ(payment.TypeStripe), + ).Limit(1).All(ctx) + if err != nil || len(instances) == 0 { + return "" + } + cfg, err := s.decryptConfig(instances[0].Config) + if err != nil || cfg == nil { + return "" + } + return cfg[payment.ConfigKeyPublishableKey] +} + // UpdatePaymentConfig updates the payment configuration settings. +// NOTE: This function exceeds 30 lines because each field requires an independent +// nil-check before serialisation — this is inherent to patch-style update patterns +// and cannot be meaningfully decomposed without introducing unnecessary abstraction. func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error { - m := make(map[string]string) - if req.Enabled != nil { - m[SettingPaymentEnabled] = strconv.FormatBool(*req.Enabled) - } - if req.MinAmount != nil { - m[SettingMinRechargeAmount] = strconv.FormatFloat(*req.MinAmount, 'f', 2, 64) - } - if req.MaxAmount != nil { - m[SettingMaxRechargeAmount] = strconv.FormatFloat(*req.MaxAmount, 'f', 2, 64) - } - if req.DailyLimit != nil { - m[SettingDailyRechargeLimit] = strconv.FormatFloat(*req.DailyLimit, 'f', 2, 64) - } - if req.OrderTimeoutMin != nil { - m[SettingOrderTimeoutMinutes] = strconv.Itoa(*req.OrderTimeoutMin) - } - if req.MaxPendingOrders != nil { - m[SettingMaxPendingOrders] = strconv.Itoa(*req.MaxPendingOrders) + m := map[string]string{ + SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled), + SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount), + SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount), + SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit), + SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin), + SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders), + SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled), + SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy), + SettingProductNamePrefix: derefStr(req.ProductNamePrefix), + SettingProductNameSuffix: derefStr(req.ProductNameSuffix), + SettingHelpImageURL: derefStr(req.HelpImageURL), + SettingHelpText: derefStr(req.HelpText), + SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled), + SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax), + SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow), + SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit), + SettingCancelWindowMode: derefStr(req.CancelRateLimitMode), } if req.EnabledTypes != nil { m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",") - } - if req.BalanceDisabled != nil { - m[SettingBalancePayDisabled] = strconv.FormatBool(*req.BalanceDisabled) - } - if req.LoadBalanceStrategy != nil { - m[SettingLoadBalanceStrategy] = *req.LoadBalanceStrategy - } - if req.ProductNamePrefix != nil { - m[SettingProductNamePrefix] = *req.ProductNamePrefix - } - if req.ProductNameSuffix != nil { - m[SettingProductNameSuffix] = *req.ProductNameSuffix - } - if len(m) == 0 { - return nil + } else { + m[SettingEnabledPaymentTypes] = "" } return s.settingRepo.SetMultiple(ctx, m) } -// --- Provider Instance CRUD --- - -func (s *PaymentConfigService) ListProviderInstances(ctx context.Context) ([]*dbent.PaymentProviderInstance, error) { - return s.entClient.PaymentProviderInstance.Query().Order(paymentproviderinstance.BySortOrder()).All(ctx) +func formatBoolOrEmpty(v *bool) string { + if v == nil { + return "" + } + return strconv.FormatBool(*v) } -func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) { - enc, err := s.encryptConfig(req.Config) - if err != nil { - return nil, err +func formatPositiveFloat(v *float64) string { + if v == nil || *v <= 0 { + return "" // empty → parsePaymentConfig uses default } - return s.entClient.PaymentProviderInstance.Create(). - SetProviderKey(req.ProviderKey).SetName(req.Name).SetConfig(enc). - SetSupportedTypes(req.SupportedTypes).SetEnabled(req.Enabled). - SetSortOrder(req.SortOrder).SetLimits(req.Limits).SetRefundEnabled(req.RefundEnabled). - Save(ctx) + return strconv.FormatFloat(*v, 'f', 2, 64) } -func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) { - u := s.entClient.PaymentProviderInstance.UpdateOneID(id) - if req.Name != nil { - u.SetName(*req.Name) +func formatPositiveInt(v *int) string { + if v == nil || *v <= 0 { + return "" } - if req.Config != nil { - enc, err := s.encryptConfig(req.Config) - if err != nil { - return nil, err - } - u.SetConfig(enc) - } - if req.SupportedTypes != nil { - u.SetSupportedTypes(*req.SupportedTypes) - } - if req.Enabled != nil { - u.SetEnabled(*req.Enabled) - } - if req.SortOrder != nil { - u.SetSortOrder(*req.SortOrder) - } - if req.Limits != nil { - u.SetLimits(*req.Limits) - } - if req.RefundEnabled != nil { - u.SetRefundEnabled(*req.RefundEnabled) - } - return u.Save(ctx) + return strconv.Itoa(*v) } -func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error { - return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx) +func derefStr(v *string) string { + if v == nil { + return "" + } + return *v } -func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) { - data, err := json.Marshal(cfg) - if err != nil { - return "", fmt.Errorf("marshal config: %w", err) +func splitTypes(s string) []string { + if s == "" { + return nil } - enc, err := payment.Encrypt(string(data), s.encryptionKey) - if err != nil { - return "", fmt.Errorf("encrypt config: %w", err) - } - return enc, nil -} - -// --- Channel CRUD --- - - -// --- Plan CRUD --- - -func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) { - return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx) -} - -func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) { - return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx) -} - -func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) { - b := s.entClient.SubscriptionPlan.Create(). - SetGroupID(req.GroupID).SetName(req.Name).SetDescription(req.Description). - SetPrice(req.Price).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit). - SetFeatures(req.Features).SetProductName(req.ProductName). - SetForSale(req.ForSale).SetSortOrder(req.SortOrder) - if req.OriginalPrice != nil { - b.SetOriginalPrice(*req.OriginalPrice) - } - return b.Save(ctx) -} - -func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req UpdatePlanRequest) (*dbent.SubscriptionPlan, error) { - u := s.entClient.SubscriptionPlan.UpdateOneID(id) - if req.GroupID != nil { - u.SetGroupID(*req.GroupID) - } - if req.Name != nil { - u.SetName(*req.Name) - } - if req.Description != nil { - u.SetDescription(*req.Description) - } - if req.Price != nil { - u.SetPrice(*req.Price) - } - if req.OriginalPrice != nil { - u.SetOriginalPrice(*req.OriginalPrice) - } - if req.ValidityDays != nil { - u.SetValidityDays(*req.ValidityDays) - } - if req.ValidityUnit != nil { - u.SetValidityUnit(*req.ValidityUnit) - } - if req.Features != nil { - u.SetFeatures(*req.Features) - } - if req.ProductName != nil { - u.SetProductName(*req.ProductName) - } - if req.ForSale != nil { - u.SetForSale(*req.ForSale) - } - if req.SortOrder != nil { - u.SetSortOrder(*req.SortOrder) - } - return u.Save(ctx) -} - -func (s *PaymentConfigService) DeletePlan(ctx context.Context, id int64) error { - return s.entClient.SubscriptionPlan.DeleteOneID(id).Exec(ctx) -} - -// GetPlan returns a subscription plan by ID. -func (s *PaymentConfigService) GetPlan(ctx context.Context, id int64) (*dbent.SubscriptionPlan, error) { - plan, err := s.entClient.SubscriptionPlan.Get(ctx, id) - if err != nil { - return nil, infraerrors.NotFound("PLAN_NOT_FOUND", "subscription plan not found") - } - return plan, nil -} - -// GetMethodLimits returns per-payment-type limits from enabled provider instances. -func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []string) ([]MethodLimits, error) { - instances, err := s.entClient.PaymentProviderInstance.Query(). - Where(paymentproviderinstance.EnabledEQ(true)).All(ctx) - if err != nil { - return nil, fmt.Errorf("query provider instances: %w", err) - } - result := make([]MethodLimits, 0, len(types)) - for _, pt := range types { - ml := MethodLimits{PaymentType: pt} - for _, inst := range instances { - if !pcInstanceSupportsType(inst, pt) { - continue - } - pcApplyInstanceLimits(inst, pt, &ml) - } - result = append(result, ml) - } - return result, nil -} - -func pcInstanceSupportsType(inst *dbent.PaymentProviderInstance, pt string) bool { - if inst.SupportedTypes == "" { - return true - } - for _, t := range strings.Split(inst.SupportedTypes, ",") { - if strings.TrimSpace(t) == pt { - return true + parts := strings.Split(s, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) } } - return false + return result } -func pcApplyInstanceLimits(inst *dbent.PaymentProviderInstance, pt string, ml *MethodLimits) { - if inst.Limits == "" { - return - } - var limits payment.InstanceLimits - if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil { - return - } - cl, ok := limits[pt] - if !ok { - return - } - if cl.DailyLimit > 0 && (ml.DailyLimit == 0 || cl.DailyLimit < ml.DailyLimit) { - ml.DailyLimit = cl.DailyLimit - } - if cl.SingleMin > 0 && (ml.SingleMin == 0 || cl.SingleMin > ml.SingleMin) { - ml.SingleMin = cl.SingleMin - } - if cl.SingleMax > 0 && (ml.SingleMax == 0 || cl.SingleMax < ml.SingleMax) { - ml.SingleMax = cl.SingleMax - } +func joinTypes(types []string) string { + return strings.Join(types, ",") } func pcParseFloat(s string, defaultVal float64) float64 { diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index de41d742..db92ff2b 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -5,12 +5,9 @@ import ( "fmt" "log/slog" "math" - "strconv" - "strings" "time" dbent "github.com/Wei-Shaw/sub2api/ent" - "github.com/Wei-Shaw/sub2api/ent/paymentauditlog" "github.com/Wei-Shaw/sub2api/ent/paymentorder" "github.com/Wei-Shaw/sub2api/internal/payment" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -19,20 +16,14 @@ import ( // --- Payment Notification & Fulfillment --- func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payment.PaymentNotification, pk string) error { - if n.Status != payment.NotificationStatusSuccess { + if n.Status != "success" { return nil } - // Look up order by out_trade_no (the external order ID we sent to the provider) - order, err := s.entClient.PaymentOrder.Query().Where(paymentorder.OutTradeNo(n.OrderID)).Only(ctx) + oid, err := parseOrderID(n.OrderID) if err != nil { - // Fallback: try legacy format (sub2_N where N is DB ID) - trimmed := strings.TrimPrefix(n.OrderID, orderIDPrefix) - if oid, parseErr := strconv.ParseInt(trimmed, 10, 64); parseErr == nil { - return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk) - } - return fmt.Errorf("order not found for out_trade_no: %s", n.OrderID) + return fmt.Errorf("invalid order ID: %s", n.OrderID) } - return s.confirmPayment(ctx, order.ID, n.TradeNo, n.Amount, pk) + return s.confirmPayment(ctx, oid, n.TradeNo, n.Amount, pk) } func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo string, paid float64, pk string) error { @@ -41,17 +32,9 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo slog.Error("order not found", "orderID", oid) return nil } - // Skip amount check when paid=0 (e.g. QueryOrder doesn't return amount). - // Also skip if paid is NaN/Inf (malformed provider data). - if paid > 0 && !math.IsNaN(paid) && !math.IsInf(paid, 0) { - if math.Abs(paid-o.PayAmount) > amountToleranceCNY { - s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo}) - return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid) - } - } - // Use order's expected amount when provider didn't report one - if paid <= 0 || math.IsNaN(paid) || math.IsInf(paid, 0) { - paid = o.PayAmount + if math.Abs(paid-o.PayAmount) > amountToleranceCNY { + s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo}) + return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid) } return s.toPaid(ctx, o, tradeNo, paid, pk) } @@ -129,7 +112,7 @@ func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) erro if err != nil { return fmt.Errorf("get order: %w", err) } - if o.OrderType == payment.OrderTypeSubscription { + if o.OrderType == "subscription" { return s.ExecuteSubscriptionFulfillment(ctx, oid) } return s.ExecuteBalanceFulfillment(ctx, oid) @@ -163,46 +146,20 @@ func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int6 return nil } -// redeemAction represents the idempotency decision for balance fulfillment. -type redeemAction int - -const ( - // redeemActionCreate: code does not exist — create it, then redeem. - redeemActionCreate redeemAction = iota - // redeemActionRedeem: code exists but is unused — skip creation, redeem only. - redeemActionRedeem - // redeemActionSkipCompleted: code exists and is already used — skip to mark completed. - redeemActionSkipCompleted -) - -// resolveRedeemAction decides the idempotency action based on an existing redeem code lookup. -// existing is the result of GetByCode; lookupErr is the error from that call. -func resolveRedeemAction(existing *RedeemCode, lookupErr error) redeemAction { - if existing == nil || lookupErr != nil { - return redeemActionCreate - } - if existing.IsUsed() { - return redeemActionSkipCompleted - } - return redeemActionRedeem -} - func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) error { // Idempotency: check if redeem code already exists (from a previous partial run) - existing, lookupErr := s.redeemService.GetByCode(ctx, o.RechargeCode) - action := resolveRedeemAction(existing, lookupErr) - - switch action { - case redeemActionSkipCompleted: - // Code already created and redeemed — just mark completed - return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") - case redeemActionCreate: + existing, _ := s.redeemService.GetByCode(ctx, o.RechargeCode) + if existing != nil { + if existing.IsUsed() { + // Code already created and redeemed — just mark completed + return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") + } + // Code exists but unused — skip creation, proceed to redeem + } else { rc := &RedeemCode{Code: o.RechargeCode, Type: RedeemTypeBalance, Value: o.Amount, Status: StatusUnused} if err := s.redeemService.CreateCode(ctx, rc); err != nil { return fmt.Errorf("create redeem code: %w", err) } - case redeemActionRedeem: - // Code exists but unused — skip creation, proceed to redeem } if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil { return fmt.Errorf("redeem balance: %w", err) @@ -255,45 +212,30 @@ func (s *PaymentService) doSub(ctx context.Context, o *dbent.PaymentOrder) error gid := *o.SubscriptionGroupID days := *o.SubscriptionDays g, err := s.groupRepo.GetByID(ctx, gid) - if err != nil || g.Status != payment.EntityStatusActive { + if err != nil || g.Status != "active" { return fmt.Errorf("group %d no longer exists or inactive", gid) } - // Idempotency: check audit log to see if subscription was already assigned. - // Prevents double-extension on retry after markCompleted fails. - if s.hasAuditLog(ctx, o.ID, "SUBSCRIPTION_SUCCESS") { - slog.Info("subscription already assigned for order, skipping", "orderID", o.ID, "groupID", gid) - return s.markCompleted(ctx, o, "SUBSCRIPTION_SUCCESS") - } - orderNote := fmt.Sprintf("payment order %d", o.ID) - _, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: orderNote}) + _, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: fmt.Sprintf("payment order %d", o.ID)}) if err != nil { return fmt.Errorf("assign subscription: %w", err) } - return s.markCompleted(ctx, o, "SUBSCRIPTION_SUCCESS") -} - -func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action string) bool { - oid := strconv.FormatInt(orderID, 10) - c, _ := s.entClient.PaymentAuditLog.Query(). - Where(paymentauditlog.OrderIDEQ(oid), paymentauditlog.ActionEQ(action)). - Limit(1).Count(ctx) - return c > 0 + now := time.Now() + _, err = s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusRecharging)).SetStatus(OrderStatusCompleted).SetCompletedAt(now).Save(ctx) + if err != nil { + return fmt.Errorf("mark completed: %w", err) + } + s.writeAuditLog(ctx, o.ID, "SUBSCRIPTION_SUCCESS", "system", map[string]any{"groupId": gid, "days": days, "amount": o.Amount}) + return nil } func (s *PaymentService) markFailed(ctx context.Context, oid int64, cause error) { now := time.Now() r := psErrMsg(cause) - // Only mark FAILED if still in RECHARGING state — prevents overwriting - // a COMPLETED order when markCompleted failed but fulfillment succeeded. - c, e := s.entClient.PaymentOrder.Update(). - Where(paymentorder.IDEQ(oid), paymentorder.StatusEQ(OrderStatusRecharging)). - SetStatus(OrderStatusFailed).SetFailedAt(now).SetFailedReason(r).Save(ctx) + _, e := s.entClient.PaymentOrder.UpdateOneID(oid).SetStatus(OrderStatusFailed).SetFailedAt(now).SetFailedReason(r).Save(ctx) if e != nil { slog.Error("mark FAILED", "orderID", oid, "error", e) } - if c > 0 { - s.writeAuditLog(ctx, oid, "FULFILLMENT_FAILED", "system", map[string]any{"reason": r}) - } + s.writeAuditLog(ctx, oid, "FULFILLMENT_FAILED", "system", map[string]any{"reason": r}) } func (s *PaymentService) RetryFulfillment(ctx context.Context, oid int64) error { diff --git a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue index 7320037d..06bc9218 100644 --- a/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue +++ b/frontend/src/views/admin/orders/AdminPaymentDashboardView.vue @@ -42,7 +42,7 @@ {{ t('payment.methods.' + method.type, method.type) }}
- ${{ method.amount.toFixed(2) }} + ¥{{ method.amount.toFixed(2) }} ({{ method.count }})
@@ -57,7 +57,7 @@ {{ idx + 1 }} {{ user.email }} - ${{ user.amount.toFixed(2) }} + ¥{{ user.amount.toFixed(2) }} diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 5a958097..5dc396ec 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -5,217 +5,82 @@