feat(payment): configurable payment mode (qrcode/redirect/popup)

- EasyPay supports 3 modes: qrcode (API/QR), redirect (new tab), popup (small window)
- Stripe supports 2 modes: popup (small window, default), redirect (new tab)
- Backend passes payment_mode through to create-order response
- Frontend opens pay URL based on configured mode
This commit is contained in:
erio
2026-04-08 18:05:24 +08:00
parent a3e3e3cd08
commit fafde2a493
12 changed files with 52 additions and 21 deletions
@@ -90,6 +90,7 @@ func (lb *DefaultLoadBalancer) SelectInstance(ctx context.Context, providerKey s
InstanceID: fmt.Sprintf("%d", selected.ID),
Config: config,
SupportedTypes: selected.SupportedTypes,
PaymentMode: selected.PaymentMode,
}, nil
}
+3 -2
View File
@@ -57,8 +57,9 @@ func (e *EasyPay) SupportedTypes() []payment.PaymentType {
func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
// Payment mode determined by instance config, not payment type.
// "redirect" → hosted page (submit.php); default → API call (mapi.php).
if e.config["paymentMode"] == "redirect" {
// "redirect"/"popup" → hosted page (submit.php); "qrcode"/default → API call (mapi.php).
mode := e.config["paymentMode"]
if mode == "redirect" || mode == "popup" {
return e.createRedirectPayment(req)
}
return e.createAPIPayment(ctx, req)
+1
View File
@@ -126,6 +126,7 @@ type InstanceSelection struct {
InstanceID string
Config map[string]string
SupportedTypes string // Comma-separated list of supported payment types from the instance
PaymentMode string // Payment display mode: "qrcode", "redirect", "popup"
}
// Provider defines the interface that all payment providers must implement.
+1 -1
View File
@@ -275,7 +275,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
return nil, fmt.Errorf("update order with payment details: %w", err)
}
s.writeAuditLog(ctx, order.ID, "ORDER_CREATED", fmt.Sprintf("user:%d", req.UserID), map[string]any{"amount": req.Amount, "paymentType": req.PaymentType, "orderType": req.OrderType})
return &CreateOrderResponse{OrderID: order.ID, Amount: order.Amount, PayAmount: payAmount, FeeRate: order.FeeRate, Status: OrderStatusPending, PaymentType: req.PaymentType, PayURL: pr.PayURL, QRCode: pr.QRCode, ClientSecret: pr.ClientSecret, ExpiresAt: order.ExpiresAt}, nil
return &CreateOrderResponse{OrderID: order.ID, Amount: order.Amount, PayAmount: payAmount, FeeRate: order.FeeRate, Status: OrderStatusPending, PaymentType: req.PaymentType, PayURL: pr.PayURL, QRCode: pr.QRCode, ClientSecret: pr.ClientSecret, ExpiresAt: order.ExpiresAt, PaymentMode: sel.PaymentMode}, nil
}
func (s *PaymentService) buildPaymentSubject(plan *dbent.SubscriptionPlan, payAmountStr string, cfg *PaymentConfig) string {
@@ -81,6 +81,7 @@ type CreateOrderResponse struct {
QRCode string `json:"qr_code,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
PaymentMode string `json:"payment_mode,omitempty"`
}
type OrderListParams struct {
@@ -37,7 +37,7 @@
<!-- Payment mode + Supported types (same row) -->
<div class="flex flex-wrap items-end gap-4">
<div v-if="form.provider_key === 'easypay'">
<div v-if="form.provider_key === 'easypay' || form.provider_key === 'stripe'">
<label class="input-label">
{{ t('admin.settings.payment.paymentMode') }}
<span class="text-red-500">*</span>
@@ -229,7 +229,9 @@ import {
PROVIDER_CALLBACK_PATHS,
WEBHOOK_PATHS,
PAYMENT_MODE_REDIRECT,
PAYMENT_MODE_API,
PAYMENT_MODE_QRCODE,
PAYMENT_MODE_POPUP,
STRIPE_PAYMENT_MODES,
getAvailableTypes,
extractBaseUrl,
} from './providerConfig'
@@ -266,7 +268,7 @@ const form = reactive({
provider_key: 'easypay',
supported_types: [] as string[],
enabled: true,
payment_mode: PAYMENT_MODE_API,
payment_mode: PAYMENT_MODE_QRCODE,
refund_enabled: false,
})
const config = reactive<Record<string, string>>({})
@@ -285,10 +287,19 @@ const stripeWebhookUrl = computed(() =>
const callbackPaths = computed(() => PROVIDER_CALLBACK_PATHS[form.provider_key] || null)
const paymentModeOptions = computed(() => [
{ value: PAYMENT_MODE_REDIRECT, label: t('admin.settings.payment.modeRedirect') },
{ value: PAYMENT_MODE_API, label: t('admin.settings.payment.modeQRCode') },
])
const paymentModeOptions = computed(() => {
if (form.provider_key === 'stripe') {
return STRIPE_PAYMENT_MODES.map((m) => ({
value: m,
label: m === PAYMENT_MODE_POPUP ? t('admin.settings.payment.modePopup') : t('admin.settings.payment.modeRedirect'),
}))
}
return [
{ value: PAYMENT_MODE_QRCODE, label: t('admin.settings.payment.modeQRCode') },
{ value: PAYMENT_MODE_REDIRECT, label: t('admin.settings.payment.modeRedirect') },
{ value: PAYMENT_MODE_POPUP, label: t('admin.settings.payment.modePopup') },
]
})
const availableTypes = computed(() => {
const base = getAvailableTypes(form.provider_key, props.allPaymentTypes, props.redirectLabel)
@@ -438,7 +449,7 @@ function handleSave() {
name: form.name,
supported_types: form.supported_types,
enabled: form.enabled,
payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '',
payment_mode: (form.provider_key === 'easypay' || form.provider_key === 'stripe') ? form.payment_mode : '',
refund_enabled: form.refund_enabled,
config: filteredConfig,
limits: serializeLimits(),
@@ -457,7 +468,7 @@ function reset(defaultKey: string) {
form.provider_key = defaultKey
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[defaultKey] || [])]
form.enabled = true
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_API : ''
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : defaultKey === 'stripe' ? PAYMENT_MODE_POPUP : ''
form.refund_enabled = false
clearConfig()
applyDefaults()
@@ -468,7 +479,7 @@ function loadProvider(provider: ProviderInstance) {
form.provider_key = provider.provider_key
form.supported_types = provider.supported_types
form.enabled = provider.enabled
form.payment_mode = provider.payment_mode || PAYMENT_MODE_API
form.payment_mode = provider.payment_mode || (provider.provider_key === 'stripe' ? PAYMENT_MODE_POPUP : PAYMENT_MODE_QRCODE)
form.refund_enabled = provider.refund_enabled
clearConfig()
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
@@ -68,7 +68,7 @@ import Icon from '@/components/icons/Icon.vue'
import ToggleSwitch from './ToggleSwitch.vue'
import type { ProviderInstance } from '@/types/payment'
import type { TypeOption } from './providerConfig'
import { PAYMENT_MODE_REDIRECT, PAYMENT_MODE_API } from './providerConfig'
import { PAYMENT_MODE_REDIRECT, PAYMENT_MODE_QRCODE, PAYMENT_MODE_POPUP } from './providerConfig'
const PROVIDER_KEY_LABELS: Record<string, string> = {
easypay: 'admin.settings.payment.providerEasypay',
@@ -96,7 +96,8 @@ const keyLabel = computed(() => t(PROVIDER_KEY_LABELS[props.provider.provider_ke
const modeLabel = computed(() => {
if (props.provider.payment_mode === PAYMENT_MODE_REDIRECT) return t('admin.settings.payment.modeRedirect')
if (props.provider.payment_mode === PAYMENT_MODE_API) return t('admin.settings.payment.modeQRCode')
if (props.provider.payment_mode === PAYMENT_MODE_QRCODE) return t('admin.settings.payment.modeQRCode')
if (props.provider.payment_mode === PAYMENT_MODE_POPUP) return t('admin.settings.payment.modePopup')
return ''
})
@@ -34,14 +34,18 @@ export const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
}
/** Available payment modes for EasyPay providers. */
export const EASYPAY_PAYMENT_MODES = ['redirect', 'api'] as const
export const EASYPAY_PAYMENT_MODES = ['qrcode', 'redirect', 'popup'] as const
/** Available payment modes for Stripe providers. */
export const STRIPE_PAYMENT_MODES = ['popup', 'redirect'] as const
/** Fixed display order for user-facing payment methods */
export const METHOD_ORDER = ['alipay', 'wxpay', 'stripe'] as const
/** Payment mode constants */
export const PAYMENT_MODE_QRCODE = 'qrcode'
export const PAYMENT_MODE_REDIRECT = 'redirect'
export const PAYMENT_MODE_API = 'api'
export const PAYMENT_MODE_POPUP = 'popup'
/** Window features for payment popup windows */
export const POPUP_WINDOW_FEATURES = 'width=520,height=700,left=200,top=100,scrollbars=yes,resizable=yes'
+1
View File
@@ -4377,6 +4377,7 @@ export default {
paymentMode: 'Payment Mode',
modeRedirect: 'Redirect',
modeQRCode: 'QR Code',
modePopup: 'Popup',
validationNameRequired: 'Provider name is required',
validationTypesRequired: 'Please select at least one supported payment type',
validationFieldRequired: '{field} is required',
+1
View File
@@ -4550,6 +4550,7 @@ export default {
paymentMode: '支付模式',
modeRedirect: '跳转',
modeQRCode: '二维码',
modePopup: '弹窗',
validationNameRequired: '服务商名称不能为空',
validationTypesRequired: '请至少选择一种支持的支付方式',
validationFieldRequired: '{field} 不能为空',
+1
View File
@@ -147,6 +147,7 @@ export interface CreateOrderResult {
client_secret?: string
pay_amount: number
expires_at: string
payment_mode?: string
}
export interface DashboardStats {
+12 -4
View File
@@ -310,13 +310,21 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
order_type: orderType,
plan_id: planId,
})
const mode = result.payment_mode || 'popup'
const openWindow = (url: string) => {
if (mode === 'redirect') {
window.open(url, '_blank')
} else {
window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES)
}
}
if (result.client_secret) {
// Stripe: open in popup window, show waiting state inline
// Stripe: open payment page, show waiting state inline
const stripeUrl = router.resolve({
path: '/payment/stripe',
query: { order_id: String(result.order_id), client_secret: result.client_secret },
}).href
window.open(stripeUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
openWindow(stripeUrl)
paymentState.value = {
orderId: result.order_id, qrCode: '', expiresAt: '',
paymentType: selectedMethod.value, payUrl: stripeUrl,
@@ -330,8 +338,8 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
}
paymentPhase.value = 'paying'
} else if (result.pay_url) {
// Redirect mode: open in popup, show waiting state inline
window.open(result.pay_url, 'paymentPopup', POPUP_WINDOW_FEATURES)
// Redirect/popup mode: open payment URL, show waiting state inline
openWindow(result.pay_url)
paymentState.value = {
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
paymentType: selectedMethod.value, payUrl: result.pay_url,