fix(payment): alipay redirect-only flow, H5 detection and popup sizing
The native Alipay provider previously tried to embed the payment page URL into a QR code on the client — the URL is not a scannable payload so the QR never worked. Merchants also hit a H5 detection mismatch whenever the backend UA sniffer missed iPadOS 13+ or embedded browsers, and the popup window was too small for Alipay's standard checkout layout (QR + account-login panel on the right), forcing the user to scroll horizontally and vertically. Changes: Backend - alipay.go: drop QR-on-URL path. Use redirect-only flow — alipay.trade.page.pay for PC (returns a gateway URL the browser opens in a new window) and alipay.trade.wap.pay for H5 (returns a URL the browser jumps to). Both flows produce pages on openapi.alipaydev.com / excashier.alipay.com; the client never renders a QR itself. - payment_handler.go: add optional is_mobile bool to CreateOrderRequest so the frontend can declare the device explicitly. Server still falls back to UA sniffing when absent. Frontend - types/payment.ts, PaymentView.vue: declare is_mobile in CreateOrderRequest and pass the computed isMobileDevice() value. - providerConfig.ts: replace the two fixed POPUP_WINDOW_FEATURES constants with getPaymentPopupFeatures(), which prefers 1250×900 (Alipay's checkout footprint), clamps to window.screen.avail* and centers the popup so it never overflows on smaller laptops. - PaymentQRDialog.vue, PaymentStatusPanel.vue, StripePaymentInline.vue, PaymentView.vue: use the new helper at all popup call sites.
This commit is contained in:
@@ -206,6 +206,10 @@ type CreateOrderRequest struct {
|
|||||||
PaymentType string `json:"payment_type" binding:"required"`
|
PaymentType string `json:"payment_type" binding:"required"`
|
||||||
OrderType string `json:"order_type"`
|
OrderType string `json:"order_type"`
|
||||||
PlanID int64 `json:"plan_id"`
|
PlanID int64 `json:"plan_id"`
|
||||||
|
// IsMobile lets the frontend declare its mobile status directly. When
|
||||||
|
// nil we fall back to User-Agent heuristics (which miss iPadOS / some
|
||||||
|
// embedded browsers that strip the "Mobile" keyword).
|
||||||
|
IsMobile *bool `json:"is_mobile,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOrder creates a new payment order.
|
// CreateOrder creates a new payment order.
|
||||||
@@ -222,12 +226,16 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mobile := isMobile(c)
|
||||||
|
if req.IsMobile != nil {
|
||||||
|
mobile = *req.IsMobile
|
||||||
|
}
|
||||||
result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
|
result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
|
||||||
UserID: subject.UserID,
|
UserID: subject.UserID,
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
PaymentType: req.PaymentType,
|
PaymentType: req.PaymentType,
|
||||||
ClientIP: c.ClientIP(),
|
ClientIP: c.ClientIP(),
|
||||||
IsMobile: isMobile(c),
|
IsMobile: mobile,
|
||||||
SrcHost: c.Request.Host,
|
SrcHost: c.Request.Host,
|
||||||
SrcURL: c.Request.Referer(),
|
SrcURL: c.Request.Referer(),
|
||||||
OrderType: req.OrderType,
|
OrderType: req.OrderType,
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import (
|
|||||||
|
|
||||||
// Alipay product codes.
|
// Alipay product codes.
|
||||||
const (
|
const (
|
||||||
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
|
|
||||||
alipayProductCodeWapPay = "QUICK_WAP_WAY"
|
alipayProductCodeWapPay = "QUICK_WAP_WAY"
|
||||||
|
alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Alipay response constants.
|
// Alipay response constants.
|
||||||
@@ -79,7 +79,12 @@ func (a *Alipay) SupportedTypes() []payment.PaymentType {
|
|||||||
return []payment.PaymentType{payment.TypeAlipay}
|
return []payment.PaymentType{payment.TypeAlipay}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePayment creates an Alipay payment page URL.
|
// CreatePayment creates an Alipay payment using redirect-only flow:
|
||||||
|
// - Mobile (H5): alipay.trade.wap.pay — returns a URL the browser jumps to.
|
||||||
|
// - PC: alipay.trade.page.pay — returns a gateway URL the browser opens in a
|
||||||
|
// new window; Alipay's own page then shows login/QR. We intentionally do
|
||||||
|
// NOT encode the URL into a QR on the client (it isn't a scannable payload
|
||||||
|
// and would produce an invalid scan result).
|
||||||
func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
|
||||||
client, err := a.getClient()
|
client, err := a.getClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -96,31 +101,31 @@ func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.IsMobile {
|
if req.IsMobile {
|
||||||
return a.createTrade(client, req, notifyURL, returnURL, true)
|
return a.createWapTrade(client, req, notifyURL, returnURL)
|
||||||
}
|
}
|
||||||
return a.createTrade(client, req, notifyURL, returnURL, false)
|
return a.createPagePayTrade(client, req, notifyURL, returnURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Alipay) createTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string, isMobile bool) (*payment.CreatePaymentResponse, error) {
|
func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||||
if isMobile {
|
param := alipay.TradeWapPay{}
|
||||||
param := alipay.TradeWapPay{}
|
param.OutTradeNo = req.OrderID
|
||||||
param.OutTradeNo = req.OrderID
|
param.TotalAmount = req.Amount
|
||||||
param.TotalAmount = req.Amount
|
param.Subject = req.Subject
|
||||||
param.Subject = req.Subject
|
param.ProductCode = alipayProductCodeWapPay
|
||||||
param.ProductCode = alipayProductCodeWapPay
|
param.NotifyURL = notifyURL
|
||||||
param.NotifyURL = notifyURL
|
param.ReturnURL = returnURL
|
||||||
param.ReturnURL = returnURL
|
|
||||||
|
|
||||||
payURL, err := client.TradeWapPay(param)
|
payURL, err := client.TradeWapPay(param)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
|
return nil, fmt.Errorf("alipay TradeWapPay: %w", err)
|
||||||
}
|
|
||||||
return &payment.CreatePaymentResponse{
|
|
||||||
TradeNo: req.OrderID,
|
|
||||||
PayURL: payURL.String(),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
return &payment.CreatePaymentResponse{
|
||||||
|
TradeNo: req.OrderID,
|
||||||
|
PayURL: payURL.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Alipay) createPagePayTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) {
|
||||||
param := alipay.TradePagePay{}
|
param := alipay.TradePagePay{}
|
||||||
param.OutTradeNo = req.OrderID
|
param.OutTradeNo = req.OrderID
|
||||||
param.TotalAmount = req.Amount
|
param.TotalAmount = req.Amount
|
||||||
@@ -136,7 +141,6 @@ func (a *Alipay) createTrade(client *alipay.Client, req payment.CreatePaymentReq
|
|||||||
return &payment.CreatePaymentResponse{
|
return &payment.CreatePaymentResponse{
|
||||||
TradeNo: req.OrderID,
|
TradeNo: req.OrderID,
|
||||||
PayURL: payURL.String(),
|
PayURL: payURL.String(),
|
||||||
QRCode: payURL.String(),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ import { usePaymentStore } from '@/stores/payment'
|
|||||||
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 { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
|
||||||
import type { PaymentOrder } from '@/types/payment'
|
import type { PaymentOrder } from '@/types/payment'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import alipayIcon from '@/assets/icons/alipay.svg'
|
import alipayIcon from '@/assets/icons/alipay.svg'
|
||||||
@@ -147,7 +147,7 @@ function getLogoForType(): string | null {
|
|||||||
|
|
||||||
function reopenPopup() {
|
function reopenPopup() {
|
||||||
if (props.payUrl) {
|
if (props.payUrl) {
|
||||||
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
window.open(props.payUrl, 'paymentPopup', getPaymentPopupFeatures())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ import { usePaymentStore } from '@/stores/payment'
|
|||||||
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 { POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
|
||||||
import type { PaymentOrder } from '@/types/payment'
|
import type { PaymentOrder } from '@/types/payment'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
@@ -194,7 +194,7 @@ const countdownDisplay = computed(() => {
|
|||||||
|
|
||||||
function reopenPopup() {
|
function reopenPopup() {
|
||||||
if (props.payUrl) {
|
if (props.payUrl) {
|
||||||
window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
window.open(props.payUrl, 'paymentPopup', getPaymentPopupFeatures())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||||
import { paymentAPI } from '@/api/payment'
|
import { paymentAPI } from '@/api/payment'
|
||||||
import { useAppStore } from '@/stores'
|
import { useAppStore } from '@/stores'
|
||||||
import { STRIPE_POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
|
||||||
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
import type { Stripe, StripeElements } from '@stripe/stripe-js'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ async function handlePay() {
|
|||||||
amount: String(props.payAmount),
|
amount: String(props.payAmount),
|
||||||
},
|
},
|
||||||
}).href
|
}).href
|
||||||
const popup = window.open(popupUrl, 'paymentPopup', STRIPE_POPUP_WINDOW_FEATURES)
|
const popup = window.open(popupUrl, 'paymentPopup', getPaymentPopupFeatures())
|
||||||
|
|
||||||
const onReady = (event: MessageEvent) => {
|
const onReady = (event: MessageEvent) => {
|
||||||
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return
|
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return
|
||||||
|
|||||||
@@ -43,11 +43,24 @@ export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct',
|
|||||||
export const PAYMENT_MODE_QRCODE = 'qrcode'
|
export const PAYMENT_MODE_QRCODE = 'qrcode'
|
||||||
export const PAYMENT_MODE_POPUP = 'popup'
|
export const PAYMENT_MODE_POPUP = 'popup'
|
||||||
|
|
||||||
/** Window features for payment popup windows */
|
/** Preferred popup size for payment gateways. Alipay's standard checkout
|
||||||
export const POPUP_WINDOW_FEATURES = 'width=1000,height=750,left=100,top=80,scrollbars=yes,resizable=yes'
|
* (QR + account login panel) needs ~1200×900 to render without any scrolling. */
|
||||||
|
const PAYMENT_POPUP_PREFERRED_WIDTH = 1250
|
||||||
|
const PAYMENT_POPUP_PREFERRED_HEIGHT = 900
|
||||||
|
|
||||||
/** Wider popup for Stripe redirect methods (Alipay checkout page needs ~1200px) */
|
/** Build a window.open features string sized to fit within the current screen
|
||||||
export const STRIPE_POPUP_WINDOW_FEATURES = 'width=1250,height=780,left=80,top=60,scrollbars=yes,resizable=yes'
|
* while preferring the above dimensions. Centers the popup on the available
|
||||||
|
* work area so nothing is clipped on smaller laptop displays. */
|
||||||
|
export function getPaymentPopupFeatures(): string {
|
||||||
|
const screen = typeof window !== 'undefined' ? window.screen : null
|
||||||
|
const availW = screen?.availWidth ?? PAYMENT_POPUP_PREFERRED_WIDTH
|
||||||
|
const availH = screen?.availHeight ?? PAYMENT_POPUP_PREFERRED_HEIGHT
|
||||||
|
const width = Math.min(PAYMENT_POPUP_PREFERRED_WIDTH, availW - 40)
|
||||||
|
const height = Math.min(PAYMENT_POPUP_PREFERRED_HEIGHT, availH - 40)
|
||||||
|
const left = Math.max(0, Math.floor((availW - width) / 2))
|
||||||
|
const top = Math.max(0, Math.floor((availH - height) / 2))
|
||||||
|
return `width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`
|
||||||
|
}
|
||||||
|
|
||||||
/** Webhook paths for each provider (relative to origin). */
|
/** Webhook paths for each provider (relative to origin). */
|
||||||
export const WEBHOOK_PATHS: Record<string, string> = {
|
export const WEBHOOK_PATHS: Record<string, string> = {
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export interface CreateOrderRequest {
|
|||||||
payment_type: string
|
payment_type: string
|
||||||
order_type: string
|
order_type: string
|
||||||
plan_id?: number
|
plan_id?: number
|
||||||
|
is_mobile?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOrderResult {
|
export interface CreateOrderResult {
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ import type { SubscriptionPlan, CheckoutInfoResponse, OrderType } from '@/types/
|
|||||||
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'
|
||||||
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
||||||
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
|
import { METHOD_ORDER, getPaymentPopupFeatures } from '@/components/payment/providerConfig'
|
||||||
import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, platformTextClass, platformLabel } from '@/utils/platformColors'
|
import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, platformTextClass, platformLabel } from '@/utils/platformColors'
|
||||||
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
|
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
|
||||||
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
|
||||||
@@ -551,9 +551,10 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
payment_type: selectedMethod.value,
|
payment_type: selectedMethod.value,
|
||||||
order_type: orderType,
|
order_type: orderType,
|
||||||
plan_id: planId,
|
plan_id: planId,
|
||||||
|
is_mobile: isMobileDevice(),
|
||||||
})
|
})
|
||||||
const openWindow = (url: string) => {
|
const openWindow = (url: string) => {
|
||||||
const win = window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES)
|
const win = window.open(url, 'paymentPopup', getPaymentPopupFeatures())
|
||||||
if (!win || win.closed) {
|
if (!win || win.closed) {
|
||||||
window.location.href = url
|
window.location.href = url
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user