mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(payment): cancel rate limit, easypay multi-CID, payment UX improvements
- EasyPay redirect passes all configured CIDs (cidAlipay, cidWxpay) comma-separated - Payment redirect opens in new window instead of current window redirect - Order action column uses styled icon buttons with tooltip - Pending order limit error returns i18n-friendly message with metadata - Cancel order rate limit feature (ported from sub2apipay) with rolling/fixed window modes - Admin settings UI for cancel rate limit configuration - Fix provider dialog triggering unintended settings save on open - Provider save now correctly calls saveSettings after provider update - API client interceptor forwards reason and metadata fields for structured error handling
This commit is contained in:
@@ -1 +1 @@
|
||||
0.1.108.70
|
||||
0.1.108.71
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ",")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '立即开通',
|
||||
|
||||
@@ -1677,6 +1677,28 @@
|
||||
<Select v-model="form.payment_load_balance_strategy" :options="loadBalanceOptions" class="w-40" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Row 3.5: Cancel rate limit -->
|
||||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-600">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" v-model="form.payment_cancel_rate_limit_enabled" class="h-4 w-4 rounded border-gray-300 text-primary-600" />
|
||||
{{ t('admin.settings.payment.cancelRateLimit') }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="form.payment_cancel_rate_limit_enabled" class="mt-3 flex flex-wrap items-end gap-3">
|
||||
<div class="w-20"><label class="input-label">{{ t('admin.settings.payment.cancelRateLimitWindow') }}</label><input v-model.number="form.payment_cancel_rate_limit_window" type="number" min="1" class="input" /></div>
|
||||
<div class="w-24">
|
||||
<label class="input-label">{{ t('admin.settings.payment.cancelRateLimitUnit') }}</label>
|
||||
<Select v-model="form.payment_cancel_rate_limit_unit" :options="cancelRateLimitUnitOptions" class="w-full" />
|
||||
</div>
|
||||
<div class="w-20"><label class="input-label">{{ t('admin.settings.payment.cancelRateLimitMax') }}</label><input v-model.number="form.payment_cancel_rate_limit_max" type="number" min="1" class="input" /></div>
|
||||
<div class="w-24">
|
||||
<label class="input-label">{{ t('admin.settings.payment.cancelRateLimitWindowMode') }}</label>
|
||||
<Select v-model="form.payment_cancel_rate_limit_window_mode" :options="cancelRateLimitModeOptions" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1.5 text-xs text-gray-400">{{ t('admin.settings.payment.cancelRateLimitHint') }}</p>
|
||||
</div>
|
||||
<!-- Row 4: Enabled payment types (provider badges like sub2apipay) -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.settings.payment.enabledPaymentTypes') }}</label>
|
||||
@@ -1729,20 +1751,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Provider Create/Edit Dialog -->
|
||||
<PaymentProviderDialog
|
||||
ref="providerDialogRef"
|
||||
:show="showProviderDialog"
|
||||
:saving="providerSaving"
|
||||
:editing="editingProvider"
|
||||
:all-key-options="providerKeyOptions"
|
||||
:enabled-key-options="enabledProviderKeyOptions"
|
||||
:all-payment-types="allPaymentTypes"
|
||||
:redirect-label="t('admin.settings.payment.easypayRedirect')"
|
||||
@close="showProviderDialog = false"
|
||||
@save="handleSaveProvider"
|
||||
/>
|
||||
<ConfirmDialog :show="showDeleteProviderDialog" :title="t('admin.settings.payment.deleteProvider')" :message="t('admin.settings.payment.deleteProviderConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeleteProvider" @cancel="showDeleteProviderDialog = false" />
|
||||
<div v-show="activeTab === 'email'" class="space-y-6">
|
||||
<!-- Email disabled hint - show when email_verify_enabled is off -->
|
||||
<div v-if="!form.email_verify_enabled" class="card">
|
||||
@@ -1995,6 +2003,21 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Provider dialogs placed outside the settings form to prevent form submission bubbling -->
|
||||
<PaymentProviderDialog
|
||||
ref="providerDialogRef"
|
||||
:show="showProviderDialog"
|
||||
:saving="providerSaving"
|
||||
:editing="editingProvider"
|
||||
:all-key-options="providerKeyOptions"
|
||||
:enabled-key-options="enabledProviderKeyOptions"
|
||||
:all-payment-types="allPaymentTypes"
|
||||
:redirect-label="t('admin.settings.payment.easypayRedirect')"
|
||||
@close="showProviderDialog = false"
|
||||
@save="handleSaveProvider"
|
||||
/>
|
||||
<ConfirmDialog :show="showDeleteProviderDialog" :title="t('admin.settings.payment.deleteProvider')" :message="t('admin.settings.payment.deleteProviderConfirm')" :confirm-text="t('common.delete')" danger @confirm="handleDeleteProvider" @cancel="showDeleteProviderDialog = false" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -2146,7 +2169,7 @@ const form = reactive<SettingsForm>({
|
||||
home_content: '',
|
||||
backend_mode_enabled: false,
|
||||
hide_ccs_import_button: false,
|
||||
payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin',
|
||||
payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin', payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_window_mode: 'rolling',
|
||||
sora_client_enabled: false,
|
||||
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
|
||||
custom_endpoints: [] as Array<{name: string; endpoint: string; description: string}>,
|
||||
@@ -2498,6 +2521,11 @@ async function saveSettings() {
|
||||
payment_product_name_suffix: form.payment_product_name_suffix,
|
||||
payment_help_image_url: form.payment_help_image_url,
|
||||
payment_help_text: form.payment_help_text,
|
||||
payment_cancel_rate_limit_enabled: form.payment_cancel_rate_limit_enabled,
|
||||
payment_cancel_rate_limit_max: Number(form.payment_cancel_rate_limit_max) || 10,
|
||||
payment_cancel_rate_limit_window: Number(form.payment_cancel_rate_limit_window) || 1,
|
||||
payment_cancel_rate_limit_unit: form.payment_cancel_rate_limit_unit,
|
||||
payment_cancel_rate_limit_window_mode: form.payment_cancel_rate_limit_window_mode,
|
||||
}
|
||||
|
||||
const updated = await adminAPI.settings.updateSettings(payload)
|
||||
@@ -2850,6 +2878,17 @@ const loadBalanceOptions = computed(() => [
|
||||
{ value: 'least-amount', label: t('admin.settings.payment.strategyLeastAmount') },
|
||||
])
|
||||
|
||||
const cancelRateLimitUnitOptions = computed(() => [
|
||||
{ value: 'minute', label: t('admin.settings.payment.cancelRateLimitUnitMinute') },
|
||||
{ value: 'hour', label: t('admin.settings.payment.cancelRateLimitUnitHour') },
|
||||
{ value: 'day', label: t('admin.settings.payment.cancelRateLimitUnitDay') },
|
||||
])
|
||||
|
||||
const cancelRateLimitModeOptions = computed(() => [
|
||||
{ value: 'rolling', label: t('admin.settings.payment.cancelRateLimitWindowModeRolling') },
|
||||
{ value: 'fixed', label: t('admin.settings.payment.cancelRateLimitWindowModeFixed') },
|
||||
])
|
||||
|
||||
async function loadProviders() {
|
||||
providersLoading.value = true
|
||||
try { const res = await adminAPI.payment.getProviders(); providers.value = res.data || [] }
|
||||
@@ -2877,9 +2916,10 @@ async function handleSaveProvider(payload: any) {
|
||||
} else {
|
||||
await adminAPI.payment.createProvider(payload)
|
||||
}
|
||||
appStore.showSuccess(t('common.saved'))
|
||||
showProviderDialog.value = false
|
||||
loadProviders()
|
||||
// Also save the overall settings so payment config changes are persisted together
|
||||
await saveSettings()
|
||||
} catch (err: unknown) {
|
||||
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
|
||||
@@ -235,13 +235,19 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
||||
} else if (result.qr_code) {
|
||||
router.push({ path: '/payment/qrcode', query: { order_id: String(result.order_id), qr: result.qr_code || '', pay_url: result.pay_url || '', expires_at: result.expires_at || '' } })
|
||||
} else if (result.pay_url) {
|
||||
window.location.href = result.pay_url
|
||||
window.open(result.pay_url, '_blank')
|
||||
} else {
|
||||
errorMessage.value = t('payment.result.failed')
|
||||
appStore.showError(errorMessage.value)
|
||||
}
|
||||
} catch (err: any) {
|
||||
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
if (err.reason === 'TOO_MANY_PENDING') {
|
||||
errorMessage.value = t('payment.errors.tooManyPending', { max: err.metadata?.max || '' })
|
||||
} else if (err.reason === 'CANCEL_RATE_LIMITED') {
|
||||
errorMessage.value = t('payment.errors.cancelRateLimited')
|
||||
} else {
|
||||
errorMessage.value = extractApiErrorMessage(err, t('payment.result.failed'))
|
||||
}
|
||||
appStore.showError(errorMessage.value)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
|
||||
@@ -36,10 +36,10 @@
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<button v-if="row.status === 'PENDING'" @click="handleCancel(row.id)" class="btn-icon text-yellow-500 hover:text-yellow-700" :title="t('payment.orders.cancel')">
|
||||
<button v-if="row.status === 'PENDING'" @click="handleCancel(row.id)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-yellow-600 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/20" :title="t('payment.orders.cancel')">
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
<button v-if="row.status === 'COMPLETED'" @click="openRefundDialog(row)" class="btn-icon text-purple-500 hover:text-purple-700" :title="t('payment.orders.requestRefund')">
|
||||
<button v-if="row.status === 'COMPLETED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20" :title="t('payment.orders.requestRefund')">
|
||||
<Icon name="dollar" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user