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:
erio
2026-04-07 13:29:39 +08:00
parent bc87384ea2
commit fd8d3e6de5
13 changed files with 296 additions and 56 deletions
+1 -1
View File
@@ -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) {
+7
View File
@@ -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 {
+28 -2
View File
@@ -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, ",")
+67 -1
View File
@@ -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
+10
View File
@@ -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
}
/**
+6 -2
View File
@@ -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
})
}
+15
View File
@@ -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',
+15
View File
@@ -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: '立即开通',
+56 -16
View File
@@ -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 {
+8 -2
View File
@@ -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
+2 -2
View File
@@ -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>