mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
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:
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,适合同主体下统一配置网页、移动和公众号场景。',
|
||||
|
||||
@@ -362,6 +362,14 @@ export default {
|
||||
cancelledDesc: '您已取消本次支付',
|
||||
waitingPayment: '等待支付...',
|
||||
cancelOrder: '取消订单',
|
||||
alipayOpening: '正在打开支付宝',
|
||||
alipayContinueInApp: '请在支付宝中完成支付',
|
||||
alipayWaitingHint: '支付结果将由服务端确认,本页面会自动更新',
|
||||
alipayFallbackTitle: '打开支付宝未成功',
|
||||
alipayFallbackHint: '可重新打开支付宝,或保存下方二维码后从支付宝相册识别',
|
||||
reopenAlipay: '重新打开支付宝',
|
||||
saveQRCode: '保存二维码',
|
||||
alipaySaveAndScanHint: '保存二维码后,打开支付宝扫一扫,从相册选择二维码',
|
||||
},
|
||||
orders: {
|
||||
title: '我的订单',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user