diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 91d8270024..f5f5b1a98d 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -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"], diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index f2173c1a6b..5e8dc41610 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -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": diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go index da8b95cf88..d9447922c2 100644 --- a/backend/internal/service/payment_service.go +++ b/backend/internal/service/payment_service.go @@ -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) } diff --git a/backend/migrations/098_remove_easypay_from_enabled_payment_types.sql b/backend/migrations/098_remove_easypay_from_enabled_payment_types.sql new file mode 100644 index 0000000000..8128ed09ff --- /dev/null +++ b/backend/migrations/098_remove_easypay_from_enabled_payment_types.sql @@ -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%'; diff --git a/frontend/src/components/payment/PaymentMethodSelector.vue b/frontend/src/components/payment/PaymentMethodSelector.vue index e232256d46..c315c5a068 100644 --- a/frontend/src/components/payment/PaymentMethodSelector.vue +++ b/frontend/src/components/payment/PaymentMethodSelector.vue @@ -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' diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 92e45a1524..bb647aee72 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 01c36e9376..bc45fe94e0 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -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', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index e8078a6f29..3653d4c51e 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1795,8 +1795,9 @@