diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 7452ab311e..84ba0b3597 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.108.70 +0.1.108.71 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 634f9e2336..adc6b79040 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -152,6 +152,11 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentProductNameSuffix: paymentCfg.ProductNameSuffix, PaymentHelpImageURL: paymentCfg.HelpImageURL, PaymentHelpText: paymentCfg.HelpText, + PaymentCancelRateLimitEnabled: paymentCfg.CancelRateLimitEnabled, + PaymentCancelRateLimitMax: paymentCfg.CancelRateLimitMax, + PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow, + PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit, + PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode, }) } @@ -250,6 +255,13 @@ type UpdateSettingsRequest struct { PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` PaymentHelpImageURL *string `json:"payment_help_image_url"` PaymentHelpText *string `json:"payment_help_text"` + + // Cancel rate limit + PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"` + PaymentCancelRateLimitMax *int `json:"payment_cancel_rate_limit_max"` + PaymentCancelRateLimitWindow *int `json:"payment_cancel_rate_limit_window"` + PaymentCancelRateLimitUnit *string `json:"payment_cancel_rate_limit_unit"` + PaymentCancelRateLimitMode *string `json:"payment_cancel_rate_limit_window_mode"` } // UpdateSettings 更新系统设置 @@ -664,19 +676,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { // Skip if no payment fields were provided (prevents accidental wipe). if h.paymentConfigService != nil && hasPaymentFields(req) { paymentReq := service.UpdatePaymentConfigRequest{ - Enabled: req.PaymentEnabled, - MinAmount: req.PaymentMinAmount, - MaxAmount: req.PaymentMaxAmount, - DailyLimit: req.PaymentDailyLimit, - OrderTimeoutMin: req.PaymentOrderTimeoutMin, - MaxPendingOrders: req.PaymentMaxPendingOrders, - EnabledTypes: req.PaymentEnabledTypes, - BalanceDisabled: req.PaymentBalanceDisabled, - LoadBalanceStrategy: req.PaymentLoadBalanceStrat, - ProductNamePrefix: req.PaymentProductNamePrefix, - ProductNameSuffix: req.PaymentProductNameSuffix, - HelpImageURL: req.PaymentHelpImageURL, - HelpText: req.PaymentHelpText, + Enabled: req.PaymentEnabled, + MinAmount: req.PaymentMinAmount, + MaxAmount: req.PaymentMaxAmount, + DailyLimit: req.PaymentDailyLimit, + OrderTimeoutMin: req.PaymentOrderTimeoutMin, + MaxPendingOrders: req.PaymentMaxPendingOrders, + EnabledTypes: req.PaymentEnabledTypes, + BalanceDisabled: req.PaymentBalanceDisabled, + LoadBalanceStrategy: req.PaymentLoadBalanceStrat, + ProductNamePrefix: req.PaymentProductNamePrefix, + ProductNameSuffix: req.PaymentProductNameSuffix, + HelpImageURL: req.PaymentHelpImageURL, + HelpText: req.PaymentHelpText, + CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled, + CancelRateLimitMax: req.PaymentCancelRateLimitMax, + CancelRateLimitWindow: req.PaymentCancelRateLimitWindow, + CancelRateLimitUnit: req.PaymentCancelRateLimitUnit, + CancelRateLimitMode: req.PaymentCancelRateLimitMode, } if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil { response.ErrorFrom(c, err) @@ -778,6 +795,11 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix, PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL, PaymentHelpText: updatedPaymentCfg.HelpText, + PaymentCancelRateLimitEnabled: updatedPaymentCfg.CancelRateLimitEnabled, + PaymentCancelRateLimitMax: updatedPaymentCfg.CancelRateLimitMax, + PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow, + PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit, + PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode, }) } @@ -789,7 +811,9 @@ func hasPaymentFields(req UpdateSettingsRequest) bool { req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil || req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil || req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil || - req.PaymentHelpText != nil + req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil || + req.PaymentCancelRateLimitMax != nil || req.PaymentCancelRateLimitWindow != nil || + req.PaymentCancelRateLimitUnit != nil || req.PaymentCancelRateLimitMode != nil } func (h *SettingHandler) auditSettingsUpdate(c *gin.Context, before *service.SystemSettings, after *service.SystemSettings, req UpdateSettingsRequest) { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 047881fe88..b419b9703d 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -112,6 +112,13 @@ type SystemSettings struct { PaymentProductNameSuffix string `json:"payment_product_name_suffix"` PaymentHelpImageURL string `json:"payment_help_image_url"` PaymentHelpText string `json:"payment_help_text"` + + // Cancel rate limit + PaymentCancelRateLimitEnabled bool `json:"payment_cancel_rate_limit_enabled"` + PaymentCancelRateLimitMax int `json:"payment_cancel_rate_limit_max"` + PaymentCancelRateLimitWindow int `json:"payment_cancel_rate_limit_window"` + PaymentCancelRateLimitUnit string `json:"payment_cancel_rate_limit_unit"` + PaymentCancelRateLimitMode string `json:"payment_cancel_rate_limit_window_mode"` } type DefaultSubscriptionSetting struct { diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index bcb62684e4..a9f4d0c6a2 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -48,7 +48,7 @@ func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) { }, nil } -func (e *EasyPay) Name() string { return "EasyPay" } +func (e *EasyPay) Name() string { return "EasyPay" } func (e *EasyPay) ProviderKey() string { return "easypay" } func (e *EasyPay) SupportedTypes() []payment.PaymentType { return []payment.PaymentType{payment.TypeEasyPay, payment.TypeAlipay, payment.TypeWxpay} @@ -77,7 +77,7 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym "return_url": returnURL, "name": req.Subject, "money": req.Amount, } - if cid := e.resolveCID(payType); cid != "" { + if cid := e.resolveAllCIDs(); cid != "" { params["cid"] = cid } params["sign"] = easyPaySign(params, e.config["pkey"]) @@ -229,6 +229,32 @@ func (e *EasyPay) resolveCID(paymentType string) string { return e.config["cid"] } +// resolveAllCIDs collects all configured CIDs (general, alipay, wxpay) and +// returns them as a comma-separated string for the redirect payment page, +// allowing the user to choose among all available channels. +func (e *EasyPay) resolveAllCIDs() string { + seen := make(map[string]struct{}) + var cids []string + for _, key := range []string{"cidAlipay", "cidWxpay"} { + v := e.config[key] + if v == "" { + continue + } + for _, c := range strings.Split(v, ",") { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + cids = append(cids, c) + } + } + return strings.Join(cids, ",") +} + func (e *EasyPay) post(ctx context.Context, endpoint string, params map[string]string) ([]byte, error) { form := url.Values{} for k, v := range params { diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index 5c84c5f397..828832bc1c 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -27,8 +27,8 @@ const ( SettingBalancePayDisabled = "BALANCE_PAYMENT_DISABLED" SettingProductNamePrefix = "PRODUCT_NAME_PREFIX" SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX" - SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL" - SettingHelpText = "PAYMENT_HELP_TEXT" + SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL" + SettingHelpText = "PAYMENT_HELP_TEXT" SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED" SettingCancelRateLimitMax = "CANCEL_RATE_LIMIT_MAX" SettingCancelWindowSize = "CANCEL_RATE_LIMIT_WINDOW" @@ -44,20 +44,27 @@ const ( // PaymentConfig holds the payment system configuration. type PaymentConfig struct { - Enabled bool `json:"enabled"` - MinAmount float64 `json:"min_amount"` - MaxAmount float64 `json:"max_amount"` - DailyLimit float64 `json:"daily_limit"` - OrderTimeoutMin int `json:"order_timeout_minutes"` - MaxPendingOrders int `json:"max_pending_orders"` - EnabledTypes []string `json:"enabled_payment_types"` - BalanceDisabled bool `json:"balance_disabled"` - LoadBalanceStrategy string `json:"load_balance_strategy"` - ProductNamePrefix string `json:"product_name_prefix"` - ProductNameSuffix string `json:"product_name_suffix"` - HelpImageURL string `json:"help_image_url"` - HelpText string `json:"help_text"` - StripePublishableKey string `json:"stripe_publishable_key,omitempty"` + Enabled bool `json:"enabled"` + MinAmount float64 `json:"min_amount"` + MaxAmount float64 `json:"max_amount"` + DailyLimit float64 `json:"daily_limit"` + OrderTimeoutMin int `json:"order_timeout_minutes"` + MaxPendingOrders int `json:"max_pending_orders"` + EnabledTypes []string `json:"enabled_payment_types"` + BalanceDisabled bool `json:"balance_disabled"` + LoadBalanceStrategy string `json:"load_balance_strategy"` + ProductNamePrefix string `json:"product_name_prefix"` + ProductNameSuffix string `json:"product_name_suffix"` + HelpImageURL string `json:"help_image_url"` + HelpText string `json:"help_text"` + StripePublishableKey string `json:"stripe_publishable_key,omitempty"` + + // Cancel rate limit settings + CancelRateLimitEnabled bool `json:"cancel_rate_limit_enabled"` + CancelRateLimitMax int `json:"cancel_rate_limit_max"` + CancelRateLimitWindow int `json:"cancel_rate_limit_window"` + CancelRateLimitUnit string `json:"cancel_rate_limit_unit"` + CancelRateLimitMode string `json:"cancel_rate_limit_window_mode"` } // UpdatePaymentConfigRequest contains fields to update payment configuration. @@ -75,6 +82,13 @@ type UpdatePaymentConfigRequest struct { ProductNameSuffix *string `json:"product_name_suffix"` HelpImageURL *string `json:"help_image_url"` HelpText *string `json:"help_text"` + + // Cancel rate limit settings + CancelRateLimitEnabled *bool `json:"cancel_rate_limit_enabled"` + CancelRateLimitMax *int `json:"cancel_rate_limit_max"` + CancelRateLimitWindow *int `json:"cancel_rate_limit_window"` + CancelRateLimitUnit *string `json:"cancel_rate_limit_unit"` + CancelRateLimitMode *string `json:"cancel_rate_limit_window_mode"` } // MethodLimits holds per-payment-type limits. @@ -164,6 +178,8 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingLoadBalanceStrategy, SettingProductNamePrefix, SettingProductNameSuffix, SettingHelpImageURL, SettingHelpText, + SettingCancelRateLimitOn, SettingCancelRateLimitMax, + SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode, } vals, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { @@ -189,6 +205,12 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme ProductNameSuffix: vals[SettingProductNameSuffix], HelpImageURL: vals[SettingHelpImageURL], HelpText: vals[SettingHelpText], + + CancelRateLimitEnabled: vals[SettingCancelRateLimitOn] == "true", + CancelRateLimitMax: pcParseInt(vals[SettingCancelRateLimitMax], 10), + CancelRateLimitWindow: pcParseInt(vals[SettingCancelWindowSize], 1), + CancelRateLimitUnit: vals[SettingCancelWindowUnit], + CancelRateLimitMode: vals[SettingCancelWindowMode], } if cfg.LoadBalanceStrategy == "" { cfg.LoadBalanceStrategy = "round-robin" @@ -239,6 +261,11 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda 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), } if req.EnabledTypes != nil { m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",") diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index 5258fa7a58..36853babe4 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -177,6 +177,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest if err != nil { return nil, err } + if err := s.checkCancelRateLimit(ctx, req.UserID, cfg); err != nil { + return nil, err + } user, err := s.userRepo.GetByID(ctx, req.UserID) if err != nil { return nil, fmt.Errorf("get user: %w", err) @@ -298,11 +301,74 @@ func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, us return fmt.Errorf("count pending orders: %w", err) } if c >= max { - return infraerrors.TooManyRequests("TOO_MANY_PENDING", fmt.Sprintf("too many pending orders (max %d)", max)) + return infraerrors.TooManyRequests("TOO_MANY_PENDING", fmt.Sprintf("too many pending orders (max %d)", max)). + WithMetadata(map[string]string{"max": strconv.Itoa(max)}) } return nil } +func (s *PaymentService) checkCancelRateLimit(ctx context.Context, userID int64, cfg *PaymentConfig) error { + if !cfg.CancelRateLimitEnabled || cfg.CancelRateLimitMax <= 0 { + return nil + } + windowStart := cancelRateLimitWindowStart(cfg) + operator := fmt.Sprintf("user:%d", userID) + count, err := s.entClient.PaymentAuditLog.Query(). + Where( + paymentauditlog.ActionEQ("ORDER_CANCELLED"), + paymentauditlog.OperatorEQ(operator), + paymentauditlog.CreatedAtGTE(windowStart), + ).Count(ctx) + if err != nil { + slog.Error("check cancel rate limit failed", "userID", userID, "error", err) + return nil // fail open + } + if count >= cfg.CancelRateLimitMax { + return infraerrors.TooManyRequests("CANCEL_RATE_LIMITED", "cancel rate limited"). + WithMetadata(map[string]string{ + "max": strconv.Itoa(cfg.CancelRateLimitMax), + "window": strconv.Itoa(cfg.CancelRateLimitWindow), + "unit": cfg.CancelRateLimitUnit, + }) + } + return nil +} + +func cancelRateLimitWindowStart(cfg *PaymentConfig) time.Time { + now := time.Now() + w := cfg.CancelRateLimitWindow + if w <= 0 { + w = 1 + } + unit := cfg.CancelRateLimitUnit + if unit == "" { + unit = "day" + } + if cfg.CancelRateLimitMode == "fixed" { + switch unit { + case "minute": + t := now.Truncate(time.Minute) + return t.Add(-time.Duration(w-1) * time.Minute) + case "day": + y, m, d := now.Date() + t := time.Date(y, m, d, 0, 0, 0, 0, now.Location()) + return t.AddDate(0, 0, -(w - 1)) + default: // hour + t := now.Truncate(time.Hour) + return t.Add(-time.Duration(w-1) * time.Hour) + } + } + // rolling window + switch unit { + case "minute": + return now.Add(-time.Duration(w) * time.Minute) + case "day": + return now.AddDate(0, 0, -w) + default: // hour + return now.Add(-time.Duration(w) * time.Hour) + } +} + func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, userID int64, amount, limit float64) error { if limit <= 0 { return nil diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 89ea0a4748..71bde0d1cc 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -103,6 +103,11 @@ export interface SystemSettings { payment_product_name_suffix: string payment_help_image_url: string payment_help_text: string + payment_cancel_rate_limit_enabled: boolean + payment_cancel_rate_limit_max: number + payment_cancel_rate_limit_window: number + payment_cancel_rate_limit_unit: string + payment_cancel_rate_limit_window_mode: string } export interface UpdateSettingsRequest { @@ -172,6 +177,11 @@ export interface UpdateSettingsRequest { payment_product_name_suffix?: string payment_help_image_url?: string payment_help_text?: string + payment_cancel_rate_limit_enabled?: boolean + payment_cancel_rate_limit_max?: number + payment_cancel_rate_limit_window?: number + payment_cancel_rate_limit_unit?: string + payment_cancel_rate_limit_window_mode?: string } /** diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 95f9ff3180..f0c6c59f04 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -95,7 +95,9 @@ apiClient.interceptors.response.use( return Promise.reject({ status: response.status, code: apiResponse.code, - message: apiResponse.message || 'Unknown error' + reason: (apiResponse as any).reason, + message: apiResponse.message || 'Unknown error', + metadata: (apiResponse as any).metadata }) } } @@ -267,8 +269,10 @@ apiClient.interceptors.response.use( return Promise.reject({ status, code: apiData.code, + reason: apiData.reason, error: apiData.error, - message: apiData.message || apiData.detail || error.message + message: apiData.message || apiData.detail || error.message, + metadata: apiData.metadata }) } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0b84111ccb..8566339e39 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4345,6 +4345,17 @@ export default { orderTimeout: 'Order Timeout', orderTimeoutHint: 'In minutes, minimum 1', maxPendingOrders: 'Max Pending Orders', + cancelRateLimit: 'Limit Cancel Rate', + cancelRateLimitHint: 'When enabled, users who exceed the cancel limit within the time window cannot create new orders', + cancelRateLimitWindow: 'Window', + cancelRateLimitUnit: 'Unit', + cancelRateLimitMax: 'Max Cancels', + cancelRateLimitUnitMinute: 'Minutes', + cancelRateLimitUnitHour: 'Hours', + cancelRateLimitUnitDay: 'Days', + cancelRateLimitWindowMode: 'Window Mode', + cancelRateLimitWindowModeRolling: 'Rolling', + cancelRateLimitWindowModeFixed: 'Fixed', helpText: 'Help Text', helpImageUrl: 'Help Image URL', manageProviders: 'Manage Providers', @@ -5120,6 +5131,10 @@ export default { refundReason: 'Refund Reason', refundReasonPlaceholder: 'Please describe your refund reason', stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.', + errors: { + tooManyPending: 'Too many pending orders (max {max}). Please complete or cancel existing orders first.', + cancelRateLimited: 'Too many cancellations. Please try again later.', + }, stripePay: 'Pay Now', stripeSuccessProcessing: 'Payment successful, processing your order...', subscribeNow: 'Subscribe Now', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 4ca7488f9b..e5a3c42483 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4518,6 +4518,17 @@ export default { orderTimeout: '订单超时时间', orderTimeoutHint: '单位:分钟,至少 1 分钟', maxPendingOrders: '最大待支付订单数', + cancelRateLimit: '限制取消频率', + cancelRateLimitHint: '启用后,用户在时间窗口内取消订单次数超限将无法创建新订单', + cancelRateLimitWindow: '时间窗口', + cancelRateLimitUnit: '周期', + cancelRateLimitMax: '最大取消次数', + cancelRateLimitUnitMinute: '分钟', + cancelRateLimitUnitHour: '小时', + cancelRateLimitUnitDay: '天', + cancelRateLimitWindowMode: '窗口模式', + cancelRateLimitWindowModeRolling: '滚动', + cancelRateLimitWindowModeFixed: '固定', helpText: '帮助文本', helpImageUrl: '帮助图片链接', manageProviders: '管理服务商', @@ -5317,6 +5328,10 @@ export default { refundReason: '退款原因', refundReasonPlaceholder: '请描述您的退款原因', stripeLoadFailed: '支付组件加载失败,请刷新页面重试', + errors: { + tooManyPending: '待支付订单过多(最多 {max} 个),请先完成或取消现有订单', + cancelRateLimited: '取消订单过于频繁,请稍后再试', + }, stripePay: '立即支付', stripeSuccessProcessing: '支付成功,正在处理订单...', subscribeNow: '立即开通', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 39df67d7f3..2a5a0632aa 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1677,6 +1677,28 @@ + +
{{ t('admin.settings.payment.cancelRateLimitHint') }}
+