From 75155903248324ec454ba66ee359bf654628dc08 Mon Sep 17 00:00:00 2001 From: erio Date: Sat, 11 Apr 2026 00:44:54 +0800 Subject: [PATCH] feat(payment): add H5/mobile payment support Backend: - Parse EasyPay `payurl2` field, prefer H5 link on mobile - Add `device=mobile` to EasyPay submit.php (popup) mode - Expand isMobile detection keywords (add ipad/ipod) Frontend: - Add `isMobileDevice()` utility (userAgentData + UA regex) - Mobile + pay_url: direct redirect instead of QR/popup - Popup blocked fallback: auto-redirect when window.open fails - Stripe WeChat Pay: dynamic client param (mobile_web vs web) --- backend/internal/handler/payment_handler.go | 7 ++++++- backend/internal/payment/provider/easypay.go | 10 +++++++++- frontend/src/utils/device.ts | 11 +++++++++++ frontend/src/views/user/PaymentView.vue | 17 ++++++++++++++++- frontend/src/views/user/StripePaymentView.vue | 3 ++- frontend/src/views/user/StripePopupView.vue | 3 ++- 6 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 frontend/src/utils/device.ts diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index e0563a42..0425fc49 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -412,5 +412,10 @@ func requireAuth(c *gin.Context) (middleware2.AuthSubject, bool) { // isMobile detects mobile user agents. func isMobile(c *gin.Context) bool { ua := strings.ToLower(c.GetHeader("User-Agent")) - return strings.Contains(ua, "mobile") || strings.Contains(ua, "android") || strings.Contains(ua, "iphone") + for _, kw := range []string{"mobile", "android", "iphone", "ipad", "ipod"} { + if strings.Contains(ua, kw) { + return true + } + } + return false } diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 814e7c4f..e33a567d 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -83,6 +83,9 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym if cid := e.resolveCID(req.PaymentType); cid != "" { params["cid"] = cid } + if req.IsMobile { + params["device"] = deviceMobile + } params["sign"] = easyPaySign(params, e.config["pkey"]) params["sign_type"] = signTypeMD5 @@ -122,6 +125,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen Msg string `json:"msg"` TradeNo string `json:"trade_no"` PayURL string `json:"payurl"` + PayURL2 string `json:"payurl2"` // H5 mobile payment URL QRCode string `json:"qrcode"` } if err := json.Unmarshal(body, &resp); err != nil { @@ -130,7 +134,11 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen if resp.Code != easypayCodeSuccess { return nil, fmt.Errorf("easypay error: %s", resp.Msg) } - return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: resp.PayURL, QRCode: resp.QRCode}, nil + payURL := resp.PayURL + if req.IsMobile && resp.PayURL2 != "" { + payURL = resp.PayURL2 + } + return &payment.CreatePaymentResponse{TradeNo: resp.TradeNo, PayURL: payURL, QRCode: resp.QRCode}, nil } // resolveURLs returns (notifyURL, returnURL) preferring request values, diff --git a/frontend/src/utils/device.ts b/frontend/src/utils/device.ts new file mode 100644 index 00000000..098bd90a --- /dev/null +++ b/frontend/src/utils/device.ts @@ -0,0 +1,11 @@ +/** + * Detect whether the current device is mobile. + * Uses navigator.userAgentData (modern API) with UA regex fallback. + */ +export function isMobileDevice(): boolean { + const nav = navigator as unknown as Record + if (nav.userAgentData && typeof (nav.userAgentData as Record).mobile === 'boolean') { + return (nav.userAgentData as Record).mobile as boolean + } + return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) +} diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 055e42f2..411798db 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -263,6 +263,7 @@ import { useSubscriptionStore } from '@/stores/subscriptions' import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' import { extractApiErrorMessage } from '@/utils/apiError' +import { isMobileDevice } from '@/utils/device' import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment' import AppLayout from '@/components/layout/AppLayout.vue' import AmountInput from '@/components/payment/AmountInput.vue' @@ -528,7 +529,10 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb plan_id: planId, }) const openWindow = (url: string) => { - window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES) + const win = window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES) + if (!win || win.closed) { + window.location.href = url + } } if (result.client_secret) { // Stripe: show Payment Element inline (user picks method → confirms → redirect if needed) @@ -539,6 +543,17 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb orderType, } paymentPhase.value = 'stripe' + } else if (isMobileDevice() && result.pay_url) { + // Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups) + paymentState.value = { + orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '', + paymentType: selectedMethod.value, payUrl: result.pay_url, + clientSecret: '', payAmount: 0, + orderType, + } + paymentPhase.value = 'paying' + window.location.href = result.pay_url + return } else if (result.qr_code) { // QR mode: show QR code inline paymentState.value = { diff --git a/frontend/src/views/user/StripePaymentView.vue b/frontend/src/views/user/StripePaymentView.vue index 6fc810c6..20a4a408 100644 --- a/frontend/src/views/user/StripePaymentView.vue +++ b/frontend/src/views/user/StripePaymentView.vue @@ -100,6 +100,7 @@ import { useRoute, useRouter } from 'vue-router' import { usePaymentStore } from '@/stores/payment' import { paymentAPI } from '@/api/payment' import { extractApiErrorMessage } from '@/utils/apiError' +import { isMobileDevice } from '@/utils/device' import type { PaymentOrder } from '@/types/payment' import type { Stripe, StripeElements } from '@stripe/stripe-js' import AppLayout from '@/components/layout/AppLayout.vue' @@ -191,7 +192,7 @@ async function confirmWechatPay(stripe: Stripe, clientSecret: string) { const { paymentIntent, error } = await (stripe as Stripe & { confirmWechatPayPayment: (cs: string, opts: Record) => Promise<{ paymentIntent?: { status: string; next_action?: { wechat_pay_display_qr_code?: { image_data_url?: string } } }; error?: { message?: string } }> }).confirmWechatPayPayment(clientSecret, { - payment_method_options: { wechat_pay: { client: 'web' } }, + payment_method_options: { wechat_pay: { client: isMobileDevice() ? 'mobile_web' : 'web' } }, }) if (error) { diff --git a/frontend/src/views/user/StripePopupView.vue b/frontend/src/views/user/StripePopupView.vue index 368e6c52..2704c62d 100644 --- a/frontend/src/views/user/StripePopupView.vue +++ b/frontend/src/views/user/StripePopupView.vue @@ -57,6 +57,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute } from 'vue-router' import { extractApiErrorMessage } from '@/utils/apiError' +import { isMobileDevice } from '@/utils/device' interface StripeWithWechatPay { confirmWechatPayPayment(clientSecret: string, options: Record): Promise<{ error?: { message?: string }; paymentIntent?: { status: string } }> @@ -129,7 +130,7 @@ async function initStripe(clientSecret: string, publishableKey: string) { // WeChat: Stripe shows its built-in QR dialog, user scans, promise resolves hint.value = t('payment.stripePopup.loadingQr') const result = await (stripe as unknown as StripeWithWechatPay).confirmWechatPayPayment(clientSecret, { - payment_method_options: { wechat_pay: { client: 'web' } }, + payment_method_options: { wechat_pay: { client: isMobileDevice() ? 'mobile_web' : 'web' } }, }) if (result.error) { error.value = result.error.message || t('payment.result.failed')