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
a27a7add
Commit
a27a7add
authored
Apr 21, 2026
by
IanShaw027
Browse files
fix payment resume result consistency
parent
e12599c1
Changes
6
Hide whitespace changes
Inline
Side-by-side
backend/internal/service/payment_resume_lookup.go
View file @
a27a7add
...
@@ -30,6 +30,15 @@ func (s *PaymentService) GetPublicOrderByResumeToken(ctx context.Context, token
...
@@ -30,6 +30,15 @@ func (s *PaymentService) GetPublicOrderByResumeToken(ctx context.Context, token
if
claims
.
PaymentType
!=
""
&&
strings
.
TrimSpace
(
order
.
PaymentType
)
!=
claims
.
PaymentType
{
if
claims
.
PaymentType
!=
""
&&
strings
.
TrimSpace
(
order
.
PaymentType
)
!=
claims
.
PaymentType
{
return
nil
,
fmt
.
Errorf
(
"resume token payment type mismatch"
)
return
nil
,
fmt
.
Errorf
(
"resume token payment type mismatch"
)
}
}
if
order
.
Status
==
OrderStatusPending
||
order
.
Status
==
OrderStatusExpired
{
result
:=
s
.
checkPaid
(
ctx
,
order
)
if
result
==
checkPaidResultAlreadyPaid
{
order
,
err
=
s
.
entClient
.
PaymentOrder
.
Get
(
ctx
,
order
.
ID
)
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"reload order by resume token: %w"
,
err
)
}
}
}
return
order
,
nil
return
order
,
nil
}
}
...
...
backend/internal/service/payment_resume_lookup_test.go
View file @
a27a7add
...
@@ -11,6 +11,35 @@ import (
...
@@ -11,6 +11,35 @@ import (
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/require"
)
)
type
paymentResumeLookupProvider
struct
{
queryCount
int
}
func
(
p
*
paymentResumeLookupProvider
)
Name
()
string
{
return
"resume-lookup-provider"
}
func
(
p
*
paymentResumeLookupProvider
)
ProviderKey
()
string
{
return
payment
.
TypeAlipay
}
func
(
p
*
paymentResumeLookupProvider
)
SupportedTypes
()
[]
payment
.
PaymentType
{
return
[]
payment
.
PaymentType
{
payment
.
TypeAlipay
}
}
func
(
p
*
paymentResumeLookupProvider
)
CreatePayment
(
context
.
Context
,
payment
.
CreatePaymentRequest
)
(
*
payment
.
CreatePaymentResponse
,
error
)
{
panic
(
"unexpected call"
)
}
func
(
p
*
paymentResumeLookupProvider
)
QueryOrder
(
context
.
Context
,
string
)
(
*
payment
.
QueryOrderResponse
,
error
)
{
p
.
queryCount
++
return
&
payment
.
QueryOrderResponse
{
Status
:
payment
.
ProviderStatusPending
},
nil
}
func
(
p
*
paymentResumeLookupProvider
)
VerifyNotification
(
context
.
Context
,
string
,
map
[
string
]
string
)
(
*
payment
.
PaymentNotification
,
error
)
{
panic
(
"unexpected call"
)
}
func
(
p
*
paymentResumeLookupProvider
)
Refund
(
context
.
Context
,
payment
.
RefundRequest
)
(
*
payment
.
RefundResponse
,
error
)
{
panic
(
"unexpected call"
)
}
func
TestGetPublicOrderByResumeTokenReturnsMatchingOrder
(
t
*
testing
.
T
)
{
func
TestGetPublicOrderByResumeTokenReturnsMatchingOrder
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
ctx
:=
context
.
Background
()
client
:=
newPaymentConfigServiceTestClient
(
t
)
client
:=
newPaymentConfigServiceTestClient
(
t
)
...
@@ -116,3 +145,58 @@ func TestGetPublicOrderByResumeTokenRejectsSnapshotMismatch(t *testing.T) {
...
@@ -116,3 +145,58 @@ func TestGetPublicOrderByResumeTokenRejectsSnapshotMismatch(t *testing.T) {
require
.
Error
(
t
,
err
)
require
.
Error
(
t
,
err
)
require
.
Contains
(
t
,
err
.
Error
(),
"resume token"
)
require
.
Contains
(
t
,
err
.
Error
(),
"resume token"
)
}
}
func
TestGetPublicOrderByResumeTokenChecksUpstreamForPendingOrder
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
client
:=
newPaymentConfigServiceTestClient
(
t
)
user
,
err
:=
client
.
User
.
Create
()
.
SetEmail
(
"resume-refresh@example.com"
)
.
SetPasswordHash
(
"hash"
)
.
SetUsername
(
"resume-refresh-user"
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
order
,
err
:=
client
.
PaymentOrder
.
Create
()
.
SetUserID
(
user
.
ID
)
.
SetUserEmail
(
user
.
Email
)
.
SetUserName
(
user
.
Username
)
.
SetAmount
(
88
)
.
SetPayAmount
(
88
)
.
SetFeeRate
(
0
)
.
SetRechargeCode
(
"RESUME-PENDING"
)
.
SetOutTradeNo
(
"sub2_resume_lookup_pending"
)
.
SetPaymentType
(
payment
.
TypeAlipay
)
.
SetPaymentTradeNo
(
"trade-pending"
)
.
SetOrderType
(
payment
.
OrderTypeBalance
)
.
SetStatus
(
OrderStatusPending
)
.
SetExpiresAt
(
time
.
Now
()
.
Add
(
time
.
Hour
))
.
SetClientIP
(
"127.0.0.1"
)
.
SetSrcHost
(
"api.example.com"
)
.
Save
(
ctx
)
require
.
NoError
(
t
,
err
)
resumeSvc
:=
NewPaymentResumeService
([]
byte
(
"0123456789abcdef0123456789abcdef"
))
token
,
err
:=
resumeSvc
.
CreateToken
(
ResumeTokenClaims
{
OrderID
:
order
.
ID
,
UserID
:
user
.
ID
,
PaymentType
:
payment
.
TypeAlipay
,
CanonicalReturnURL
:
"https://app.example.com/payment/result"
,
})
require
.
NoError
(
t
,
err
)
registry
:=
payment
.
NewRegistry
()
provider
:=
&
paymentResumeLookupProvider
{}
registry
.
Register
(
provider
)
svc
:=
&
PaymentService
{
entClient
:
client
,
registry
:
registry
,
resumeService
:
resumeSvc
,
providersLoaded
:
true
,
}
got
,
err
:=
svc
.
GetPublicOrderByResumeToken
(
ctx
,
token
)
require
.
NoError
(
t
,
err
)
require
.
Equal
(
t
,
order
.
ID
,
got
.
ID
)
require
.
Equal
(
t
,
1
,
provider
.
queryCount
)
}
frontend/src/components/payment/PaymentStatusPanel.vue
View file @
a27a7add
...
@@ -194,6 +194,10 @@ const countdownDisplay = computed(() => {
...
@@ -194,6 +194,10 @@ const countdownDisplay = computed(() => {
return
m
.
toString
().
padStart
(
2
,
'
0
'
)
+
'
:
'
+
s
.
toString
().
padStart
(
2
,
'
0
'
)
return
m
.
toString
().
padStart
(
2
,
'
0
'
)
+
'
:
'
+
s
.
toString
().
padStart
(
2
,
'
0
'
)
})
})
function
isSuccessStatus
(
status
:
string
|
null
|
undefined
):
boolean
{
return
status
===
'
COMPLETED
'
||
status
===
'
PAID
'
||
status
===
'
RECHARGING
'
}
function
reopenPopup
()
{
function
reopenPopup
()
{
if
(
props
.
payUrl
)
{
if
(
props
.
payUrl
)
{
const
win
=
window
.
open
(
props
.
payUrl
,
'
paymentPopup
'
,
POPUP_WINDOW_FEATURES
)
const
win
=
window
.
open
(
props
.
payUrl
,
'
paymentPopup
'
,
POPUP_WINDOW_FEATURES
)
...
@@ -222,7 +226,7 @@ async function pollStatus() {
...
@@ -222,7 +226,7 @@ async function pollStatus() {
if
(
!
props
.
orderId
||
outcome
.
value
)
return
if
(
!
props
.
orderId
||
outcome
.
value
)
return
const
order
=
await
paymentStore
.
pollOrderStatus
(
props
.
orderId
)
const
order
=
await
paymentStore
.
pollOrderStatus
(
props
.
orderId
)
if
(
!
order
)
return
if
(
!
order
)
return
if
(
order
.
status
===
'
COMPLETED
'
||
order
.
status
===
'
PAID
'
)
{
if
(
isSuccessStatus
(
order
.
status
)
)
{
cleanup
()
cleanup
()
paidOrder
.
value
=
order
paidOrder
.
value
=
order
setOutcome
(
'
success
'
)
setOutcome
(
'
success
'
)
...
...
frontend/src/components/payment/__tests__/PaymentStatusPanel.spec.ts
0 → 100644
View file @
a27a7add
import
{
afterEach
,
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
flushPromises
,
mount
}
from
'
@vue/test-utils
'
const
pollOrderStatus
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
cancelOrder
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
showError
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
toCanvas
=
vi
.
hoisted
(()
=>
vi
.
fn
())
vi
.
mock
(
'
vue-i18n
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-i18n
'
)
>
(
'
vue-i18n
'
)
return
{
...
actual
,
useI18n
:
()
=>
({
t
:
(
key
:
string
)
=>
key
,
}),
}
})
vi
.
mock
(
'
@/stores/payment
'
,
()
=>
({
usePaymentStore
:
()
=>
({
pollOrderStatus
,
}),
}))
vi
.
mock
(
'
@/stores
'
,
()
=>
({
useAppStore
:
()
=>
({
showError
,
}),
}))
vi
.
mock
(
'
@/api/payment
'
,
()
=>
({
paymentAPI
:
{
cancelOrder
,
},
}))
vi
.
mock
(
'
qrcode
'
,
()
=>
({
default
:
{
toCanvas
,
},
}))
import
PaymentStatusPanel
from
'
../PaymentStatusPanel.vue
'
const
orderFactory
=
(
status
:
string
)
=>
({
id
:
42
,
user_id
:
9
,
amount
:
88
,
pay_amount
:
88
,
fee_rate
:
0
,
payment_type
:
'
alipay
'
,
out_trade_no
:
'
sub2_20260420abcd1234
'
,
status
,
order_type
:
'
balance
'
,
created_at
:
'
2026-04-20T12:00:00Z
'
,
expires_at
:
'
2099-01-01T12:30:00Z
'
,
refund_amount
:
0
,
})
describe
(
'
PaymentStatusPanel
'
,
()
=>
{
beforeEach
(()
=>
{
vi
.
useFakeTimers
()
pollOrderStatus
.
mockReset
()
cancelOrder
.
mockReset
()
showError
.
mockReset
()
toCanvas
.
mockReset
().
mockResolvedValue
(
undefined
)
})
afterEach
(()
=>
{
vi
.
useRealTimers
()
})
it
(
'
treats RECHARGING as a successful terminal state
'
,
async
()
=>
{
pollOrderStatus
.
mockResolvedValue
(
orderFactory
(
'
RECHARGING
'
))
const
wrapper
=
mount
(
PaymentStatusPanel
,
{
props
:
{
orderId
:
42
,
qrCode
:
'
https://pay.example.com/qr/42
'
,
expiresAt
:
'
2099-01-01T12:30:00Z
'
,
paymentType
:
'
alipay
'
,
orderType
:
'
balance
'
,
},
global
:
{
stubs
:
{
Icon
:
true
,
},
},
})
await
flushPromises
()
await
vi
.
advanceTimersByTimeAsync
(
3000
)
await
flushPromises
()
expect
(
pollOrderStatus
).
toHaveBeenCalledWith
(
42
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
emitted
(
'
success
'
)).
toHaveLength
(
1
)
})
})
frontend/src/views/user/PaymentResultView.vue
View file @
a27a7add
...
@@ -177,6 +177,15 @@ function isPendingStatus(status: string | null | undefined): boolean {
...
@@ -177,6 +177,15 @@ function isPendingStatus(status: string | null | undefined): boolean {
return
PENDING_STATUSES
.
has
(
normalizeOrderStatus
(
status
))
return
PENDING_STATUSES
.
has
(
normalizeOrderStatus
(
status
))
}
}
async
function
resolveOrderFromResumeToken
(
resumeToken
:
string
):
Promise
<
PaymentOrder
|
null
>
{
try
{
const
result
=
await
paymentAPI
.
resolveOrderPublicByResumeToken
(
resumeToken
)
return
result
.
data
}
catch
(
_err
:
unknown
)
{
return
null
}
}
function
clearStatusRefreshTimer
():
void
{
function
clearStatusRefreshTimer
():
void
{
if
(
statusRefreshTimer
!==
null
)
{
if
(
statusRefreshTimer
!==
null
)
{
clearTimeout
(
statusRefreshTimer
)
clearTimeout
(
statusRefreshTimer
)
...
@@ -230,15 +239,13 @@ onMounted(async () => {
...
@@ -230,15 +239,13 @@ onMounted(async () => {
}
}
}
}
if
(
!
order
.
value
&&
resumeToken
)
{
if
(
resumeToken
)
{
try
{
const
resolvedOrder
=
await
resolveOrderFromResumeToken
(
resumeToken
)
const
result
=
await
paymentAPI
.
resolveOrder
PublicByResumeToken
(
resumeToken
)
if
(
resolve
d
Order
)
{
order
.
value
=
res
ult
.
data
order
.
value
=
res
olvedOrder
if
(
!
orderId
)
{
if
(
!
orderId
)
{
orderId
=
res
ult
.
data
.
id
orderId
=
res
olvedOrder
.
id
}
}
}
catch
(
_err
:
unknown
)
{
// Resume token recovery failed; do not trust legacy public out_trade_no fallback.
}
}
}
}
...
@@ -278,12 +285,7 @@ onMounted(async () => {
...
@@ -278,12 +285,7 @@ onMounted(async () => {
const
refreshOrder
=
async
():
Promise
<
PaymentOrder
|
null
>
=>
{
const
refreshOrder
=
async
():
Promise
<
PaymentOrder
|
null
>
=>
{
if
(
resumeToken
)
{
if
(
resumeToken
)
{
try
{
return
await
resolveOrderFromResumeToken
(
resumeToken
)
const
result
=
await
paymentAPI
.
resolveOrderPublicByResumeToken
(
resumeToken
)
return
result
.
data
}
catch
(
_err
:
unknown
)
{
return
null
}
}
}
if
(
orderId
)
{
if
(
orderId
)
{
...
...
frontend/src/views/user/__tests__/PaymentResultView.spec.ts
View file @
a27a7add
...
@@ -116,6 +116,58 @@ describe('PaymentResultView', () => {
...
@@ -116,6 +116,58 @@ describe('PaymentResultView', () => {
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.failed
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.failed
'
)
})
})
it
(
'
prefers the public resume-token result over a stale restored DB snapshot
'
,
async
()
=>
{
routeState
.
query
=
{
resume_token
:
'
resume-authoritative
'
,
order_id
:
'
42
'
,
status
:
'
success
'
,
}
window
.
localStorage
.
setItem
(
PAYMENT_RECOVERY_STORAGE_KEY
,
JSON
.
stringify
({
orderId
:
42
,
amount
:
88
,
qrCode
:
''
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
clientSecret
:
''
,
payAmount
:
88
,
orderType
:
'
balance
'
,
paymentMode
:
'
popup
'
,
resumeToken
:
'
resume-authoritative
'
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
}))
pollOrderStatus
.
mockResolvedValue
({
...
orderFactory
(
'
PENDING
'
),
amount
:
88
,
pay_amount
:
88
,
fee_rate
:
0
,
})
resolveOrderPublicByResumeToken
.
mockResolvedValue
({
data
:
{
...
orderFactory
(
'
PAID
'
),
amount
:
100
,
pay_amount
:
103
,
fee_rate
:
3
,
},
})
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
stubs
:
{
OrderStatusBadge
:
true
,
},
},
})
await
flushPromises
()
expect
(
pollOrderStatus
).
toHaveBeenCalledWith
(
42
)
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-authoritative
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
103.00
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
100.00
'
)
})
it
(
'
refreshes a pending resume-token result until the order becomes paid
'
,
async
()
=>
{
it
(
'
refreshes a pending resume-token result until the order becomes paid
'
,
async
()
=>
{
vi
.
useFakeTimers
()
vi
.
useFakeTimers
()
routeState
.
query
=
{
routeState
.
query
=
{
...
...
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