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:
erio
2026-04-05 18:32:24 +08:00
parent 8cb2409ea5
commit aa151ef0e1
13 changed files with 606 additions and 52 deletions
+7
View File
@@ -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{
+9 -1
View File
@@ -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{
+10
View File
@@ -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)
}
+8 -7
View File
@@ -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)
}
+9
View File
@@ -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)
}
}
+202 -25
View File
@@ -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))
}
+8
View File
@@ -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;
+107
View File
@@ -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',
},
},
}
+107
View File
@@ -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: '费率倍数',
},
},
}
-19
View File
@@ -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,