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)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
11
frontend/src/utils/device.ts
Normal file
11
frontend/src/utils/device.ts
Normal file
@@ -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<string, unknown>
|
||||
if (nav.userAgentData && typeof (nav.userAgentData as Record<string, unknown>).mobile === 'boolean') {
|
||||
return (nav.userAgentData as Record<string, unknown>).mobile as boolean
|
||||
}
|
||||
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<string, unknown>) => 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) {
|
||||
|
||||
@@ -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<string, unknown>): 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')
|
||||
|
||||
Reference in New Issue
Block a user