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") - }} -

-
- -
- -
- -