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.
|
// isMobile detects mobile user agents.
|
||||||
func isMobile(c *gin.Context) bool {
|
func isMobile(c *gin.Context) bool {
|
||||||
ua := strings.ToLower(c.GetHeader("User-Agent"))
|
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 != "" {
|
if cid := e.resolveCID(req.PaymentType); cid != "" {
|
||||||
params["cid"] = cid
|
params["cid"] = cid
|
||||||
}
|
}
|
||||||
|
if req.IsMobile {
|
||||||
|
params["device"] = deviceMobile
|
||||||
|
}
|
||||||
params["sign"] = easyPaySign(params, e.config["pkey"])
|
params["sign"] = easyPaySign(params, e.config["pkey"])
|
||||||
params["sign_type"] = signTypeMD5
|
params["sign_type"] = signTypeMD5
|
||||||
|
|
||||||
@@ -122,6 +125,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen
|
|||||||
Msg string `json:"msg"`
|
Msg string `json:"msg"`
|
||||||
TradeNo string `json:"trade_no"`
|
TradeNo string `json:"trade_no"`
|
||||||
PayURL string `json:"payurl"`
|
PayURL string `json:"payurl"`
|
||||||
|
PayURL2 string `json:"payurl2"` // H5 mobile payment URL
|
||||||
QRCode string `json:"qrcode"`
|
QRCode string `json:"qrcode"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &resp); err != nil {
|
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 {
|
if resp.Code != easypayCodeSuccess {
|
||||||
return nil, fmt.Errorf("easypay error: %s", resp.Msg)
|
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,
|
// 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 { useAppStore } from '@/stores'
|
||||||
import { paymentAPI } from '@/api/payment'
|
import { paymentAPI } from '@/api/payment'
|
||||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
|
import { isMobileDevice } from '@/utils/device'
|
||||||
import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment'
|
import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import AmountInput from '@/components/payment/AmountInput.vue'
|
import AmountInput from '@/components/payment/AmountInput.vue'
|
||||||
@@ -528,7 +529,10 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
|||||||
plan_id: planId,
|
plan_id: planId,
|
||||||
})
|
})
|
||||||
const openWindow = (url: string) => {
|
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) {
|
if (result.client_secret) {
|
||||||
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
|
// 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,
|
orderType,
|
||||||
}
|
}
|
||||||
paymentPhase.value = 'stripe'
|
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) {
|
} else if (result.qr_code) {
|
||||||
// QR mode: show QR code inline
|
// QR mode: show QR code inline
|
||||||
paymentState.value = {
|
paymentState.value = {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { usePaymentStore } from '@/stores/payment'
|
import { usePaymentStore } from '@/stores/payment'
|
||||||
import { paymentAPI } from '@/api/payment'
|
import { paymentAPI } from '@/api/payment'
|
||||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
|
import { isMobileDevice } from '@/utils/device'
|
||||||
import type { PaymentOrder } from '@/types/payment'
|
import type { PaymentOrder } from '@/types/payment'
|
||||||
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
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 & {
|
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: (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, {
|
}).confirmWechatPayPayment(clientSecret, {
|
||||||
payment_method_options: { wechat_pay: { client: 'web' } },
|
payment_method_options: { wechat_pay: { client: isMobileDevice() ? 'mobile_web' : 'web' } },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
|
import { isMobileDevice } from '@/utils/device'
|
||||||
|
|
||||||
interface StripeWithWechatPay {
|
interface StripeWithWechatPay {
|
||||||
confirmWechatPayPayment(clientSecret: string, options: Record<string, unknown>): Promise<{ error?: { message?: string }; paymentIntent?: { status: string } }>
|
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
|
// WeChat: Stripe shows its built-in QR dialog, user scans, promise resolves
|
||||||
hint.value = t('payment.stripePopup.loadingQr')
|
hint.value = t('payment.stripePopup.loadingQr')
|
||||||
const result = await (stripe as unknown as StripeWithWechatPay).confirmWechatPayPayment(clientSecret, {
|
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) {
|
if (result.error) {
|
||||||
error.value = result.error.message || t('payment.result.failed')
|
error.value = result.error.message || t('payment.result.failed')
|
||||||
|
|||||||
Reference in New Issue
Block a user