Keep pending payment results in processing state
This commit is contained in:
@@ -5441,6 +5441,8 @@ export default {
|
|||||||
result: {
|
result: {
|
||||||
success: 'Payment Successful',
|
success: 'Payment Successful',
|
||||||
subscriptionSuccess: 'Subscription Successful',
|
subscriptionSuccess: 'Subscription Successful',
|
||||||
|
processing: 'Payment Processing',
|
||||||
|
processingHint: 'Payment confirmation is still pending. This page will refresh automatically.',
|
||||||
failed: 'Payment Failed',
|
failed: 'Payment Failed',
|
||||||
backToRecharge: 'Back to Recharge',
|
backToRecharge: 'Back to Recharge',
|
||||||
viewOrders: 'View Orders',
|
viewOrders: 'View Orders',
|
||||||
|
|||||||
@@ -5629,6 +5629,8 @@ export default {
|
|||||||
result: {
|
result: {
|
||||||
success: '支付成功',
|
success: '支付成功',
|
||||||
subscriptionSuccess: '订阅成功',
|
subscriptionSuccess: '订阅成功',
|
||||||
|
processing: '支付处理中',
|
||||||
|
processingHint: '支付结果仍在确认中,页面会自动刷新。',
|
||||||
failed: '支付失败',
|
failed: '支付失败',
|
||||||
backToRecharge: '返回充值',
|
backToRecharge: '返回充值',
|
||||||
viewOrders: '查看订单',
|
viewOrders: '查看订单',
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="isPending"
|
||||||
|
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/30">
|
||||||
|
<div class="h-10 w-10 animate-spin rounded-full border-4 border-yellow-500 border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
<div v-else
|
<div v-else
|
||||||
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||||
<svg class="h-10 w-10 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-10 w-10 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
@@ -22,8 +26,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="mt-4 text-2xl font-bold text-gray-900 dark:text-white">
|
<h2 class="mt-4 text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
{{ isSuccess ? t('payment.result.success') : t('payment.result.failed') }}
|
{{ statusTitle }}
|
||||||
</h2>
|
</h2>
|
||||||
|
<p v-if="isPending" class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('payment.result.processingHint') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- Order Info -->
|
<!-- Order Info -->
|
||||||
<div v-if="order" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
|
<div v-if="order" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
|
||||||
@@ -90,7 +97,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
||||||
@@ -117,6 +124,12 @@ interface ReturnInfo {
|
|||||||
const returnInfo = ref<ReturnInfo | null>(null)
|
const returnInfo = ref<ReturnInfo | null>(null)
|
||||||
|
|
||||||
const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING'])
|
const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING'])
|
||||||
|
const PENDING_STATUSES = new Set(['PENDING', 'CREATED', 'WAITING', 'PROCESSING'])
|
||||||
|
const STATUS_REFRESH_INTERVAL_MS = 2000
|
||||||
|
const STATUS_REFRESH_MAX_ATTEMPTS = 15
|
||||||
|
|
||||||
|
let statusRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const refreshAttempts = ref(0)
|
||||||
|
|
||||||
/** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */
|
/** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */
|
||||||
const baseAmount = computed(() => {
|
const baseAmount = computed(() => {
|
||||||
@@ -131,13 +144,65 @@ const feeAmount = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isSuccess = computed(() => {
|
const isSuccess = computed(() => {
|
||||||
return !!order.value && SUCCESS_STATUSES.has(order.value.status)
|
return isSuccessStatus(order.value?.status)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPending = computed(() => {
|
||||||
|
return isPendingStatus(order.value?.status)
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusTitle = computed(() => {
|
||||||
|
if (isSuccess.value) {
|
||||||
|
return t('payment.result.success')
|
||||||
|
}
|
||||||
|
if (isPending.value) {
|
||||||
|
return t('payment.result.processing')
|
||||||
|
}
|
||||||
|
return t('payment.result.failed')
|
||||||
})
|
})
|
||||||
|
|
||||||
function normalizedOrderPaymentType(paymentType: string): string {
|
function normalizedOrderPaymentType(paymentType: string): string {
|
||||||
return normalizePaymentMethodForDisplay(paymentType) || paymentType
|
return normalizePaymentMethodForDisplay(paymentType) || paymentType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOrderStatus(status: string | null | undefined): string {
|
||||||
|
return String(status || '').trim().toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSuccessStatus(status: string | null | undefined): boolean {
|
||||||
|
return SUCCESS_STATUSES.has(normalizeOrderStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPendingStatus(status: string | null | undefined): boolean {
|
||||||
|
return PENDING_STATUSES.has(normalizeOrderStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStatusRefreshTimer(): void {
|
||||||
|
if (statusRefreshTimer !== null) {
|
||||||
|
clearTimeout(statusRefreshTimer)
|
||||||
|
statusRefreshTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>) | null): void {
|
||||||
|
clearStatusRefreshTimer()
|
||||||
|
if (!refreshOrder || !isPending.value || refreshAttempts.value >= STATUS_REFRESH_MAX_ATTEMPTS) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
statusRefreshTimer = setTimeout(async () => {
|
||||||
|
refreshAttempts.value += 1
|
||||||
|
const refreshedOrder = await refreshOrder()
|
||||||
|
if (refreshedOrder) {
|
||||||
|
order.value = refreshedOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPendingStatus(order.value?.status)) {
|
||||||
|
scheduleStatusRefresh(refreshOrder)
|
||||||
|
}
|
||||||
|
}, STATUS_REFRESH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const resumeToken = typeof route.query.resume_token === 'string'
|
const resumeToken = typeof route.query.resume_token === 'string'
|
||||||
? route.query.resume_token
|
? route.query.resume_token
|
||||||
@@ -145,6 +210,7 @@ onMounted(async () => {
|
|||||||
const routeOrderId = Number(route.query.order_id) || 0
|
const routeOrderId = Number(route.query.order_id) || 0
|
||||||
const outTradeNo = String(route.query.out_trade_no || '')
|
const outTradeNo = String(route.query.out_trade_no || '')
|
||||||
let orderId = 0
|
let orderId = 0
|
||||||
|
let canUseLegacyPublicVerify = false
|
||||||
|
|
||||||
if (resumeToken && typeof window !== 'undefined') {
|
if (resumeToken && typeof window !== 'undefined') {
|
||||||
const restored = readPaymentRecoverySnapshot(
|
const restored = readPaymentRecoverySnapshot(
|
||||||
@@ -191,6 +257,7 @@ onMounted(async () => {
|
|||||||
const hasLegacyFallbackContext = typeof route.query.trade_status === 'string'
|
const hasLegacyFallbackContext = typeof route.query.trade_status === 'string'
|
||||||
&& route.query.trade_status.trim() !== ''
|
&& route.query.trade_status.trim() !== ''
|
||||||
if (!order.value && !resumeToken && !orderId && outTradeNo && hasLegacyFallbackContext) {
|
if (!order.value && !resumeToken && !orderId && outTradeNo && hasLegacyFallbackContext) {
|
||||||
|
canUseLegacyPublicVerify = true
|
||||||
returnInfo.value = {
|
returnInfo.value = {
|
||||||
outTradeNo,
|
outTradeNo,
|
||||||
money: String(route.query.money || ''),
|
money: String(route.query.money || ''),
|
||||||
@@ -208,6 +275,45 @@ onMounted(async () => {
|
|||||||
} catch (_e: unknown) { /* fall through */ }
|
} catch (_e: unknown) { /* fall through */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshOrder = async (): Promise<PaymentOrder | null> => {
|
||||||
|
if (resumeToken) {
|
||||||
|
try {
|
||||||
|
const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken)
|
||||||
|
return result.data
|
||||||
|
} catch (_err: unknown) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderId) {
|
||||||
|
return await paymentStore.pollOrderStatus(orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canUseLegacyPublicVerify && outTradeNo) {
|
||||||
|
try {
|
||||||
|
const result = await paymentAPI.verifyOrderPublic(outTradeNo)
|
||||||
|
return result.data
|
||||||
|
} catch (_err: unknown) {
|
||||||
|
try {
|
||||||
|
const result = await paymentAPI.verifyOrder(outTradeNo)
|
||||||
|
return result.data
|
||||||
|
} catch (_e: unknown) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPendingStatus(order.value?.status)) {
|
||||||
|
scheduleStatusRefresh(refreshOrder)
|
||||||
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearStatusRefreshTimer()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
|
||||||
const routeState = vi.hoisted(() => ({
|
const routeState = vi.hoisted(() => ({
|
||||||
@@ -73,7 +73,11 @@ describe('PaymentResultView', () => {
|
|||||||
window.localStorage.clear()
|
window.localStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('restores order id from a matching resume token and does not trust query success flags', async () => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a pending state instead of a failure state when the restored order is still pending', async () => {
|
||||||
routeState.query = {
|
routeState.query = {
|
||||||
resume_token: 'resume-42',
|
resume_token: 'resume-42',
|
||||||
order_id: '999',
|
order_id: '999',
|
||||||
@@ -107,8 +111,43 @@ describe('PaymentResultView', () => {
|
|||||||
|
|
||||||
expect(pollOrderStatus).toHaveBeenCalledWith(42)
|
expect(pollOrderStatus).toHaveBeenCalledWith(42)
|
||||||
expect(verifyOrderPublic).not.toHaveBeenCalled()
|
expect(verifyOrderPublic).not.toHaveBeenCalled()
|
||||||
expect(wrapper.text()).toContain('payment.result.failed')
|
expect(wrapper.text()).toContain('payment.result.processing')
|
||||||
expect(wrapper.text()).not.toContain('payment.result.success')
|
expect(wrapper.text()).not.toContain('payment.result.success')
|
||||||
|
expect(wrapper.text()).not.toContain('payment.result.failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes a pending resume-token result until the order becomes paid', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
routeState.query = {
|
||||||
|
resume_token: 'resume-77',
|
||||||
|
}
|
||||||
|
resolveOrderPublicByResumeToken
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: orderFactory('PENDING'),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: orderFactory('PAID'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mount(PaymentResultView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
OrderStatusBadge: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1)
|
||||||
|
expect(wrapper.text()).toContain('payment.result.processing')
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2)
|
||||||
|
expect(wrapper.text()).toContain('payment.result.success')
|
||||||
|
expect(wrapper.text()).not.toContain('payment.result.failed')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not fall back to public out_trade_no verification when resume_token recovery fails', async () => {
|
it('does not fall back to public out_trade_no verification when resume_token recovery fails', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user