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
29caf851
Commit
29caf851
authored
Apr 22, 2026
by
IanShaw027
Browse files
fix(frontend): stabilize wechat payment resume recovery
parent
d6a04bb7
Changes
6
Hide whitespace changes
Inline
Side-by-side
frontend/src/views/user/PaymentResultView.vue
View file @
29caf851
...
...
@@ -181,6 +181,54 @@ function isPendingStatus(status: string | null | undefined): boolean {
return
PENDING_STATUSES
.
has
(
normalizeOrderStatus
(
status
))
}
function
readRouteQueryString
(
key
:
string
):
string
{
const
value
=
route
.
query
[
key
]
if
(
Array
.
isArray
(
value
))
{
return
typeof
value
[
0
]
===
'
string
'
?
value
[
0
]
:
''
}
return
typeof
value
===
'
string
'
?
value
:
''
}
function
restoreRecoverySnapshot
(
context
:
{
resumeToken
:
string
routeOrderId
:
number
routeOutTradeNo
:
string
})
{
if
(
typeof
window
===
'
undefined
'
)
{
return
null
}
const
rawSnapshot
=
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)
if
(
!
rawSnapshot
)
{
return
null
}
if
(
context
.
resumeToken
)
{
return
readPaymentRecoverySnapshot
(
rawSnapshot
,
{
resumeToken
:
context
.
resumeToken
,
})
}
if
(
!
context
.
routeOrderId
&&
!
context
.
routeOutTradeNo
)
{
return
null
}
const
restored
=
readPaymentRecoverySnapshot
(
rawSnapshot
)
if
(
!
restored
)
{
return
null
}
if
(
context
.
routeOrderId
>
0
&&
restored
.
orderId
!==
context
.
routeOrderId
)
{
return
null
}
if
(
context
.
routeOutTradeNo
&&
restored
.
outTradeNo
!==
context
.
routeOutTradeNo
)
{
return
null
}
return
restored
}
async
function
resolveOrderFromResumeToken
(
resumeToken
:
string
):
Promise
<
PaymentOrder
|
null
>
{
try
{
const
result
=
await
paymentAPI
.
resolveOrderPublicByResumeToken
(
resumeToken
)
...
...
@@ -239,24 +287,21 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
}
onMounted
(
async
()
=>
{
const
resumeToken
=
typeof
route
.
query
.
resume_token
===
'
string
'
?
route
.
query
.
resume_token
:
''
const
routeOrderId
=
Number
(
route
.
query
.
order_id
)
||
0
let
outTradeNo
=
String
(
route
.
query
.
out_trade_no
||
''
)
const
resumeToken
=
readRouteQueryString
(
'
resume_token
'
)
const
routeOrderId
=
Number
(
readRouteQueryString
(
'
order_id
'
))
||
0
let
outTradeNo
=
readRouteQueryString
(
'
out_trade_no
'
)
let
orderId
=
0
if
(
typeof
window
!==
'
undefined
'
)
{
const
restored
=
readPaymentRecoverySnapshot
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
),
resumeToken
?
{
resumeToken
}
:
{},
)
if
(
restored
?.
orderId
)
{
orderId
=
restored
.
orderId
}
if
(
!
outTradeNo
&&
restored
?.
outTradeNo
)
{
outTradeNo
=
restored
.
outTradeNo
}
const
restored
=
restoreRecoverySnapshot
({
resumeToken
,
routeOrderId
,
routeOutTradeNo
:
outTradeNo
,
})
if
(
restored
?.
orderId
)
{
orderId
=
restored
.
orderId
}
if
(
!
outTradeNo
&&
restored
?.
outTradeNo
)
{
outTradeNo
=
restored
.
outTradeNo
}
if
(
resumeToken
)
{
...
...
@@ -266,15 +311,14 @@ onMounted(async () => {
if
(
!
orderId
)
{
orderId
=
resolvedOrder
.
id
}
}
else
if
(
routeOrderId
>
0
)
{
orderId
=
routeOrderId
}
}
if
(
!
resumeToken
)
{
}
else
if
(
routeOrderId
>
0
)
{
orderId
=
routeOrderId
}
const
hasLegacyFallbackContext
=
typeof
route
.
query
.
trade_status
===
'
string
'
&&
route
.
query
.
trade_status
.
trim
()
!==
''
const
hasLegacyFallbackContext
=
readRouteQueryString
(
'
trade_status
'
).
trim
()
!==
''
const
shouldUsePublicOutTradeNo
=
!
resumeToken
&&
outTradeNo
!==
''
&&
(
hasLegacyFallbackContext
||
routeOrderId
>
0
||
orderId
>
0
)
if
(
!
order
.
value
&&
shouldUsePublicOutTradeNo
)
{
...
...
@@ -287,7 +331,7 @@ onMounted(async () => {
}
}
if
(
!
order
.
value
&&
!
resumeToken
&&
o
rderId
)
{
if
(
!
order
.
value
&&
orderId
&&
(
!
resumeToken
||
routeO
rderId
>
0
)
)
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
...
...
frontend/src/views/user/PaymentView.vue
View file @
29caf851
...
...
@@ -409,6 +409,43 @@ async function redirectToPaymentResult(state: PaymentRecoverySnapshot): Promise<
}
)
}
function
buildWechatOAuthAuthorizeUrl
(
authorizeUrl
:
string
,
context
:
{
paymentType
:
string
;
orderType
:
OrderType
;
planId
?:
number
;
orderAmount
:
number
}
,
):
string
{
const
normalizedUrl
=
authorizeUrl
.
trim
()
if
(
!
normalizedUrl
||
typeof
window
===
'
undefined
'
)
{
return
normalizedUrl
}
try
{
const
targetUrl
=
new
URL
(
normalizedUrl
,
window
.
location
.
origin
)
const
redirectPath
=
targetUrl
.
searchParams
.
get
(
'
redirect
'
)
||
'
/purchase
'
const
redirectUrl
=
new
URL
(
redirectPath
,
window
.
location
.
origin
)
const
paymentType
=
normalizeVisibleMethod
(
context
.
paymentType
)
||
context
.
paymentType
.
trim
()
||
'
wxpay
'
redirectUrl
.
searchParams
.
set
(
'
payment_type
'
,
paymentType
)
redirectUrl
.
searchParams
.
set
(
'
order_type
'
,
context
.
orderType
)
if
(
context
.
planId
)
{
redirectUrl
.
searchParams
.
set
(
'
plan_id
'
,
String
(
context
.
planId
))
}
else
{
redirectUrl
.
searchParams
.
delete
(
'
plan_id
'
)
}
if
(
context
.
orderAmount
>
0
)
{
redirectUrl
.
searchParams
.
set
(
'
amount
'
,
String
(
context
.
orderAmount
))
}
else
{
redirectUrl
.
searchParams
.
delete
(
'
amount
'
)
}
targetUrl
.
searchParams
.
set
(
'
redirect
'
,
`${redirectUrl.pathname
}
${redirectUrl.search
}
`
)
return
targetUrl
.
toString
()
}
catch
{
return
normalizedUrl
}
}
function
onPaymentDone
()
{
const
wasSubscription
=
paymentState
.
value
.
orderType
===
'
subscription
'
resetPayment
()
...
...
@@ -676,7 +713,12 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
)
if
(
decision
.
kind
===
'
wechat_oauth
'
&&
decision
.
oauth
?.
authorize_url
)
{
window
.
location
.
href
=
decision
.
oauth
.
authorize_url
window
.
location
.
href
=
buildWechatOAuthAuthorizeUrl
(
decision
.
oauth
.
authorize_url
,
{
paymentType
:
visibleMethod
,
orderType
,
planId
,
orderAmount
,
}
)
return
}
...
...
frontend/src/views/user/__tests__/PaymentResultView.spec.ts
View file @
29caf851
...
...
@@ -220,6 +220,41 @@ describe('PaymentResultView', () => {
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
it
(
'
falls back to order_id polling when resume-token recovery fails
'
,
async
()
=>
{
routeState
.
query
=
{
resume_token
:
'
resume-fail
'
,
order_id
:
'
77
'
,
}
window
.
localStorage
.
setItem
(
PAYMENT_RECOVERY_STORAGE_KEY
,
JSON
.
stringify
({
...
recoverySnapshotFactory
(
'
resume-fail
'
),
orderId
:
42
,
}),
)
resolveOrderPublicByResumeToken
.
mockRejectedValueOnce
(
new
Error
(
'
resume failed
'
))
pollOrderStatus
.
mockResolvedValueOnce
({
...
orderFactory
(
'
PAID
'
),
id
:
77
,
})
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
stubs
:
{
OrderStatusBadge
:
true
,
},
},
})
await
flushPromises
()
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-fail
'
)
expect
(
pollOrderStatus
).
toHaveBeenCalledWith
(
77
)
expect
(
verifyOrderPublic
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
it
(
'
does not fall back to public out_trade_no verification when resume_token recovery fails
'
,
async
()
=>
{
routeState
.
query
=
{
resume_token
:
'
resume-fail
'
,
...
...
@@ -241,6 +276,32 @@ describe('PaymentResultView', () => {
expect
(
verifyOrderPublic
).
not
.
toHaveBeenCalled
()
})
it
(
'
ignores a stale global recovery snapshot when legacy return markers do not identify the order
'
,
async
()
=>
{
routeState
.
query
=
{
trade_status
:
'
TRADE_SUCCESS
'
,
}
window
.
localStorage
.
setItem
(
PAYMENT_RECOVERY_STORAGE_KEY
,
JSON
.
stringify
(
recoverySnapshotFactory
(
'
resume-stale
'
)),
)
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
stubs
:
{
OrderStatusBadge
:
true
,
},
},
})
await
flushPromises
()
expect
(
resolveOrderPublicByResumeToken
).
not
.
toHaveBeenCalled
()
expect
(
verifyOrderPublic
).
not
.
toHaveBeenCalled
()
expect
(
pollOrderStatus
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.failed
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
sub2_20260420abcd1234
'
)
})
it
(
'
uses public out_trade_no verification when no signed resume context is available
'
,
async
()
=>
{
routeState
.
query
=
{
out_trade_no
:
'
legacy-123
'
,
...
...
frontend/src/views/user/__tests__/PaymentView.spec.ts
View file @
29caf851
...
...
@@ -109,6 +109,35 @@ function checkoutInfoFixture() {
}
}
function
checkoutInfoWithPlansFixture
()
{
return
{
data
:
{
...
checkoutInfoFixture
().
data
,
plans
:
[
{
id
:
7
,
group_id
:
3
,
name
:
'
Starter
'
,
description
:
''
,
price
:
128
,
original_price
:
0
,
validity_days
:
30
,
validity_unit
:
'
day
'
,
rate_multiplier
:
1
,
daily_limit_usd
:
null
,
weekly_limit_usd
:
null
,
monthly_limit_usd
:
null
,
features
:
[],
group_platform
:
'
openai
'
,
sort_order
:
1
,
for_sale
:
true
,
group_name
:
'
OpenAI
'
,
},
],
},
}
}
function
jsapiOrderFixture
(
resumeToken
:
string
)
{
return
{
order_id
:
123
,
...
...
@@ -131,6 +160,24 @@ function jsapiOrderFixture(resumeToken: string) {
}
}
function
oauthOrderFixture
()
{
return
{
order_id
:
456
,
amount
:
128
,
pay_amount
:
128
,
fee_rate
:
0
,
expires_at
:
'
2099-01-01T00:10:00.000Z
'
,
payment_type
:
'
wxpay
'
,
result_type
:
'
oauth_required
'
as
const
,
oauth
:
{
authorize_url
:
'
/api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay&redirect=%2Fpurchase%3Ffrom%3Dwechat
'
,
appid
:
'
wx123
'
,
scope
:
'
snsapi_base
'
,
redirect_url
:
'
/auth/wechat/payment/callback
'
,
},
}
}
describe
(
'
PaymentView WeChat JSAPI flow
'
,
()
=>
{
beforeEach
(()
=>
{
routeState
.
path
=
'
/purchase
'
...
...
@@ -239,4 +286,78 @@ describe('PaymentView WeChat JSAPI flow', () => {
}))
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
it
(
'
keeps subscription resume context for token-only WeChat callbacks
'
,
async
()
=>
{
routeState
.
query
=
{
wechat_resume
:
'
1
'
,
wechat_resume_token
:
'
resume-subscription-7
'
,
payment_type
:
'
wxpay_direct
'
,
order_type
:
'
subscription
'
,
plan_id
:
'
7
'
,
}
getCheckoutInfo
.
mockResolvedValue
(
checkoutInfoWithPlansFixture
())
createOrder
.
mockResolvedValue
(
oauthOrderFixture
())
const
originalLocation
=
window
.
location
const
locationState
=
{
href
:
'
http://localhost/purchase
'
,
origin
:
'
http://localhost
'
,
}
Object
.
defineProperty
(
window
,
'
location
'
,
{
configurable
:
true
,
value
:
locationState
,
})
shallowMount
(
PaymentView
,
{
global
:
{
stubs
:
{
Teleport
:
true
,
Transition
:
false
,
},
},
})
await
flushPromises
()
await
flushPromises
()
expect
(
routerReplace
).
toHaveBeenCalledWith
({
path
:
'
/purchase
'
,
query
:
{}
})
expect
(
createOrder
).
toHaveBeenCalledWith
(
expect
.
objectContaining
({
payment_type
:
'
wxpay
'
,
order_type
:
'
subscription
'
,
plan_id
:
7
,
wechat_resume_token
:
'
resume-subscription-7
'
,
}))
expect
(
locationState
.
href
).
toContain
(
'
/api/v1/auth/oauth/wechat/payment/start?
'
)
expect
(
new
URL
(
locationState
.
href
,
'
http://localhost
'
).
searchParams
.
get
(
'
redirect
'
)).
toBe
(
'
/purchase?from=wechat&payment_type=wxpay&order_type=subscription&plan_id=7
'
,
)
Object
.
defineProperty
(
window
,
'
location
'
,
{
configurable
:
true
,
value
:
originalLocation
,
})
})
it
(
'
shows explicit H5 authorization guidance instead of failing silently
'
,
async
()
=>
{
routeState
.
query
=
{
wechat_resume
:
'
1
'
,
wechat_resume_token
:
'
resume-token-h5
'
,
payment_type
:
'
wxpay_direct
'
,
}
createOrder
.
mockRejectedValueOnce
({
reason
:
'
WECHAT_H5_NOT_AUTHORIZED
'
})
shallowMount
(
PaymentView
,
{
global
:
{
stubs
:
{
Teleport
:
true
,
Transition
:
false
,
},
},
})
await
flushPromises
()
await
flushPromises
()
expect
(
showError
).
toHaveBeenCalledWith
(
'
payment.errors.wechatH5NotAuthorized payment.errors.wechatOpenInWeChatHint
'
,
)
})
})
frontend/src/views/user/__tests__/paymentWechatResume.spec.ts
View file @
29caf851
...
...
@@ -14,8 +14,9 @@ describe('parseWechatResumeRoute', () => {
},
[],
88
)).
toEqual
({
wechatResumeToken
:
'
resume-token-123
'
,
paymentType
:
'
wxpay
'
,
orderType
:
'
balance
'
,
orderType
:
'
subscription
'
,
orderAmount
:
0
,
planId
:
7
,
})
})
...
...
frontend/src/views/user/paymentWechatResume.ts
View file @
29caf851
...
...
@@ -37,12 +37,20 @@ export function parseWechatResumeRoute(
}
const
wechatResumeToken
=
readQueryString
(
query
,
'
wechat_resume_token
'
)
const
paymentType
=
normalizeVisibleMethod
(
readQueryString
(
query
,
'
payment_type
'
))
||
'
wxpay
'
const
planId
=
Number
.
parseInt
(
readQueryString
(
query
,
'
plan_id
'
),
10
)
const
hasPlanId
=
Number
.
isFinite
(
planId
)
&&
planId
>
0
const
orderType
=
readQueryString
(
query
,
'
order_type
'
)
===
'
subscription
'
||
hasPlanId
?
'
subscription
'
:
'
balance
'
if
(
wechatResumeToken
)
{
return
{
wechatResumeToken
,
paymentType
:
'
wxpay
'
,
orderType
:
'
balance
'
,
paymentType
,
orderType
,
orderAmount
:
0
,
planId
:
hasPlanId
?
planId
:
undefined
,
}
}
...
...
@@ -51,9 +59,6 @@ export function parseWechatResumeRoute(
return
null
}
const
paymentType
=
normalizeVisibleMethod
(
readQueryString
(
query
,
'
payment_type
'
))
||
'
wxpay
'
const
orderType
=
readQueryString
(
query
,
'
order_type
'
)
===
'
subscription
'
?
'
subscription
'
:
'
balance
'
const
planId
=
Number
.
parseInt
(
readQueryString
(
query
,
'
plan_id
'
),
10
)
const
rawAmount
=
Number
.
parseFloat
(
readQueryString
(
query
,
'
amount
'
))
const
orderAmount
=
Number
.
isFinite
(
rawAmount
)
&&
rawAmount
>
0
?
rawAmount
...
...
@@ -66,7 +71,7 @@ export function parseWechatResumeRoute(
paymentType
,
orderType
,
orderAmount
,
planId
:
Number
.
isFinite
(
planId
)
&&
p
lanId
>
0
?
planId
:
undefined
,
planId
:
hasP
lanId
?
planId
:
undefined
,
}
}
...
...
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