From b22d00e54159437e9464a4b6a433dbdd10f22f44 Mon Sep 17 00:00:00 2001
From: IanShaw027
Date: Tue, 21 Apr 2026 23:17:45 +0800
Subject: [PATCH] feat: drive visible payment methods from enabled providers
---
.../internal/service/payment_config_limits.go | 34 +-
.../service/payment_config_limits_test.go | 29 +-
.../service/payment_config_providers.go | 29 +-
.../service/payment_config_providers_test.go | 103 ++++++
.../service/payment_config_service.go | 49 +--
.../service/payment_config_service_test.go | 18 +-
backend/internal/service/payment_order.go | 11 +-
.../service/payment_order_jsapi_test.go | 99 +----
.../service/payment_resume_service.go | 23 +-
.../service/payment_resume_service_test.go | 58 +--
.../payment_visible_method_instances.go | 166 +++++++++
frontend/src/i18n/locales/en.ts | 2 +
frontend/src/i18n/locales/zh.ts | 2 +
frontend/src/views/admin/SettingsView.vue | 342 +++++++-----------
.../admin/__tests__/SettingsView.spec.ts | 150 ++++----
15 files changed, 609 insertions(+), 506 deletions(-)
create mode 100644 backend/internal/service/payment_visible_method_instances.go
diff --git a/backend/internal/service/payment_config_limits.go b/backend/internal/service/payment_config_limits.go
index f30b119a..67979d5b 100644
--- a/backend/internal/service/payment_config_limits.go
+++ b/backend/internal/service/payment_config_limits.go
@@ -20,18 +20,7 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
return nil, fmt.Errorf("query provider instances: %w", err)
}
typeInstances := pcGroupByPaymentType(instances)
- if s.settingRepo != nil {
- vals, err := s.settingRepo.GetMultiple(ctx, []string{
- SettingPaymentVisibleMethodAlipayEnabled,
- SettingPaymentVisibleMethodAlipaySource,
- SettingPaymentVisibleMethodWxpayEnabled,
- SettingPaymentVisibleMethodWxpaySource,
- })
- if err != nil {
- return nil, fmt.Errorf("query visible method settings: %w", err)
- }
- typeInstances = pcApplyVisibleMethodRouting(typeInstances, vals, buildVisibleMethodSourceAvailability(instances))
- }
+ typeInstances = pcApplyEnabledVisibleMethodInstances(typeInstances, instances)
resp := &MethodLimitsResponse{
Methods: make(map[string]MethodLimits, len(typeInstances)),
}
@@ -43,6 +32,27 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
return resp, nil
}
+func pcApplyEnabledVisibleMethodInstances(typeInstances map[string][]*dbent.PaymentProviderInstance, instances []*dbent.PaymentProviderInstance) map[string][]*dbent.PaymentProviderInstance {
+ if len(typeInstances) == 0 {
+ return typeInstances
+ }
+
+ filtered := make(map[string][]*dbent.PaymentProviderInstance, len(typeInstances))
+ for paymentType, groupedInstances := range typeInstances {
+ filtered[paymentType] = groupedInstances
+ }
+
+ for _, method := range []string{payment.TypeAlipay, payment.TypeWxpay} {
+ matching := filterEnabledVisibleMethodInstances(instances, method)
+ if len(matching) != 1 {
+ delete(filtered, method)
+ continue
+ }
+ filtered[method] = []*dbent.PaymentProviderInstance{matching[0]}
+ }
+ return filtered
+}
+
func pcApplyVisibleMethodRouting(typeInstances map[string][]*dbent.PaymentProviderInstance, vals map[string]string, available map[string]bool) map[string][]*dbent.PaymentProviderInstance {
if len(typeInstances) == 0 {
return typeInstances
diff --git a/backend/internal/service/payment_config_limits_test.go b/backend/internal/service/payment_config_limits_test.go
index 4a9d663d..b3925583 100644
--- a/backend/internal/service/payment_config_limits_test.go
+++ b/backend/internal/service/payment_config_limits_test.go
@@ -301,7 +301,7 @@ func TestPcInstanceTypeLimits(t *testing.T) {
})
}
-func TestGetAvailableMethodLimitsRespectsVisibleMethodRouting(t *testing.T) {
+func TestGetAvailableMethodLimitsHidesConflictingVisibleMethodProviders(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
@@ -341,14 +341,6 @@ func TestGetAvailableMethodLimitsRespectsVisibleMethodRouting(t *testing.T) {
svc := &PaymentConfigService{
entClient: client,
- settingRepo: &paymentConfigSettingRepoStub{
- values: map[string]string{
- SettingPaymentVisibleMethodAlipayEnabled: "true",
- SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceEasyPayAlipay,
- SettingPaymentVisibleMethodWxpayEnabled: "false",
- SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat,
- },
- },
}
resp, err := svc.GetAvailableMethodLimits(ctx)
@@ -356,17 +348,18 @@ func TestGetAvailableMethodLimitsRespectsVisibleMethodRouting(t *testing.T) {
t.Fatalf("GetAvailableMethodLimits returned error: %v", err)
}
- alipayLimits, ok := resp.Methods[payment.TypeAlipay]
+ if _, ok := resp.Methods[payment.TypeAlipay]; ok {
+ t.Fatalf("alipay should be hidden when multiple enabled providers claim it, got %v", resp.Methods[payment.TypeAlipay])
+ }
+
+ wxpayLimits, ok := resp.Methods[payment.TypeWxpay]
if !ok {
- t.Fatalf("expected visible alipay limits, got %v", resp.Methods)
+ t.Fatalf("expected wxpay limits to remain visible, got %v", resp.Methods)
}
- if alipayLimits.SingleMin != 20 || alipayLimits.SingleMax != 200 {
- t.Fatalf("alipay limits = %+v, want easypay-only min=20 max=200", alipayLimits)
+ if wxpayLimits.SingleMin != 30 || wxpayLimits.SingleMax != 300 {
+ t.Fatalf("wxpay limits = %+v, want official-only min=30 max=300", wxpayLimits)
}
- if _, ok := resp.Methods[payment.TypeWxpay]; ok {
- t.Fatalf("wxpay should be hidden when visible method is disabled, got %v", resp.Methods[payment.TypeWxpay])
- }
- if resp.GlobalMin != 20 || resp.GlobalMax != 200 {
- t.Fatalf("global range = (%v, %v), want (20, 200)", resp.GlobalMin, resp.GlobalMax)
+ if resp.GlobalMin != 30 || resp.GlobalMax != 300 {
+ t.Fatalf("global range = (%v, %v), want (30, 300)", resp.GlobalMin, resp.GlobalMax)
}
}
diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go
index 3c406b45..9e1b5039 100644
--- a/backend/internal/service/payment_config_providers.go
+++ b/backend/internal/service/payment_config_providers.go
@@ -108,6 +108,9 @@ func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req C
if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil {
return nil, err
}
+ if err := s.validateVisibleMethodEnablementConflicts(ctx, 0, req.ProviderKey, typesStr, req.Enabled); err != nil {
+ return nil, err
+ }
enc, err := s.encryptConfig(req.Config)
if err != nil {
return nil, err
@@ -136,6 +139,21 @@ func validateProviderRequest(providerKey, name, supportedTypes string) error {
// NOTE: This function exceeds 30 lines due to per-field nil-check patch update
// boilerplate and pending-order safety checks.
func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
+ current, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ nextEnabled := current.Enabled
+ if req.Enabled != nil {
+ nextEnabled = *req.Enabled
+ }
+ nextSupportedTypes := current.SupportedTypes
+ if req.SupportedTypes != nil {
+ nextSupportedTypes = joinTypes(req.SupportedTypes)
+ }
+ if err := s.validateVisibleMethodEnablementConflicts(ctx, id, current.ProviderKey, nextSupportedTypes, nextEnabled); err != nil {
+ return nil, err
+ }
if req.Config != nil {
hasSensitive := false
for k := range req.Config {
@@ -188,11 +206,7 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
}
if count > 0 {
// Load current instance to compare types
- inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
- if err != nil {
- return nil, fmt.Errorf("load provider instance: %w", err)
- }
- oldTypes := strings.Split(inst.SupportedTypes, ",")
+ oldTypes := strings.Split(current.SupportedTypes, ",")
newTypes := req.SupportedTypes
for _, ot := range oldTypes {
ot = strings.TrimSpace(ot)
@@ -237,10 +251,7 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
if req.RefundEnabled != nil {
refundEnabled = *req.RefundEnabled
} else {
- inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
- if err == nil {
- refundEnabled = inst.RefundEnabled
- }
+ refundEnabled = current.RefundEnabled
}
if refundEnabled {
u.SetAllowUserRefund(true)
diff --git a/backend/internal/service/payment_config_providers_test.go b/backend/internal/service/payment_config_providers_test.go
index 2aaa874f..3d1b19fd 100644
--- a/backend/internal/service/payment_config_providers_test.go
+++ b/backend/internal/service/payment_config_providers_test.go
@@ -3,8 +3,10 @@
package service
import (
+ "context"
"testing"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -185,3 +187,104 @@ func TestJoinTypes(t *testing.T) {
})
}
}
+
+func TestCreateProviderInstanceRejectsConflictingVisibleMethodEnablement(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{
+ entClient: client,
+ encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
+ }
+
+ _, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
+ ProviderKey: "easypay",
+ Name: "EasyPay Alipay",
+ Config: map[string]string{"pid": "1001"},
+ SupportedTypes: []string{"alipay"},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ _, err = svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
+ ProviderKey: "alipay",
+ Name: "Official Alipay",
+ Config: map[string]string{"appId": "app-1"},
+ SupportedTypes: []string{"alipay"},
+ Enabled: true,
+ })
+ require.Error(t, err)
+ require.Equal(t, "PAYMENT_PROVIDER_CONFLICT", infraerrors.Reason(err))
+}
+
+func TestUpdateProviderInstanceRejectsEnablingConflictingVisibleMethodProvider(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{
+ entClient: client,
+ encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
+ }
+
+ existing, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
+ ProviderKey: "easypay",
+ Name: "EasyPay WeChat",
+ Config: map[string]string{"pid": "2001"},
+ SupportedTypes: []string{"wxpay"},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, existing)
+
+ candidate, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
+ ProviderKey: "wxpay",
+ Name: "Official WeChat",
+ Config: map[string]string{"appId": "wx-app"},
+ SupportedTypes: []string{"wxpay"},
+ Enabled: false,
+ })
+ require.NoError(t, err)
+
+ _, err = svc.UpdateProviderInstance(ctx, candidate.ID, UpdateProviderInstanceRequest{
+ Enabled: boolPtrValue(true),
+ })
+ require.Error(t, err)
+ require.Equal(t, "PAYMENT_PROVIDER_CONFLICT", infraerrors.Reason(err))
+}
+
+func TestUpdateProviderInstancePersistsEnabledAndSupportedTypes(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{
+ entClient: client,
+ encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
+ }
+
+ instance, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
+ ProviderKey: "easypay",
+ Name: "EasyPay",
+ Config: map[string]string{"pid": "3001"},
+ SupportedTypes: []string{"alipay"},
+ Enabled: false,
+ })
+ require.NoError(t, err)
+
+ _, err = svc.UpdateProviderInstance(ctx, instance.ID, UpdateProviderInstanceRequest{
+ Enabled: boolPtrValue(true),
+ SupportedTypes: []string{"alipay", "wxpay"},
+ })
+ require.NoError(t, err)
+
+ saved, err := client.PaymentProviderInstance.Get(ctx, instance.ID)
+ require.NoError(t, err)
+ require.True(t, saved.Enabled)
+ require.Equal(t, "alipay,wxpay", saved.SupportedTypes)
+}
+
+func boolPtrValue(v bool) *bool {
+ return &v
+}
diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go
index 2d1f3f42..02d061ae 100644
--- a/backend/internal/service/payment_config_service.go
+++ b/backend/internal/service/payment_config_service.go
@@ -209,17 +209,6 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo
return nil, fmt.Errorf("get payment config settings: %w", err)
}
cfg := s.parsePaymentConfig(vals)
- if s.entClient != nil {
- instances, err := s.entClient.PaymentProviderInstance.Query().
- Where(paymentproviderinstance.EnabledEQ(true)).
- All(ctx)
- if err != nil {
- return nil, fmt.Errorf("list enabled provider instances: %w", err)
- }
- cfg.EnabledTypes = applyVisibleMethodRoutingToEnabledTypes(cfg.EnabledTypes, vals, buildVisibleMethodSourceAvailability(instances))
- } else {
- cfg.EnabledTypes = applyVisibleMethodRoutingToEnabledTypes(cfg.EnabledTypes, vals, nil)
- }
// Load Stripe publishable key from the first enabled Stripe provider instance
cfg.StripePublishableKey = s.getStripePublishableKey(ctx)
return cfg, nil
@@ -305,25 +294,25 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
}
}
m := map[string]string{
- SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
- SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
- SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
- SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
- SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
- SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
- SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
- SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier),
- SettingRechargeFeeRate: formatNonNegativeFloat(req.RechargeFeeRate),
- SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
- SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
- SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
- SettingHelpImageURL: derefStr(req.HelpImageURL),
- SettingHelpText: derefStr(req.HelpText),
- SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
- SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
- SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
- SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
- SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
+ SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
+ SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
+ SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
+ SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
+ SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
+ SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
+ SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
+ SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier),
+ SettingRechargeFeeRate: formatNonNegativeFloat(req.RechargeFeeRate),
+ SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
+ SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
+ SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
+ SettingHelpImageURL: derefStr(req.HelpImageURL),
+ SettingHelpText: derefStr(req.HelpText),
+ SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
+ SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
+ SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
+ SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
+ SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
SettingPaymentVisibleMethodAlipaySource: derefStr(req.VisibleMethodAlipaySource),
SettingPaymentVisibleMethodWxpaySource: derefStr(req.VisibleMethodWxpaySource),
SettingPaymentVisibleMethodAlipayEnabled: formatBoolOrEmpty(req.VisibleMethodAlipayEnabled),
diff --git a/backend/internal/service/payment_config_service_test.go b/backend/internal/service/payment_config_service_test.go
index d58ee234..f04f4697 100644
--- a/backend/internal/service/payment_config_service_test.go
+++ b/backend/internal/service/payment_config_service_test.go
@@ -3,6 +3,8 @@ package service
import (
"context"
"database/sql"
+ "fmt"
+ "strings"
"testing"
dbent "github.com/Wei-Shaw/sub2api/ent"
@@ -302,7 +304,7 @@ func TestBuildVisibleMethodSourceAvailability(t *testing.T) {
}
}
-func TestGetPaymentConfigAppliesVisibleMethodRouting(t *testing.T) {
+func TestGetPaymentConfigKeepsStoredEnabledTypes(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
@@ -321,11 +323,7 @@ func TestGetPaymentConfigAppliesVisibleMethodRouting(t *testing.T) {
entClient: client,
settingRepo: &paymentConfigSettingRepoStub{
values: map[string]string{
- SettingEnabledPaymentTypes: "alipay,wxpay,stripe",
- SettingPaymentVisibleMethodAlipayEnabled: "true",
- SettingPaymentVisibleMethodAlipaySource: "easypay",
- SettingPaymentVisibleMethodWxpayEnabled: "true",
- SettingPaymentVisibleMethodWxpaySource: "wxpay",
+ SettingEnabledPaymentTypes: "alipay,wxpay,stripe",
},
},
}
@@ -335,7 +333,7 @@ func TestGetPaymentConfigAppliesVisibleMethodRouting(t *testing.T) {
t.Fatalf("GetPaymentConfig returned error: %v", err)
}
- want := []string{payment.TypeAlipay, payment.TypeStripe}
+ want := []string{payment.TypeAlipay, payment.TypeWxpay, payment.TypeStripe}
if len(cfg.EnabledTypes) != len(want) {
t.Fatalf("EnabledTypes len = %d, want %d (%v)", len(cfg.EnabledTypes), len(want), cfg.EnabledTypes)
}
@@ -349,7 +347,11 @@ func TestGetPaymentConfigAppliesVisibleMethodRouting(t *testing.T) {
func newPaymentConfigServiceTestClient(t *testing.T) *dbent.Client {
t.Helper()
- db, err := sql.Open("sqlite", "file:payment_config_service?mode=memory&cache=shared")
+ dbName := fmt.Sprintf(
+ "file:%s?mode=memory&cache=shared",
+ strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()),
+ )
+ db, err := sql.Open("sqlite", dbName)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go
index def45543..4740cb0f 100644
--- a/backend/internal/service/payment_order.go
+++ b/backend/internal/service/payment_order.go
@@ -326,20 +326,17 @@ func requestNeedsWeChatJSAPICompatibility(req CreateOrderRequest) bool {
}
func (s *PaymentService) usesOfficialWxpayVisibleMethod(ctx context.Context) bool {
- if s == nil || s.configService == nil || s.configService.settingRepo == nil {
+ if s == nil || s.configService == nil {
return false
}
- vals, err := s.configService.settingRepo.GetMultiple(ctx, []string{
- SettingPaymentVisibleMethodWxpayEnabled,
- SettingPaymentVisibleMethodWxpaySource,
- })
+ inst, err := s.configService.resolveEnabledVisibleMethodInstance(ctx, payment.TypeWxpay)
if err != nil {
return false
}
- if vals[SettingPaymentVisibleMethodWxpayEnabled] != "true" {
+ if inst == nil {
return false
}
- return NormalizeVisibleMethodSource(payment.TypeWxpay, vals[SettingPaymentVisibleMethodWxpaySource]) == VisibleMethodSourceOfficialWechat
+ return inst.ProviderKey == payment.TypeWxpay
}
func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.PaymentOrder, req CreateOrderRequest, cfg *PaymentConfig, limitAmount float64, payAmountStr string, payAmount float64, plan *dbent.SubscriptionPlan, sel *payment.InstanceSelection) (*CreateOrderResponse, error) {
diff --git a/backend/internal/service/payment_order_jsapi_test.go b/backend/internal/service/payment_order_jsapi_test.go
index 25f209af..a89d0380 100644
--- a/backend/internal/service/payment_order_jsapi_test.go
+++ b/backend/internal/service/payment_order_jsapi_test.go
@@ -2,115 +2,32 @@ package service
import (
"context"
- "encoding/json"
- "fmt"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
-const jsapiTestEncryptionKey = "0123456789abcdef0123456789abcdef"
-
-func TestSelectCreateOrderInstancePrefersJSAPICompatibleWxpayInstance(t *testing.T) {
+func TestUsesOfficialWxpayVisibleMethodDerivesFromEnabledProviderInstance(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
- compatibleConfig := mustEncryptJSAPITestConfig(t, map[string]string{
- "appId": "wx-merchant-app",
- "mpAppId": "wx-mp-app",
- "mchId": "mch-compatible",
- "privateKey": "private-key",
- "apiV3Key": jsapiTestEncryptionKey,
- "publicKey": "public-key",
- "publicKeyId": "key-compatible",
- "certSerial": "serial-compatible",
- })
- incompatibleConfig := mustEncryptJSAPITestConfig(t, map[string]string{
- "appId": "wx-merchant-other",
- "mpAppId": "wx-mp-other",
- "mchId": "mch-incompatible",
- "privateKey": "private-key",
- "apiV3Key": jsapiTestEncryptionKey,
- "publicKey": "public-key",
- "publicKeyId": "key-incompatible",
- "certSerial": "serial-incompatible",
- })
-
- compatible, err := client.PaymentProviderInstance.Create().
+ _, err := client.PaymentProviderInstance.Create().
SetProviderKey(payment.TypeWxpay).
- SetName("wxpay-compatible").
- SetConfig(compatibleConfig).
+ SetName("Official WeChat").
+ SetConfig("{}").
SetSupportedTypes("wxpay").
SetEnabled(true).
SetSortOrder(1).
Save(ctx)
if err != nil {
- t.Fatalf("create compatible wxpay instance: %v", err)
- }
- _, err = client.PaymentProviderInstance.Create().
- SetProviderKey(payment.TypeWxpay).
- SetName("wxpay-incompatible").
- SetConfig(incompatibleConfig).
- SetSupportedTypes("wxpay").
- SetEnabled(true).
- SetSortOrder(2).
- Save(ctx)
- if err != nil {
- t.Fatalf("create incompatible wxpay instance: %v", err)
+ t.Fatalf("create official wxpay instance: %v", err)
}
- configService := &PaymentConfigService{
- entClient: client,
- settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{
- SettingPaymentVisibleMethodWxpayEnabled: "true",
- SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat,
- SettingKeyWeChatConnectEnabled: "true",
- SettingKeyWeChatConnectAppID: "wx-mp-app",
- SettingKeyWeChatConnectAppSecret: "wechat-secret",
- SettingKeyWeChatConnectMode: "mp",
- SettingKeyWeChatConnectScopes: "snsapi_base",
- SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback",
- SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback",
- }},
- encryptionKey: []byte(jsapiTestEncryptionKey),
- }
- loadBalancer := newVisibleMethodLoadBalancer(
- payment.NewDefaultLoadBalancer(client, []byte(jsapiTestEncryptionKey)),
- configService,
- )
svc := &PaymentService{
- entClient: client,
- loadBalancer: loadBalancer,
- configService: configService,
+ configService: &PaymentConfigService{entClient: client},
}
- sel, err := svc.selectCreateOrderInstance(ctx, CreateOrderRequest{
- PaymentType: payment.TypeWxpay,
- OpenID: "openid-123",
- IsWeChatBrowser: true,
- }, &PaymentConfig{LoadBalanceStrategy: string(payment.StrategyRoundRobin)}, 12.5)
- if err != nil {
- t.Fatalf("selectCreateOrderInstance returned error: %v", err)
- }
- if sel == nil {
- t.Fatal("expected selected instance, got nil")
- }
- expectedInstanceID := fmt.Sprintf("%d", compatible.ID)
- if sel.InstanceID != expectedInstanceID {
- t.Fatalf("selected instance id = %q, want %q", sel.InstanceID, expectedInstanceID)
+ if !svc.usesOfficialWxpayVisibleMethod(ctx) {
+ t.Fatal("expected official wxpay visible method to be detected from enabled provider instance")
}
}
-
-func mustEncryptJSAPITestConfig(t *testing.T, config map[string]string) string {
- t.Helper()
-
- data, err := json.Marshal(config)
- if err != nil {
- t.Fatalf("marshal config: %v", err)
- }
- encrypted, err := payment.Encrypt(string(data), []byte(jsapiTestEncryptionKey))
- if err != nil {
- t.Fatalf("encrypt config: %v", err)
- }
- return encrypted
-}
diff --git a/backend/internal/service/payment_resume_service.go b/backend/internal/service/payment_resume_service.go
index 1806f5da..1538ecbf 100644
--- a/backend/internal/service/payment_resume_service.go
+++ b/backend/internal/service/payment_resume_service.go
@@ -40,8 +40,8 @@ const (
paymentResumeNotConfiguredCode = "PAYMENT_RESUME_NOT_CONFIGURED"
paymentResumeNotConfiguredMessage = "payment resume tokens require a configured signing key"
- paymentResumeTokenTTL = 24 * time.Hour
- wechatPaymentResumeTokenTTL = 15 * time.Minute
+ paymentResumeTokenTTL = 24 * time.Hour
+ wechatPaymentResumeTokenTTL = 15 * time.Minute
)
type ResumeTokenClaims struct {
@@ -163,7 +163,7 @@ func VisibleMethodProviderKeyForSource(method, source string) (string, bool) {
}
func newVisibleMethodLoadBalancer(inner payment.LoadBalancer, configService *PaymentConfigService) payment.LoadBalancer {
- if inner == nil || configService == nil || configService.settingRepo == nil {
+ if inner == nil || configService == nil || configService.entClient == nil {
return inner
}
return &visibleMethodLoadBalancer{inner: inner, configService: configService}
@@ -179,21 +179,14 @@ func (lb *visibleMethodLoadBalancer) SelectInstance(ctx context.Context, provide
return lb.inner.SelectInstance(ctx, providerKey, paymentType, strategy, orderAmount)
}
- enabledKey := visibleMethodEnabledSettingKey(visibleMethod)
- sourceKey := visibleMethodSourceSettingKey(visibleMethod)
- vals, err := lb.configService.settingRepo.GetMultiple(ctx, []string{enabledKey, sourceKey})
+ inst, err := lb.configService.resolveEnabledVisibleMethodInstance(ctx, visibleMethod)
if err != nil {
- return nil, fmt.Errorf("load visible method routing for %s: %w", visibleMethod, err)
+ return nil, err
}
- if vals[enabledKey] != "true" {
- return nil, fmt.Errorf("visible payment method %s is disabled", visibleMethod)
+ if inst == nil {
+ return nil, fmt.Errorf("visible payment method %s has no enabled provider instance", visibleMethod)
}
-
- targetProviderKey, ok := VisibleMethodProviderKeyForSource(visibleMethod, vals[sourceKey])
- if !ok {
- return nil, fmt.Errorf("visible payment method %s has no valid source", visibleMethod)
- }
- return lb.inner.SelectInstance(ctx, targetProviderKey, paymentType, strategy, orderAmount)
+ return lb.inner.SelectInstance(ctx, inst.ProviderKey, paymentType, strategy, orderAmount)
}
func visibleMethodEnabledSettingKey(method string) string {
diff --git a/backend/internal/service/payment_resume_service_test.go b/backend/internal/service/payment_resume_service_test.go
index 7fa8dca1..275b4a94 100644
--- a/backend/internal/service/payment_resume_service_test.go
+++ b/backend/internal/service/payment_resume_service_test.go
@@ -344,21 +344,30 @@ func TestVisibleMethodProviderKeyForSource(t *testing.T) {
}
}
-func TestVisibleMethodLoadBalancerUsesConfiguredSource(t *testing.T) {
+func TestVisibleMethodLoadBalancerUsesEnabledProviderInstance(t *testing.T) {
t.Parallel()
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ _, err := client.PaymentProviderInstance.Create().
+ SetProviderKey(payment.TypeAlipay).
+ SetName("Official Alipay").
+ SetConfig("{}").
+ SetSupportedTypes("alipay").
+ SetEnabled(true).
+ SetSortOrder(1).
+ Save(ctx)
+ if err != nil {
+ t.Fatalf("create alipay provider: %v", err)
+ }
+
inner := &captureLoadBalancer{}
configService := &PaymentConfigService{
- settingRepo: &paymentSettingRepoStub{
- values: map[string]string{
- SettingPaymentVisibleMethodAlipayEnabled: "true",
- SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceOfficialAlipay,
- },
- },
+ entClient: client,
}
lb := newVisibleMethodLoadBalancer(inner, configService)
- _, err := lb.SelectInstance(context.Background(), "", payment.TypeAlipay, payment.StrategyRoundRobin, 12.5)
+ _, err = lb.SelectInstance(ctx, "", payment.TypeAlipay, payment.StrategyRoundRobin, 12.5)
if err != nil {
t.Fatalf("SelectInstance returned error: %v", err)
}
@@ -367,47 +376,20 @@ func TestVisibleMethodLoadBalancerUsesConfiguredSource(t *testing.T) {
}
}
-func TestVisibleMethodLoadBalancerRejectsDisabledVisibleMethod(t *testing.T) {
+func TestVisibleMethodLoadBalancerRejectsMissingEnabledVisibleMethodProvider(t *testing.T) {
t.Parallel()
inner := &captureLoadBalancer{}
configService := &PaymentConfigService{
- settingRepo: &paymentSettingRepoStub{
- values: map[string]string{
- SettingPaymentVisibleMethodWxpayEnabled: "false",
- SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat,
- },
- },
+ entClient: newPaymentConfigServiceTestClient(t),
}
lb := newVisibleMethodLoadBalancer(inner, configService)
if _, err := lb.SelectInstance(context.Background(), "", payment.TypeWxpay, payment.StrategyRoundRobin, 9.9); err == nil {
- t.Fatal("SelectInstance should reject disabled visible method")
+ t.Fatal("SelectInstance should reject when no enabled provider instance exists")
}
}
-type paymentSettingRepoStub struct {
- values map[string]string
-}
-
-func (s *paymentSettingRepoStub) Get(context.Context, string) (*Setting, error) { return nil, nil }
-func (s *paymentSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
- return s.values[key], nil
-}
-func (s *paymentSettingRepoStub) Set(context.Context, string, string) error { return nil }
-func (s *paymentSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
- out := make(map[string]string, len(keys))
- for _, key := range keys {
- out[key] = s.values[key]
- }
- return out, nil
-}
-func (s *paymentSettingRepoStub) SetMultiple(context.Context, map[string]string) error { return nil }
-func (s *paymentSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
- return s.values, nil
-}
-func (s *paymentSettingRepoStub) Delete(context.Context, string) error { return nil }
-
type captureLoadBalancer struct {
lastProviderKey string
lastPaymentType string
diff --git a/backend/internal/service/payment_visible_method_instances.go b/backend/internal/service/payment_visible_method_instances.go
new file mode 100644
index 00000000..477e8e8b
--- /dev/null
+++ b/backend/internal/service/payment_visible_method_instances.go
@@ -0,0 +1,166 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+)
+
+func enabledVisibleMethodsForProvider(providerKey, supportedTypes string) []string {
+ methodSet := make(map[string]struct{}, 2)
+ addMethod := func(method string) {
+ method = NormalizeVisibleMethod(method)
+ switch method {
+ case payment.TypeAlipay, payment.TypeWxpay:
+ methodSet[method] = struct{}{}
+ }
+ }
+
+ switch strings.TrimSpace(providerKey) {
+ case payment.TypeAlipay:
+ if strings.TrimSpace(supportedTypes) == "" {
+ addMethod(payment.TypeAlipay)
+ break
+ }
+ for _, supportedType := range splitTypes(supportedTypes) {
+ if NormalizeVisibleMethod(supportedType) == payment.TypeAlipay {
+ addMethod(payment.TypeAlipay)
+ break
+ }
+ }
+ case payment.TypeWxpay:
+ if strings.TrimSpace(supportedTypes) == "" {
+ addMethod(payment.TypeWxpay)
+ break
+ }
+ for _, supportedType := range splitTypes(supportedTypes) {
+ if NormalizeVisibleMethod(supportedType) == payment.TypeWxpay {
+ addMethod(payment.TypeWxpay)
+ break
+ }
+ }
+ case payment.TypeEasyPay:
+ for _, supportedType := range splitTypes(supportedTypes) {
+ addMethod(supportedType)
+ }
+ }
+
+ methods := make([]string, 0, len(methodSet))
+ for _, method := range []string{payment.TypeAlipay, payment.TypeWxpay} {
+ if _, ok := methodSet[method]; ok {
+ methods = append(methods, method)
+ }
+ }
+ return methods
+}
+
+func providerSupportsVisibleMethod(inst *dbent.PaymentProviderInstance, method string) bool {
+ if inst == nil || !inst.Enabled {
+ return false
+ }
+ method = NormalizeVisibleMethod(method)
+ for _, candidate := range enabledVisibleMethodsForProvider(inst.ProviderKey, inst.SupportedTypes) {
+ if candidate == method {
+ return true
+ }
+ }
+ return false
+}
+
+func filterEnabledVisibleMethodInstances(instances []*dbent.PaymentProviderInstance, method string) []*dbent.PaymentProviderInstance {
+ filtered := make([]*dbent.PaymentProviderInstance, 0, len(instances))
+ for _, inst := range instances {
+ if providerSupportsVisibleMethod(inst, method) {
+ filtered = append(filtered, inst)
+ }
+ }
+ return filtered
+}
+
+func buildPaymentProviderConflictError(method string, conflicting *dbent.PaymentProviderInstance) error {
+ metadata := map[string]string{
+ "payment_method": NormalizeVisibleMethod(method),
+ }
+ if conflicting != nil {
+ metadata["conflicting_provider_id"] = fmt.Sprintf("%d", conflicting.ID)
+ metadata["conflicting_provider_key"] = conflicting.ProviderKey
+ metadata["conflicting_provider_name"] = conflicting.Name
+ }
+ return infraerrors.Conflict(
+ "PAYMENT_PROVIDER_CONFLICT",
+ fmt.Sprintf("%s payment already has an enabled provider instance", NormalizeVisibleMethod(method)),
+ ).WithMetadata(metadata)
+}
+
+func (s *PaymentConfigService) validateVisibleMethodEnablementConflicts(
+ ctx context.Context,
+ excludeID int64,
+ providerKey string,
+ supportedTypes string,
+ enabled bool,
+) error {
+ if s == nil || s.entClient == nil || !enabled {
+ return nil
+ }
+
+ claimedMethods := enabledVisibleMethodsForProvider(providerKey, supportedTypes)
+ if len(claimedMethods) == 0 {
+ return nil
+ }
+
+ query := s.entClient.PaymentProviderInstance.Query().
+ Where(paymentproviderinstance.EnabledEQ(true))
+ if excludeID > 0 {
+ query = query.Where(paymentproviderinstance.IDNEQ(excludeID))
+ }
+ instances, err := query.All(ctx)
+ if err != nil {
+ return fmt.Errorf("query enabled payment providers: %w", err)
+ }
+
+ for _, method := range claimedMethods {
+ for _, inst := range instances {
+ if providerSupportsVisibleMethod(inst, method) {
+ return buildPaymentProviderConflictError(method, inst)
+ }
+ }
+ }
+ return nil
+}
+
+func (s *PaymentConfigService) resolveEnabledVisibleMethodInstance(
+ ctx context.Context,
+ method string,
+) (*dbent.PaymentProviderInstance, error) {
+ if s == nil || s.entClient == nil {
+ return nil, nil
+ }
+
+ method = NormalizeVisibleMethod(method)
+ if method != payment.TypeAlipay && method != payment.TypeWxpay {
+ return nil, nil
+ }
+
+ instances, err := s.entClient.PaymentProviderInstance.Query().
+ Where(paymentproviderinstance.EnabledEQ(true)).
+ Order(paymentproviderinstance.BySortOrder()).
+ All(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("query enabled payment providers: %w", err)
+ }
+
+ matching := filterEnabledVisibleMethodInstances(instances, method)
+ switch len(matching) {
+ case 0:
+ return nil, nil
+ case 1:
+ return matching[0], nil
+ default:
+ return nil, buildPaymentProviderConflictError(method, matching[0])
+ }
+}
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 06fde462..6637dfad 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -4757,6 +4757,7 @@ export default {
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
refundEnabled: 'Allow Refund',
allowUserRefund: 'Allow User Refund',
+ enableConflict: '{method} already has an enabled provider instance: {provider}. Disable the existing instance before switching.',
},
balanceNotify: {
title: 'Balance Low Notification',
@@ -5612,6 +5613,7 @@ export default {
alipayMobileUnavailable: 'This page could not hand off to Alipay.',
alipayMobileOpenHint: 'Allow the current page to open the Alipay app, or retry from the system browser.',
PENDING_ORDERS: 'This provider has pending orders. Please wait for them to complete before making changes.',
+ PAYMENT_PROVIDER_CONFLICT: 'Another enabled provider instance is already serving this payment method. Disable it before continuing.',
},
stripePay: 'Pay Now',
stripeSuccessProcessing: 'Payment successful, processing your order...',
diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts
index 8de3d623..ace4d6df 100644
--- a/frontend/src/i18n/locales/zh.ts
+++ b/frontend/src/i18n/locales/zh.ts
@@ -4921,6 +4921,7 @@ export default {
supportedTypesHint: '逗号分隔,如 alipay,wxpay',
refundEnabled: '允许退款',
allowUserRefund: '允许用户退款',
+ enableConflict: '{method} 已有启用中的服务商实例:{provider}。请先停用现有实例后再启用或切换。',
},
balanceNotify: {
title: '余额不足提醒',
@@ -5800,6 +5801,7 @@ export default {
alipayMobileUnavailable: '当前页面未成功跳转到支付宝。',
alipayMobileOpenHint: '请允许当前页面打开支付宝 App,或改用系统浏览器重新发起支付。',
PENDING_ORDERS: '该服务商有未完成的订单,请等待订单完成后再操作',
+ PAYMENT_PROVIDER_CONFLICT: '该支付方式已有其他启用中的服务商实例,请先停用后再继续。',
},
stripePay: '立即支付',
stripeSuccessProcessing: '支付成功,正在处理订单...',
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index fcf5de25..96e179c2 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -4160,73 +4160,6 @@
-
-
-
-
-
-
- {{
- t("admin.settings.paymentVisibleMethods.methodHint")
- }}
-
-
-
-
-
-
-
-
-
- {{
- t("admin.settings.paymentVisibleMethods.sourceHint")
- }}
-
-
-
-
@@ -4742,15 +4675,12 @@ import {
buildAuthSourceDefaultsState,
defaultWeChatConnectScopesForMode,
deriveWeChatConnectStoredMode,
- getPaymentVisibleMethodSourceOptions,
- normalizePaymentVisibleMethodSource,
normalizeDefaultSubscriptionSettings,
resolveWeChatConnectModeCapabilities,
} from "@/api/admin/settings";
import type {
AuthSourceDefaultsState,
AuthSourceType,
- PaymentVisibleMethod,
SystemSettings,
UpdateSettingsRequest,
DefaultSubscriptionSetting,
@@ -4777,6 +4707,7 @@ import { useClipboard } from "@/composables/useClipboard";
import { extractApiErrorMessage } from "@/utils/apiError";
import { useAppStore } from "@/stores";
import { useAdminSettingsStore } from "@/stores/adminSettings";
+import { normalizeVisibleMethod } from "@/components/payment/paymentFlow";
import {
isRegistrationEmailSuffixDomainValid,
normalizeRegistrationEmailSuffixDomain,
@@ -4788,10 +4719,6 @@ const { t, locale } = useI18n();
const appStore = useAppStore();
const adminSettingsStore = useAdminSettingsStore();
-function localText(zh: string, en: string): string {
- return locale.value.startsWith("zh") ? zh : en;
-}
-
type SettingsTab =
| "general"
| "security"
@@ -4908,10 +4835,6 @@ type SettingsForm = Omit<
wechat_connect_mobile_enabled: boolean;
oidc_connect_client_secret: string;
force_email_on_third_party_signup: boolean;
- payment_visible_method_alipay_source: string;
- payment_visible_method_wxpay_source: string;
- payment_visible_method_alipay_enabled: boolean;
- payment_visible_method_wxpay_enabled: boolean;
openai_advanced_scheduler_enabled: boolean;
};
@@ -4957,10 +4880,6 @@ const form = reactive
({
payment_cancel_rate_limit_window: 1,
payment_cancel_rate_limit_unit: "day",
payment_cancel_rate_limit_window_mode: "rolling",
- payment_visible_method_alipay_source: "",
- payment_visible_method_wxpay_source: "",
- payment_visible_method_alipay_enabled: false,
- payment_visible_method_wxpay_enabled: false,
table_default_page_size: tablePageSizeDefault,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [] as Array<{
@@ -5099,86 +5018,6 @@ const authSourceDefaultsMeta = computed(() => [
},
]);
-const paymentVisibleMethodCards = computed(() => [
- {
- key: "alipay" as const,
- title: t("payment.methods.alipay"),
- enabledField: "payment_visible_method_alipay_enabled" as const,
- sourceField: "payment_visible_method_alipay_source" as const,
- },
- {
- key: "wxpay" as const,
- title: t("payment.methods.wxpay"),
- enabledField: "payment_visible_method_wxpay_enabled" as const,
- sourceField: "payment_visible_method_wxpay_source" as const,
- },
-]);
-
-function getPaymentVisibleMethodEnabled(method: "alipay" | "wxpay"): boolean {
- return method === "alipay"
- ? form.payment_visible_method_alipay_enabled
- : form.payment_visible_method_wxpay_enabled;
-}
-
-function setPaymentVisibleMethodEnabled(
- method: "alipay" | "wxpay",
- enabled: boolean,
-) {
- if (method === "alipay") {
- form.payment_visible_method_alipay_enabled = enabled;
- return;
- }
- form.payment_visible_method_wxpay_enabled = enabled;
-}
-
-function getPaymentVisibleMethodSource(method: "alipay" | "wxpay"): string {
- return method === "alipay"
- ? form.payment_visible_method_alipay_source
- : form.payment_visible_method_wxpay_source;
-}
-
-function getPaymentVisibleMethodSourceSelectOptions(
- method: PaymentVisibleMethod,
-) {
- return getPaymentVisibleMethodSourceOptions(method).map((option) => ({
- value: option.value,
- label: localText(option.labelZh, option.labelEn),
- }));
-}
-
-function setPaymentVisibleMethodSource(
- method: "alipay" | "wxpay",
- source: string | number | boolean | null,
-) {
- const normalized = normalizePaymentVisibleMethodSource(method, source);
- if (method === "alipay") {
- form.payment_visible_method_alipay_source = normalized;
- return;
- }
- form.payment_visible_method_wxpay_source = normalized;
-}
-
-function validatePaymentVisibleMethodSelections(): boolean {
- for (const visibleMethod of paymentVisibleMethodCards.value) {
- if (!getPaymentVisibleMethodEnabled(visibleMethod.key)) {
- continue;
- }
-
- if (getPaymentVisibleMethodSource(visibleMethod.key)) {
- continue;
- }
-
- appStore.showError(
- t("admin.settings.paymentVisibleMethods.sourceRequiredError", {
- title: visibleMethod.title,
- }),
- );
- return false;
- }
-
- return true;
-}
-
// Proxies for web search emulation ProxySelector
const webSearchProxies = ref([]);
@@ -5660,16 +5499,6 @@ async function loadSettings() {
form.default_subscriptions = normalizeDefaultSubscriptionSettings(
settings.default_subscriptions,
);
- form.payment_visible_method_alipay_source =
- normalizePaymentVisibleMethodSource(
- "alipay",
- settings.payment_visible_method_alipay_source,
- );
- form.payment_visible_method_wxpay_source =
- normalizePaymentVisibleMethodSource(
- "wxpay",
- settings.payment_visible_method_wxpay_source,
- );
registrationEmailSuffixWhitelistTags.value =
normalizeRegistrationEmailSuffixDomains(
settings.registration_email_suffix_whitelist,
@@ -5873,7 +5702,6 @@ async function saveSettings() {
);
return;
}
-
// Validate URL fields — novalidate disables browser-native checks, so we validate here
const isValidHttpUrl = (url: string): boolean => {
if (!url) return true;
@@ -6028,18 +5856,6 @@ async function saveSettings() {
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
payment_cancel_rate_limit_window_mode:
form.payment_cancel_rate_limit_window_mode,
- payment_visible_method_alipay_source: normalizePaymentVisibleMethodSource(
- "alipay",
- form.payment_visible_method_alipay_source,
- ),
- payment_visible_method_wxpay_source: normalizePaymentVisibleMethodSource(
- "wxpay",
- form.payment_visible_method_wxpay_source,
- ),
- payment_visible_method_alipay_enabled:
- form.payment_visible_method_alipay_enabled,
- payment_visible_method_wxpay_enabled:
- form.payment_visible_method_wxpay_enabled,
openai_advanced_scheduler_enabled: form.openai_advanced_scheduler_enabled,
// Balance & quota notification
balance_low_notify_enabled: form.balance_low_notify_enabled,
@@ -6062,16 +5878,6 @@ async function saveSettings() {
}
}
Object.assign(authSourceDefaults, buildAuthSourceDefaultsState(updated));
- form.payment_visible_method_alipay_source =
- normalizePaymentVisibleMethodSource(
- "alipay",
- updated.payment_visible_method_alipay_source,
- );
- form.payment_visible_method_wxpay_source =
- normalizePaymentVisibleMethodSource(
- "wxpay",
- updated.payment_visible_method_wxpay_source,
- );
registrationEmailSuffixWhitelistTags.value =
normalizeRegistrationEmailSuffixDomains(
updated.registration_email_suffix_whitelist,
@@ -6588,8 +6394,98 @@ const cancelRateLimitModeOptions = computed(() => [
const paymentErrorMap = computed(() => ({
PENDING_ORDERS: t("payment.errors.PENDING_ORDERS"),
+ PAYMENT_PROVIDER_CONFLICT: t("payment.errors.PAYMENT_PROVIDER_CONFLICT"),
}));
+type ProviderEnablementCandidate = Pick<
+ ProviderInstance,
+ "id" | "provider_key" | "supported_types" | "enabled" | "name"
+>;
+
+function getProviderVisibleMethods(
+ provider: ProviderEnablementCandidate,
+): Array<"alipay" | "wxpay"> {
+ if (!provider.enabled) {
+ return [];
+ }
+
+ const supportedTypes = Array.isArray(provider.supported_types)
+ ? provider.supported_types
+ : [];
+ const methods = new Set<"alipay" | "wxpay">();
+ const addMethod = (type: string) => {
+ const method = normalizeVisibleMethod(type);
+ if (method === "alipay" || method === "wxpay") {
+ methods.add(method);
+ }
+ };
+
+ if (provider.provider_key === "alipay") {
+ if (supportedTypes.length === 0) {
+ methods.add("alipay");
+ } else {
+ supportedTypes.forEach((type) => {
+ if (normalizeVisibleMethod(type) === "alipay") {
+ methods.add("alipay");
+ }
+ });
+ }
+ } else if (provider.provider_key === "wxpay") {
+ if (supportedTypes.length === 0) {
+ methods.add("wxpay");
+ } else {
+ supportedTypes.forEach((type) => {
+ if (normalizeVisibleMethod(type) === "wxpay") {
+ methods.add("wxpay");
+ }
+ });
+ }
+ } else if (provider.provider_key === "easypay") {
+ supportedTypes.forEach(addMethod);
+ }
+
+ return Array.from(methods);
+}
+
+function findProviderEnablementConflict(
+ candidate: ProviderEnablementCandidate,
+): { method: "alipay" | "wxpay"; conflicting: ProviderInstance } | null {
+ const claimedMethods = getProviderVisibleMethods(candidate);
+ if (claimedMethods.length === 0) {
+ return null;
+ }
+
+ for (const other of providers.value) {
+ if (other.id === candidate.id || !other.enabled) {
+ continue;
+ }
+
+ const otherMethods = getProviderVisibleMethods(other);
+ const matchedMethod = claimedMethods.find((method) =>
+ otherMethods.includes(method),
+ );
+ if (matchedMethod) {
+ return {
+ method: matchedMethod,
+ conflicting: other,
+ };
+ }
+ }
+
+ return null;
+}
+
+function showProviderEnablementConflict(
+ conflict: { method: "alipay" | "wxpay"; conflicting: ProviderInstance },
+) {
+ appStore.showError(
+ t("admin.settings.payment.enableConflict", {
+ method: t(`payment.methods.${conflict.method}`),
+ provider: conflict.conflicting.name,
+ }),
+ );
+}
+
async function loadProviders() {
providersLoading.value = true;
try {
@@ -6619,6 +6515,21 @@ function openEditProvider(provider: ProviderInstance) {
async function handleSaveProvider(payload: Partial) {
providerSaving.value = true;
try {
+ const candidate: ProviderEnablementCandidate = {
+ id: editingProvider.value?.id ?? 0,
+ provider_key:
+ payload.provider_key ?? editingProvider.value?.provider_key ?? "",
+ supported_types:
+ payload.supported_types ?? editingProvider.value?.supported_types ?? [],
+ enabled: payload.enabled ?? editingProvider.value?.enabled ?? false,
+ name: payload.name ?? editingProvider.value?.name ?? "",
+ };
+ const conflict = findProviderEnablementConflict(candidate);
+ if (conflict) {
+ showProviderEnablementConflict(conflict);
+ return;
+ }
+
if (editingProvider.value) {
await adminAPI.payment.updateProvider(editingProvider.value.id, payload);
} else {
@@ -6647,6 +6558,20 @@ async function handleToggleField(
else if (field === "refund_enabled") newValue = !provider.refund_enabled;
else newValue = !provider.allow_user_refund;
+ if (field === "enabled" && newValue) {
+ const conflict = findProviderEnablementConflict({
+ id: provider.id,
+ provider_key: provider.provider_key,
+ supported_types: provider.supported_types,
+ enabled: true,
+ name: provider.name,
+ });
+ if (conflict) {
+ showProviderEnablementConflict(conflict);
+ return;
+ }
+ }
+
const payload: Record = { [field]: newValue };
// Cascade: turning off refund_enabled also turns off allow_user_refund
if (field === "refund_enabled" && !newValue) {
@@ -6654,13 +6579,7 @@ async function handleToggleField(
}
try {
await adminAPI.payment.updateProvider(provider.id, payload);
- if (field === "enabled") provider.enabled = newValue;
- else if (field === "refund_enabled") {
- provider.refund_enabled = newValue;
- if (!newValue) provider.allow_user_refund = false;
- } else {
- provider.allow_user_refund = newValue;
- }
+ await loadProviders();
} catch (err: unknown) {
appStore.showError(
extractApiErrorMessage(err, t("common.error"), paymentErrorMap.value),
@@ -6672,11 +6591,22 @@ async function handleToggleType(provider: ProviderInstance, type: string) {
const updated = provider.supported_types.includes(type)
? provider.supported_types.filter((t) => t !== type)
: [...provider.supported_types, type];
+ const conflict = findProviderEnablementConflict({
+ id: provider.id,
+ provider_key: provider.provider_key,
+ supported_types: updated,
+ enabled: provider.enabled,
+ name: provider.name,
+ });
+ if (conflict) {
+ showProviderEnablementConflict(conflict);
+ return;
+ }
try {
await adminAPI.payment.updateProvider(provider.id, {
supported_types: updated,
} as any);
- provider.supported_types = updated;
+ await loadProviders();
} catch (err: unknown) {
appStore.showError(
extractApiErrorMessage(err, t("common.error"), paymentErrorMap.value),
@@ -6700,11 +6630,7 @@ async function handleReorderProviders(
} as Partial),
),
);
- // Update local state to match new order
- for (const u of updates) {
- const p = providers.value.find((p) => p.id === u.id);
- if (p) p.sort_order = u.sort_order;
- }
+ await loadProviders();
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t("common.error")));
loadProviders();
diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
index ee998971..88b2e73a 100644
--- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts
+++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
@@ -17,6 +17,9 @@ const {
getGroups,
listProxies,
getProviders,
+ updateProvider,
+ createProvider,
+ deleteProvider,
fetchPublicSettings,
adminSettingsFetch,
showError,
@@ -34,6 +37,9 @@ const {
getGroups: vi.fn(),
listProxies: vi.fn(),
getProviders: vi.fn(),
+ updateProvider: vi.fn(),
+ createProvider: vi.fn(),
+ deleteProvider: vi.fn(),
fetchPublicSettings: vi.fn(),
adminSettingsFetch: vi.fn(),
showError: vi.fn(),
@@ -61,6 +67,9 @@ vi.mock("@/api", () => ({
},
payment: {
getProviders,
+ updateProvider,
+ createProvider,
+ deleteProvider,
},
},
}));
@@ -413,6 +422,9 @@ describe("admin SettingsView payment visible method controls", () => {
getGroups.mockReset();
listProxies.mockReset();
getProviders.mockReset();
+ updateProvider.mockReset();
+ createProvider.mockReset();
+ deleteProvider.mockReset();
fetchPublicSettings.mockReset();
adminSettingsFetch.mockReset();
showError.mockReset();
@@ -467,98 +479,93 @@ describe("admin SettingsView payment visible method controls", () => {
adminSettingsFetch.mockResolvedValue(undefined);
});
- it("loads canonical source options and normalizes existing values", async () => {
+ it("does not render legacy visible payment method controls", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
- const paymentSourceSelects = wrapper
- .findAll("select.select-stub")
- .filter((node) =>
- ["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
- );
-
- expect(paymentSourceSelects).toHaveLength(2);
-
- const alipaySelect = paymentSourceSelects.find(
- (node) => node.attributes("data-placeholder") === "alipay",
- );
- const wxpaySelect = paymentSourceSelects.find(
- (node) => node.attributes("data-placeholder") === "wxpay",
- );
-
- expect(alipaySelect?.element.value).toBe("official_alipay");
- expect(
- alipaySelect?.findAll("option").map((option) => option.element.value),
- ).toEqual(["", "official_alipay", "easypay_alipay"]);
-
- expect(wxpaySelect?.element.value).toBe("");
- expect(
- wxpaySelect?.findAll("option").map((option) => option.element.value),
- ).toEqual(["", "official_wxpay", "easypay_wxpay"]);
+ expect(wrapper.text()).not.toContain("可见方式");
+ expect(wrapper.text()).not.toContain("支付来源");
});
- it("saves canonical source keys selected from the dropdowns", async () => {
+ it("does not submit legacy visible payment method settings", async () => {
const wrapper = mountView();
await flushPromises();
await openPaymentTab(wrapper);
-
- const paymentSourceSelects = wrapper
- .findAll("select.select-stub")
- .filter((node) =>
- ["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
- );
-
- const alipaySelect = paymentSourceSelects.find(
- (node) => node.attributes("data-placeholder") === "alipay",
- );
- const wxpaySelect = paymentSourceSelects.find(
- (node) => node.attributes("data-placeholder") === "wxpay",
- );
-
- await alipaySelect?.setValue("easypay_alipay");
- await wxpaySelect?.setValue("official_wxpay");
await wrapper.find("form").trigger("submit.prevent");
await flushPromises();
expect(updateSettings).toHaveBeenCalledTimes(1);
- expect(updateSettings).toHaveBeenCalledWith(
- expect.objectContaining({
- payment_visible_method_alipay_source: "easypay_alipay",
- payment_visible_method_wxpay_source: "official_wxpay",
- payment_visible_method_alipay_enabled: true,
- payment_visible_method_wxpay_enabled: true,
- }),
- );
+ const payload = updateSettings.mock.calls[0]?.[0];
+ expect(payload).not.toHaveProperty("payment_visible_method_alipay_source");
+ expect(payload).not.toHaveProperty("payment_visible_method_wxpay_source");
+ expect(payload).not.toHaveProperty("payment_visible_method_alipay_enabled");
+ expect(payload).not.toHaveProperty("payment_visible_method_wxpay_enabled");
});
- it("blocks saving when a visible payment method is enabled without a source", async () => {
- const wrapper = mountView();
+ it("updates provider enablement immediately and reloads providers", async () => {
+ const provider = {
+ id: 7,
+ provider_key: "alipay",
+ name: "Official Alipay",
+ config: {},
+ supported_types: ["alipay"],
+ enabled: false,
+ payment_mode: "",
+ refund_enabled: false,
+ allow_user_refund: false,
+ limits: "",
+ sort_order: 0,
+ };
+ getProviders.mockReset();
+ getProviders
+ .mockResolvedValueOnce({ data: [provider] })
+ .mockResolvedValueOnce({ data: [{ ...provider, enabled: true }] });
+ updateProvider.mockResolvedValue({ data: { ...provider, enabled: true } });
+
+ const PaymentProviderListStub = defineComponent({
+ emits: ["toggleField"],
+ setup(_, { emit }) {
+ return () =>
+ h(
+ "button",
+ {
+ class: "provider-toggle-stub",
+ onClick: () => emit("toggleField", provider, "enabled"),
+ },
+ "toggle provider",
+ );
+ },
+ });
+
+ const wrapper = mount(SettingsView, {
+ global: {
+ stubs: {
+ AppLayout: AppLayoutStub,
+ Select: SelectStub,
+ Toggle: ToggleStub,
+ Icon: true,
+ ConfirmDialog: true,
+ PaymentProviderList: PaymentProviderListStub,
+ PaymentProviderDialog: true,
+ GroupBadge: true,
+ GroupOptionItem: true,
+ ProxySelector: true,
+ ImageUpload: true,
+ BackupSettings: true,
+ },
+ },
+ });
await flushPromises();
await openPaymentTab(wrapper);
-
- const paymentSourceSelects = wrapper
- .findAll("select.select-stub")
- .filter((node) =>
- ["alipay", "wxpay"].includes(node.attributes("data-placeholder")),
- );
-
- const alipaySelect = paymentSourceSelects.find(
- (node) => node.attributes("data-placeholder") === "alipay",
- );
-
- await alipaySelect?.setValue("");
- await wrapper.find("form").trigger("submit.prevent");
+ await wrapper.get(".provider-toggle-stub").trigger("click");
await flushPromises();
- expect(updateSettings).not.toHaveBeenCalled();
- expect(showError).toHaveBeenCalled();
- expect(String(showError.mock.calls.at(-1)?.[0] ?? "")).toContain(
- "支付来源",
- );
+ expect(updateProvider).toHaveBeenCalledWith(7, { enabled: true });
+ expect(getProviders).toHaveBeenCalledTimes(2);
});
it("renders advanced scheduler copy as local experimental gateway policy", async () => {
@@ -588,6 +595,9 @@ describe("admin SettingsView wechat connect controls", () => {
getGroups.mockReset();
listProxies.mockReset();
getProviders.mockReset();
+ updateProvider.mockReset();
+ createProvider.mockReset();
+ deleteProvider.mockReset();
fetchPublicSettings.mockReset();
adminSettingsFetch.mockReset();
showError.mockReset();