mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-22 11:58:56 +00:00
fix: resolve all audit findings — registry init, expiry task, dashboard, i18n, stripe
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
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
@@ -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: '费率倍数',
|
||||
},
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
@@ -2068,8 +2068,6 @@ const form = reactive<SettingsForm>({
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user