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:
erio
2026-04-19 01:40:25 +08:00
parent 61a008f7e4
commit c3cb0280ef
8 changed files with 62 additions and 35 deletions

View File

@@ -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())
}
}

View File

@@ -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())
}
}

View File

@@ -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

View File

@@ -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<string, string> = {

View File

@@ -154,6 +154,7 @@ export interface CreateOrderRequest {
payment_type: string
order_type: string
plan_id?: number
is_mobile?: boolean
}
export interface CreateOrderResult {

View File

@@ -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
}