diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 1ddb8ae2..854dca54 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -206,6 +206,10 @@ type CreateOrderRequest struct { PaymentType string `json:"payment_type" binding:"required"` OrderType string `json:"order_type"` 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. @@ -222,12 +226,16 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) { return } + mobile := isMobile(c) + if req.IsMobile != nil { + mobile = *req.IsMobile + } result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{ UserID: subject.UserID, Amount: req.Amount, PaymentType: req.PaymentType, ClientIP: c.ClientIP(), - IsMobile: isMobile(c), + IsMobile: mobile, SrcHost: c.Request.Host, SrcURL: c.Request.Referer(), OrderType: req.OrderType, diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go index af8a90c6..fe8ea89c 100644 --- a/backend/internal/payment/provider/alipay.go +++ b/backend/internal/payment/provider/alipay.go @@ -15,8 +15,8 @@ import ( // Alipay product codes. const ( - alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY" alipayProductCodeWapPay = "QUICK_WAP_WAY" + alipayProductCodePagePay = "FAST_INSTANT_TRADE_PAY" ) // Alipay response constants. @@ -79,7 +79,12 @@ func (a *Alipay) SupportedTypes() []payment.PaymentType { 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) { client, err := a.getClient() if err != nil { @@ -96,31 +101,31 @@ func (a *Alipay) CreatePayment(_ context.Context, req payment.CreatePaymentReque } 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) { - if isMobile { - param := alipay.TradeWapPay{} - param.OutTradeNo = req.OrderID - param.TotalAmount = req.Amount - param.Subject = req.Subject - param.ProductCode = alipayProductCodeWapPay - param.NotifyURL = notifyURL - param.ReturnURL = returnURL +func (a *Alipay) createWapTrade(client *alipay.Client, req payment.CreatePaymentRequest, notifyURL, returnURL string) (*payment.CreatePaymentResponse, error) { + param := alipay.TradeWapPay{} + param.OutTradeNo = req.OrderID + param.TotalAmount = req.Amount + param.Subject = req.Subject + param.ProductCode = alipayProductCodeWapPay + param.NotifyURL = notifyURL + param.ReturnURL = returnURL - payURL, err := client.TradeWapPay(param) - if err != nil { - return nil, fmt.Errorf("alipay TradeWapPay: %w", err) - } - return &payment.CreatePaymentResponse{ - TradeNo: req.OrderID, - PayURL: payURL.String(), - }, nil + payURL, err := client.TradeWapPay(param) + if err != nil { + return nil, fmt.Errorf("alipay TradeWapPay: %w", err) } + 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.OutTradeNo = req.OrderID param.TotalAmount = req.Amount @@ -136,7 +141,6 @@ func (a *Alipay) createTrade(client *alipay.Client, req payment.CreatePaymentReq return &payment.CreatePaymentResponse{ TradeNo: req.OrderID, PayURL: payURL.String(), - QRCode: payURL.String(), }, nil } diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue index b9026e78..db90c3b6 100644 --- a/frontend/src/components/payment/PaymentQRDialog.vue +++ b/frontend/src/components/payment/PaymentQRDialog.vue @@ -79,7 +79,7 @@ import { usePaymentStore } from '@/stores/payment' import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' 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 QRCode from 'qrcode' import alipayIcon from '@/assets/icons/alipay.svg' @@ -147,7 +147,7 @@ function getLogoForType(): string | null { function reopenPopup() { if (props.payUrl) { - window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES) + window.open(props.payUrl, 'paymentPopup', getPaymentPopupFeatures()) } } diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index 974dee66..17541e59 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -125,7 +125,7 @@ import { usePaymentStore } from '@/stores/payment' import { useAppStore } from '@/stores' import { paymentAPI } from '@/api/payment' 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 Icon from '@/components/icons/Icon.vue' import QRCode from 'qrcode' @@ -194,7 +194,7 @@ const countdownDisplay = computed(() => { function reopenPopup() { if (props.payUrl) { - window.open(props.payUrl, 'paymentPopup', POPUP_WINDOW_FEATURES) + window.open(props.payUrl, 'paymentPopup', getPaymentPopupFeatures()) } } diff --git a/frontend/src/components/payment/StripePaymentInline.vue b/frontend/src/components/payment/StripePaymentInline.vue index b8fd55ef..3ddff8c8 100644 --- a/frontend/src/components/payment/StripePaymentInline.vue +++ b/frontend/src/components/payment/StripePaymentInline.vue @@ -70,7 +70,7 @@ import { useRouter } from 'vue-router' import { extractApiErrorMessage } from '@/utils/apiError' import { paymentAPI } from '@/api/payment' 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 Icon from '@/components/icons/Icon.vue' @@ -151,7 +151,7 @@ async function handlePay() { amount: String(props.payAmount), }, }).href - const popup = window.open(popupUrl, 'paymentPopup', STRIPE_POPUP_WINDOW_FEATURES) + const popup = window.open(popupUrl, 'paymentPopup', getPaymentPopupFeatures()) const onReady = (event: MessageEvent) => { if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return diff --git a/frontend/src/components/payment/providerConfig.ts b/frontend/src/components/payment/providerConfig.ts index a83787fd..bf2d4177 100644 --- a/frontend/src/components/payment/providerConfig.ts +++ b/frontend/src/components/payment/providerConfig.ts @@ -43,11 +43,24 @@ export const METHOD_ORDER = ['alipay', 'alipay_direct', 'wxpay', 'wxpay_direct', export const PAYMENT_MODE_QRCODE = 'qrcode' export const PAYMENT_MODE_POPUP = 'popup' -/** Window features for payment popup windows */ -export const POPUP_WINDOW_FEATURES = 'width=1000,height=750,left=100,top=80,scrollbars=yes,resizable=yes' +/** Preferred popup size for payment gateways. Alipay's standard checkout + * (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) */ -export const STRIPE_POPUP_WINDOW_FEATURES = 'width=1250,height=780,left=80,top=60,scrollbars=yes,resizable=yes' +/** Build a window.open features string sized to fit within the current screen + * 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). */ export const WEBHOOK_PATHS: Record = { diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 7ecbb9a9..6f2eec51 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -154,6 +154,7 @@ export interface CreateOrderRequest { payment_type: string order_type: string plan_id?: number + is_mobile?: boolean } export interface CreateOrderResult { diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index e91df5da..3f1401b3 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -277,7 +277,7 @@ import type { SubscriptionPlan, CheckoutInfoResponse, OrderType } from '@/types/ import AppLayout from '@/components/layout/AppLayout.vue' import AmountInput from '@/components/payment/AmountInput.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 SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.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, order_type: orderType, plan_id: planId, + is_mobile: isMobileDevice(), }) const openWindow = (url: string) => { - const win = window.open(url, 'paymentPopup', POPUP_WINDOW_FEATURES) + const win = window.open(url, 'paymentPopup', getPaymentPopupFeatures()) if (!win || win.closed) { window.location.href = url }