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
ddf80f5e
Unverified
Commit
ddf80f5e
authored
Apr 22, 2026
by
Wesley Liddick
Committed by
GitHub
Apr 22, 2026
Browse files
Merge pull request #1799 from IanShaw027/rebuild/auth-identity-foundation
fix(auth,payment,profile): 修复认证身份和支付系统的后续问题
parents
4d0483f5
c048ca80
Changes
140
Hide whitespace changes
Inline
Side-by-side
frontend/src/router/__tests__/guards.spec.ts
View file @
ddf80f5e
...
...
@@ -78,12 +78,13 @@ function simulateGuard(
return
authState
.
isAdmin
?
'
/admin/dashboard
'
:
'
/dashboard
'
}
if
(
authState
.
backendModeEnabled
&&
!
authState
.
isAuthenticated
)
{
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
,
'
/payment/result
'
]
const
callbackPaths
=
[
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
'
/auth/wechat/callback
'
,
'
/auth/wechat/payment/callback
'
,
]
const
pendingAuthPaths
=
[
'
/register
'
,
'
/email-verify
'
]
const
isAllowed
=
...
...
@@ -126,12 +127,13 @@ function simulateGuard(
if
(
authState
.
isAuthenticated
&&
authState
.
isAdmin
)
{
return
null
}
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
,
'
/payment/result
'
]
const
callbackPaths
=
[
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
'
/auth/wechat/callback
'
,
'
/auth/wechat/payment/callback
'
,
]
const
pendingAuthPaths
=
[
'
/register
'
,
'
/email-verify
'
]
const
isAllowed
=
...
...
@@ -448,6 +450,30 @@ describe('路由守卫逻辑', () => {
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: WeChat payment callback route is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/auth/wechat/payment/callback
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: /payment/result is allowed
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAdmin
:
false
,
isSimpleMode
:
false
,
backendModeEnabled
:
true
,
hasPendingAuthSession
:
false
,
}
const
redirect
=
simulateGuard
(
'
/payment/result
'
,
{
requiresAuth
:
false
},
authState
)
expect
(
redirect
).
toBeNull
()
})
it
(
'
unauthenticated: /register is allowed when a pending auth session exists
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
...
...
frontend/src/router/__tests__/wechat-route.spec.ts
View file @
ddf80f5e
...
...
@@ -52,4 +52,13 @@ describe('router WeChat OAuth route', () => {
expect
(
route
?.
meta
.
requiresAuth
).
toBe
(
false
)
expect
(
route
?.
meta
.
title
).
toBe
(
'
WeChat OAuth Callback
'
)
})
it
(
'
registers the WeChat payment callback route as a public route
'
,
async
()
=>
{
const
{
default
:
router
}
=
await
import
(
'
@/router
'
)
const
route
=
router
.
getRoutes
().
find
((
record
)
=>
record
.
name
===
'
WeChatPaymentOAuthCallback
'
)
expect
(
route
?.
path
).
toBe
(
'
/auth/wechat/payment/callback
'
)
expect
(
route
?.
meta
.
requiresAuth
).
toBe
(
false
)
expect
(
route
?.
meta
.
title
).
toBe
(
'
WeChat Payment Callback
'
)
})
})
frontend/src/router/index.ts
View file @
ddf80f5e
...
...
@@ -542,12 +542,13 @@ let authInitialized = false
const
navigationLoading
=
useNavigationLoadingState
()
// 延迟初始化预加载,传入 router 实例
let
routePrefetch
:
ReturnType
<
typeof
useRoutePrefetch
>
|
null
=
null
const
BACKEND_MODE_ALLOWED_PATHS
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
const
BACKEND_MODE_ALLOWED_PATHS
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
,
'
/payment/result
'
]
const
BACKEND_MODE_CALLBACK_PATHS
=
[
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
'
/auth/wechat/callback
'
,
'
/auth/wechat/payment/callback
'
,
]
const
BACKEND_MODE_PENDING_AUTH_PATHS
=
[
'
/register
'
,
'
/email-verify
'
]
...
...
frontend/src/views/admin/SettingsView.vue
View file @
ddf80f5e
...
...
@@ -2032,7 +2032,7 @@
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_use_pkce
"
:
disabled
=
"
tru
e
"
data
-
testid
=
"
oidc-connect-use-pkc
e
"
/>
<
/div
>
...
...
@@ -2046,7 +2046,7 @@
<
/div
>
<
Toggle
v
-
model
=
"
form.oidc_connect_validate_id_token
"
:
disabled
=
"
true
"
data
-
testid
=
"
oidc-connect-validate-id-token
"
/>
<
/div
>
...
...
@@ -3763,11 +3763,7 @@
<
p
class
=
"
mt-1 text-sm text-gray-500 dark:text-gray-400
"
>
{{
t
(
"
admin.settings.payment.description
"
)
}}
<
a
:
href
=
"
locale === 'zh'
? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md'
: 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md'
"
:
href
=
"
paymentGuideHref
"
target
=
"
_blank
"
rel
=
"
noopener noreferrer
"
class
=
"
ml-2 inline-flex items-center text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300
"
...
...
@@ -4140,11 +4136,7 @@
<
p
class
=
"
mt-2 text-xs text-gray-400 dark:text-gray-500
"
>
{{
t
(
"
admin.settings.payment.enabledPaymentTypesHint
"
)
}}
<
a
:
href
=
"
locale === 'zh'
? 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT_CN.md#%E6%94%AF%E6%8C%81%E7%9A%84%E6%94%AF%E4%BB%98%E6%96%B9%E5%BC%8F'
: 'https://github.com/Wei-Shaw/sub2api/blob/main/docs/PAYMENT.md#supported-payment-methods'
"
:
href
=
"
paymentGuideHref
"
target
=
"
_blank
"
rel
=
"
noopener noreferrer
"
class
=
"
ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300
"
...
...
@@ -4729,6 +4721,12 @@ function localText(zh: string, en: string): string {
return
locale
.
value
.
startsWith
(
"
zh
"
)
?
zh
:
en
;
}
const
paymentGuideHref
=
computed
(()
=>
locale
.
value
.
startsWith
(
"
zh
"
)
?
"
https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98
"
:
"
https://github.com/Wei-Shaw/sub2api/blob/main/README.md#payment
"
,
);
type
SettingsTab
=
|
"
general
"
|
"
security
"
...
...
@@ -4961,8 +4959,8 @@ const form = reactive<SettingsForm>({
oidc_connect_redirect_url
:
""
,
oidc_connect_frontend_redirect_url
:
"
/auth/oidc/callback
"
,
oidc_connect_token_auth_method
:
"
client_secret_post
"
,
oidc_connect_use_pkce
:
tru
e
,
oidc_connect_validate_id_token
:
tru
e
,
oidc_connect_use_pkce
:
fals
e
,
oidc_connect_validate_id_token
:
fals
e
,
oidc_connect_allowed_signing_algs
:
"
RS256,ES256,PS256
"
,
oidc_connect_clock_skew_seconds
:
120
,
oidc_connect_require_email_verified
:
false
,
...
...
@@ -5846,8 +5844,8 @@ async function saveSettings() {
oidc_connect_frontend_redirect_url
:
form
.
oidc_connect_frontend_redirect_url
,
oidc_connect_token_auth_method
:
form
.
oidc_connect_token_auth_method
,
oidc_connect_use_pkce
:
tru
e
,
oidc_connect_validate_id_token
:
true
,
oidc_connect_use_pkce
:
form
.
oidc_connect_use_pkc
e
,
oidc_connect_validate_id_token
:
form
.
oidc_connect_validate_id_token
,
oidc_connect_allowed_signing_algs
:
form
.
oidc_connect_allowed_signing_algs
,
oidc_connect_clock_skew_seconds
:
form
.
oidc_connect_clock_skew_seconds
,
oidc_connect_require_email_verified
:
...
...
frontend/src/views/admin/__tests__/SettingsView.spec.ts
View file @
ddf80f5e
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
"
vitest
"
;
import
{
defineComponent
,
h
,
ref
}
from
"
vue
"
;
import
{
defineComponent
,
h
}
from
"
vue
"
;
import
{
flushPromises
,
mount
}
from
"
@vue/test-utils
"
;
import
SettingsView
from
"
../SettingsView.vue
"
;
...
...
@@ -46,6 +46,8 @@ const {
showSuccess
:
vi
.
fn
(),
}));
const
localeRef
=
vi
.
hoisted
(()
=>
({
value
:
"
zh-CN
"
}));
vi
.
mock
(
"
@/api
"
,
()
=>
({
adminAPI
:
{
settings
:
{
...
...
@@ -149,6 +151,8 @@ vi.mock("vue-i18n", async () => {
"
admin.settings.paymentVisibleMethods.sourceLabel
"
:
"
支付来源
"
,
"
admin.settings.paymentVisibleMethods.sourceHint
"
:
"
启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。
"
,
"
admin.settings.paymentVisibleMethods.sourceRequiredError
"
:
"
{title} 已启用,请先选择支付来源。
"
,
"
admin.settings.payment.configGuide
"
:
"
查看支付配置说明
"
,
"
admin.settings.payment.findProvider
"
:
"
查看支持的支付方式
"
,
"
admin.settings.openaiExperimentalScheduler.title
"
:
"
OpenAI 实验调度策略
"
,
"
admin.settings.openaiExperimentalScheduler.description
"
:
"
默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。
"
,
};
...
...
@@ -157,7 +161,7 @@ vi.mock("vue-i18n", async () => {
useI18n
:
()
=>
({
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
(
translations
[
key
]
??
key
).
replace
(
/
\{(\w
+
)\}
/g
,
(
_
,
token
)
=>
params
?.[
token
]
??
`{
${
token
}
}`
),
locale
:
ref
(
"
zh-CN
"
)
,
locale
:
localeRef
,
}),
};
});
...
...
@@ -429,6 +433,7 @@ describe("admin SettingsView payment visible method controls", () => {
adminSettingsFetch
.
mockReset
();
showError
.
mockReset
();
showSuccess
.
mockReset
();
localeRef
.
value
=
"
zh-CN
"
;
getSettings
.
mockResolvedValue
({
...
baseSettingsResponse
});
updateSettings
.
mockImplementation
(
async
(
payload
)
=>
({
...
...
@@ -489,6 +494,30 @@ describe("admin SettingsView payment visible method controls", () => {
expect
(
wrapper
.
text
()).
not
.
toContain
(
"
支付来源
"
);
});
it
(
"
links payment guidance to README sections instead of removed payment docs
"
,
async
()
=>
{
const
wrapper
=
mountView
();
await
flushPromises
();
await
openPaymentTab
(
wrapper
);
const
paymentLinks
=
wrapper
.
findAll
(
"
a
"
)
.
filter
((
node
)
=>
[
"
查看支付配置说明
"
,
"
查看支持的支付方式
"
].
includes
(
node
.
text
()),
);
expect
(
paymentLinks
).
toHaveLength
(
2
);
expect
(
paymentLinks
[
0
]?.
attributes
(
"
href
"
)).
toBe
(
"
https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98
"
,
);
expect
(
paymentLinks
[
1
]?.
attributes
(
"
href
"
)).
toBe
(
"
https://github.com/Wei-Shaw/sub2api/blob/main/README_CN.md#%E6%94%AF%E4%BB%98
"
,
);
for
(
const
link
of
paymentLinks
)
{
expect
(
link
.
attributes
(
"
href
"
)).
not
.
toContain
(
"
docs/PAYMENT
"
);
}
});
it
(
"
does not submit legacy visible payment method settings
"
,
async
()
=>
{
const
wrapper
=
mountView
();
...
...
@@ -776,4 +805,28 @@ describe("admin SettingsView wechat connect controls", () => {
).
toBe
(
true
);
expect
(
wrapper
.
text
()).
toContain
(
"
首次绑定时授权
"
);
});
it
(
"
preserves optional OIDC compatibility flags instead of forcing them on save
"
,
async
()
=>
{
getSettings
.
mockResolvedValueOnce
({
...
baseSettingsResponse
,
oidc_connect_enabled
:
true
,
oidc_connect_use_pkce
:
false
,
oidc_connect_validate_id_token
:
false
,
});
const
wrapper
=
mountView
();
await
flushPromises
();
await
openSecurityTab
(
wrapper
);
await
wrapper
.
find
(
"
form
"
).
trigger
(
"
submit.prevent
"
);
await
flushPromises
();
expect
(
updateSettings
).
toHaveBeenCalledTimes
(
1
);
expect
(
updateSettings
).
toHaveBeenCalledWith
(
expect
.
objectContaining
({
oidc_connect_use_pkce
:
false
,
oidc_connect_validate_id_token
:
false
,
}),
);
});
});
frontend/src/views/auth/LinuxDoCallbackView.vue
View file @
ddf80f5e
...
...
@@ -456,7 +456,14 @@ function resolvePendingAccountAction(
if
(
raw
===
'
email_required
'
||
raw
===
'
create_account_required
'
||
raw
===
'
create_account
'
)
{
return
'
create_account
'
}
if
(
raw
===
'
bind_login_required
'
||
raw
===
'
bind_login
'
)
{
if
(
raw
===
'
bind_login_required
'
||
raw
===
'
bind_login
'
||
raw
===
'
existing_account
'
||
raw
===
'
existing_account_required
'
||
raw
===
'
existing_account_binding_required
'
||
raw
===
'
adopt_existing_user_by_email
'
)
{
return
'
bind_login
'
}
return
'
none
'
...
...
@@ -603,6 +610,14 @@ async function finalizePendingAccountResponse(completion: LinuxDoPendingActionRe
return
}
if
(
completion
.
auth_result
===
'
pending_session
'
)
{
needsInvitation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
isProcessing
.
value
=
false
persistPendingAuthSession
(
redirect
)
return
}
await
finalizeCompletion
(
completion
,
redirect
)
}
...
...
@@ -612,9 +627,9 @@ async function handleSubmitInvitation() {
isSubmitting
.
value
=
true
try
{
const
tokenData
=
legacyPendingOAuthToken
.
value
const
completion
:
LinuxDoPendingActionResponse
=
legacyPendingOAuthToken
.
value
?
(
await
apiClient
.
post
<
OAuthToke
nResponse
>
(
'
/auth/oauth/linuxdo/complete-registration
'
,
{
await
apiClient
.
post
<
LinuxDoPendingActio
nResponse
>
(
'
/auth/oauth/linuxdo/complete-registration
'
,
{
pending_oauth_token
:
legacyPendingOAuthToken
.
value
,
invitation_code
:
invitationCode
.
value
.
trim
(),
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
...
...
@@ -624,10 +639,7 @@ async function handleSubmitInvitation() {
invitationCode
.
value
.
trim
(),
currentAdoptionDecision
()
)
persistOAuthTokenContext
(
tokenData
)
await
authStore
.
setToken
(
tokenData
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirectTo
.
value
)
await
finalizePendingAccountResponse
(
completion
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
invitationError
.
value
=
...
...
frontend/src/views/auth/OidcCallbackView.vue
View file @
ddf80f5e
...
...
@@ -632,6 +632,14 @@ async function finalizePendingAccountResponse(completion: PendingOidcCompletion)
return
}
if
(
completion
.
auth_result
===
'
pending_session
'
)
{
needsInvitation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
isProcessing
.
value
=
false
persistPendingAuthSession
(
redirect
)
return
}
await
finalizeCompletion
(
completion
,
redirect
)
}
...
...
@@ -641,9 +649,9 @@ async function handleSubmitInvitation() {
isSubmitting
.
value
=
true
try
{
const
tokenData
=
legacyPendingOAuthToken
.
value
const
completion
:
PendingOidcCompletion
=
legacyPendingOAuthToken
.
value
?
(
await
apiClient
.
post
<
OAuthTokenResponse
>
(
'
/auth/oauth/oidc/complete-registration
'
,
{
await
apiClient
.
post
<
PendingOidcCompletion
>
(
'
/auth/oauth/oidc/complete-registration
'
,
{
pending_oauth_token
:
legacyPendingOAuthToken
.
value
,
invitation_code
:
invitationCode
.
value
.
trim
(),
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
...
...
@@ -653,10 +661,7 @@ async function handleSubmitInvitation() {
invitationCode
.
value
.
trim
(),
currentAdoptionDecision
()
)
persistOAuthTokenContext
(
tokenData
)
await
authStore
.
setToken
(
tokenData
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirectTo
.
value
)
await
finalizePendingAccountResponse
(
completion
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
invitationError
.
value
=
...
...
frontend/src/views/auth/WechatCallbackView.vue
View file @
ddf80f5e
...
...
@@ -613,8 +613,12 @@ async function handleBindCurrentAccount() {
return
}
prepareOAuthBindAccessTokenCookie
()
window
.
location
.
href
=
startURL
try
{
await
prepareOAuthBindAccessTokenCookie
()
window
.
location
.
href
=
startURL
}
catch
(
e
:
unknown
)
{
errorMessage
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
}
}
async
function
handleExistingAccountBinding
()
{
...
...
@@ -840,6 +844,14 @@ async function finalizePendingAccountResponse(completion: PendingWeChatCompletio
return
}
if
(
completion
.
auth_result
===
'
pending_session
'
)
{
needsInvitation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
isProcessing
.
value
=
false
persistPendingAuthSession
(
redirect
)
return
}
await
finalizeCompletion
(
completion
,
redirect
)
}
...
...
@@ -849,9 +861,9 @@ async function handleSubmitInvitation() {
isSubmitting
.
value
=
true
try
{
const
tokenData
=
legacyPendingOAuthToken
.
value
const
completion
:
PendingWeChatCompletion
=
legacyPendingOAuthToken
.
value
?
(
await
apiClient
.
post
<
OAuthTokenResponse
>
(
'
/auth/oauth/wechat/complete-registration
'
,
{
await
apiClient
.
post
<
PendingWeChatCompletion
>
(
'
/auth/oauth/wechat/complete-registration
'
,
{
pending_oauth_token
:
legacyPendingOAuthToken
.
value
,
invitation_code
:
invitationCode
.
value
.
trim
(),
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
...
...
@@ -861,10 +873,7 @@ async function handleSubmitInvitation() {
invitationCode
.
value
.
trim
(),
currentAdoptionDecision
()
)
persistOAuthTokenContext
(
tokenData
)
await
authStore
.
setToken
(
tokenData
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirectTo
.
value
)
await
finalizePendingAccountResponse
(
completion
)
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
invitationError
.
value
=
...
...
frontend/src/views/auth/WechatPaymentCallbackView.vue
View file @
ddf80f5e
...
...
@@ -85,6 +85,12 @@ function normalizeRedirectPath(path: string | null | undefined): string {
return
value
}
function
appendQueryParam
(
query
:
Record
<
string
,
string
>
,
key
:
string
,
value
:
string
)
{
if
(
value
)
{
query
[
key
]
=
value
}
}
function
goBackToPayment
()
{
void
router
.
replace
(
'
/purchase
'
)
}
...
...
@@ -102,12 +108,19 @@ onMounted(async () => {
}
const
resumeToken
=
readParam
(
'
wechat_resume_token
'
)
const
openid
=
readParam
(
'
openid
'
)
const
state
=
readParam
(
'
state
'
)
const
scope
=
readParam
(
'
scope
'
)
const
paymentType
=
readParam
(
'
payment_type
'
)
const
amount
=
readParam
(
'
amount
'
)
const
orderType
=
readParam
(
'
order_type
'
)
const
planId
=
readParam
(
'
plan_id
'
)
const
redirectURL
=
new
URL
(
normalizeRedirectPath
(
readParam
(
'
redirect
'
)),
window
.
location
.
origin
,
)
if
(
!
resumeToken
)
{
if
(
!
resumeToken
&&
!
openid
)
{
errorMessage
.
value
=
t
(
'
auth.wechatPayment.callbackMissingResumeToken
'
)
return
}
...
...
@@ -115,7 +128,18 @@ onMounted(async () => {
const
query
:
Record
<
string
,
string
>
=
{
...
Object
.
fromEntries
(
redirectURL
.
searchParams
.
entries
()),
wechat_resume
:
'
1
'
,
wechat_resume_token
:
resumeToken
,
}
if
(
resumeToken
)
{
query
.
wechat_resume_token
=
resumeToken
}
else
{
query
.
openid
=
openid
appendQueryParam
(
query
,
'
state
'
,
state
)
appendQueryParam
(
query
,
'
scope
'
,
scope
)
appendQueryParam
(
query
,
'
payment_type
'
,
paymentType
)
appendQueryParam
(
query
,
'
amount
'
,
amount
)
appendQueryParam
(
query
,
'
order_type
'
,
orderType
)
appendQueryParam
(
query
,
'
plan_id
'
,
planId
)
}
await
router
.
replace
({
...
...
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
View file @
ddf80f5e
...
...
@@ -336,6 +336,33 @@ describe('LinuxDoCallbackView', () => {
)
})
it
(
'
keeps rendering bind-login UI for legacy pending bind responses instead of treating them as success
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
adopt_existing_user_by_email
'
,
redirect
:
'
/profile/security
'
,
email
:
'
existing@example.com
'
})
const
wrapper
=
mount
(
LinuxDoCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
expect
(
showSuccess
).
not
.
toHaveBeenCalled
()
expect
(
replace
).
not
.
toHaveBeenCalled
()
expect
((
wrapper
.
get
(
'
[data-testid="linuxdo-bind-login-email"]
'
).
element
as
HTMLInputElement
).
value
).
toBe
(
'
existing@example.com
'
)
})
it
(
'
persists a pending auth session when the oauth flow still needs account creation
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
email_required
'
,
...
...
@@ -409,6 +436,50 @@ describe('LinuxDoCallbackView', () => {
})
})
it
(
'
keeps the oauth flow active when complete-registration returns another pending step
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
redirect
:
'
/dashboard
'
,
adoption_required
:
true
,
suggested_display_name
:
'
LinuxDo Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/linuxdo.png
'
})
completeLinuxDoOAuthRegistration
.
mockResolvedValue
({
auth_result
:
'
pending_session
'
,
step
:
'
choose_account_action_required
'
,
redirect
:
'
/dashboard
'
,
email
:
'
fresh@example.com
'
,
resolved_email
:
'
fresh@example.com
'
,
force_email_on_signup
:
true
,
adoption_required
:
true
})
const
wrapper
=
mount
(
LinuxDoCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
await
wrapper
.
find
(
'
input[type="text"]
'
).
setValue
(
'
invite-code
'
)
await
wrapper
.
find
(
'
button
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
completeLinuxDoOAuthRegistration
).
toHaveBeenCalledWith
(
'
invite-code
'
,
{
adoptDisplayName
:
true
,
adoptAvatar
:
true
})
expect
(
setToken
).
not
.
toHaveBeenCalled
()
expect
(
replace
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
auth.oauthFlow.bindExistingAccount
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
auth.oauthFlow.createNewAccount
'
)
})
it
(
'
collects email, password, and verify code for pending oauth account creation and submits adoption decisions
'
,
async
()
=>
{
getPublicSettings
.
mockResolvedValue
({
invitation_code_enabled
:
true
,
...
...
frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts
View file @
ddf80f5e
...
...
@@ -385,6 +385,50 @@ describe('OidcCallbackView', () => {
})
})
it
(
'
keeps the oauth flow active when complete-registration returns another pending step
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
redirect
:
'
/dashboard
'
,
adoption_required
:
true
,
suggested_display_name
:
'
OIDC Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/oidc.png
'
})
completeOIDCOAuthRegistration
.
mockResolvedValue
({
auth_result
:
'
pending_session
'
,
step
:
'
choose_account_action_required
'
,
redirect
:
'
/dashboard
'
,
email
:
'
fresh@example.com
'
,
resolved_email
:
'
fresh@example.com
'
,
force_email_on_signup
:
true
,
adoption_required
:
true
})
const
wrapper
=
mount
(
OidcCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
}
}
})
await
flushPromises
()
await
wrapper
.
find
(
'
input[type="text"]
'
).
setValue
(
'
invite-code
'
)
await
wrapper
.
find
(
'
button
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
completeOIDCOAuthRegistration
).
toHaveBeenCalledWith
(
'
invite-code
'
,
{
adoptDisplayName
:
true
,
adoptAvatar
:
true
})
expect
(
setToken
).
not
.
toHaveBeenCalled
()
expect
(
replace
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
auth.oauthFlow.bindExistingAccount
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
auth.oauthFlow.createNewAccount
'
)
})
it
(
'
collects email, password, and verify code for pending oauth account creation and submits adoption decisions
'
,
async
()
=>
{
getPublicSettings
.
mockResolvedValue
({
oidc_oauth_provider_name
:
'
ExampleID
'
,
...
...
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
View file @
ddf80f5e
...
...
@@ -517,6 +517,50 @@ describe('WechatCallbackView', () => {
expect
(
replaceMock
).
toHaveBeenCalledWith
(
'
/subscriptions
'
)
})
it
(
'
keeps the oauth flow active when complete-registration returns another pending step
'
,
async
()
=>
{
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
redirect
:
'
/dashboard
'
,
adoption_required
:
true
,
suggested_display_name
:
'
WeChat Nick
'
,
suggested_avatar_url
:
'
https://cdn.example/wechat.png
'
,
})
completeWeChatOAuthRegistrationMock
.
mockResolvedValue
({
auth_result
:
'
pending_session
'
,
step
:
'
choose_account_action_required
'
,
redirect
:
'
/dashboard
'
,
email
:
'
fresh@example.com
'
,
resolved_email
:
'
fresh@example.com
'
,
force_email_on_signup
:
true
,
adoption_required
:
true
,
})
const
wrapper
=
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
await
wrapper
.
find
(
'
input[type="text"]
'
).
setValue
(
'
invite-code
'
)
await
wrapper
.
find
(
'
button
'
).
trigger
(
'
click
'
)
await
flushPromises
()
expect
(
completeWeChatOAuthRegistrationMock
).
toHaveBeenCalledWith
(
'
invite-code
'
,
{
adoptDisplayName
:
true
,
adoptAvatar
:
true
,
})
expect
(
setTokenMock
).
not
.
toHaveBeenCalled
()
expect
(
replaceMock
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
get
(
'
[data-testid="wechat-choice-bind-existing"]
'
).
exists
()).
toBe
(
true
)
expect
(
wrapper
.
get
(
'
[data-testid="wechat-choice-create-account"]
'
).
exists
()).
toBe
(
true
)
})
it
(
'
offers existing-account email collection during invitation flow
'
,
async
()
=>
{
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
...
...
@@ -577,6 +621,34 @@ describe('WechatCallbackView', () => {
expect
(
locationState
.
current
.
href
).
toContain
(
'
mode=open
'
)
})
it
(
'
shows an error and stays on the page when preparing bind-token for the current account fails
'
,
async
()
=>
{
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
redirect
:
'
/usage
'
,
})
getAuthTokenMock
.
mockReturnValue
(
'
current-auth-token
'
)
prepareOAuthBindAccessTokenCookieMock
.
mockRejectedValue
(
new
Error
(
'
bind token failed
'
))
const
wrapper
=
mount
(
WechatCallbackView
,
{
global
:
{
stubs
:
{
AuthLayout
:
{
template
:
'
<div><slot /></div>
'
},
Icon
:
true
,
RouterLink
:
{
template
:
'
<a><slot /></a>
'
},
transition
:
false
,
},
},
})
await
flushPromises
()
await
wrapper
.
get
(
'
[data-testid="existing-account-submit"]
'
).
trigger
(
'
click
'
).
catch
(()
=>
undefined
)
await
flushPromises
()
expect
(
showErrorMock
).
toHaveBeenCalledWith
(
'
bind token failed
'
)
expect
(
locationState
.
current
.
href
).
toBe
(
'
http://localhost/auth/wechat/callback
'
)
})
it
(
'
collects email, password, and verify code for pending oauth account creation and submits adoption decisions
'
,
async
()
=>
{
getPublicSettingsMock
.
mockResolvedValue
({
invitation_code_enabled
:
true
,
...
...
frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts
View file @
ddf80f5e
...
...
@@ -79,6 +79,29 @@ describe('WechatPaymentCallbackView', () => {
})
})
it
(
'
redirects legacy openid callback payloads back to purchase while preserving resume context
'
,
async
()
=>
{
locationState
.
current
.
hash
=
'
#openid=openid-123&state=oauth-state&scope=snsapi_base&payment_type=wxpay_direct&amount=128&order_type=subscription&plan_id=7&redirect=%2Fpayment%3Ffrom%3Dwechat
'
mount
(
WechatPaymentCallbackView
)
await
flushPromises
()
expect
(
replaceMock
).
toHaveBeenCalledWith
({
path
:
'
/purchase
'
,
query
:
{
from
:
'
wechat
'
,
wechat_resume
:
'
1
'
,
openid
:
'
openid-123
'
,
state
:
'
oauth-state
'
,
scope
:
'
snsapi_base
'
,
payment_type
:
'
wxpay_direct
'
,
amount
:
'
128
'
,
order_type
:
'
subscription
'
,
plan_id
:
'
7
'
,
},
})
})
it
(
'
shows an error when the callback payload is missing the resume token
'
,
async
()
=>
{
locationState
.
current
.
hash
=
'
#payment_type=wxpay
'
...
...
frontend/src/views/user/PaymentResultView.vue
View file @
ddf80f5e
...
...
@@ -101,7 +101,11 @@ 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
'
import
{
PAYMENT_RECOVERY_STORAGE_KEY
,
readPaymentRecoverySnapshot
}
from
'
@/components/payment/paymentFlow
'
import
{
PAYMENT_RECOVERY_STORAGE_KEY
,
clearPaymentRecoverySnapshot
,
readPaymentRecoverySnapshot
,
}
from
'
@/components/payment/paymentFlow
'
import
{
usePaymentStore
}
from
'
@/stores/payment
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
...
...
@@ -177,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
)
...
...
@@ -186,6 +238,15 @@ async function resolveOrderFromResumeToken(resumeToken: string): Promise<Payment
}
}
async
function
resolveOrderFromOutTradeNo
(
outTradeNo
:
string
):
Promise
<
PaymentOrder
|
null
>
{
try
{
const
result
=
await
paymentAPI
.
verifyOrderPublic
(
outTradeNo
)
return
result
.
data
}
catch
(
_err
:
unknown
)
{
return
null
}
}
function
clearStatusRefreshTimer
():
void
{
if
(
statusRefreshTimer
!==
null
)
{
clearTimeout
(
statusRefreshTimer
)
...
...
@@ -193,6 +254,18 @@ function clearStatusRefreshTimer(): void {
}
}
function
clearRecoverySnapshot
():
void
{
if
(
typeof
window
===
'
undefined
'
)
return
clearPaymentRecoverySnapshot
(
window
.
localStorage
,
PAYMENT_RECOVERY_STORAGE_KEY
)
}
function
clearRecoverySnapshotForTerminalStatus
(
status
:
string
|
null
|
undefined
):
void
{
if
(
!
status
)
return
if
(
!
isPendingStatus
(
status
))
{
clearRecoverySnapshot
()
}
}
function
scheduleStatusRefresh
(
refreshOrder
:
(()
=>
Promise
<
PaymentOrder
|
null
>
)
|
null
):
void
{
clearStatusRefreshTimer
()
if
(
!
refreshOrder
||
!
isPending
.
value
||
refreshAttempts
.
value
>=
STATUS_REFRESH_MAX_ATTEMPTS
)
{
...
...
@@ -204,6 +277,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
const
refreshedOrder
=
await
refreshOrder
()
if
(
refreshedOrder
)
{
order
.
value
=
refreshedOrder
clearRecoverySnapshotForTerminalStatus
(
refreshedOrder
.
status
)
}
if
(
isPendingStatus
(
order
.
value
?.
status
))
{
...
...
@@ -213,29 +287,22 @@ 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
const
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
(
resumeToken
&&
typeof
window
!==
'
undefined
'
)
{
const
restored
=
re
adPayment
RecoverySnapshot
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)
,
{
resumeToken
}
,
)
if
(
restored
?.
orderId
)
{
orderId
=
restored
.
orderId
}
let
resumeTokenLookupFailed
=
false
const
restored
=
re
store
RecoverySnapshot
(
{
resumeToken
,
routeOrderId
,
routeOutTradeNo
:
outTradeNo
,
})
if
(
restored
?
.
orderId
)
{
orderId
=
restored
.
orderId
}
if
(
!
order
.
value
&&
resumeToken
&&
orderId
)
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
// Fall through to signed resume-token recovery below.
}
if
(
!
outTradeNo
&&
restored
?.
outTradeNo
)
{
outTradeNo
=
restored
.
outTradeNo
}
if
(
resumeToken
)
{
...
...
@@ -245,14 +312,20 @@ onMounted(async () => {
if
(
!
orderId
)
{
orderId
=
resolvedOrder
.
id
}
}
else
if
(
routeOrderId
>
0
)
{
resumeTokenLookupFailed
=
true
orderId
=
routeOrderId
}
else
{
resumeTokenLookupFailed
=
true
}
}
if
(
!
resumeToken
)
{
}
else
if
(
routeOrderId
>
0
)
{
orderId
=
routeOrderId
}
if
(
!
order
.
value
&&
!
resumeToken
&&
orderId
)
{
const
hasLegacyFallbackContext
=
readRouteQueryString
(
'
trade_status
'
).
trim
()
!==
''
const
shouldUsePublicOutTradeNo
=
outTradeNo
!==
''
&&
(
hasLegacyFallbackContext
||
routeOrderId
>
0
||
orderId
>
0
)
if
(
!
order
.
value
&&
orderId
&&
(
!
resumeToken
||
routeOrderId
>
0
))
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
...
...
@@ -260,9 +333,17 @@ onMounted(async () => {
}
}
const
hasLegacyFallbackContext
=
typeof
route
.
query
.
trade_status
===
'
string
'
&&
route
.
query
.
trade_status
.
trim
()
!==
''
if
(
!
order
.
value
&&
!
resumeToken
&&
!
orderId
&&
outTradeNo
&&
hasLegacyFallbackContext
)
{
if
(
!
order
.
value
&&
shouldUsePublicOutTradeNo
&&
(
!
resumeToken
||
resumeTokenLookupFailed
))
{
const
legacyOrder
=
await
resolveOrderFromOutTradeNo
(
outTradeNo
)
if
(
legacyOrder
)
{
order
.
value
=
legacyOrder
if
(
!
orderId
)
{
orderId
=
legacyOrder
.
id
}
}
}
if
(
!
order
.
value
&&
!
orderId
&&
outTradeNo
&&
hasLegacyFallbackContext
)
{
returnInfo
.
value
=
{
outTradeNo
,
money
:
String
(
route
.
query
.
money
||
''
),
...
...
@@ -273,11 +354,22 @@ onMounted(async () => {
const
refreshOrder
=
async
():
Promise
<
PaymentOrder
|
null
>
=>
{
if
(
resumeToken
)
{
return
await
resolveOrderFromResumeToken
(
resumeToken
)
const
resolvedOrder
=
await
resolveOrderFromResumeToken
(
resumeToken
)
if
(
resolvedOrder
)
{
return
resolvedOrder
}
}
if
(
orderId
)
{
return
await
paymentStore
.
pollOrderStatus
(
orderId
)
try
{
return
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
// Fall through to legacy public verification when order polling is unavailable.
}
}
if
(
shouldUsePublicOutTradeNo
)
{
return
await
resolveOrderFromOutTradeNo
(
outTradeNo
)
}
return
null
...
...
@@ -285,6 +377,10 @@ onMounted(async () => {
if
(
isPendingStatus
(
order
.
value
?.
status
))
{
scheduleStatusRefresh
(
refreshOrder
)
}
else
if
(
order
.
value
)
{
clearRecoverySnapshotForTerminalStatus
(
order
.
value
.
status
)
}
else
if
(
returnInfo
.
value
)
{
clearRecoverySnapshot
()
}
loading
.
value
=
false
})
...
...
frontend/src/views/user/PaymentView.vue
View file @
ddf80f5e
...
...
@@ -276,7 +276,7 @@ import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
PaymentMethodOption
}
from
'
@/components/payment/PaymentMethodSelector.vue
'
import
{
buildPaymentErrorToastMessage
,
describePaymentScenarioError
}
from
'
./paymentUx
'
import
{
parseWechatResumeRoute
,
stripWechatResumeQuery
}
from
'
./paymentWechatResume
'
import
{
hasWechatResumeQuery
,
parseWechatResumeRoute
,
stripWechatResumeQuery
}
from
'
./paymentWechatResume
'
const
{
t
}
=
useI18n
()
const
route
=
useRoute
()
...
...
@@ -329,6 +329,7 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
expiresAt
:
''
,
paymentType
:
''
,
payUrl
:
''
,
outTradeNo
:
''
,
clientSecret
:
''
,
payAmount
:
0
,
orderType
:
''
,
...
...
@@ -391,6 +392,60 @@ function resetPayment() {
removeRecoverySnapshot
()
}
async
function
redirectToPaymentResult
(
state
:
PaymentRecoverySnapshot
):
Promise
<
void
>
{
const
query
:
Record
<
string
,
string
|
undefined
>
=
{
}
if
(
state
.
orderId
>
0
)
{
query
.
order_id
=
String
(
state
.
orderId
)
}
if
(
state
.
outTradeNo
)
{
query
.
out_trade_no
=
state
.
outTradeNo
}
if
(
state
.
resumeToken
)
{
query
.
resume_token
=
state
.
resumeToken
}
await
router
.
push
({
path
:
'
/payment/result
'
,
query
,
}
)
}
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
()
...
...
@@ -658,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
}
...
...
@@ -680,12 +740,23 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
return
}
if
(
decision
.
kind
===
'
wechat_jsapi
'
&&
decision
.
jsapi
)
{
const
jsapiResult
=
await
invokeWechatJsapiPayment
(
decision
.
jsapi
as
Record
<
string
,
unknown
>
)
const
errMsg
=
String
(
jsapiResult
.
err_msg
||
''
).
toLowerCase
()
if
(
errMsg
.
includes
(
'
cancel
'
))
{
appStore
.
showInfo
(
t
(
'
payment.qr.cancelled
'
))
}
else
if
(
errMsg
&&
!
errMsg
.
includes
(
'
ok
'
))
{
applyScenarioError
({
reason
:
'
WECHAT_JSAPI_FAILED
'
,
message
:
errMsg
}
,
visibleMethod
)
try
{
const
jsapiResult
=
await
invokeWechatJsapiPayment
(
decision
.
jsapi
as
Record
<
string
,
unknown
>
)
const
errMsg
=
String
(
jsapiResult
.
err_msg
||
''
).
toLowerCase
()
if
(
errMsg
.
includes
(
'
cancel
'
))
{
appStore
.
showInfo
(
t
(
'
payment.qr.cancelled
'
))
resetPayment
()
}
else
if
(
errMsg
&&
!
errMsg
.
includes
(
'
ok
'
))
{
applyScenarioError
({
reason
:
'
WECHAT_JSAPI_FAILED
'
,
message
:
errMsg
}
,
visibleMethod
)
resetPayment
()
}
else
{
const
resultState
=
{
...
decision
.
paymentState
}
resetPayment
()
await
redirectToPaymentResult
(
resultState
)
}
}
catch
(
err
:
unknown
)
{
resetPayment
()
throw
err
}
return
}
...
...
@@ -789,9 +860,14 @@ onMounted(async () => {
selectedMethod
.
value
=
sorted
[
0
]
}
if
(
typeof
window
!==
'
undefined
'
)
{
if
(
hasWechatResumeQuery
(
route
.
query
))
{
removeRecoverySnapshot
()
}
const
routeResumeToken
=
typeof
route
.
query
.
resume_token
===
'
string
'
?
route
.
query
.
resume_token
:
undefined
:
typeof
route
.
query
.
wechat_resume_token
===
'
string
'
?
route
.
query
.
wechat_resume_token
:
undefined
const
restored
=
readPaymentRecoverySnapshot
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
),
{
resumeToken
:
routeResumeToken
}
,
...
...
frontend/src/views/user/__tests__/PaymentResultView.spec.ts
View file @
ddf80f5e
...
...
@@ -7,7 +7,7 @@ const routeState = vi.hoisted(() => ({
const
routerPush
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
pollOrderStatus
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
verifyOrder
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
verifyOrder
Public
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
resolveOrderPublicByResumeToken
=
vi
.
hoisted
(()
=>
vi
.
fn
())
vi
.
mock
(
'
vue-router
'
,
async
()
=>
{
...
...
@@ -37,7 +37,7 @@ vi.mock('@/stores/payment', () => ({
vi
.
mock
(
'
@/api/payment
'
,
()
=>
({
paymentAPI
:
{
verifyOrder
,
verifyOrder
Public
,
resolveOrderPublicByResumeToken
,
},
}))
...
...
@@ -60,12 +60,28 @@ const orderFactory = (status: string) => ({
refund_amount
:
0
,
})
const
recoverySnapshotFactory
=
(
resumeToken
:
string
)
=>
({
orderId
:
42
,
amount
:
88
,
qrCode
:
''
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
outTradeNo
:
'
sub2_20260420abcd1234
'
,
clientSecret
:
''
,
payAmount
:
88
,
orderType
:
'
balance
'
,
paymentMode
:
'
popup
'
,
resumeToken
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
})
describe
(
'
PaymentResultView
'
,
()
=>
{
beforeEach
(()
=>
{
routeState
.
query
=
{}
routerPush
.
mockReset
()
pollOrderStatus
.
mockReset
()
verifyOrder
.
mockReset
()
verifyOrder
Public
.
mockReset
()
resolveOrderPublicByResumeToken
.
mockReset
()
window
.
localStorage
.
clear
()
})
...
...
@@ -87,6 +103,7 @@ describe('PaymentResultView', () => {
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
outTradeNo
:
'
sub2_20260420abcd1234
'
,
clientSecret
:
''
,
payAmount
:
88
,
orderType
:
'
balance
'
,
...
...
@@ -94,7 +111,9 @@ describe('PaymentResultView', () => {
resumeToken
:
'
resume-42
'
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
}))
pollOrderStatus
.
mockResolvedValue
(
orderFactory
(
'
PENDING
'
))
resolveOrderPublicByResumeToken
.
mockResolvedValue
({
data
:
orderFactory
(
'
PENDING
'
),
})
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
...
...
@@ -106,7 +125,8 @@ describe('PaymentResultView', () => {
await
flushPromises
()
expect
(
pollOrderStatus
).
toHaveBeenCalledWith
(
42
)
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-42
'
)
expect
(
pollOrderStatus
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.processing
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.failed
'
)
...
...
@@ -125,6 +145,7 @@ describe('PaymentResultView', () => {
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
outTradeNo
:
'
sub2_20260420abcd1234
'
,
clientSecret
:
''
,
payAmount
:
88
,
orderType
:
'
balance
'
,
...
...
@@ -132,12 +153,6 @@ describe('PaymentResultView', () => {
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
'
),
...
...
@@ -157,11 +172,12 @@ describe('PaymentResultView', () => {
await
flushPromises
()
expect
(
pollOrderStatus
).
toHaveBeenCalled
With
(
42
)
expect
(
pollOrderStatus
).
not
.
toHaveBeenCalled
(
)
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-authoritative
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
103.00
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
100.00
'
)
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
it
(
'
refreshes a pending resume-token result until the order becomes paid
'
,
async
()
=>
{
...
...
@@ -169,6 +185,10 @@ describe('PaymentResultView', () => {
routeState
.
query
=
{
resume_token
:
'
resume-77
'
,
}
window
.
localStorage
.
setItem
(
PAYMENT_RECOVERY_STORAGE_KEY
,
JSON
.
stringify
(
recoverySnapshotFactory
(
'
resume-77
'
)),
)
resolveOrderPublicByResumeToken
.
mockResolvedValueOnce
({
data
:
orderFactory
(
'
PENDING
'
),
...
...
@@ -189,6 +209,7 @@ describe('PaymentResultView', () => {
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledTimes
(
1
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.processing
'
)
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
not
.
toBeNull
()
await
vi
.
advanceTimersByTimeAsync
(
2000
)
await
flushPromises
()
...
...
@@ -196,17 +217,59 @@ describe('PaymentResultView', () => {
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledTimes
(
2
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.failed
'
)
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
()
=>
{
it
(
'
fall
s
back to public out_trade_no verification when resume_token recovery fails
in legacy return flows
'
,
async
()
=>
{
routeState
.
query
=
{
resume_token
:
'
resume-fail
'
,
out_trade_no
:
'
legacy-should-not-run
'
,
trade_status
:
'
TRADE_SUCCESS
'
,
}
resolveOrderPublicByResumeToken
.
mockRejectedValueOnce
(
new
Error
(
'
resume failed
'
))
verifyOrderPublic
.
mockResolvedValueOnce
({
data
:
{
...
orderFactory
(
'
PAID
'
),
out_trade_no
:
'
legacy-should-not-run
'
,
},
})
mount
(
PaymentResultView
,
{
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
stubs
:
{
OrderStatusBadge
:
true
,
...
...
@@ -217,16 +280,47 @@ describe('PaymentResultView', () => {
await
flushPromises
()
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-fail
'
)
expect
(
verifyOrder
).
not
.
toHaveBeenCalled
()
expect
(
verifyOrderPublic
).
toHaveBeenCalledWith
(
'
legacy-should-not-run
'
)
expect
(
pollOrderStatus
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
})
it
(
'
does not use anonymous out_trade_no verification when no signed resume context is available
'
,
async
()
=>
{
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
'
,
trade_status
:
'
TRADE_SUCCESS
'
,
}
verifyOrderPublic
.
mockResolvedValue
({
data
:
orderFactory
(
'
PAID
'
),
})
mount
(
PaymentResultView
,
{
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
stubs
:
{
OrderStatusBadge
:
true
,
...
...
@@ -236,7 +330,9 @@ describe('PaymentResultView', () => {
await
flushPromises
()
expect
(
verifyOrder
).
not
.
toHaveBeenCalled
()
expect
(
verifyOrderPublic
).
toHaveBeenCalledWith
(
'
legacy-123
'
)
expect
(
pollOrderStatus
).
not
.
toHaveBeenCalled
()
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
})
it
(
'
does not use public out_trade_no verification for bare order numbers without legacy return markers
'
,
async
()
=>
{
...
...
@@ -254,7 +350,7 @@ describe('PaymentResultView', () => {
await
flushPromises
()
expect
(
verifyOrder
).
not
.
toHaveBeenCalled
()
expect
(
verifyOrder
Public
).
not
.
toHaveBeenCalled
()
})
it
(
'
resolves order by resume token when local recovery snapshot is missing
'
,
async
()
=>
{
...
...
frontend/src/views/user/__tests__/PaymentView.spec.ts
0 → 100644
View file @
ddf80f5e
import
{
beforeEach
,
describe
,
expect
,
it
,
vi
}
from
'
vitest
'
import
{
flushPromises
,
shallowMount
}
from
'
@vue/test-utils
'
import
PaymentView
from
'
../PaymentView.vue
'
import
{
PAYMENT_RECOVERY_STORAGE_KEY
}
from
'
@/components/payment/paymentFlow
'
const
routeState
=
vi
.
hoisted
(()
=>
({
path
:
'
/purchase
'
,
query
:
{}
as
Record
<
string
,
unknown
>
,
}))
const
routerReplace
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
routerPush
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
routerResolve
=
vi
.
hoisted
(()
=>
vi
.
fn
(()
=>
({
href
:
'
/payment/stripe?mock=1
'
})))
const
createOrder
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
refreshUser
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
fetchActiveSubscriptions
=
vi
.
hoisted
(()
=>
vi
.
fn
().
mockResolvedValue
(
undefined
))
const
showError
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
showInfo
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
getCheckoutInfo
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
bridgeInvoke
=
vi
.
hoisted
(()
=>
vi
.
fn
())
vi
.
mock
(
'
vue-router
'
,
async
()
=>
{
const
actual
=
await
vi
.
importActual
<
typeof
import
(
'
vue-router
'
)
>
(
'
vue-router
'
)
return
{
...
actual
,
useRoute
:
()
=>
routeState
,
useRouter
:
()
=>
({
replace
:
routerReplace
,
push
:
routerPush
,
resolve
:
routerResolve
,
}),
}
})
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/auth
'
,
()
=>
({
useAuthStore
:
()
=>
({
user
:
{
username
:
'
demo-user
'
,
balance
:
0
,
},
refreshUser
,
}),
}))
vi
.
mock
(
'
@/stores/payment
'
,
()
=>
({
usePaymentStore
:
()
=>
({
createOrder
,
}),
}))
vi
.
mock
(
'
@/stores/subscriptions
'
,
()
=>
({
useSubscriptionStore
:
()
=>
({
activeSubscriptions
:
[],
fetchActiveSubscriptions
,
}),
}))
vi
.
mock
(
'
@/stores
'
,
()
=>
({
useAppStore
:
()
=>
({
showError
,
showInfo
,
}),
}))
vi
.
mock
(
'
@/api/payment
'
,
()
=>
({
paymentAPI
:
{
getCheckoutInfo
,
},
}))
vi
.
mock
(
'
@/utils/device
'
,
()
=>
({
isMobileDevice
:
()
=>
true
,
}))
function
checkoutInfoFixture
()
{
return
{
data
:
{
methods
:
{
wxpay
:
{
daily_limit
:
0
,
daily_used
:
0
,
daily_remaining
:
0
,
single_min
:
0
,
single_max
:
0
,
fee_rate
:
0
,
available
:
true
,
},
},
global_min
:
0
,
global_max
:
0
,
plans
:
[],
balance_disabled
:
false
,
balance_recharge_multiplier
:
1
,
recharge_fee_rate
:
0
,
help_text
:
''
,
help_image_url
:
''
,
stripe_publishable_key
:
''
,
},
}
}
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
,
amount
:
88
,
pay_amount
:
88
,
fee_rate
:
0
,
expires_at
:
'
2099-01-01T00:10:00.000Z
'
,
payment_type
:
'
wxpay
'
,
out_trade_no
:
'
sub2_jsapi_123
'
,
result_type
:
'
jsapi_ready
'
as
const
,
resume_token
:
resumeToken
,
jsapi
:
{
appId
:
'
wx123
'
,
timeStamp
:
'
1712345678
'
,
nonceStr
:
'
nonce
'
,
package
:
'
prepay_id=wx123
'
,
signType
:
'
RSA
'
,
paySign
:
'
signed
'
,
},
}
}
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
'
routeState
.
query
=
{
wechat_resume
:
'
1
'
,
wechat_resume_token
:
'
resume-token-123
'
,
}
routerReplace
.
mockReset
().
mockResolvedValue
(
undefined
)
routerPush
.
mockReset
().
mockResolvedValue
(
undefined
)
routerResolve
.
mockClear
()
createOrder
.
mockReset
()
refreshUser
.
mockReset
()
fetchActiveSubscriptions
.
mockReset
().
mockResolvedValue
(
undefined
)
showError
.
mockReset
()
showInfo
.
mockReset
()
getCheckoutInfo
.
mockReset
().
mockResolvedValue
(
checkoutInfoFixture
())
bridgeInvoke
.
mockReset
()
window
.
localStorage
.
clear
()
;(
window
as
Window
&
{
WeixinJSBridge
?:
{
invoke
:
typeof
bridgeInvoke
}
}).
WeixinJSBridge
=
{
invoke
:
bridgeInvoke
,
}
})
it
(
'
resets payment state and redirects to /payment/result after JSAPI reports success
'
,
async
()
=>
{
createOrder
.
mockResolvedValue
(
jsapiOrderFixture
(
'
resume-token-123
'
))
bridgeInvoke
.
mockImplementation
((
_action
,
_payload
,
callback
)
=>
{
callback
({
err_msg
:
'
get_brand_wcpay_request:ok
'
})
})
shallowMount
(
PaymentView
,
{
global
:
{
stubs
:
{
Teleport
:
true
,
Transition
:
false
,
},
},
})
await
flushPromises
()
await
flushPromises
()
expect
(
routerReplace
).
toHaveBeenCalledWith
({
path
:
'
/purchase
'
,
query
:
{}
})
expect
(
routerPush
).
toHaveBeenCalledWith
({
path
:
'
/payment/result
'
,
query
:
{
order_id
:
'
123
'
,
out_trade_no
:
'
sub2_jsapi_123
'
,
resume_token
:
'
resume-token-123
'
,
},
})
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
it
(
'
resets payment state when JSAPI reports cancellation
'
,
async
()
=>
{
createOrder
.
mockResolvedValue
(
jsapiOrderFixture
(
'
resume-token-cancel
'
))
bridgeInvoke
.
mockImplementation
((
_action
,
_payload
,
callback
)
=>
{
callback
({
err_msg
:
'
get_brand_wcpay_request:cancel
'
})
})
shallowMount
(
PaymentView
,
{
global
:
{
stubs
:
{
Teleport
:
true
,
Transition
:
false
,
},
},
})
await
flushPromises
()
await
flushPromises
()
expect
(
showInfo
).
toHaveBeenCalledWith
(
'
payment.qr.cancelled
'
)
expect
(
routerPush
).
not
.
toHaveBeenCalled
()
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
})
it
(
'
clears stale recovery state when JSAPI never becomes available
'
,
async
()
=>
{
vi
.
useFakeTimers
()
createOrder
.
mockResolvedValue
(
jsapiOrderFixture
(
'
resume-token-missing-bridge
'
))
;(
window
as
Window
&
{
WeixinJSBridge
?:
{
invoke
:
typeof
bridgeInvoke
}
}).
WeixinJSBridge
=
undefined
const
wrapper
=
shallowMount
(
PaymentView
,
{
global
:
{
stubs
:
{
Teleport
:
true
,
Transition
:
false
,
},
},
})
await
flushPromises
()
await
vi
.
advanceTimersByTimeAsync
(
4000
)
await
flushPromises
()
await
flushPromises
()
expect
(
showError
).
toHaveBeenCalledWith
(
'
payment.errors.wechatJsapiUnavailable payment.errors.wechatOpenInWeChatHint
'
,
)
expect
(
routerPush
).
not
.
toHaveBeenCalled
()
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
toBeNull
()
expect
(
wrapper
.
html
()).
not
.
toContain
(
'
payment-status-panel-stub
'
)
})
it
(
'
clears a stale recovery snapshot before handling wechat resume callback params
'
,
async
()
=>
{
createOrder
.
mockRejectedValueOnce
(
new
Error
(
'
resume failed
'
))
window
.
localStorage
.
setItem
(
PAYMENT_RECOVERY_STORAGE_KEY
,
JSON
.
stringify
({
orderId
:
999
,
amount
:
66
,
qrCode
:
'
stale-qr
'
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/stale
'
,
outTradeNo
:
'
stale-out-trade-no
'
,
clientSecret
:
''
,
payAmount
:
66
,
orderType
:
'
balance
'
,
paymentMode
:
'
popup
'
,
resumeToken
:
''
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
}))
shallowMount
(
PaymentView
,
{
global
:
{
stubs
:
{
Teleport
:
true
,
Transition
:
false
,
},
},
})
await
flushPromises
()
await
flushPromises
()
expect
(
createOrder
).
toHaveBeenCalledWith
(
expect
.
objectContaining
({
wechat_resume_token
:
'
resume-token-123
'
,
}))
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__/paymentUx.spec.ts
View file @
ddf80f5e
...
...
@@ -28,6 +28,16 @@ describe('describePaymentScenarioError', () => {
})
})
it
(
'
maps WeChat H5 authorization errors when provider aliases use wxpay_direct
'
,
()
=>
{
expect
(
describePaymentScenarioError
(
{
reason
:
'
WECHAT_H5_NOT_AUTHORIZED
'
},
{
paymentMethod
:
'
wxpay_direct
'
,
isMobile
:
true
,
isWechatBrowser
:
false
},
)).
toEqual
({
messageKey
:
'
payment.errors.wechatH5NotAuthorized
'
,
hintKey
:
'
payment.errors.wechatOpenInWeChatHint
'
,
})
})
it
(
'
maps missing WeixinJSBridge to a JSAPI-specific prompt
'
,
()
=>
{
expect
(
describePaymentScenarioError
(
new
Error
(
'
WeixinJSBridge is unavailable
'
),
...
...
frontend/src/views/user/__tests__/paymentWechatResume.spec.ts
View file @
ddf80f5e
...
...
@@ -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 @
ddf80f5e
...
...
@@ -19,22 +19,38 @@ function readQueryString(query: LocationQuery, key: string): string {
return
typeof
value
===
'
string
'
?
value
:
''
}
export
function
hasWechatResumeQuery
(
query
:
LocationQuery
):
boolean
{
if
(
readQueryString
(
query
,
'
wechat_resume
'
)
===
'
1
'
)
{
return
true
}
return
readQueryString
(
query
,
'
wechat_resume_token
'
)
!==
''
||
readQueryString
(
query
,
'
openid
'
)
!==
''
}
export
function
parseWechatResumeRoute
(
query
:
LocationQuery
,
plans
:
SubscriptionPlan
[],
fallbackBalanceAmount
:
number
,
):
ParsedWechatResumeRoute
|
null
{
if
(
readQueryString
(
query
,
'
wechat_resume
'
)
!==
'
1
'
)
{
if
(
!
hasWechatResumeQuery
(
query
)
)
{
return
null
}
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
,
}
}
...
...
@@ -43,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
...
...
@@ -58,7 +71,7 @@ export function parseWechatResumeRoute(
paymentType
,
orderType
,
orderAmount
,
planId
:
Number
.
isFinite
(
planId
)
&&
p
lanId
>
0
?
planId
:
undefined
,
planId
:
hasP
lanId
?
planId
:
undefined
,
}
}
...
...
Prev
1
…
3
4
5
6
7
Next
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