Commit 75155903 authored by erio's avatar erio
Browse files

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)
parent e3a000e0
......@@ -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,
......
/**
* 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')
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment