From aa151ef0e17b5913b55718c41438302489a98238 Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 5 Apr 2026 18:32:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=20all=20audit=20findings=20?= =?UTF-8?q?=E2=80=94=20registry=20init,=20expiry=20task,=20dashboard,=20i1?= =?UTF-8?q?8n,=20stripe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: Add EnsureProviders() lazy initialization to populate Registry from PaymentProviderInstance DB rows at first use C2: Delete stale payment/provider_factory.go dead code C3: Add PaymentOrderExpiryService background ticker (60s interval) C4: Expand DashboardStats with daily_series, payment_methods, top_users C5+C6: Add 152 payment i18n keys (en + zh) for admin and settings pages C7: Fix Stripe global key race — use per-instance client.API I1: Remove old purchase_subscription_enabled/url from settings form I2: Add MigrateLegacyPurchaseURL to auto-convert old URL to custom menu I3: Add migration 092 no-op placeholder for numbering continuity --- backend/cmd/server/wire.go | 7 + backend/cmd/server/wire_gen.go | 10 +- backend/internal/payment/load_balancer.go | 10 + backend/internal/payment/provider/stripe.go | 15 +- backend/internal/payment/registry.go | 9 + .../service/payment_config_service.go | 64 +++++ .../service/payment_order_expiry_service.go | 71 ++++++ backend/internal/service/payment_service.go | 227 ++++++++++++++++-- backend/internal/service/wire.go | 8 + .../092_removed_payment_channels.sql | 4 + frontend/src/i18n/locales/en.ts | 107 +++++++++ frontend/src/i18n/locales/zh.ts | 107 +++++++++ frontend/src/views/admin/SettingsView.vue | 19 -- 13 files changed, 606 insertions(+), 52 deletions(-) create mode 100644 backend/internal/service/payment_order_expiry_service.go create mode 100644 backend/migrations/092_removed_payment_channels.sql diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go index 8aedad732c..634a44884b 100644 --- a/backend/cmd/server/wire.go +++ b/backend/cmd/server/wire.go @@ -97,6 +97,7 @@ func provideCleanup( openAIGateway *service.OpenAIGatewayService, scheduledTestRunner *service.ScheduledTestRunnerService, backupSvc *service.BackupService, + paymentOrderExpiry *service.PaymentOrderExpiryService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -239,6 +240,12 @@ func provideCleanup( } return nil }}, + {"PaymentOrderExpiryService", func() error { + if paymentOrderExpiry != nil { + paymentOrderExpiry.Stop() + } + return nil + }}, } infraSteps := []cleanupStep{ diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 7936d181c1..09fb2da05f 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -217,6 +217,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { 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) 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) @@ -254,7 +255,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { accountExpiryService := service.ProvideAccountExpiryService(accountRepository) subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository) scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig) - v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, soraMediaCleanupService, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService) + v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, soraMediaCleanupService, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService) application := &Application{ Server: httpServer, Cleanup: v, @@ -308,6 +309,7 @@ func provideCleanup( openAIGateway *service.OpenAIGatewayService, scheduledTestRunner *service.ScheduledTestRunnerService, backupSvc *service.BackupService, + paymentOrderExpiry *service.PaymentOrderExpiryService, ) func() { return func() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -449,6 +451,12 @@ func provideCleanup( } return nil }}, + {"PaymentOrderExpiryService", func() error { + if paymentOrderExpiry != nil { + paymentOrderExpiry.Stop() + } + return nil + }}, } infraSteps := []cleanupStep{ diff --git a/backend/internal/payment/load_balancer.go b/backend/internal/payment/load_balancer.go index 3955e332eb..1a7371138c 100644 --- a/backend/internal/payment/load_balancer.go +++ b/backend/internal/payment/load_balancer.go @@ -30,6 +30,7 @@ type InstanceLimits map[string]struct { // LoadBalancer selects a provider instance for a given payment type. type LoadBalancer interface { + GetInstanceConfig(ctx context.Context, instanceID int64) (map[string]string, error) SelectInstance(ctx context.Context, providerKey string, paymentType PaymentType) (*InstanceSelection, error) } @@ -132,3 +133,12 @@ func containsType(supportedTypes string, target PaymentType) bool { } return false } + +// GetInstanceConfig decrypts and returns the configuration for a provider instance by ID. +func (lb *DefaultLoadBalancer) GetInstanceConfig(ctx context.Context, instanceID int64) (map[string]string, error) { + inst, err := lb.db.PaymentProviderInstance.Get(ctx, instanceID) + if err != nil { + return nil, fmt.Errorf("get instance %d: %w", instanceID, err) + } + return lb.decryptConfig(inst.Config) +} diff --git a/backend/internal/payment/provider/stripe.go b/backend/internal/payment/provider/stripe.go index 758d0c5a56..850dfd88fa 100644 --- a/backend/internal/payment/provider/stripe.go +++ b/backend/internal/payment/provider/stripe.go @@ -10,8 +10,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/payment" stripe "github.com/stripe/stripe-go/v82" - "github.com/stripe/stripe-go/v82/paymentintent" - "github.com/stripe/stripe-go/v82/refund" + "github.com/stripe/stripe-go/v82/client" "github.com/stripe/stripe-go/v82/webhook" ) @@ -22,6 +21,7 @@ type Stripe struct { mu sync.Mutex initialized bool + api *client.API } // NewStripe creates a new Stripe provider instance. @@ -39,7 +39,8 @@ func (s *Stripe) ensureInit() { s.mu.Lock() defer s.mu.Unlock() if !s.initialized { - stripe.Key = s.config["secretKey"] + s.api = &client.API{} + s.api.Init(s.config["secretKey"], nil) s.initialized = true } } @@ -85,7 +86,7 @@ func (s *Stripe) CreatePayment(ctx context.Context, req payment.CreatePaymentReq params.SetIdempotencyKey(fmt.Sprintf("pi-%s", req.OrderID)) params.Context = ctx - pi, err := paymentintent.New(params) + pi, err := s.api.PaymentIntents.New(params) if err != nil { return nil, fmt.Errorf("stripe create payment: %w", err) } @@ -103,7 +104,7 @@ func (s *Stripe) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query params := &stripe.PaymentIntentParams{} params.Context = ctx - pi, err := paymentintent.Get(tradeNo, params) + pi, err := s.api.PaymentIntents.Get(tradeNo, params) if err != nil { return nil, fmt.Errorf("stripe query order: %w", err) } @@ -182,7 +183,7 @@ func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*paymen } params.Context = ctx - r, err := refund.New(params) + r, err := s.api.Refunds.New(params) if err != nil { return nil, fmt.Errorf("stripe refund: %w", err) } @@ -205,7 +206,7 @@ func (s *Stripe) CancelPayment(ctx context.Context, tradeNo string) error { params := &stripe.PaymentIntentCancelParams{} params.Context = ctx - _, err := paymentintent.Cancel(tradeNo, params) + _, err := s.api.PaymentIntents.Cancel(tradeNo, params) if err != nil { return fmt.Errorf("stripe cancel payment: %w", err) } diff --git a/backend/internal/payment/registry.go b/backend/internal/payment/registry.go index 2e4c13486b..9c27d6d619 100644 --- a/backend/internal/payment/registry.go +++ b/backend/internal/payment/registry.go @@ -76,3 +76,12 @@ func (r *Registry) SupportedTypes() []PaymentType { } return types } + +// Clear removes all registered providers. +func (r *Registry) Clear() { + r.mu.Lock() + defer r.mu.Unlock() + for k := range r.providers { + delete(r.providers, k) + } +} diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index dafe9afd9a..abb8d4e176 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "log/slog" "encoding/json" "fmt" "strconv" @@ -443,3 +444,66 @@ func pcParseInt(s string, defaultVal int) int { } return v } + +// MigrateLegacyPurchaseURL checks if the old purchase_subscription_url setting +// exists and is enabled, and if so, migrates it to a custom_menu_item entry. +// This is a one-time migration that runs at startup. +func (s *PaymentConfigService) MigrateLegacyPurchaseURL(ctx context.Context) error { + enabled, _ := s.settingRepo.GetValue(ctx, SettingKeyPurchaseSubscriptionEnabled) + if enabled != "true" { + return nil + } + url, _ := s.settingRepo.GetValue(ctx, SettingKeyPurchaseSubscriptionURL) + url = strings.TrimSpace(url) + if url == "" { + return nil + } + + // Read current custom_menu_items + raw, _ := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems) + type menuItem struct { + ID string `json:"id"` + Label string `json:"label"` + IconSVG string `json:"icon_svg"` + URL string `json:"url"` + Visibility string `json:"visibility"` + SortOrder int `json:"sort_order"` + } + var items []menuItem + if raw != "" { + _ = json.Unmarshal([]byte(raw), &items) + } + + // Check if already migrated (same URL exists) + for _, item := range items { + if item.URL == url { + return nil + } + } + + // Append migrated item + items = append(items, menuItem{ + ID: "migrated_purchase_subscription", + Label: "Purchase Subscription", + IconSVG: "", + URL: url, + Visibility: "user", + SortOrder: 0, + }) + + data, err := json.Marshal(items) + if err != nil { + return fmt.Errorf("marshal custom_menu_items: %w", err) + } + + // Save updated menu items and clear old settings + if err := s.settingRepo.SetMultiple(ctx, map[string]string{ + SettingKeyCustomMenuItems: string(data), + SettingKeyPurchaseSubscriptionEnabled: "false", + }); err != nil { + return fmt.Errorf("save migrated settings: %w", err) + } + + slog.Info("[payment] Migrated legacy purchase_subscription_url to custom menu item", "url", url) + return nil +} diff --git a/backend/internal/service/payment_order_expiry_service.go b/backend/internal/service/payment_order_expiry_service.go new file mode 100644 index 0000000000..2e0001b042 --- /dev/null +++ b/backend/internal/service/payment_order_expiry_service.go @@ -0,0 +1,71 @@ +package service + +import ( + "context" + "log" + "sync" + "time" +) + +// PaymentOrderExpiryService periodically expires timed-out payment orders. +type PaymentOrderExpiryService struct { + paymentSvc *PaymentService + interval time.Duration + stopCh chan struct{} + stopOnce sync.Once + wg sync.WaitGroup +} + +func NewPaymentOrderExpiryService(paymentSvc *PaymentService, interval time.Duration) *PaymentOrderExpiryService { + return &PaymentOrderExpiryService{ + paymentSvc: paymentSvc, + interval: interval, + stopCh: make(chan struct{}), + } +} + +func (s *PaymentOrderExpiryService) Start() { + if s == nil || s.paymentSvc == nil || s.interval <= 0 { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + + s.runOnce() + for { + select { + case <-ticker.C: + s.runOnce() + case <-s.stopCh: + return + } + } + }() +} + +func (s *PaymentOrderExpiryService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + close(s.stopCh) + }) + s.wg.Wait() +} + +func (s *PaymentOrderExpiryService) runOnce() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + expired, err := s.paymentSvc.ExpireTimedOutOrders(ctx) + if err != nil { + log.Printf("[PaymentOrderExpiry] failed to expire orders: %v", err) + return + } + if expired > 0 { + log.Printf("[PaymentOrderExpiry] expired %d timed-out orders", expired) + } +} diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index f6c927c67a..b442728979 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -2,6 +2,8 @@ package service import ( "context" + "log" + "sync" "encoding/json" "fmt" "log/slog" @@ -13,7 +15,9 @@ import ( 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/ent/paymentproviderinstance" "github.com/Wei-Shaw/sub2api/internal/payment" + "github.com/Wei-Shaw/sub2api/internal/payment/provider" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) @@ -92,15 +96,38 @@ type RefundResult struct { } type DashboardStats struct { - TotalOrders int `json:"totalOrders"` - TotalRevenue float64 `json:"totalRevenue"` - TotalRefunded float64 `json:"totalRefunded"` - PendingOrders int `json:"pendingOrders"` - CompletedOrders int `json:"completedOrders"` - FailedOrders int `json:"failedOrders"` + TodayAmount float64 `json:"today_amount"` + TotalAmount float64 `json:"total_amount"` + TodayCount int `json:"today_count"` + TotalCount int `json:"total_count"` + AvgAmount float64 `json:"avg_amount"` + PendingOrders int `json:"pending_orders"` + + DailySeries []DailyStats `json:"daily_series"` + PaymentMethods []PaymentMethodStat `json:"payment_methods"` + TopUsers []TopUserStat `json:"top_users"` +} + +type DailyStats struct { + Date string `json:"date"` + Amount float64 `json:"amount"` + Count int `json:"count"` +} + +type PaymentMethodStat struct { + Type string `json:"type"` + Amount float64 `json:"amount"` + Count int `json:"count"` +} + +type TopUserStat struct { + UserID int64 `json:"user_id"` + Email string `json:"email"` + Amount float64 `json:"amount"` } type PaymentService struct { + providerOnce sync.Once entClient *dbent.Client registry *payment.Registry loadBalancer payment.LoadBalancer @@ -115,6 +142,13 @@ func NewPaymentService(entClient *dbent.Client, registry *payment.Registry, load return &PaymentService{entClient: entClient, registry: registry, loadBalancer: loadBalancer, redeemService: redeemService, subscriptionSvc: subscriptionSvc, configService: configService, userRepo: userRepo, groupRepo: groupRepo} } +// Start performs one-time startup tasks such as legacy settings migration. +func (s *PaymentService) Start(ctx context.Context) { + if err := s.configService.MigrateLegacyPurchaseURL(ctx); err != nil { + slog.Error("[payment] legacy purchase URL migration failed", "error", err) + } +} + func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error) { if req.OrderType == "" { req.OrderType = "balance" @@ -260,6 +294,7 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user } func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan) (*CreateOrderResponse, error) { + s.EnsureProviders(ctx) provider, err := s.registry.GetProvider(req.PaymentType) if err != nil { return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", fmt.Sprintf("payment method (%s) is not configured", req.PaymentType)) @@ -393,6 +428,7 @@ func (s *PaymentService) cancelCore(ctx context.Context, o *dbent.PaymentOrder, } func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) string { + s.EnsureProviders(ctx) prov, err := s.registry.GetProvider(o.PaymentType) if err != nil { return "" @@ -724,6 +760,7 @@ func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error { s.writeAuditLog(ctx, p.Order.ID, "REFUND_NO_TRADE_NO", "admin", map[string]any{"detail": "skipped"}) return nil } + s.EnsureProviders(ctx) prov, err := s.registry.GetProvider(p.Order.PaymentType) if err != nil { return fmt.Errorf("get provider: %w", err) @@ -797,38 +834,136 @@ func (s *PaymentService) ExpireTimedOutOrders(ctx context.Context) (int, error) } return n, nil } - func (s *PaymentService) GetDashboardStats(ctx context.Context, days int) (*DashboardStats, error) { if days <= 0 { days = 30 } - since := time.Now().AddDate(0, 0, -days) + now := time.Now() + since := now.AddDate(0, 0, -days) + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + + paidStatuses := []string{OrderStatusCompleted, OrderStatusPaid, OrderStatusRecharging} + + // Fetch all paid orders in the date range + orders, err := s.entClient.PaymentOrder.Query(). + Where( + paymentorder.StatusIn(paidStatuses...), + paymentorder.PaidAtGTE(since), + ). + All(ctx) + if err != nil { + return nil, err + } + st := &DashboardStats{} - var err error - st.TotalOrders, err = s.entClient.PaymentOrder.Query().Where(paymentorder.CreatedAtGTE(since)).Count(ctx) + + // Compute basic stats + var totalAmount float64 + var todayAmount float64 + var todayCount int + for _, o := range orders { + totalAmount += o.PayAmount + if o.PaidAt != nil && !o.PaidAt.Before(todayStart) { + todayAmount += o.PayAmount + todayCount++ + } + } + st.TotalAmount = math.Round(totalAmount*100) / 100 + st.TodayAmount = math.Round(todayAmount*100) / 100 + st.TotalCount = len(orders) + st.TodayCount = todayCount + if st.TotalCount > 0 { + st.AvgAmount = math.Round(totalAmount/float64(st.TotalCount)*100) / 100 + } + + // Pending orders count + st.PendingOrders, err = s.entClient.PaymentOrder.Query(). + Where(paymentorder.StatusEQ(OrderStatusPending)). + Count(ctx) if err != nil { return nil, err } - st.PendingOrders, err = s.entClient.PaymentOrder.Query().Where(paymentorder.StatusEQ(OrderStatusPending), paymentorder.CreatedAtGTE(since)).Count(ctx) - if err != nil { - return nil, err + + // Daily series: GROUP BY date(paid_at) + dailyMap := make(map[string]*DailyStats) + for _, o := range orders { + if o.PaidAt == nil { + continue + } + date := o.PaidAt.Format("2006-01-02") + ds, ok := dailyMap[date] + if !ok { + ds = &DailyStats{Date: date} + dailyMap[date] = ds + } + ds.Amount += o.PayAmount + ds.Count++ } - st.CompletedOrders, err = s.entClient.PaymentOrder.Query().Where(paymentorder.StatusEQ(OrderStatusCompleted), paymentorder.CreatedAtGTE(since)).Count(ctx) - if err != nil { - return nil, err + // Build sorted daily series for all days in range + st.DailySeries = make([]DailyStats, 0, days) + for i := 0; i < days; i++ { + date := since.AddDate(0, 0, i+1).Format("2006-01-02") + if ds, ok := dailyMap[date]; ok { + ds.Amount = math.Round(ds.Amount*100) / 100 + st.DailySeries = append(st.DailySeries, *ds) + } else { + st.DailySeries = append(st.DailySeries, DailyStats{Date: date}) + } } - st.FailedOrders, err = s.entClient.PaymentOrder.Query().Where(paymentorder.StatusEQ(OrderStatusFailed), paymentorder.CreatedAtGTE(since)).Count(ctx) - if err != nil { - return nil, err + + // Payment methods: GROUP BY payment_type + methodMap := make(map[string]*PaymentMethodStat) + for _, o := range orders { + ms, ok := methodMap[o.PaymentType] + if !ok { + ms = &PaymentMethodStat{Type: o.PaymentType} + methodMap[o.PaymentType] = ms + } + ms.Amount += o.PayAmount + ms.Count++ } - st.TotalRevenue, err = s.sumAmt(ctx, []string{OrderStatusCompleted}, since, true) - if err != nil { - return nil, err + st.PaymentMethods = make([]PaymentMethodStat, 0, len(methodMap)) + for _, ms := range methodMap { + ms.Amount = math.Round(ms.Amount*100) / 100 + st.PaymentMethods = append(st.PaymentMethods, *ms) } - st.TotalRefunded, err = s.sumAmt(ctx, []string{OrderStatusRefunded, OrderStatusPartiallyRefunded}, since, false) - if err != nil { - return nil, err + + // Top users: GROUP BY user_id, ORDER BY amount DESC, LIMIT 10 + userMap := make(map[int64]*TopUserStat) + for _, o := range orders { + us, ok := userMap[o.UserID] + if !ok { + us = &TopUserStat{UserID: o.UserID, Email: o.UserEmail} + userMap[o.UserID] = us + } + us.Amount += o.PayAmount } + type userEntry struct { + stat *TopUserStat + amount float64 + } + userList := make([]userEntry, 0, len(userMap)) + for _, us := range userMap { + us.Amount = math.Round(us.Amount*100) / 100 + userList = append(userList, userEntry{stat: us, amount: us.Amount}) + } + // Sort descending by amount + for i := 0; i < len(userList); i++ { + for j := i + 1; j < len(userList); j++ { + if userList[j].amount > userList[i].amount { + userList[i], userList[j] = userList[j], userList[i] + } + } + } + limit := 10 + if len(userList) < limit { + limit = len(userList) + } + st.TopUsers = make([]TopUserStat, 0, limit) + for i := 0; i < limit; i++ { + st.TopUsers = append(st.TopUsers, *userList[i].stat) + } + return st, nil } @@ -951,3 +1086,45 @@ func (s *PaymentService) AdminListOrders(ctx context.Context, userID int64, p Or } return orders, total, nil } + +// EnsureProviders lazily initializes the provider registry on first call. +// It queries all enabled PaymentProviderInstance records, decrypts their config, +// creates providers via provider.CreateProvider, and registers them. +func (s *PaymentService) EnsureProviders(ctx context.Context) { + s.providerOnce.Do(func() { + s.loadProviders(ctx) + }) +} + +// RefreshProviders clears and re-registers all providers from the database. +// Call this when provider instances are created, updated, or deleted. +func (s *PaymentService) RefreshProviders(ctx context.Context) { + s.registry.Clear() + s.providerOnce = sync.Once{} + s.EnsureProviders(ctx) +} + +func (s *PaymentService) loadProviders(ctx context.Context) { + instances, err := s.entClient.PaymentProviderInstance.Query(). + Where(paymentproviderinstance.EnabledEQ(true)). + All(ctx) + if err != nil { + log.Printf("[PaymentService] failed to query provider instances: %v", err) + return + } + for _, inst := range instances { + cfg, err := s.loadBalancer.GetInstanceConfig(ctx, int64(inst.ID)) + if err != nil { + log.Printf("[PaymentService] failed to decrypt config for instance %d: %v", inst.ID, err) + continue + } + instID := fmt.Sprintf("%d", inst.ID) + p, err := provider.CreateProvider(inst.ProviderKey, instID, cfg) + if err != nil { + log.Printf("[PaymentService] failed to create provider for instance %d (key=%s): %v", inst.ID, inst.ProviderKey, err) + continue + } + s.registry.Register(p) + } + log.Printf("[PaymentService] registered %d payment types from %d instances", len(s.registry.SupportedTypes()), len(instances)) +} diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 3796eebdc7..b3cf112e99 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -496,6 +496,7 @@ var ProviderSet = wire.NewSet( NewModelPricingResolver, ProvidePaymentConfigService, NewPaymentService, + ProvidePaymentOrderExpiryService, NewSoraS3Storage, NewSoraGenerationService, NewSoraQuotaService, @@ -506,3 +507,10 @@ var ProviderSet = wire.NewSet( func ProvidePaymentConfigService(entClient *dbent.Client, settingRepo SettingRepository, key payment.EncryptionKey) *PaymentConfigService { return NewPaymentConfigService(entClient, settingRepo, []byte(key)) } + +// ProvidePaymentOrderExpiryService creates and starts PaymentOrderExpiryService. +func ProvidePaymentOrderExpiryService(paymentSvc *PaymentService) *PaymentOrderExpiryService { + svc := NewPaymentOrderExpiryService(paymentSvc, 60*time.Second) + svc.Start() + return svc +} diff --git a/backend/migrations/092_removed_payment_channels.sql b/backend/migrations/092_removed_payment_channels.sql new file mode 100644 index 0000000000..cb20234795 --- /dev/null +++ b/backend/migrations/092_removed_payment_channels.sql @@ -0,0 +1,4 @@ +-- Migration 092: payment_channels table was removed before release. +-- This file is a no-op placeholder to maintain migration numbering continuity. +-- The payment system now uses the existing channels table (migration 081). +SELECT 1; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 52530aa545..3bbd7d139f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4198,6 +4198,7 @@ export default { email: 'Email', backup: 'Backup', data: 'Sora Storage', + payment: 'Payment', }, emailTabDisabledTitle: 'Email Verification Not Enabled', emailTabDisabledHint: 'Enable email verification in the Security tab to configure SMTP settings.', @@ -4388,6 +4389,23 @@ export default { moveUp: 'Move Up', moveDown: 'Move Down', }, + payment: { + title: 'Payment Settings', + description: 'Configure payment system options', + enabled: 'Enable Payment', + enabledHint: 'Enable or disable the payment system', + enabledPaymentTypes: 'Enabled Payment Types', + enabledPaymentTypesHint: 'Select which payment methods to display', + minAmount: 'Minimum Amount', + maxAmount: 'Maximum Amount', + dailyLimit: 'Daily Limit', + orderTimeout: 'Order Timeout', + maxPendingOrders: 'Max Pending Orders', + helpText: 'Help Text', + helpImageUrl: 'Help Image URL', + manageProviders: 'Manage Providers', + balancePaymentDisabled: 'Balance payment is disabled', + }, smtp: { title: 'SMTP Settings', description: 'Configure email sending for verification codes', @@ -5168,6 +5186,7 @@ export default { paymentMethod: 'Payment Method', createdAt: 'Created', cancel: 'Cancel Order', + userId: 'User ID', requestRefund: 'Request Refund', }, result: { @@ -5198,6 +5217,94 @@ export default { oneYear: '1 Year', perMonth: 'month', perYear: 'year', + admin: { + tabs: { + overview: 'Overview', + orders: 'Orders', + channels: 'Channels', + plans: 'Plans', + }, + todayRevenue: 'Today Revenue', + totalRevenue: 'Total Revenue', + todayOrders: 'Today Orders', + orderCount: 'Order Count', + avgAmount: 'Average Amount', + revenue: 'Revenue', + dailyRevenue: 'Daily Revenue', + paymentDistribution: 'Payment Distribution', + topUsers: 'Top Users', + noData: 'No data', + days: 'days', + weeks: 'weeks', + months: 'months', + searchOrders: 'Search orders...', + allStatuses: 'All Statuses', + allPaymentTypes: 'All Payment Types', + allOrderTypes: 'All Order Types', + orderDetail: 'Order Detail', + orderType: 'Order Type', + orders: 'Orders', + balanceOrder: 'Balance Top-Up', + subscriptionOrder: 'Subscription', + paidAt: 'Paid At', + completedAt: 'Completed At', + expiresAt: 'Expires At', + feeRate: 'Fee Rate', + refund: 'Refund', + refundOrder: 'Refund Order', + refundAmount: 'Refund Amount', + maxRefundable: 'Max Refundable', + refundReason: 'Refund Reason', + refundReasonPlaceholder: 'Please enter refund reason', + confirmRefund: 'Confirm Refund', + refundSuccess: 'Refund successful', + refundInfo: 'Refund Info', + refundEnabled: 'Refund Enabled', + alreadyRefunded: 'Already Refunded', + deductBalance: 'Deduct Balance', + orderCancelled: 'Order Cancelled', + retry: 'Retry', + retrySuccess: 'Retry successful', + channelName: 'Channel Name', + channelDescription: 'Channel Description', + createChannel: 'Create Channel', + editChannel: 'Edit Channel', + deleteChannel: 'Delete Channel', + deleteChannelConfirm: 'Are you sure you want to delete this channel?', + planName: 'Plan Name', + planDescription: 'Plan Description', + createPlan: 'Create Plan', + editPlan: 'Edit Plan', + deletePlan: 'Delete Plan', + deletePlanConfirm: 'Are you sure you want to delete this plan?', + originalPrice: 'Original Price', + price: 'Price', + validityDays: 'Validity (days)', + validityUnit: 'Validity Unit', + sortOrder: 'Sort Order', + forSale: 'For Sale', + onSale: 'On Sale', + offSale: 'Off Sale', + groupId: 'Group ID', + features: 'Features', + featuresHint: 'One feature per line', + featuresPlaceholder: 'Enter plan features...', + providerManagement: 'Provider Management', + providerManagementDesc: 'Manage payment provider instances', + createProvider: 'Create Provider', + editProvider: 'Edit Provider', + deleteProvider: 'Delete Provider', + deleteProviderConfirm: 'Are you sure you want to delete this provider?', + providerName: 'Provider Name', + providerKey: 'Provider Key', + selectProviderKey: 'Select Provider Key', + providerConfig: 'Provider Config', + noProviders: 'No providers configured', + noProvidersHint: 'Create a provider instance to start accepting payments', + supportedTypes: 'Supported Payment Types', + supportedTypesHint: 'Select the payment types this provider supports', + rateMultiplier: 'Rate Multiplier', + }, }, } diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ae8069dc45..0fcbd134cf 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4364,6 +4364,7 @@ export default { email: '邮件设置', backup: '数据备份', data: 'Sora 存储', + payment: '支付设置', }, emailTabDisabledTitle: '邮箱验证未启用', emailTabDisabledHint: '请在「安全与认证」选项卡中启用邮箱验证后,再配置 SMTP 设置。', @@ -4553,6 +4554,23 @@ export default { moveUp: '上移', moveDown: '下移', }, + payment: { + title: '支付设置', + description: '配置支付系统选项', + enabled: '启用支付', + enabledHint: '启用或禁用支付系统', + enabledPaymentTypes: '启用的支付方式', + enabledPaymentTypesHint: '选择要显示的支付方式', + minAmount: '最低金额', + maxAmount: '最高金额', + dailyLimit: '每日限额', + orderTimeout: '订单超时时间', + maxPendingOrders: '最大待支付订单数', + helpText: '帮助文本', + helpImageUrl: '帮助图片链接', + manageProviders: '管理服务商', + balancePaymentDisabled: '余额支付已禁用', + }, smtp: { title: 'SMTP 设置', description: '配置用于发送验证码的邮件服务', @@ -5357,6 +5375,7 @@ export default { paymentMethod: '支付方式', createdAt: '创建时间', cancel: '取消订单', + userId: '用户 ID', requestRefund: '申请退款', }, result: { @@ -5387,6 +5406,94 @@ export default { oneYear: '1 年', perMonth: '月', perYear: '年', + admin: { + tabs: { + overview: '概览', + orders: '订单管理', + channels: '支付渠道', + plans: '订阅套餐', + }, + todayRevenue: '今日收入', + totalRevenue: '总收入', + todayOrders: '今日订单', + orderCount: '订单数', + avgAmount: '平均金额', + revenue: '收入', + dailyRevenue: '每日收入', + paymentDistribution: '支付方式分布', + topUsers: '消费排行', + noData: '暂无数据', + days: '天', + weeks: '周', + months: '月', + searchOrders: '搜索订单...', + allStatuses: '全部状态', + allPaymentTypes: '全部支付方式', + allOrderTypes: '全部订单类型', + orderDetail: '订单详情', + orderType: '订单类型', + orders: '订单', + balanceOrder: '余额充值', + subscriptionOrder: '订阅', + paidAt: '支付时间', + completedAt: '完成时间', + expiresAt: '过期时间', + feeRate: '手续费率', + refund: '退款', + refundOrder: '退款订单', + refundAmount: '退款金额', + maxRefundable: '最大可退金额', + refundReason: '退款原因', + refundReasonPlaceholder: '请输入退款原因', + confirmRefund: '确认退款', + refundSuccess: '退款成功', + refundInfo: '退款信息', + refundEnabled: '允许退款', + alreadyRefunded: '已退款', + deductBalance: '扣除余额', + orderCancelled: '订单已取消', + retry: '重试', + retrySuccess: '重试成功', + channelName: '渠道名称', + channelDescription: '渠道描述', + createChannel: '创建渠道', + editChannel: '编辑渠道', + deleteChannel: '删除渠道', + deleteChannelConfirm: '确定要删除此渠道吗?', + planName: '套餐名称', + planDescription: '套餐描述', + createPlan: '创建套餐', + editPlan: '编辑套餐', + deletePlan: '删除套餐', + deletePlanConfirm: '确定要删除此套餐吗?', + originalPrice: '原价', + price: '价格', + validityDays: '有效期(天)', + validityUnit: '有效期单位', + sortOrder: '排序', + forSale: '上架状态', + onSale: '上架', + offSale: '下架', + groupId: '分组 ID', + features: '功能特性', + featuresHint: '每行一个特性', + featuresPlaceholder: '输入套餐特性...', + providerManagement: '服务商管理', + providerManagementDesc: '管理支付服务商实例', + createProvider: '创建服务商', + editProvider: '编辑服务商', + deleteProvider: '删除服务商', + deleteProviderConfirm: '确定要删除此服务商吗?', + providerName: '服务商名称', + providerKey: '服务商标识', + selectProviderKey: '选择服务商标识', + providerConfig: '服务商配置', + noProviders: '暂无服务商', + noProvidersHint: '创建一个服务商实例以开始接受支付', + supportedTypes: '支持的支付方式', + supportedTypesHint: '选择此服务商支持的支付方式', + rateMultiplier: '费率倍数', + }, }, } diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 8843db2af6..af61468d67 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -2068,8 +2068,6 @@ const form = reactive({ home_content: '', backend_mode_enabled: false, hide_ccs_import_button: false, - purchase_subscription_enabled: false, - purchase_subscription_url: '', payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_enabled_types: '', payment_help_image_url: '', payment_help_text: '', sora_client_enabled: false, custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>, @@ -2355,21 +2353,6 @@ async function saveSettings() { // Optional URL fields: auto-clear invalid values so they don't cause backend 400 errors if (!isValidHttpUrl(form.frontend_url)) form.frontend_url = '' if (!isValidHttpUrl(form.doc_url)) form.doc_url = '' - // Purchase URL: required when enabled; auto-clear when disabled to avoid backend rejection - if (form.purchase_subscription_enabled) { - if (!form.purchase_subscription_url) { - appStore.showError(t('admin.settings.purchase.url') + ': URL is required when purchase is enabled') - saving.value = false - return - } - if (!isValidHttpUrl(form.purchase_subscription_url)) { - appStore.showError(t('admin.settings.purchase.url') + ': must be an absolute http(s) URL (e.g. https://example.com)') - saving.value = false - return - } - } else if (!isValidHttpUrl(form.purchase_subscription_url)) { - form.purchase_subscription_url = '' - } const payload: UpdateSettingsRequest = { registration_enabled: form.registration_enabled, @@ -2393,8 +2376,6 @@ async function saveSettings() { home_content: form.home_content, backend_mode_enabled: form.backend_mode_enabled, hide_ccs_import_button: form.hide_ccs_import_button, - purchase_subscription_enabled: form.purchase_subscription_enabled, - purchase_subscription_url: form.purchase_subscription_url, payment_enabled: form.payment_enabled, payment_min_amount: form.payment_min_amount, payment_max_amount: form.payment_max_amount,