Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
c297d011
Commit
c297d011
authored
Apr 21, 2026
by
IanShaw027
Browse files
Keep pending payment results in processing state
parent
067eb23d
Changes
4
Show whitespace changes
Inline
Side-by-side
frontend/src/i18n/locales/en.ts
View file @
c297d011
...
...
@@ -5441,6 +5441,8 @@ export default {
result
:
{
success
:
'
Payment Successful
'
,
subscriptionSuccess
:
'
Subscription Successful
'
,
processing
:
'
Payment Processing
'
,
processingHint
:
'
Payment confirmation is still pending. This page will refresh automatically.
'
,
failed
:
'
Payment Failed
'
,
backToRecharge
:
'
Back to Recharge
'
,
viewOrders
:
'
View Orders
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
c297d011
...
...
@@ -5629,6 +5629,8 @@ export default {
result
:
{
success
:
'
支付成功
'
,
subscriptionSuccess
:
'
订阅成功
'
,
processing
:
'
支付处理中
'
,
processingHint
:
'
支付结果仍在确认中,页面会自动刷新。
'
,
failed
:
'
支付失败
'
,
backToRecharge
:
'
返回充值
'
,
viewOrders
:
'
查看订单
'
,
...
...
frontend/src/views/user/PaymentResultView.vue
View file @
c297d011
...
...
@@ -15,6 +15,10 @@
<path
stroke-linecap=
"round"
stroke-linejoin=
"round"
d=
"M5 13l4 4L19 7"
/>
</svg>
</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
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"
>
...
...
@@ -22,8 +26,11 @@
</svg>
</div>
<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>
<p
v-if=
"isPending"
class=
"mt-2 text-sm text-gray-500 dark:text-gray-400"
>
{{
t
(
'
payment.result.processingHint
'
)
}}
</p>
</div>
<!-- Order Info -->
<div
v-if=
"order"
class=
"rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800"
>
...
...
@@ -90,7 +97,7 @@
</template>
<
script
setup
lang=
"ts"
>
import
{
ref
,
computed
,
onMounted
}
from
'
vue
'
import
{
ref
,
computed
,
onBeforeUnmount
,
onMounted
}
from
'
vue
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
OrderStatusBadge
from
'
@/components/payment/OrderStatusBadge.vue
'
...
...
@@ -117,6 +124,12 @@ interface ReturnInfo {
const
returnInfo
=
ref
<
ReturnInfo
|
null
>
(
null
)
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 */
const
baseAmount
=
computed
(()
=>
{
...
...
@@ -131,13 +144,65 @@ const feeAmount = 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
{
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
()
=>
{
const
resumeToken
=
typeof
route
.
query
.
resume_token
===
'
string
'
?
route
.
query
.
resume_token
...
...
@@ -145,6 +210,7 @@ onMounted(async () => {
const
routeOrderId
=
Number
(
route
.
query
.
order_id
)
||
0
const
outTradeNo
=
String
(
route
.
query
.
out_trade_no
||
''
)
let
orderId
=
0
let
canUseLegacyPublicVerify
=
false
if
(
resumeToken
&&
typeof
window
!==
'
undefined
'
)
{
const
restored
=
readPaymentRecoverySnapshot
(
...
...
@@ -191,6 +257,7 @@ onMounted(async () => {
const
hasLegacyFallbackContext
=
typeof
route
.
query
.
trade_status
===
'
string
'
&&
route
.
query
.
trade_status
.
trim
()
!==
''
if
(
!
order
.
value
&&
!
resumeToken
&&
!
orderId
&&
outTradeNo
&&
hasLegacyFallbackContext
)
{
canUseLegacyPublicVerify
=
true
returnInfo
.
value
=
{
outTradeNo
,
money
:
String
(
route
.
query
.
money
||
''
),
...
...
@@ -208,6 +275,45 @@ onMounted(async () => {
}
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
})
onBeforeUnmount
(()
=>
{
clearStatusRefreshTimer
()
})
</
script
>
frontend/src/views/user/__tests__/PaymentResultView.spec.ts
View file @
c297d011
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
afterEach
,
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
const
routeState
=
vi
.
hoisted
(()
=>
({
...
...
@@ -73,7 +73,11 @@ describe('PaymentResultView', () => {
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
=
{
resume_token
:
'
resume-42
'
,
order_id
:
'
999
'
,
...
...
@@ -107,8 +111,43 @@ describe('PaymentResultView', () => {
expect
(
pollOrderStatus
).
toHaveBeenCalledWith
(
42
)
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.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
()
=>
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment