Merge pull request #4734 from wjx2951874/feat/alipay-mobile-precreate-deep-link

feat(payment): add mobile Alipay precreate deep link
This commit is contained in:
Wesley Liddick
2026-07-23 14:06:18 +08:00
committed by GitHub
33 changed files with 1085 additions and 117 deletions
+2
View File
@@ -607,6 +607,7 @@ export interface SystemSettings {
payment_cancel_rate_limit_unit: string;
payment_cancel_rate_limit_window_mode: string;
payment_alipay_force_qrcode?: boolean;
payment_alipay_mobile_precreate_deep_link?: boolean;
payment_visible_method_alipay_source?: string;
payment_visible_method_wxpay_source?: string;
payment_visible_method_alipay_enabled?: boolean;
@@ -888,6 +889,7 @@ export interface UpdateSettingsRequest {
payment_cancel_rate_limit_unit?: string;
payment_cancel_rate_limit_window_mode?: string;
payment_alipay_force_qrcode?: boolean;
payment_alipay_mobile_precreate_deep_link?: boolean;
payment_visible_method_alipay_source?: string;
payment_visible_method_wxpay_source?: string;
payment_visible_method_alipay_enabled?: boolean;
@@ -69,8 +69,104 @@
<!-- ═══ Active States: QR or Popup waiting ═══ -->
<!-- Mobile Alipay app handoff. The QR fallback stays hidden until launch timeout. -->
<template v-else-if="isMobileAlipayDeepLink">
<template v-if="!deepLinkFallbackVisible">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4 py-4 text-center">
<div
v-if="deepLinkState === 'launching'"
class="h-10 w-10 animate-spin rounded-full border-4 border-[#00AEEF] border-t-transparent"
></div>
<div
v-else
class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-50 dark:bg-blue-950/30"
>
<Icon name="checkCircle" size="lg" class="text-[#00AEEF]" />
</div>
<p class="text-lg font-semibold text-gray-900 dark:text-white">
{{ deepLinkState === 'backgrounded' ? t('payment.qr.alipayContinueInApp') : t('payment.qr.alipayOpening') }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.alipayWaitingHint') }}</p>
<button
v-if="deepLinkState === 'backgrounded'"
data-test="reopen-alipay"
class="btn btn-alipay inline-flex items-center gap-2 text-sm"
@click="reopenAlipay"
>
<Icon name="externalLink" size="sm" />
{{ t('payment.qr.reopenAlipay') }}
</button>
</div>
</div>
<div class="card p-4 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiresIn') }}</p>
<p class="mt-1 text-2xl font-bold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">{{ t('payment.qr.waitingPayment') }}</p>
</div>
</template>
<template v-else>
<div data-test="alipay-qr-fallback" class="card p-6">
<div class="flex flex-col items-center space-y-4">
<div class="text-center">
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.qr.alipayFallbackTitle') }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.alipayFallbackHint') }}</p>
</div>
<div class="w-full space-y-2 border-y border-gray-100 py-3 text-sm dark:border-dark-600">
<div class="flex items-start justify-between gap-4">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-semibold text-gray-900 dark:text-white">{{ displayPaymentAmount }}</span>
</div>
<div class="flex items-start justify-between gap-4">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</span>
<span class="max-w-[70%] break-all text-right font-mono text-xs text-gray-900 dark:text-white">
{{ displayOrderNumber }}
</span>
</div>
<div class="flex items-start justify-between gap-4">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.qr.expiresIn') }}</span>
<span class="font-semibold tabular-nums text-gray-900 dark:text-white">{{ countdownDisplay }}</span>
</div>
</div>
<div :class="['relative rounded-lg border-2 p-4', qrBorderClass]">
<canvas ref="qrCanvas" class="mx-auto"></canvas>
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span :class="['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]">
<img :src="qrLogoIcon" alt="" class="h-5 w-5 brightness-0 invert" />
</span>
</div>
</div>
<p class="text-center text-sm leading-6 text-gray-600 dark:text-gray-300">
{{ t('payment.qr.alipaySaveAndScanHint') }}
</p>
<div class="grid w-full gap-2 sm:grid-cols-2">
<button
data-test="reopen-alipay"
class="btn btn-alipay inline-flex items-center justify-center gap-2"
@click="reopenAlipay"
>
<Icon name="externalLink" size="sm" />
{{ t('payment.qr.reopenAlipay') }}
</button>
<button
data-test="save-alipay-qr"
class="btn btn-secondary inline-flex items-center justify-center gap-2"
@click="saveQRCode"
>
<Icon name="download" size="sm" />
{{ t('payment.qr.saveQRCode') }}
</button>
</div>
<button class="btn btn-secondary w-full" @click="handleDone">
{{ t('payment.result.backToRecharge') }}
</button>
</div>
</div>
</template>
</template>
<!-- QR Code Mode -->
<template v-else-if="qrUrl">
<template v-else-if="showQRCode">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4">
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ scanTitle }}</p>
@@ -122,7 +218,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
@@ -136,15 +232,24 @@ import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
import paymentIcon from '@/assets/icons/payment.svg'
import {
createAlipayDeepLinkLauncher,
type AlipayDeepLinkLauncher,
type AlipayDeepLinkState,
} from './alipayDeepLink'
const props = defineProps<{
orderId: number
amount?: number
payAmount?: number
qrCode: string
expiresAt: string
paymentType: string
payUrl?: string
orderType?: string
currency?: string
outTradeNo?: string
mobileAlipayDeepLink?: boolean
}>()
type PaymentOutcome = 'success' | 'cancelled' | 'expired'
@@ -161,6 +266,8 @@ const qrUrl = ref('')
const remainingSeconds = ref(0)
const cancelling = ref(false)
const paidOrder = ref<PaymentOrder | null>(null)
const deepLinkState = ref<AlipayDeepLinkState>('idle')
const deepLinkFallbackVisible = ref(false)
const paymentCurrency = computed(() => normalizePaymentCurrency(props.currency))
const creditedAmountSymbol = currencySymbol('USD')
const localeCode = computed(() => {
@@ -179,12 +286,15 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
let verifyAttempts = 0
let lastVerifyAt = 0
let alipayLauncher: AlipayDeepLinkLauncher | null = null
const VERIFY_RETRY_INTERVAL_MS = 15000
const VERIFY_RETRY_MAX_ATTEMPTS = 6
const isAlipay = computed(() => isBuiltInAlipayMethod(props.paymentType))
const isWxpay = computed(() => isBuiltInWxpayMethod(props.paymentType))
const isMobileAlipayDeepLink = computed(() => props.mobileAlipayDeepLink === true && isAlipay.value && !!qrUrl.value)
const showQRCode = computed(() => !!qrUrl.value && (!isMobileAlipayDeepLink.value || deepLinkFallbackVisible.value))
const qrBorderClass = computed(() => {
if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
@@ -222,6 +332,9 @@ const countdownDisplay = computed(() => {
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
})
const displayPaymentAmount = computed(() => formatGatewayAmount(props.payAmount || props.amount || 0))
const displayOrderNumber = computed(() => props.outTradeNo || `#${props.orderId}`)
function formatGatewayAmount(value: number, currency?: string | null): string {
return formatPaymentAmount(value, currency || paymentCurrency.value, localeCode.value)
}
@@ -247,15 +360,40 @@ function setOutcome(next: PaymentOutcome) {
async function renderQR() {
await nextTick()
if (!qrCanvas.value || !qrUrl.value) return
if (!showQRCode.value || !qrCanvas.value || !qrUrl.value) return
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
width: 220, margin: 2,
errorCorrectionLevel: 'M',
})
}
function updateDeepLinkState(state: AlipayDeepLinkState) {
deepLinkState.value = state
if (state === 'fallback') {
deepLinkFallbackVisible.value = true
renderQR()
} else if (state === 'backgrounded') {
deepLinkFallbackVisible.value = false
}
}
function reopenAlipay() {
alipayLauncher?.launch()
}
function saveQRCode() {
const canvas = qrCanvas.value
if (!canvas) return
const link = document.createElement('a')
link.href = canvas.toDataURL('image/png')
link.download = `alipay-${props.outTradeNo || props.orderId}.png`
document.body.appendChild(link)
link.click()
link.remove()
}
async function tryRecoverPendingOrder(order: PaymentOrder): Promise<PaymentOrder> {
if (!isWxpay.value) return order
if (!isWxpay.value && !isMobileAlipayDeepLink.value) return order
const outTradeNo = String(order.out_trade_no || '').trim()
if (!outTradeNo) return order
const normalizedStatus = String(order.status || '').trim().toUpperCase()
@@ -333,6 +471,8 @@ function handleDone() { cleanup(); emit('done') }
function cleanup() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
alipayLauncher?.dispose()
alipayLauncher = null
}
// Initialize on mount
@@ -347,6 +487,18 @@ startCountdown(seconds)
pollTimer = setInterval(pollStatus, 3000)
renderQR()
watch(() => qrUrl.value, () => renderQR())
watch([() => qrUrl.value, showQRCode], () => renderQR())
onMounted(() => {
if (!isMobileAlipayDeepLink.value) return
alipayLauncher = createAlipayDeepLinkLauncher({
qrCode: qrUrl.value,
document,
lifecycleTarget: window,
userAgent: window.navigator.userAgent,
assignLocation: (url) => window.location.assign(url),
onStateChange: updateDeepLinkState,
})
alipayLauncher.launch()
})
onUnmounted(() => cleanup())
</script>
@@ -184,4 +184,132 @@ describe('PaymentStatusPanel', () => {
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.emitted('success')).toHaveLength(1)
})
it('actively verifies a pending mobile Alipay precreate order', async () => {
const originalLocation = window.location
const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden')
Object.defineProperty(window, 'location', {
configurable: true,
value: { assign: vi.fn() },
})
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false,
})
pollOrderStatus.mockResolvedValue(orderFactory('PENDING'))
verifyOrder.mockResolvedValue({ data: orderFactory('COMPLETED') })
const wrapper = mount(PaymentStatusPanel, {
props: {
orderId: 42,
amount: 88,
payAmount: 88,
qrCode: 'https://qr.alipay.com/dynamic-order-42',
expiresAt: '2099-01-01T12:30:00Z',
paymentType: 'alipay',
orderType: 'balance',
outTradeNo: 'sub2_20260420abcd1234',
mobileAlipayDeepLink: true,
},
global: { stubs: { Icon: true } },
})
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(verifyOrder).toHaveBeenCalledWith('sub2_20260420abcd1234')
expect(wrapper.emitted('success')).toHaveLength(1)
wrapper.unmount()
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation })
if (originalHidden) Object.defineProperty(document, 'hidden', originalHidden)
})
it('keeps the QR fallback hidden until the Alipay app launch times out', async () => {
const originalLocation = window.location
const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden')
const assign = vi.fn()
Object.defineProperty(window, 'location', {
configurable: true,
value: { assign },
})
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => false,
})
const wrapper = mount(PaymentStatusPanel, {
props: {
orderId: 42,
amount: 88,
payAmount: 88,
qrCode: 'https://qr.alipay.com/dynamic-order-42',
expiresAt: '2099-01-01T12:30:00Z',
paymentType: 'alipay',
orderType: 'balance',
outTradeNo: 'sub2_20260420abcd1234',
mobileAlipayDeepLink: true,
},
global: { stubs: { Icon: true } },
})
await flushPromises()
expect(assign).toHaveBeenCalledWith(expect.stringContaining('alipays://platformapi/startapp?saId=10000007&qrcode='))
expect(wrapper.find('[data-test="alipay-qr-fallback"]').exists()).toBe(false)
await vi.advanceTimersByTimeAsync(2200)
await flushPromises()
expect(wrapper.find('[data-test="alipay-qr-fallback"]').exists()).toBe(true)
expect(wrapper.text()).toContain('payment.qr.saveQRCode')
expect(wrapper.text()).toContain('sub2_20260420abcd1234')
expect(toCanvas).toHaveBeenCalledWith(expect.any(HTMLCanvasElement), 'https://qr.alipay.com/dynamic-order-42', expect.any(Object))
wrapper.unmount()
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation })
if (originalHidden) Object.defineProperty(document, 'hidden', originalHidden)
})
it('does not show the QR fallback after the page enters the background', async () => {
const originalLocation = window.location
const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden')
let hidden = false
Object.defineProperty(window, 'location', {
configurable: true,
value: { assign: vi.fn() },
})
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => hidden,
})
const wrapper = mount(PaymentStatusPanel, {
props: {
orderId: 42,
amount: 88,
payAmount: 88,
qrCode: 'https://qr.alipay.com/dynamic-order-42',
expiresAt: '2099-01-01T12:30:00Z',
paymentType: 'alipay',
orderType: 'balance',
outTradeNo: 'sub2_20260420abcd1234',
mobileAlipayDeepLink: true,
},
global: { stubs: { Icon: true } },
})
await flushPromises()
hidden = true
document.dispatchEvent(new Event('visibilitychange'))
await vi.advanceTimersByTimeAsync(2200)
await flushPromises()
expect(wrapper.find('[data-test="alipay-qr-fallback"]').exists()).toBe(false)
expect(wrapper.text()).toContain('payment.qr.alipayContinueInApp')
wrapper.unmount()
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation })
if (originalHidden) Object.defineProperty(document, 'hidden', originalHidden)
})
})
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS,
ALIPAY_EMBEDDED_BROWSER_FALLBACK_DELAY_MS,
buildAlipayDeepLink,
createAlipayDeepLinkLauncher,
} from '../alipayDeepLink'
class FakeEventTarget {
private readonly listeners = new Map<string, Set<EventListener>>()
addEventListener(type: string, listener: EventListener) {
const listeners = this.listeners.get(type) ?? new Set<EventListener>()
listeners.add(listener)
this.listeners.set(type, listeners)
}
removeEventListener(type: string, listener: EventListener) {
this.listeners.get(type)?.delete(listener)
}
dispatch(type: string) {
for (const listener of this.listeners.get(type) ?? []) {
listener(new Event(type))
}
}
}
class FakeVisibilityDocument extends FakeEventTarget {
hidden = false
}
describe('Alipay deep link', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('URL-encodes the dynamic qr_code exactly once', () => {
const qrCode = 'https://qr.alipay.com/bax123?subject=A B&return=https%3A%2F%2Fexample.com%2Fpaid'
const deepLink = buildAlipayDeepLink(qrCode)
expect(deepLink).toBe(
`alipays://platformapi/startapp?saId=10000007&qrcode=${encodeURIComponent(qrCode)}`,
)
expect(decodeURIComponent(deepLink.split('&qrcode=')[1])).toBe(qrCode)
})
it('shows fallback after the visible-page timeout', async () => {
const visibility = new FakeVisibilityDocument()
const lifecycle = new FakeEventTarget()
const assignLocation = vi.fn()
const onStateChange = vi.fn()
const launcher = createAlipayDeepLinkLauncher({
qrCode: 'https://qr.alipay.com/dynamic-order-1',
document: visibility,
lifecycleTarget: lifecycle,
userAgent: 'Mozilla/5.0 Mobile Safari',
assignLocation,
onStateChange,
})
launcher.launch()
expect(assignLocation).toHaveBeenCalledWith(buildAlipayDeepLink('https://qr.alipay.com/dynamic-order-1'))
expect(onStateChange).toHaveBeenLastCalledWith('launching')
await vi.advanceTimersByTimeAsync(ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS - 1)
expect(onStateChange).not.toHaveBeenCalledWith('fallback')
await vi.advanceTimersByTimeAsync(1)
expect(onStateChange).toHaveBeenLastCalledWith('fallback')
})
it('keeps fallback hidden when visibilitychange reports the page in background', async () => {
const visibility = new FakeVisibilityDocument()
const lifecycle = new FakeEventTarget()
const onStateChange = vi.fn()
const launcher = createAlipayDeepLinkLauncher({
qrCode: 'https://qr.alipay.com/dynamic-order-2',
document: visibility,
lifecycleTarget: lifecycle,
userAgent: 'Mozilla/5.0 iPhone',
assignLocation: vi.fn(),
onStateChange,
})
launcher.launch()
visibility.hidden = true
visibility.dispatch('visibilitychange')
await vi.advanceTimersByTimeAsync(ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS)
expect(onStateChange).toHaveBeenLastCalledWith('backgrounded')
expect(onStateChange).not.toHaveBeenCalledWith('fallback')
})
it.each([
'Mozilla/5.0 MicroMessenger/8.0',
'Mozilla/5.0 MQQBrowser/13.7 Mobile',
'Mozilla/5.0 Mobile QQ/9.0',
])('uses the fast fallback window in restricted browser %s', async (userAgent) => {
const visibility = new FakeVisibilityDocument()
const onStateChange = vi.fn()
const launcher = createAlipayDeepLinkLauncher({
qrCode: 'https://qr.alipay.com/dynamic-order-3',
document: visibility,
lifecycleTarget: new FakeEventTarget(),
userAgent,
assignLocation: vi.fn(),
onStateChange,
})
launcher.launch()
await vi.advanceTimersByTimeAsync(ALIPAY_EMBEDDED_BROWSER_FALLBACK_DELAY_MS)
expect(onStateChange).toHaveBeenLastCalledWith('fallback')
})
it('treats pagehide as a successful handoff', async () => {
const visibility = new FakeVisibilityDocument()
const lifecycle = new FakeEventTarget()
const onStateChange = vi.fn()
const launcher = createAlipayDeepLinkLauncher({
qrCode: 'https://qr.alipay.com/dynamic-order-4',
document: visibility,
lifecycleTarget: lifecycle,
userAgent: 'Mozilla/5.0 Android',
assignLocation: vi.fn(),
onStateChange,
})
launcher.launch()
lifecycle.dispatch('pagehide')
await vi.advanceTimersByTimeAsync(ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS)
expect(onStateChange).toHaveBeenLastCalledWith('backgrounded')
expect(onStateChange).not.toHaveBeenCalledWith('fallback')
})
})
@@ -248,6 +248,34 @@ describe('decidePaymentLaunch', () => {
expect(decision.paymentState.qrCode).toBe('https://pay.example.com/qr/session')
})
it('launches the Alipay app for a mobile precreate order', () => {
const decision = decidePaymentLaunch(createOrderResult({
qr_code: 'https://qr.alipay.com/dynamic-order-101',
alipay_mobile_precreate_deep_link: true,
}), {
visibleMethod: 'alipay',
orderType: 'balance',
isMobile: true,
})
expect(decision.kind).toBe('alipay_deep_link')
expect(decision.paymentState.qrCode).toBe('https://qr.alipay.com/dynamic-order-101')
expect(decision.paymentState.alipayMobilePrecreateDeepLink).toBe(true)
})
it('keeps the desktop Alipay QR flow when a precreate marker is present', () => {
const decision = decidePaymentLaunch(createOrderResult({
qr_code: 'https://qr.alipay.com/dynamic-order-102',
alipay_mobile_precreate_deep_link: true,
}), {
visibleMethod: 'alipay',
orderType: 'balance',
isMobile: false,
})
expect(decision.kind).toBe('qr_waiting')
})
it('does not affect non-alipay methods when forceQRCode is enabled', () => {
const decision = decidePaymentLaunch(createOrderResult({
pay_url: 'https://pay.example.com/mobile/session',
@@ -317,6 +345,21 @@ describe('buildCreateOrderPayload', () => {
})
})
it('keeps is_mobile true when mobile precreate takes priority over forceQRCode', () => {
expect(buildCreateOrderPayload({
amount: 50,
paymentType: 'alipay',
orderType: 'balance',
origin: 'https://app.example.com',
isMobile: true,
isWechatBrowser: false,
forceQRCode: true,
mobilePrecreateDeepLink: true,
})).toMatchObject({
is_mobile: true,
})
})
it('still passes is_mobile: true when forceQRCode is enabled for non-alipay methods', () => {
expect(buildCreateOrderPayload({
amount: 50,
@@ -0,0 +1,107 @@
export const ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS = 2200
export const ALIPAY_EMBEDDED_BROWSER_FALLBACK_DELAY_MS = 300
export type AlipayDeepLinkState = 'idle' | 'launching' | 'backgrounded' | 'fallback'
const ALIPAY_DEEP_LINK_PREFIX = 'alipays://platformapi/startapp?saId=10000007&qrcode='
export function buildAlipayDeepLink(qrCode: string): string {
const dynamicQRCode = qrCode.trim()
if (!dynamicQRCode) return ''
return `${ALIPAY_DEEP_LINK_PREFIX}${encodeURIComponent(dynamicQRCode)}`
}
export function isAlipaySchemeRestrictedBrowser(userAgent: string): boolean {
return /MicroMessenger|MQQBrowser|\bQQ\//i.test(userAgent)
}
interface EventTargetLike {
addEventListener(type: string, listener: EventListener): void
removeEventListener(type: string, listener: EventListener): void
}
interface VisibilityDocumentLike extends EventTargetLike {
readonly hidden: boolean
}
export interface AlipayDeepLinkLauncherOptions {
qrCode: string
document: VisibilityDocumentLike
lifecycleTarget: EventTargetLike
userAgent: string
assignLocation: (url: string) => void
onStateChange: (state: AlipayDeepLinkState) => void
setTimer?: typeof setTimeout
clearTimer?: typeof clearTimeout
}
export interface AlipayDeepLinkLauncher {
launch(): void
dispose(): void
}
export function createAlipayDeepLinkLauncher(options: AlipayDeepLinkLauncherOptions): AlipayDeepLinkLauncher {
const setTimer = options.setTimer ?? setTimeout
const clearTimer = options.clearTimer ?? clearTimeout
let timer: ReturnType<typeof setTimeout> | null = null
let disposed = false
const setState = (state: AlipayDeepLinkState) => {
if (!disposed) options.onStateChange(state)
}
const clearFallbackTimer = () => {
if (timer) {
clearTimer(timer)
timer = null
}
}
const markBackgrounded = () => {
clearFallbackTimer()
setState('backgrounded')
}
const handleVisibilityChange: EventListener = () => {
if (options.document.hidden) markBackgrounded()
}
const handlePageHide: EventListener = () => markBackgrounded()
options.document.addEventListener('visibilitychange', handleVisibilityChange)
options.lifecycleTarget.addEventListener('pagehide', handlePageHide)
return {
launch() {
if (disposed) return
clearFallbackTimer()
const deepLink = buildAlipayDeepLink(options.qrCode)
if (!deepLink) {
setState('fallback')
return
}
setState('launching')
try {
options.assignLocation(deepLink)
} catch {
setState('fallback')
return
}
const delay = isAlipaySchemeRestrictedBrowser(options.userAgent)
? ALIPAY_EMBEDDED_BROWSER_FALLBACK_DELAY_MS
: ALIPAY_DEEP_LINK_FALLBACK_DELAY_MS
timer = setTimer(() => {
timer = null
if (options.document.hidden) {
setState('backgrounded')
return
}
setState('fallback')
}, delay)
},
dispose() {
clearFallbackTimer()
options.document.removeEventListener('visibilitychange', handleVisibilityChange)
options.lifecycleTarget.removeEventListener('pagehide', handlePageHide)
disposed = true
},
}
}
+20 -2
View File
@@ -22,6 +22,7 @@ export type VisiblePaymentMethod = 'alipay' | 'wxpay' | 'stripe' | 'airwallex'
export type StripeVisibleMethod = 'alipay' | 'wechat_pay'
export type PaymentLaunchKind =
| 'qr_waiting'
| 'alipay_deep_link'
| 'redirect_waiting'
| 'stripe_popup'
| 'stripe_route'
@@ -47,6 +48,7 @@ export interface PaymentRecoverySnapshot {
orderType: OrderType | ''
paymentMode: string
resumeToken: string
alipayMobilePrecreateDeepLink?: boolean
createdAt: number
}
@@ -57,6 +59,8 @@ export interface PaymentLaunchContext {
isWechatBrowser?: boolean
/** When true, Alipay payments always use QR code regardless of device type */
forceQRCode?: boolean
/** When true, the new mobile Alipay precreate flow takes priority over forceQRCode */
mobilePrecreateDeepLink?: boolean
now?: number
stripePopupUrl?: string
stripeRouteUrl?: string
@@ -82,6 +86,8 @@ export interface BuildCreateOrderPayloadInput {
isWechatBrowser: boolean
/** When true, Alipay payments always use QR code (passes is_mobile: false to backend) */
forceQRCode?: boolean
/** When true, keep the real mobile signal so the backend can select precreate */
mobilePrecreateDeepLink?: boolean
}
type CreateOrderFlowResult = CreateOrderResult & {
@@ -117,7 +123,7 @@ export function buildCreateOrderPayload(input: BuildCreateOrderPayloadInput): Cr
const normalizedOrigin = (input.origin || '').trim().replace(/\/+$/, '')
// When forceQRCode is enabled for alipay, always tell the backend this is not a mobile
// request so it generates a QR code instead of a mobile-redirect URL.
const effectiveMobile = (input.forceQRCode && visibleMethod === 'alipay')
const effectiveMobile = (input.forceQRCode && !input.mobilePrecreateDeepLink && visibleMethod === 'alipay')
? false
: input.isMobile
const payload: CreateOrderRequest = {
@@ -162,6 +168,7 @@ export function decidePaymentLaunch(
orderType: context.orderType,
paymentMode: (result.payment_mode || '').trim(),
resumeToken: result.resume_token || '',
alipayMobilePrecreateDeepLink: result.alipay_mobile_precreate_deep_link === true,
}, context.now)
if (visibleMethod === 'airwallex' && baseState.clientSecret && baseState.intentId) {
@@ -198,10 +205,19 @@ export function decidePaymentLaunch(
return { kind: 'wechat_jsapi', paymentState: baseState, recovery: baseState, jsapi: jsapiPayload }
}
if (
visibleMethod === 'alipay'
&& context.isMobile
&& baseState.alipayMobilePrecreateDeepLink
&& baseState.qrCode
) {
return { kind: 'alipay_deep_link', paymentState: baseState, recovery: baseState }
}
const normalizedPaymentMode = baseState.paymentMode.trim().toLowerCase()
// When forceQRCode is on for alipay, treat the device as desktop so the mobile-redirect
// branch is bypassed and we fall through to qr_waiting.
const effectiveMobile = (context.forceQRCode && visibleMethod === 'alipay')
const effectiveMobile = (context.forceQRCode && !context.mobilePrecreateDeepLink && visibleMethod === 'alipay')
? false
: context.isMobile
const prefersRedirect = normalizedPaymentMode === 'redirect'
@@ -279,6 +295,7 @@ export function readPaymentRecoverySnapshot(
|| typeof parsed.payAmount !== 'number'
|| typeof parsed.paymentMode !== 'string'
|| typeof parsed.resumeToken !== 'string'
|| (parsed.alipayMobilePrecreateDeepLink != null && typeof parsed.alipayMobilePrecreateDeepLink !== 'boolean')
|| typeof parsed.createdAt !== 'number'
) {
return null
@@ -310,6 +327,7 @@ export function readPaymentRecoverySnapshot(
orderType: parsed.orderType === 'subscription' ? 'subscription' : 'balance',
paymentMode: parsed.paymentMode,
resumeToken: parsed.resumeToken,
alipayMobilePrecreateDeepLink: parsed.alipayMobilePrecreateDeepLink === true,
createdAt: parsed.createdAt,
}
} catch {
@@ -585,6 +585,8 @@ export default {
cancelRateLimitWindowModeFixed: 'Fixed',
alipayForceQRCode: 'Force Alipay QR Code',
alipayForceQRCodeHint: 'When enabled, mobile Alipay users always see a QR code instead of being redirected to the mobile payment page',
alipayMobilePrecreateDeepLink: 'Mobile Alipay Precreate Handoff',
alipayMobilePrecreateDeepLinkHint: 'Use official Alipay precreate on mobile, open the Alipay app, and show the dynamic QR only if handoff fails. This takes priority over Force Alipay QR Code',
helpText: 'Help Text',
helpImageUrl: 'Help Image URL',
manageProviders: 'Manage Providers',
+8
View File
@@ -338,6 +338,14 @@ export default {
cancelledDesc: 'You have cancelled this payment.',
waitingPayment: 'Waiting for payment...',
cancelOrder: 'Cancel Order',
alipayOpening: 'Opening Alipay',
alipayContinueInApp: 'Complete payment in Alipay',
alipayWaitingHint: 'The server will confirm the payment and update this page automatically',
alipayFallbackTitle: 'Alipay did not open',
alipayFallbackHint: 'Try opening Alipay again, or save the QR code and scan it from your Alipay photo album',
reopenAlipay: 'Open Alipay Again',
saveQRCode: 'Save QR Code',
alipaySaveAndScanHint: 'Save the QR code, open Alipay Scan, then select it from your photo album',
},
orders: {
title: 'My Orders',
@@ -580,6 +580,8 @@ export default {
cancelRateLimitWindowModeFixed: '固定',
alipayForceQRCode: '支付宝强制二维码支付',
alipayForceQRCodeHint: '启用后,移动端支付宝用户将统一使用二维码扫码支付,不再跳转至手机网站支付',
alipayMobilePrecreateDeepLink: '支付宝移动端当面付唤起',
alipayMobilePrecreateDeepLinkHint: '启用后,移动端官方支付宝订单调用当面付并尝试打开支付宝;失败时显示动态二维码。该设置优先于强制二维码支付',
helpText: '帮助文本',
helpImageUrl: '帮助图片链接',
manageProviders: '管理服务商',
@@ -677,7 +679,7 @@ export default {
guideOpenLabel: '开通:',
guideCallLabel: '调用:',
guideFallbackLabel: '降级:',
alipayGuideSummary: '桌面优先扫码单,失败再走收银台;移动优先手机网站支付。',
alipayGuideSummary: '桌面优先扫码单,失败再走收银台;移动默认手机网站支付,也可启用当面付唤起。',
alipayGuideFaceToFaceTitle: '当面付 / 扫码支付',
alipayGuideFaceToFaceOpen: '需开通当面付或扫码支付能力。',
alipayGuideFaceToFaceCall: '桌面端下单时优先调用 alipay.trade.precreate,前台直接渲染二维码。',
@@ -688,7 +690,7 @@ export default {
alipayGuidePagePayFallback: '同时保留打开收银台入口,用户可手动重新拉起支付页。',
alipayGuideWapTitle: '手机网站支付',
alipayGuideWapOpen: '需开通手机网站支付。',
alipayGuideWapCall: '移动端优先调用 alipay.trade.wap.pay,跳转支付宝收银台。',
alipayGuideWapCall: '默认调用 alipay.trade.wap.pay;开启移动端当面付唤起后改用 alipay.trade.precreate。',
alipayGuideWapFallback: '未开通或返回异常时,前端自动改走扫码支付并提示未开通移动支付。',
wxpayGuideSummary: '桌面优先 Native 扫码,移动端按浏览器环境走 JSAPI 或 H5。',
wxpayGuideNote: '当前表单默认共用一个 App ID,适合同主体下统一配置网页、移动和公众号场景。',
+8
View File
@@ -362,6 +362,14 @@ export default {
cancelledDesc: '您已取消本次支付',
waitingPayment: '等待支付...',
cancelOrder: '取消订单',
alipayOpening: '正在打开支付宝',
alipayContinueInApp: '请在支付宝中完成支付',
alipayWaitingHint: '支付结果将由服务端确认,本页面会自动更新',
alipayFallbackTitle: '打开支付宝未成功',
alipayFallbackHint: '可重新打开支付宝,或保存下方二维码后从支付宝相册识别',
reopenAlipay: '重新打开支付宝',
saveQRCode: '保存二维码',
alipaySaveAndScanHint: '保存二维码后,打开支付宝扫一扫,从相册选择二维码',
},
orders: {
title: '我的订单',
+3
View File
@@ -76,6 +76,8 @@ export interface CheckoutInfoResponse {
stripe_publishable_key: string
/** When true, Alipay payments on mobile always show the QR code instead of redirecting */
alipay_force_qrcode?: boolean
/** When true, official Alipay mobile orders use precreate plus an Alipay app deep link */
alipay_mobile_precreate_deep_link?: boolean
}
// ==================== Orders ====================
@@ -214,6 +216,7 @@ export interface CreateOrderResult {
out_trade_no?: string
payment_mode?: string
resume_token?: string
alipay_mobile_precreate_deep_link?: boolean
oauth?: WechatOAuthInfo
jsapi?: WechatJSAPIPayload
jsapi_payload?: WechatJSAPIPayload
+35
View File
@@ -7035,6 +7035,38 @@
}}</span>
</div>
</div>
<div>
<label class="input-label">{{
t("admin.settings.payment.alipayMobilePrecreateDeepLink")
}}</label>
<div class="flex items-center gap-2">
<button
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
form.payment_alipay_mobile_precreate_deep_link
? 'bg-primary-500'
: 'bg-gray-300 dark:bg-dark-600',
]"
@click="
form.payment_alipay_mobile_precreate_deep_link =
!form.payment_alipay_mobile_precreate_deep_link
"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
form.payment_alipay_mobile_precreate_deep_link
? 'translate-x-5'
: 'translate-x-0',
]"
/>
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">{{
t("admin.settings.payment.alipayMobilePrecreateDeepLinkHint")
}}</span>
</div>
</div>
</div>
<!-- Row 4: Enabled payment types (provider badges like sub2apipay) -->
<div>
@@ -8425,6 +8457,7 @@ const form = reactive<SettingsForm>({
payment_cancel_rate_limit_unit: "day",
payment_cancel_rate_limit_window_mode: "rolling",
payment_alipay_force_qrcode: false,
payment_alipay_mobile_precreate_deep_link: false,
table_default_page_size: tablePageSizeDefault,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [] as Array<{
@@ -10096,6 +10129,8 @@ async function saveSettings() {
payment_cancel_rate_limit_window_mode:
form.payment_cancel_rate_limit_window_mode,
payment_alipay_force_qrcode: form.payment_alipay_force_qrcode,
payment_alipay_mobile_precreate_deep_link:
form.payment_alipay_mobile_precreate_deep_link,
openai_low_upstream_rate_priority_enabled:
form.openai_low_upstream_rate_priority_enabled,
openai_oauth_scheduling_rate_multiplier:
+10 -1
View File
@@ -16,12 +16,16 @@
<template v-if="paymentPhase === 'paying'">
<PaymentStatusPanel
:order-id="paymentState.orderId"
:amount="paymentState.amount"
:pay-amount="paymentState.payAmount"
:qr-code="paymentState.qrCode"
:expires-at="paymentState.expiresAt"
:payment-type="paymentState.paymentType"
:pay-url="paymentState.payUrl"
:order-type="paymentState.orderType"
:currency="paymentState.currency || selectedCurrency"
:out-trade-no="paymentState.outTradeNo"
:mobile-alipay-deep-link="paymentState.alipayMobilePrecreateDeepLink"
@done="onPaymentDone"
@success="onPaymentSuccess"
@settled="onPaymentSettled"
@@ -360,6 +364,7 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
orderType: '',
paymentMode: '',
resumeToken: '',
alipayMobilePrecreateDeepLink: false,
createdAt: 0,
}
}
@@ -480,12 +485,14 @@ function onPaymentDone() {
}
}
function onPaymentSuccess() {
async function onPaymentSuccess() {
const completedPayment = { ...paymentState.value }
removeRecoverySnapshot()
authStore.refreshUser()
if (paymentState.value.orderType === 'subscription') {
subscriptionStore.fetchActiveSubscriptions(true).catch(() => {})
}
await redirectToPaymentResult(completedPayment)
}
function onPaymentSettled() {
@@ -772,6 +779,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
isMobile: isMobileDevice(),
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
forceQRCode: !!(checkout.value.alipay_force_qrcode && normalizeVisibleMethod(requestType) === 'alipay'),
mobilePrecreateDeepLink: checkout.value.alipay_mobile_precreate_deep_link === true,
})
if (options.openid) {
payload.openid = options.openid
@@ -820,6 +828,7 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
isMobile: isMobileDevice(),
isWechatBrowser: typeof window !== 'undefined' && /MicroMessenger/i.test(window.navigator.userAgent),
forceQRCode: !!(checkout.value.alipay_force_qrcode && visibleMethod === 'alipay'),
mobilePrecreateDeepLink: checkout.value.alipay_mobile_precreate_deep_link === true,
stripePopupUrl: stripeRouteUrl,
stripeRouteUrl,
airwallexRouteUrl,