feat(payment): add EasyPay dual-mode support (redirect + API)

- Backend: EasyPay provider supports paymentMode config (redirect/api)
  - redirect: builds submit.php signed URL, browser redirects to hosted page
  - api: calls mapi.php, returns payurl/qrcode (existing behavior)
- Add TypeEasyPay payment type constant
- Fix notify/return URL fallback to instance config
- Handle empty TradeNo for redirect mode (SetNillable)
- Frontend: add paymentMode dropdown in EasyPay provider config
- Auto-update supported_types when paymentMode changes
- Add easypay icon/color in PaymentMethodSelector
- Add i18n for easypay, paymentMode labels
- Rename "启用的支付方式" to "服务商"
- Migration 098: clean easypay from ENABLED_PAYMENT_TYPES
This commit is contained in:
erio
2026-04-06 20:57:45 +08:00
parent 76193ee3c9
commit bfcc487a6b
8 changed files with 142 additions and 13 deletions
+63 -3
View File
@@ -23,6 +23,11 @@ const (
easypayCodeSuccess = 1
easypayStatusPaid = 1
easypayHTTPTimeout = 10 * time.Second
// EasyPayModeRedirect uses submit.php — browser redirects to EasyPay hosted page.
EasyPayModeRedirect = "redirect"
// EasyPayModeAPI uses mapi.php — server calls API, returns payurl/qrcode.
EasyPayModeAPI = "api"
)
// EasyPay implements payment.Provider for the EasyPay aggregation platform.
@@ -47,17 +52,58 @@ 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 {
if e.config["paymentMode"] == EasyPayModeRedirect {
return []payment.PaymentType{payment.TypeEasyPay}
}
return []payment.PaymentType{payment.TypeAlipay, payment.TypeWxpay}
}
func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
if e.config["paymentMode"] == EasyPayModeRedirect {
return e.createRedirectPayment(req)
}
return e.createAPIPayment(ctx, req)
}
// createRedirectPayment builds a submit.php URL for browser redirect.
// No server-side API call — the user is redirected to EasyPay's hosted page.
// TradeNo is empty; it arrives via the notify callback after payment.
func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
notifyURL, returnURL := e.resolveURLs(req)
payType := req.PaymentType
if payType == "easypay" {
payType = "alipay" // default for hosted page; user can switch on EasyPay's UI
}
params := map[string]string{
"pid": e.config["pid"], "type": payType,
"out_trade_no": req.OrderID, "notify_url": notifyURL,
"return_url": returnURL, "name": req.Subject,
"money": req.Amount,
}
if cid := e.resolveCID(payType); cid != "" {
params["cid"] = cid
}
params["sign"] = easyPaySign(params, e.config["pkey"])
params["sign_type"] = "MD5"
q := url.Values{}
for k, v := range params {
q.Set(k, v)
}
payURL := e.config["apiBase"] + "/submit.php?" + q.Encode()
return &payment.CreatePaymentResponse{PayURL: payURL}, nil
}
// createAPIPayment calls mapi.php to get payurl/qrcode (existing behavior).
func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
notifyURL, returnURL := e.resolveURLs(req)
params := map[string]string{
"pid": e.config["pid"], "type": req.PaymentType,
"out_trade_no": req.OrderID, "notify_url": req.NotifyURL,
"return_url": req.ReturnURL, "name": req.Subject,
"out_trade_no": req.OrderID, "notify_url": notifyURL,
"return_url": returnURL, "name": req.Subject,
"money": req.Amount, "clientip": req.ClientIP,
}
if cid := e.resolveCID(req.PaymentType); cid != "" {
@@ -89,6 +135,20 @@ func (e *EasyPay) CreatePayment(ctx context.Context, req payment.CreatePaymentRe
return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: resp.PayURL, QRCode: resp.QRCode}, nil
}
// resolveURLs returns (notifyURL, returnURL) preferring request values,
// falling back to instance config.
func (e *EasyPay) resolveURLs(req payment.CreatePaymentRequest) (string, string) {
notifyURL := req.NotifyURL
if notifyURL == "" {
notifyURL = e.config["notifyUrl"]
}
returnURL := req.ReturnURL
if returnURL == "" {
returnURL = e.config["returnUrl"]
}
return notifyURL, returnURL
}
func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
params := map[string]string{
"act": "order", "pid": e.config["pid"],
+3
View File
@@ -14,6 +14,7 @@ const (
TypeAlipayDirect PaymentType = "alipay_direct"
TypeWxpayDirect PaymentType = "wxpay_direct"
TypeStripe PaymentType = "stripe"
TypeEasyPay PaymentType = "easypay"
)
// Order status constants shared across payment and service layers.
@@ -36,6 +37,8 @@ const (
// For example, "alipay_direct" -> "alipay".
func GetBasePaymentType(t string) string {
switch {
case t == "easypay":
return "easypay"
case len(t) >= 6 && t[:6] == "alipay":
return "alipay"
case len(t) >= 5 && t[:5] == "wxpay":
+1 -1
View File
@@ -339,7 +339,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
if err != nil {
return nil, infraerrors.ServiceUnavailable("PAYMENT_GATEWAY_ERROR", "payment method is temporarily unavailable")
}
_, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).SetPaymentTradeNo(pr.TradeNo).SetNillablePayURL(psNilIfEmpty(pr.PayURL)).SetNillableQrCode(psNilIfEmpty(pr.QRCode)).SetNillableProviderInstanceID(psNilIfEmpty(sel.InstanceID)).Save(ctx)
_, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).SetNillablePayURL(psNilIfEmpty(pr.PayURL)).SetNillableQrCode(psNilIfEmpty(pr.QRCode)).SetNillableProviderInstanceID(psNilIfEmpty(sel.InstanceID)).Save(ctx)
if err != nil {
return nil, fmt.Errorf("update order with payment details: %w", err)
}
@@ -0,0 +1,17 @@
-- 098_remove_easypay_from_enabled_payment_types.sql
--
-- Removes "easypay" from ENABLED_PAYMENT_TYPES setting.
-- "easypay" is a provider key, not a payment type. Valid payment types
-- are: alipay, wxpay, alipay_direct, wxpay_direct, stripe.
--
-- Idempotent: safe to run multiple times.
UPDATE settings
SET value = array_to_string(
array_remove(
string_to_array(value, ','),
'easypay'
), ','
)
WHERE key = 'ENABLED_PAYMENT_TYPES'
AND value LIKE '%easypay%';
@@ -67,6 +67,7 @@ function isStripe(type: string) {
}
function iconBgClass(type: string): string {
if (type === 'easypay') return 'bg-[#FF6B35]'
if (type.includes('alipay')) return 'bg-[#00AEEF]'
if (type.includes('wxpay')) return 'bg-[#07C160]'
if (type === 'stripe') return 'bg-[#635bff]'
@@ -74,12 +75,14 @@ function iconBgClass(type: string): string {
}
function iconLabel(type: string): string {
if (type === 'easypay') return '易'
if (type.includes('alipay')) return '\u652f'
if (type.includes('wxpay')) return '\u5fae'
return type[0]?.toUpperCase() || '?'
}
function methodSelectedClass(type: string): string {
if (type === 'easypay') return 'border-[#FF6B35] bg-orange-50 text-gray-900 shadow-sm dark:bg-orange-950 dark:text-gray-100'
if (type.includes('alipay')) return 'border-[#00AEEF] bg-blue-50 text-gray-900 shadow-sm dark:bg-blue-950 dark:text-gray-100'
if (type.includes('wxpay')) return 'border-[#07C160] bg-green-50 text-gray-900 shadow-sm dark:bg-green-950 dark:text-gray-100'
if (type === 'stripe') return 'border-[#635bff] bg-indigo-50 text-gray-900 shadow-sm dark:bg-indigo-950 dark:text-gray-100'
+4
View File
@@ -4356,6 +4356,9 @@ export default {
providerStripe: 'Stripe',
typeDisabled: 'type disabled',
enableTypesFirst: 'Enable at least one payment type above first',
field_paymentMode: 'Payment Mode',
field_paymentMode_redirect: 'Redirect (Hosted Page)',
field_paymentMode_api: 'API Mode (QR Code)',
field_apiBase: 'API Base URL',
field_notifyUrl: 'Notify URL',
field_returnUrl: 'Return URL',
@@ -5041,6 +5044,7 @@ export default {
actualPay: 'Actual Payment',
createOrder: 'Confirm Payment',
methods: {
easypay: 'EasyPay',
alipay: 'Alipay',
wxpay: 'WeChat Pay',
stripe: 'Stripe',
+7 -3
View File
@@ -4507,8 +4507,8 @@ export default {
description: '配置支付系统选项',
enabled: '启用支付',
enabledHint: '启用或禁用支付系统',
enabledPaymentTypes: '启用的支付方式',
enabledPaymentTypesHint: '选择要显示的支付方式',
enabledPaymentTypes: '服务商',
enabledPaymentTypesHint: '选择要启用的服务商',
minAmount: '最低金额',
maxAmount: '最高金额',
dailyLimit: '每日限额',
@@ -4528,7 +4528,10 @@ export default {
providerWxpay: '微信官方',
providerStripe: 'Stripe',
typeDisabled: '类型已禁用',
enableTypesFirst: '请先在上方启用至少一种支付方式',
enableTypesFirst: '请先在上方启用至少一种服务商',
field_paymentMode: '支付模式',
field_paymentMode_redirect: '跳转支付(托管页面)',
field_paymentMode_api: 'API 模式(二维码)',
field_apiBase: 'API 基础地址',
field_notifyUrl: '异步通知地址',
field_returnUrl: '同步跳转地址',
@@ -5238,6 +5241,7 @@ export default {
actualPay: '实付金额',
createOrder: '确认支付',
methods: {
easypay: '易支付',
alipay: '支付宝',
wxpay: '微信支付',
stripe: 'Stripe',
+44 -6
View File
@@ -1795,8 +1795,9 @@
<div class="space-y-3">
<div v-for="field in currentProviderFields" :key="field.key">
<label class="input-label">{{ field.label }} <span v-if="field.optional" class="text-xs text-gray-400">({{ t('common.optional') }})</span><span v-else class="text-red-500"> *</span></label>
<textarea v-if="field.sensitive && field.key.toLowerCase().includes('key') && field.key !== 'pkey'" v-model="providerConfig[field.key]" rows="3" class="input font-mono text-xs" />
<input v-else :type="field.sensitive ? 'password' : 'text'" v-model="providerConfig[field.key]" class="input" />
<Select v-if="field.type === 'select'" v-model="providerConfig[field.key]" :options="field.options || []" @change="onProviderConfigChange(field.key)" />
<textarea v-else-if="field.sensitive && field.key.toLowerCase().includes('key') && field.key !== 'pkey'" v-model="providerConfig[field.key]" rows="3" class="input font-mono text-xs" :required="!field.optional && !editingProvider" />
<input v-else :type="field.sensitive ? 'password' : 'text'" v-model="providerConfig[field.key]" class="input" :required="!field.optional && !editingProvider" />
</div>
</div>
<div v-if="!currentProviderFields.length" class="text-sm text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.selectProviderKey') }}</div>
@@ -2897,14 +2898,14 @@ async function saveBetaPolicySettings() {
// ==================== Provider Management ====================
const PROVIDER_SUPPORTED_TYPES: Record<string, string[]> = {
easypay: ['alipay', 'wxpay'],
easypay: ['easypay', 'alipay', 'wxpay'],
alipay: ['alipay'],
wxpay: ['wxpay'],
stripe: ['stripe'],
}
const allPaymentTypes = computed(() => [
{ value: 'easypay', label: t('admin.settings.payment.providerEasypay') },
{ value: 'easypay', label: t('payment.methods.easypay') },
{ value: 'alipay', label: t('payment.methods.alipay') },
{ value: 'wxpay', label: t('payment.methods.wxpay') },
{ value: 'stripe', label: t('payment.methods.stripe') },
@@ -2945,10 +2946,16 @@ interface ConfigFieldDef {
label: string
sensitive: boolean
optional?: boolean
type?: 'text' | 'select'
options?: { value: string; label: string }[]
}
const PROVIDER_CONFIG_FIELDS: Record<string, ConfigFieldDef[]> = {
easypay: [
{ key: 'paymentMode', label: '', sensitive: false, type: 'select', options: [
{ value: 'redirect', label: '' },
{ value: 'api', label: '' },
]},
{ key: 'pid', label: 'PID', sensitive: false },
{ key: 'pkey', label: 'PKey', sensitive: true },
{ key: 'apiBase', label: '', sensitive: false },
@@ -2984,6 +2991,10 @@ const currentProviderFields = computed(() => {
return fields.map(f => ({
...f,
label: f.label || t(`admin.settings.payment.field_${f.key}`),
options: f.options?.map(o => ({
...o,
label: o.label || t(`admin.settings.payment.field_${f.key}_${o.value}`),
})),
}))
})
const providerKeyOptions = computed(() => [
@@ -2997,8 +3008,11 @@ const providerKeyOptions = computed(() => [
const enabledProviderKeyOptions = computed(() => {
const enabled = form.payment_enabled_types.split(',').map(s => s.trim()).filter(Boolean)
return providerKeyOptions.value.filter(opt => {
// easypay is available if easypay OR alipay OR wxpay is enabled
// easypay provider is available if easypay, alipay, or wxpay payment type is enabled
if (opt.value === 'easypay') return enabled.includes('easypay') || enabled.includes('alipay') || enabled.includes('wxpay')
// alipay/wxpay direct providers match their payment type
if (opt.value === 'alipay') return enabled.includes('alipay')
if (opt.value === 'wxpay') return enabled.includes('wxpay')
return enabled.includes(opt.value)
})
})
@@ -3007,6 +3021,8 @@ const enabledProviderKeyOptions = computed(() => {
function isProviderEnabled(providerKey: string): boolean {
const enabled = form.payment_enabled_types.split(',').map(s => s.trim()).filter(Boolean)
if (providerKey === 'easypay') return enabled.includes('easypay') || enabled.includes('alipay') || enabled.includes('wxpay')
if (providerKey === 'alipay') return enabled.includes('alipay')
if (providerKey === 'wxpay') return enabled.includes('wxpay')
return enabled.includes(providerKey)
}
const loadBalanceOptions = computed(() => [
@@ -3038,6 +3054,22 @@ function toggleProviderSupportedType(type: string) {
function onProviderKeyChange() {
// Auto-select all supported types for the new provider key
providerForm.supported_types = (PROVIDER_SUPPORTED_TYPES[providerForm.provider_key] || []).join(',')
// For easypay, set default payment mode and update supported types accordingly
if (providerForm.provider_key === 'easypay' && !providerConfig['paymentMode']) {
providerConfig['paymentMode'] = 'redirect'
providerForm.supported_types = 'easypay'
}
}
function onProviderConfigChange(key: string) {
// When easypay paymentMode changes, auto-update supported types
if (key === 'paymentMode' && providerForm.provider_key === 'easypay') {
if (providerConfig['paymentMode'] === 'redirect') {
providerForm.supported_types = 'easypay'
} else {
providerForm.supported_types = 'alipay,wxpay'
}
}
}
function providerKeyLabel(key: string): string {
@@ -3052,10 +3084,16 @@ async function loadProviders() {
function resetProviderForm() {
const defaultKey = enabledProviderKeyOptions.value[0]?.value || 'easypay'
providerForm.name = ''; providerForm.provider_key = defaultKey
providerForm.supported_types = (PROVIDER_SUPPORTED_TYPES[defaultKey] || []).join(',')
providerForm.enabled = true; providerForm.refund_enabled = false
// Clear all config keys
Object.keys(providerConfig).forEach(k => delete providerConfig[k])
// Set defaults based on provider key
if (defaultKey === 'easypay') {
providerConfig['paymentMode'] = 'redirect'
providerForm.supported_types = 'easypay'
} else {
providerForm.supported_types = (PROVIDER_SUPPORTED_TYPES[defaultKey] || []).join(',')
}
}
function openCreateProvider() { editingProvider.value = null; resetProviderForm(); showProviderDialog.value = true }
function openEditProvider(provider: ProviderInstance) {