feat: drive visible payment methods from enabled providers

This commit is contained in:
IanShaw027
2026-04-21 23:17:45 +08:00
parent 54dc176725
commit b22d00e541
15 changed files with 609 additions and 506 deletions

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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),

View File

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

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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])
}
}