From bd43ed23fd7740317858b4cd1182c92436b13854 Mon Sep 17 00:00:00 2001 From: erio Date: Wed, 8 Apr 2026 02:18:02 +0800 Subject: [PATCH] feat(payment): union-based limits aggregation and amount-method filtering Backend: change limits aggregation from intersection (most restrictive) to union (least restrictive) across provider instances. Since the load balancer can route to any instance, users should see the widest possible range. Add global_min/global_max precomputed by backend for quick amount buttons. Frontend: auto-disable payment methods that can't handle the entered amount, auto-switch to first available method, show warning when no method fits. Refactor: extract ChannelLimits named type, unionFloat helper to eliminate repeated min/max/daily logic, split limits code to payment_config_limits.go, replace magic strings with payment.Type* constants. --- backend/internal/handler/payment_handler.go | 9 +- backend/internal/payment/load_balancer.go | 7 +- .../internal/service/payment_config_limits.go | 175 ++++++++++++++++++ .../service/payment_config_service.go | 99 +--------- frontend/src/api/payment.ts | 4 +- frontend/src/i18n/locales/en.ts | 1 + frontend/src/i18n/locales/zh.ts | 1 + frontend/src/types/payment.ts | 7 + frontend/src/views/user/PaymentView.vue | 88 +++++---- 9 files changed, 256 insertions(+), 135 deletions(-) create mode 100644 backend/internal/service/payment_config_limits.go diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index b1e8dcbdce..168508c9f0 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -90,17 +90,12 @@ func (h *PaymentHandler) GetChannels(c *gin.Context) { // GetLimits returns per-payment-type limits derived from enabled provider instances. // GET /api/v1/payment/limits func (h *PaymentHandler) GetLimits(c *gin.Context) { - limits, err := h.configService.GetAvailableMethodLimits(c.Request.Context()) + resp, err := h.configService.GetAvailableMethodLimits(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) return } - // Return as map keyed by payment type (frontend expects Record) - result := make(map[string]service.MethodLimits, len(limits)) - for _, ml := range limits { - result[ml.PaymentType] = ml - } - response.Success(c, result) + response.Success(c, resp) } // CreateOrderRequest is the request body for creating a payment order. diff --git a/backend/internal/payment/load_balancer.go b/backend/internal/payment/load_balancer.go index 0c80254482..b6374a74d9 100644 --- a/backend/internal/payment/load_balancer.go +++ b/backend/internal/payment/load_balancer.go @@ -21,13 +21,16 @@ const ( StrategyLeastAmount Strategy = "least_amount" ) -// InstanceLimits holds per-channel limits for a provider instance (JSON). -type InstanceLimits map[string]struct { +// ChannelLimits holds limits for a single payment channel within a provider instance. +type ChannelLimits struct { DailyLimit float64 `json:"dailyLimit,omitempty"` SingleMin float64 `json:"singleMin,omitempty"` SingleMax float64 `json:"singleMax,omitempty"` } +// InstanceLimits holds per-channel limits for a provider instance (JSON). +type InstanceLimits map[string]ChannelLimits + // LoadBalancer selects a provider instance for a given payment type. type LoadBalancer interface { GetInstanceConfig(ctx context.Context, instanceID int64) (map[string]string, error) diff --git a/backend/internal/service/payment_config_limits.go b/backend/internal/service/payment_config_limits.go new file mode 100644 index 0000000000..c71223608c --- /dev/null +++ b/backend/internal/service/payment_config_limits.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance" + "github.com/Wei-Shaw/sub2api/internal/payment" +) + +// stripeSubTypes are types that should be aggregated under TypeStripe for user-facing display. +var stripeSubTypes = map[string]bool{string(payment.TypeCard): true, string(payment.TypeLink): true} + +// GetAvailableMethodLimits collects all payment types from enabled provider +// instances and returns limits for each, plus the global widest range. +// Stripe sub-types (card, link) are aggregated under "stripe". +func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*MethodLimitsResponse, error) { + instances, err := s.entClient.PaymentProviderInstance.Query(). + Where(paymentproviderinstance.EnabledEQ(true)).All(ctx) + if err != nil { + return nil, fmt.Errorf("query provider instances: %w", err) + } + typeInstances := pcGroupByPaymentType(instances) + resp := &MethodLimitsResponse{ + Methods: make(map[string]MethodLimits, len(typeInstances)), + } + for pt, insts := range typeInstances { + ml := pcAggregateMethodLimits(pt, insts) + resp.Methods[ml.PaymentType] = ml + } + resp.GlobalMin, resp.GlobalMax = pcComputeGlobalRange(resp.Methods) + return resp, nil +} + +// GetMethodLimits returns per-payment-type limits from enabled provider instances. +func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []string) ([]MethodLimits, error) { + instances, err := s.entClient.PaymentProviderInstance.Query(). + Where(paymentproviderinstance.EnabledEQ(true)).All(ctx) + if err != nil { + return nil, fmt.Errorf("query provider instances: %w", err) + } + result := make([]MethodLimits, 0, len(types)) + for _, pt := range types { + var matching []*dbent.PaymentProviderInstance + for _, inst := range instances { + if payment.InstanceSupportsType(inst.SupportedTypes, pt) { + matching = append(matching, inst) + } + } + result = append(result, pcAggregateMethodLimits(pt, matching)) + } + return result, nil +} + +// pcGroupByPaymentType groups instances by user-facing payment type. +// Stripe sub-types (card, link) are merged under "stripe". +// Uses a seen set to avoid counting one instance twice. +func pcGroupByPaymentType(instances []*dbent.PaymentProviderInstance) map[string][]*dbent.PaymentProviderInstance { + typeInstances := make(map[string][]*dbent.PaymentProviderInstance) + seen := make(map[string]map[int64]bool) + add := func(key string, inst *dbent.PaymentProviderInstance) { + if seen[key] == nil { + seen[key] = make(map[int64]bool) + } + if !seen[key][int64(inst.ID)] { + seen[key][int64(inst.ID)] = true + typeInstances[key] = append(typeInstances[key], inst) + } + } + for _, inst := range instances { + for _, t := range splitTypes(inst.SupportedTypes) { + if stripeSubTypes[t] || t == payment.TypeStripe { + add(payment.TypeStripe, inst) + } else { + add(t, inst) + } + } + if inst.ProviderKey == payment.TypeStripe { + add(payment.TypeStripe, inst) + } + } + return typeInstances +} + +// pcInstanceTypeLimits extracts per-type limits from a provider instance. +// Returns (limits, true) if configured; (zero, false) if unlimited. +func pcInstanceTypeLimits(inst *dbent.PaymentProviderInstance, pt string) (payment.ChannelLimits, bool) { + if inst.Limits == "" { + return payment.ChannelLimits{}, false + } + var limits payment.InstanceLimits + if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil { + return payment.ChannelLimits{}, false + } + cl, ok := limits[pt] + return cl, ok +} + +// unionFloat merges a single limit value into the aggregate using UNION semantics. +// - For "min" fields (wantMin=true): keeps the lowest non-zero value +// - For "max"/"cap" fields (wantMin=false): keeps the highest non-zero value +// - If any value is 0 (unlimited), the result is unlimited. +// +// Returns (aggregated value, still limited). +func unionFloat(agg float64, limited bool, val float64, wantMin bool) (float64, bool) { + if val == 0 { + return agg, false + } + if !limited { + return agg, false + } + if agg == 0 { + return val, true + } + if wantMin && val < agg { + return val, true + } + if !wantMin && val > agg { + return val, true + } + return agg, true +} + +// pcAggregateMethodLimits computes the UNION (least restrictive) of limits +// across all provider instances for a given payment type. +// +// Since the load balancer can route an order to any available instance, +// the user should see the widest possible range: +// - SingleMin: lowest floor across instances; 0 if any is unlimited +// - SingleMax: highest ceiling across instances; 0 if any is unlimited +// - DailyLimit: highest cap across instances; 0 if any is unlimited +func pcAggregateMethodLimits(pt string, instances []*dbent.PaymentProviderInstance) MethodLimits { + ml := MethodLimits{PaymentType: pt} + minLimited, maxLimited, dailyLimited := true, true, true + + for _, inst := range instances { + cl, hasLimits := pcInstanceTypeLimits(inst, pt) + if !hasLimits { + return MethodLimits{PaymentType: pt} // any unlimited instance → all zeros + } + ml.SingleMin, minLimited = unionFloat(ml.SingleMin, minLimited, cl.SingleMin, true) + ml.SingleMax, maxLimited = unionFloat(ml.SingleMax, maxLimited, cl.SingleMax, false) + ml.DailyLimit, dailyLimited = unionFloat(ml.DailyLimit, dailyLimited, cl.DailyLimit, false) + } + + if !minLimited { + ml.SingleMin = 0 + } + if !maxLimited { + ml.SingleMax = 0 + } + if !dailyLimited { + ml.DailyLimit = 0 + } + return ml +} + +// pcComputeGlobalRange computes the widest [min, max] across all methods. +// Uses the same union logic: lowest min, highest max, 0 if any is unlimited. +func pcComputeGlobalRange(methods map[string]MethodLimits) (globalMin, globalMax float64) { + minLimited, maxLimited := true, true + for _, ml := range methods { + globalMin, minLimited = unionFloat(globalMin, minLimited, ml.SingleMin, true) + globalMax, maxLimited = unionFloat(globalMax, maxLimited, ml.SingleMax, false) + } + if !minLimited { + globalMin = 0 + } + if !maxLimited { + globalMax = 0 + } + return globalMin, globalMax +} diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index de77c30b9e..f6d4556fa9 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -101,6 +101,14 @@ type MethodLimits struct { SingleMax float64 `json:"single_max"` } +// MethodLimitsResponse is the full response for the user-facing /limits API. +// It includes per-method limits and the global widest range (union of all methods). +type MethodLimitsResponse struct { + Methods map[string]MethodLimits `json:"methods"` + GlobalMin float64 `json:"global_min"` // 0 = no minimum + GlobalMax float64 `json:"global_max"` // 0 = no maximum +} + type CreateProviderInstanceRequest struct { ProviderKey string `json:"provider_key"` Name string `json:"name"` @@ -234,7 +242,7 @@ func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) stri instances, err := s.entClient.PaymentProviderInstance.Query(). Where( paymentproviderinstance.EnabledEQ(true), - paymentproviderinstance.ProviderKeyEQ("stripe"), + paymentproviderinstance.ProviderKeyEQ(payment.TypeStripe), ).Limit(1).All(ctx) if err != nil || len(instances) == 0 { return "" @@ -415,7 +423,7 @@ func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, pla // validProviderKeys enumerates all recognized provider keys. var validProviderKeys = map[string]bool{ - "easypay": true, "alipay": true, "wxpay": true, "stripe": true, + payment.TypeEasyPay: true, payment.TypeAlipay: true, payment.TypeWxpay: true, payment.TypeStripe: true, } func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req CreateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) { @@ -686,93 +694,6 @@ func (s *PaymentConfigService) GetPlan(ctx context.Context, id int64) (*dbent.Su return plan, nil } -// stripeSubTypes are types that should be aggregated under "stripe" for user-facing display. -var stripeSubTypes = map[string]bool{"card": true, "link": true} - -// GetAvailableMethodLimits collects all payment types from enabled provider -// instances and returns limits for each. This is used by the user-facing payment -// page to discover which payment methods are actually available. -// Stripe sub-types (card, link) are aggregated under "stripe". -func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) ([]MethodLimits, error) { - instances, err := s.entClient.PaymentProviderInstance.Query(). - Where(paymentproviderinstance.EnabledEQ(true)).All(ctx) - if err != nil { - return nil, fmt.Errorf("query provider instances: %w", err) - } - // Collect unique payment types from all enabled providers - typeSet := make(map[string]bool) - for _, inst := range instances { - for _, t := range splitTypes(inst.SupportedTypes) { - // Stripe sub-types (card, link) are represented as "stripe" to users - if stripeSubTypes[t] || t == "stripe" { - typeSet["stripe"] = true - } else { - typeSet[t] = true - } - } - } - result := make([]MethodLimits, 0, len(typeSet)) - for pt := range typeSet { - ml := MethodLimits{PaymentType: pt} - for _, inst := range instances { - if !payment.InstanceSupportsType(inst.SupportedTypes, pt) { - // For "stripe", check if instance supports any stripe sub-type - if pt == "stripe" && inst.ProviderKey == "stripe" { - pcApplyInstanceLimits(inst, pt, &ml) - } - continue - } - pcApplyInstanceLimits(inst, pt, &ml) - } - result = append(result, ml) - } - return result, nil -} - -// GetMethodLimits returns per-payment-type limits from enabled provider instances. -func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []string) ([]MethodLimits, error) { - instances, err := s.entClient.PaymentProviderInstance.Query(). - Where(paymentproviderinstance.EnabledEQ(true)).All(ctx) - if err != nil { - return nil, fmt.Errorf("query provider instances: %w", err) - } - result := make([]MethodLimits, 0, len(types)) - for _, pt := range types { - ml := MethodLimits{PaymentType: pt} - for _, inst := range instances { - if !payment.InstanceSupportsType(inst.SupportedTypes, pt) { - continue - } - pcApplyInstanceLimits(inst, pt, &ml) - } - result = append(result, ml) - } - return result, nil -} - -func pcApplyInstanceLimits(inst *dbent.PaymentProviderInstance, pt string, ml *MethodLimits) { - if inst.Limits == "" { - return - } - var limits payment.InstanceLimits - if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil { - return - } - cl, ok := limits[pt] - if !ok { - return - } - if cl.DailyLimit > 0 && (ml.DailyLimit == 0 || cl.DailyLimit < ml.DailyLimit) { - ml.DailyLimit = cl.DailyLimit - } - if cl.SingleMin > 0 && (ml.SingleMin == 0 || cl.SingleMin > ml.SingleMin) { - ml.SingleMin = cl.SingleMin - } - if cl.SingleMax > 0 && (ml.SingleMax == 0 || cl.SingleMax < ml.SingleMax) { - ml.SingleMax = cl.SingleMax - } -} - func pcParseFloat(s string, defaultVal float64) float64 { if s == "" { return defaultVal diff --git a/frontend/src/api/payment.ts b/frontend/src/api/payment.ts index 46492a07c4..9276765ae5 100644 --- a/frontend/src/api/payment.ts +++ b/frontend/src/api/payment.ts @@ -8,7 +8,7 @@ import type { PaymentConfig, SubscriptionPlan, PaymentChannel, - MethodLimit, + MethodLimitsResponse, CreateOrderRequest, CreateOrderResult, PaymentOrder @@ -33,7 +33,7 @@ export const paymentAPI = { /** Get payment method limits and fee rates */ getLimits() { - return apiClient.get>('/payment/limits') + return apiClient.get('/payment/limits') }, /** Create a new payment order */ diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 6595a8ae46..375b646a85 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -5143,6 +5143,7 @@ export default { confirmCancel: 'Are you sure you want to cancel this order?', amountTooLow: 'Minimum amount is {min}', amountTooHigh: 'Maximum amount is {max}', + amountNoMethod: 'No payment method available for this amount', refundReason: 'Refund Reason', refundReasonPlaceholder: 'Please describe your refund reason', stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 20a3e33943..ef922c6d15 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -5340,6 +5340,7 @@ export default { confirmCancel: '确定要取消此订单吗?', amountTooLow: '最低金额为 {min}', amountTooHigh: '最高金额为 {max}', + amountNoMethod: '该金额没有可用的支付方式', refundReason: '退款原因', refundReasonPlaceholder: '请描述您的退款原因', stripeLoadFailed: '支付组件加载失败,请刷新页面重试', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 7388a9f819..bda0618e0e 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -48,6 +48,13 @@ export interface MethodLimit { available: boolean } +/** Response from /payment/limits API */ +export interface MethodLimitsResponse { + methods: Record + global_min: number // widest min across all methods; 0 = no minimum + global_max: number // widest max across all methods; 0 = no maximum +} + // ==================== Orders ==================== export interface PaymentOrder { diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index eb383b4d2c..464bdd3d03 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -30,8 +30,8 @@

{{ amountError }}

@@ -121,7 +121,7 @@ import { usePaymentStore } from '@/stores/payment' import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' import { extractApiErrorMessage } from '@/utils/apiError' -import type { SubscriptionPlan, MethodLimit } from '@/types/payment' +import type { SubscriptionPlan, MethodLimitsResponse } from '@/types/payment' import AppLayout from '@/components/layout/AppLayout.vue' import BaseDialog from '@/components/common/BaseDialog.vue' import AmountInput from '@/components/payment/AmountInput.vue' @@ -148,7 +148,8 @@ const activeTab = ref<'recharge' | 'subscription'>('recharge') const amount = ref(null) const selectedMethod = ref('') const selectedPlan = ref(null) -const methodLimits = ref>({}) +// Limits response from backend (per-method limits + precomputed global range) +const limitsData = ref({ methods: {}, global_min: 0, global_max: 0 }) const tabs = computed(() => { const result: { key: 'recharge' | 'subscription'; label: string }[] = [] @@ -157,29 +158,38 @@ const tabs = computed(() => { return result }) -// Available methods derived from limits API (actual provider types) -const enabledMethods = computed(() => Object.keys(methodLimits.value)) -// 0 = no limit; provider-level overrides global -const minAmount = computed(() => { - const limit = methodLimits.value[selectedMethod.value] - if (limit?.single_min && limit.single_min > 0) return limit.single_min - return config.value?.min_amount && config.value.min_amount > 0 ? config.value.min_amount : 0 -}) -const maxAmount = computed(() => { - const limit = methodLimits.value[selectedMethod.value] - if (limit?.single_max && limit.single_max > 0) return limit.single_max - return config.value?.max_amount && config.value.max_amount > 0 ? config.value.max_amount : 0 -}) +const enabledMethods = computed(() => Object.keys(limitsData.value.methods)) +const validAmount = computed(() => amount.value ?? 0) + +// Check if an amount fits a method's [min, max]. 0 = no limit. +function amountFitsMethod(amt: number, methodType: string): boolean { + if (amt <= 0) return true + const ml = limitsData.value.methods[methodType] + if (!ml) return false + if (ml.single_min > 0 && amt < ml.single_min) return false + if (ml.single_max > 0 && amt > ml.single_max) return false + return true +} + +// Global range for AmountInput quick buttons (precomputed by backend) +const globalMinAmount = computed(() => limitsData.value.global_min) +const globalMaxAmount = computed(() => limitsData.value.global_max) + +// Selected method's limits (for validation and error messages) +const selectedLimit = computed(() => limitsData.value.methods[selectedMethod.value]) const methodOptions = computed(() => enabledMethods.value.map((type) => { - const limit = methodLimits.value[type] - return { type, fee_rate: limit?.fee_rate ?? 0, available: limit?.available !== false } + const ml = limitsData.value.methods[type] + return { + type, + fee_rate: ml?.fee_rate ?? 0, + available: ml?.available !== false && amountFitsMethod(validAmount.value, type), + } }) ) -const validAmount = computed(() => amount.value ?? 0) -const feeRate = computed(() => methodLimits.value[selectedMethod.value]?.fee_rate ?? 0) +const feeRate = computed(() => selectedLimit.value?.fee_rate ?? 0) const feeAmount = computed(() => feeRate.value > 0 && validAmount.value > 0 ? Math.ceil(((validAmount.value * feeRate.value) / 100) * 100) / 100 @@ -193,17 +203,30 @@ const totalAmount = computed(() => const amountError = computed(() => { if (validAmount.value <= 0) return '' - if (minAmount.value > 0 && validAmount.value < minAmount.value) return t('payment.amountTooLow', { min: minAmount.value }) - if (maxAmount.value > 0 && validAmount.value > maxAmount.value) return t('payment.amountTooHigh', { max: maxAmount.value }) + // No method can handle this amount + if (!enabledMethods.value.some((m) => amountFitsMethod(validAmount.value, m))) { + return t('payment.amountNoMethod') + } + // Selected method can't handle this amount (but others can) + const ml = selectedLimit.value + if (ml) { + if (ml.single_min > 0 && validAmount.value < ml.single_min) return t('payment.amountTooLow', { min: ml.single_min }) + if (ml.single_max > 0 && validAmount.value > ml.single_max) return t('payment.amountTooHigh', { max: ml.single_max }) + } return '' }) -const canSubmit = computed(() => { - const limitInfo = methodLimits.value[selectedMethod.value] - if (validAmount.value <= 0) return false - if (minAmount.value > 0 && validAmount.value < minAmount.value) return false - if (maxAmount.value > 0 && validAmount.value > maxAmount.value) return false - return limitInfo?.available !== false +const canSubmit = computed(() => + validAmount.value > 0 + && amountFitsMethod(validAmount.value, selectedMethod.value) + && selectedLimit.value?.available !== false +) + +// Auto-switch to first available method when current selection can't handle the amount +watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method]) => { + if (amt <= 0 || amountFitsMethod(amt, method)) return + const available = enabledMethods.value.find((m) => amountFitsMethod(amt, m)) + if (available) selectedMethod.value = available }) function openSubscribeDialog(plan: SubscriptionPlan) { @@ -273,7 +296,7 @@ onMounted(async () => { await paymentStore.fetchConfig(true) try { const limitsRes = await paymentAPI.getLimits() - methodLimits.value = limitsRes.data + limitsData.value = limitsRes.data } catch (e) { /* limits endpoint may not exist */ } if (enabledMethods.value.length) { const order: readonly string[] = METHOD_ORDER @@ -292,8 +315,3 @@ onMounted(async () => { finally { loading.value = false } }) - -