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(
...
@@ -78,12 +78,13 @@ function simulateGuard(
return
authState
.
isAdmin
?
'
/admin/dashboard
'
:
'
/dashboard
'
return
authState
.
isAdmin
?
'
/admin/dashboard
'
:
'
/dashboard
'
}
}
if
(
authState
.
backendModeEnabled
&&
!
authState
.
isAuthenticated
)
{
if
(
authState
.
backendModeEnabled
&&
!
authState
.
isAuthenticated
)
{
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
,
'
/payment/result
'
]
const
callbackPaths
=
[
const
callbackPaths
=
[
'
/auth/callback
'
,
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
'
/auth/wechat/callback
'
,
'
/auth/wechat/payment/callback
'
,
]
]
const
pendingAuthPaths
=
[
'
/register
'
,
'
/email-verify
'
]
const
pendingAuthPaths
=
[
'
/register
'
,
'
/email-verify
'
]
const
isAllowed
=
const
isAllowed
=
...
@@ -126,12 +127,13 @@ function simulateGuard(
...
@@ -126,12 +127,13 @@ function simulateGuard(
if
(
authState
.
isAuthenticated
&&
authState
.
isAdmin
)
{
if
(
authState
.
isAuthenticated
&&
authState
.
isAdmin
)
{
return
null
return
null
}
}
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
]
const
allowed
=
[
'
/login
'
,
'
/key-usage
'
,
'
/setup
'
,
'
/payment/result
'
]
const
callbackPaths
=
[
const
callbackPaths
=
[
'
/auth/callback
'
,
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
'
/auth/wechat/callback
'
,
'
/auth/wechat/payment/callback
'
,
]
]
const
pendingAuthPaths
=
[
'
/register
'
,
'
/email-verify
'
]
const
pendingAuthPaths
=
[
'
/register
'
,
'
/email-verify
'
]
const
isAllowed
=
const
isAllowed
=
...
@@ -448,6 +450,30 @@ describe('路由守卫逻辑', () => {
...
@@ -448,6 +450,30 @@ describe('路由守卫逻辑', () => {
expect
(
redirect
).
toBeNull
()
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
'
,
()
=>
{
it
(
'
unauthenticated: /register is allowed when a pending auth session exists
'
,
()
=>
{
const
authState
:
MockAuthState
=
{
const
authState
:
MockAuthState
=
{
isAuthenticated
:
false
,
isAuthenticated
:
false
,
...
...
frontend/src/router/__tests__/wechat-route.spec.ts
View file @
ddf80f5e
...
@@ -52,4 +52,13 @@ describe('router WeChat OAuth route', () => {
...
@@ -52,4 +52,13 @@ describe('router WeChat OAuth route', () => {
expect
(
route
?.
meta
.
requiresAuth
).
toBe
(
false
)
expect
(
route
?.
meta
.
requiresAuth
).
toBe
(
false
)
expect
(
route
?.
meta
.
title
).
toBe
(
'
WeChat OAuth Callback
'
)
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
...
@@ -542,12 +542,13 @@ let authInitialized = false
const
navigationLoading
=
useNavigationLoadingState
()
const
navigationLoading
=
useNavigationLoadingState
()
// 延迟初始化预加载,传入 router 实例
// 延迟初始化预加载,传入 router 实例
let
routePrefetch
:
ReturnType
<
typeof
useRoutePrefetch
>
|
null
=
null
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
=
[
const
BACKEND_MODE_CALLBACK_PATHS
=
[
'
/auth/callback
'
,
'
/auth/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/linuxdo/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/oidc/callback
'
,
'
/auth/wechat/callback
'
'
/auth/wechat/callback
'
,
'
/auth/wechat/payment/callback
'
,
]
]
const
BACKEND_MODE_PENDING_AUTH_PATHS
=
[
'
/register
'
,
'
/email-verify
'
]
const
BACKEND_MODE_PENDING_AUTH_PATHS
=
[
'
/register
'
,
'
/email-verify
'
]
...
...
frontend/src/views/admin/SettingsView.vue
View file @
ddf80f5e
...
@@ -2032,7 +2032,7 @@
...
@@ -2032,7 +2032,7 @@
</div>
</div>
<Toggle
<Toggle
v-model="form.oidc_connect_use_pkce"
v-model="form.oidc_connect_use_pkce"
:
disabled
=
"
tru
e
"
data-testid="oidc-connect-use-pkc
e"
/>
/>
</div>
</div>
...
@@ -2046,7 +2046,7 @@
...
@@ -2046,7 +2046,7 @@
</div>
</div>
<Toggle
<Toggle
v-model="form.oidc_connect_validate_id_token"
v-model="form.oidc_connect_validate_id_token"
:
disabled
=
"
true
"
data-testid="oidc-connect-validate-id-token
"
/>
/>
</div>
</div>
...
@@ -3763,11 +3763,7 @@
...
@@ -3763,11 +3763,7 @@
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.payment.description") }}
{{ t("admin.settings.payment.description") }}
<a
<a
:
href
=
"
:href="paymentGuideHref"
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'
"
target="_blank"
target="_blank"
rel="noopener noreferrer"
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"
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 @@
...
@@ -4140,11 +4136,7 @@
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
{{ t("admin.settings.payment.enabledPaymentTypesHint") }}
{{ t("admin.settings.payment.enabledPaymentTypesHint") }}
<a
<a
:
href
=
"
:href="paymentGuideHref"
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'
"
target="_blank"
target="_blank"
rel="noopener noreferrer"
rel="noopener noreferrer"
class="ml-1 text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300"
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 {
...
@@ -4729,6 +4721,12 @@ function localText(zh: string, en: string): string {
return locale.value.startsWith("zh") ? zh : en;
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 =
type SettingsTab =
| "general"
| "general"
| "security"
| "security"
...
@@ -4961,8 +4959,8 @@ const form = reactive<SettingsForm>({
...
@@ -4961,8 +4959,8 @@ const form = reactive<SettingsForm>({
oidc_connect_redirect_url: "",
oidc_connect_redirect_url: "",
oidc_connect_frontend_redirect_url: "/auth/oidc/callback",
oidc_connect_frontend_redirect_url: "/auth/oidc/callback",
oidc_connect_token_auth_method: "client_secret_post",
oidc_connect_token_auth_method: "client_secret_post",
oidc_connect_use_pkce
:
tru
e
,
oidc_connect_use_pkce:
fals
e,
oidc_connect_validate_id_token
:
tru
e
,
oidc_connect_validate_id_token:
fals
e,
oidc_connect_allowed_signing_algs: "RS256,ES256,PS256",
oidc_connect_allowed_signing_algs: "RS256,ES256,PS256",
oidc_connect_clock_skew_seconds: 120,
oidc_connect_clock_skew_seconds: 120,
oidc_connect_require_email_verified: false,
oidc_connect_require_email_verified: false,
...
@@ -5846,8 +5844,8 @@ async function saveSettings() {
...
@@ -5846,8 +5844,8 @@ async function saveSettings() {
oidc_connect_frontend_redirect_url:
oidc_connect_frontend_redirect_url:
form.oidc_connect_frontend_redirect_url,
form.oidc_connect_frontend_redirect_url,
oidc_connect_token_auth_method: form.oidc_connect_token_auth_method,
oidc_connect_token_auth_method: form.oidc_connect_token_auth_method,
oidc_connect_use_pkce
:
tru
e
,
oidc_connect_use_pkce:
form.oidc_connect_use_pkc
e,
oidc_connect_validate_id_token
:
true
,
oidc_connect_validate_id_token:
form.oidc_connect_validate_id_token
,
oidc_connect_allowed_signing_algs: form.oidc_connect_allowed_signing_algs,
oidc_connect_allowed_signing_algs: form.oidc_connect_allowed_signing_algs,
oidc_connect_clock_skew_seconds: form.oidc_connect_clock_skew_seconds,
oidc_connect_clock_skew_seconds: form.oidc_connect_clock_skew_seconds,
oidc_connect_require_email_verified:
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
{
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
{
flushPromises
,
mount
}
from
"
@vue/test-utils
"
;
import
SettingsView
from
"
../SettingsView.vue
"
;
import
SettingsView
from
"
../SettingsView.vue
"
;
...
@@ -46,6 +46,8 @@ const {
...
@@ -46,6 +46,8 @@ const {
showSuccess
:
vi
.
fn
(),
showSuccess
:
vi
.
fn
(),
}));
}));
const
localeRef
=
vi
.
hoisted
(()
=>
({
value
:
"
zh-CN
"
}));
vi
.
mock
(
"
@/api
"
,
()
=>
({
vi
.
mock
(
"
@/api
"
,
()
=>
({
adminAPI
:
{
adminAPI
:
{
settings
:
{
settings
:
{
...
@@ -149,6 +151,8 @@ vi.mock("vue-i18n", async () => {
...
@@ -149,6 +151,8 @@ vi.mock("vue-i18n", async () => {
"
admin.settings.paymentVisibleMethods.sourceLabel
"
:
"
支付来源
"
,
"
admin.settings.paymentVisibleMethods.sourceLabel
"
:
"
支付来源
"
,
"
admin.settings.paymentVisibleMethods.sourceHint
"
:
"
启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。
"
,
"
admin.settings.paymentVisibleMethods.sourceHint
"
:
"
启用后必须明确选择一个来源;未配置状态不会对外展示该支付方式。
"
,
"
admin.settings.paymentVisibleMethods.sourceRequiredError
"
:
"
{title} 已启用,请先选择支付来源。
"
,
"
admin.settings.paymentVisibleMethods.sourceRequiredError
"
:
"
{title} 已启用,请先选择支付来源。
"
,
"
admin.settings.payment.configGuide
"
:
"
查看支付配置说明
"
,
"
admin.settings.payment.findProvider
"
:
"
查看支持的支付方式
"
,
"
admin.settings.openaiExperimentalScheduler.title
"
:
"
OpenAI 实验调度策略
"
,
"
admin.settings.openaiExperimentalScheduler.title
"
:
"
OpenAI 实验调度策略
"
,
"
admin.settings.openaiExperimentalScheduler.description
"
:
"
默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。
"
,
"
admin.settings.openaiExperimentalScheduler.description
"
:
"
默认关闭。开启后仅影响本网关在 OpenAI 账号间的实验性调度选择逻辑,不代表上游 OpenAI 官方能力。
"
,
};
};
...
@@ -157,7 +161,7 @@ vi.mock("vue-i18n", async () => {
...
@@ -157,7 +161,7 @@ vi.mock("vue-i18n", async () => {
useI18n
:
()
=>
({
useI18n
:
()
=>
({
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
t
:
(
key
:
string
,
params
?:
Record
<
string
,
string
>
)
=>
(
translations
[
key
]
??
key
).
replace
(
/
\{(\w
+
)\}
/g
,
(
_
,
token
)
=>
params
?.[
token
]
??
`{
${
token
}
}`
),
(
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", () => {
...
@@ -429,6 +433,7 @@ describe("admin SettingsView payment visible method controls", () => {
adminSettingsFetch
.
mockReset
();
adminSettingsFetch
.
mockReset
();
showError
.
mockReset
();
showError
.
mockReset
();
showSuccess
.
mockReset
();
showSuccess
.
mockReset
();
localeRef
.
value
=
"
zh-CN
"
;
getSettings
.
mockResolvedValue
({
...
baseSettingsResponse
});
getSettings
.
mockResolvedValue
({
...
baseSettingsResponse
});
updateSettings
.
mockImplementation
(
async
(
payload
)
=>
({
updateSettings
.
mockImplementation
(
async
(
payload
)
=>
({
...
@@ -489,6 +494,30 @@ describe("admin SettingsView payment visible method controls", () => {
...
@@ -489,6 +494,30 @@ describe("admin SettingsView payment visible method controls", () => {
expect
(
wrapper
.
text
()).
not
.
toContain
(
"
支付来源
"
);
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
()
=>
{
it
(
"
does not submit legacy visible payment method settings
"
,
async
()
=>
{
const
wrapper
=
mountView
();
const
wrapper
=
mountView
();
...
@@ -776,4 +805,28 @@ describe("admin SettingsView wechat connect controls", () => {
...
@@ -776,4 +805,28 @@ describe("admin SettingsView wechat connect controls", () => {
).
toBe
(
true
);
).
toBe
(
true
);
expect
(
wrapper
.
text
()).
toContain
(
"
首次绑定时授权
"
);
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(
...
@@ -456,7 +456,14 @@ function resolvePendingAccountAction(
if
(
raw
===
'
email_required
'
||
raw
===
'
create_account_required
'
||
raw
===
'
create_account
'
)
{
if
(
raw
===
'
email_required
'
||
raw
===
'
create_account_required
'
||
raw
===
'
create_account
'
)
{
return
'
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
'
bind_login
'
}
}
return
'
none
'
return
'
none
'
...
@@ -603,6 +610,14 @@ async function finalizePendingAccountResponse(completion: LinuxDoPendingActionRe
...
@@ -603,6 +610,14 @@ async function finalizePendingAccountResponse(completion: LinuxDoPendingActionRe
return
return
}
}
if
(
completion
.
auth_result
===
'
pending_session
'
)
{
needsInvitation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
isProcessing
.
value
=
false
persistPendingAuthSession
(
redirect
)
return
}
await
finalizeCompletion
(
completion
,
redirect
)
await
finalizeCompletion
(
completion
,
redirect
)
}
}
...
@@ -612,9 +627,9 @@ async function handleSubmitInvitation() {
...
@@ -612,9 +627,9 @@ async function handleSubmitInvitation() {
isSubmitting
.
value
=
true
isSubmitting
.
value
=
true
try
{
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
,
pending_oauth_token
:
legacyPendingOAuthToken
.
value
,
invitation_code
:
invitationCode
.
value
.
trim
(),
invitation_code
:
invitationCode
.
value
.
trim
(),
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
...
@@ -624,10 +639,7 @@ async function handleSubmitInvitation() {
...
@@ -624,10 +639,7 @@ async function handleSubmitInvitation() {
invitationCode
.
value
.
trim
(),
invitationCode
.
value
.
trim
(),
currentAdoptionDecision
()
currentAdoptionDecision
()
)
)
persistOAuthTokenContext
(
tokenData
)
await
finalizePendingAccountResponse
(
completion
)
await
authStore
.
setToken
(
tokenData
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
invitationError
.
value
=
invitationError
.
value
=
...
...
frontend/src/views/auth/OidcCallbackView.vue
View file @
ddf80f5e
...
@@ -632,6 +632,14 @@ async function finalizePendingAccountResponse(completion: PendingOidcCompletion)
...
@@ -632,6 +632,14 @@ async function finalizePendingAccountResponse(completion: PendingOidcCompletion)
return
return
}
}
if
(
completion
.
auth_result
===
'
pending_session
'
)
{
needsInvitation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
isProcessing
.
value
=
false
persistPendingAuthSession
(
redirect
)
return
}
await
finalizeCompletion
(
completion
,
redirect
)
await
finalizeCompletion
(
completion
,
redirect
)
}
}
...
@@ -641,9 +649,9 @@ async function handleSubmitInvitation() {
...
@@ -641,9 +649,9 @@ async function handleSubmitInvitation() {
isSubmitting
.
value
=
true
isSubmitting
.
value
=
true
try
{
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
,
pending_oauth_token
:
legacyPendingOAuthToken
.
value
,
invitation_code
:
invitationCode
.
value
.
trim
(),
invitation_code
:
invitationCode
.
value
.
trim
(),
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
...
@@ -653,10 +661,7 @@ async function handleSubmitInvitation() {
...
@@ -653,10 +661,7 @@ async function handleSubmitInvitation() {
invitationCode
.
value
.
trim
(),
invitationCode
.
value
.
trim
(),
currentAdoptionDecision
()
currentAdoptionDecision
()
)
)
persistOAuthTokenContext
(
tokenData
)
await
finalizePendingAccountResponse
(
completion
)
await
authStore
.
setToken
(
tokenData
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
invitationError
.
value
=
invitationError
.
value
=
...
...
frontend/src/views/auth/WechatCallbackView.vue
View file @
ddf80f5e
...
@@ -613,8 +613,12 @@ async function handleBindCurrentAccount() {
...
@@ -613,8 +613,12 @@ async function handleBindCurrentAccount() {
return
return
}
}
prepareOAuthBindAccessTokenCookie
()
try
{
window
.
location
.
href
=
startURL
await
prepareOAuthBindAccessTokenCookie
()
window
.
location
.
href
=
startURL
}
catch
(
e
:
unknown
)
{
errorMessage
.
value
=
getRequestErrorMessage
(
e
,
t
(
'
auth.loginFailed
'
))
}
}
}
async
function
handleExistingAccountBinding
()
{
async
function
handleExistingAccountBinding
()
{
...
@@ -840,6 +844,14 @@ async function finalizePendingAccountResponse(completion: PendingWeChatCompletio
...
@@ -840,6 +844,14 @@ async function finalizePendingAccountResponse(completion: PendingWeChatCompletio
return
return
}
}
if
(
completion
.
auth_result
===
'
pending_session
'
)
{
needsInvitation
.
value
=
false
needsAdoptionConfirmation
.
value
=
false
isProcessing
.
value
=
false
persistPendingAuthSession
(
redirect
)
return
}
await
finalizeCompletion
(
completion
,
redirect
)
await
finalizeCompletion
(
completion
,
redirect
)
}
}
...
@@ -849,9 +861,9 @@ async function handleSubmitInvitation() {
...
@@ -849,9 +861,9 @@ async function handleSubmitInvitation() {
isSubmitting
.
value
=
true
isSubmitting
.
value
=
true
try
{
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
,
pending_oauth_token
:
legacyPendingOAuthToken
.
value
,
invitation_code
:
invitationCode
.
value
.
trim
(),
invitation_code
:
invitationCode
.
value
.
trim
(),
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
...
serializeAdoptionDecision
(
currentAdoptionDecision
())
...
@@ -861,10 +873,7 @@ async function handleSubmitInvitation() {
...
@@ -861,10 +873,7 @@ async function handleSubmitInvitation() {
invitationCode
.
value
.
trim
(),
invitationCode
.
value
.
trim
(),
currentAdoptionDecision
()
currentAdoptionDecision
()
)
)
persistOAuthTokenContext
(
tokenData
)
await
finalizePendingAccountResponse
(
completion
)
await
authStore
.
setToken
(
tokenData
.
access_token
)
appStore
.
showSuccess
(
t
(
'
auth.loginSuccess
'
))
await
router
.
replace
(
redirectTo
.
value
)
}
catch
(
e
:
unknown
)
{
}
catch
(
e
:
unknown
)
{
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
const
err
=
e
as
{
message
?:
string
;
response
?:
{
data
?:
{
message
?:
string
}
}
}
invitationError
.
value
=
invitationError
.
value
=
...
...
frontend/src/views/auth/WechatPaymentCallbackView.vue
View file @
ddf80f5e
...
@@ -85,6 +85,12 @@ function normalizeRedirectPath(path: string | null | undefined): string {
...
@@ -85,6 +85,12 @@ function normalizeRedirectPath(path: string | null | undefined): string {
return
value
return
value
}
}
function
appendQueryParam
(
query
:
Record
<
string
,
string
>
,
key
:
string
,
value
:
string
)
{
if
(
value
)
{
query
[
key
]
=
value
}
}
function
goBackToPayment
()
{
function
goBackToPayment
()
{
void
router
.
replace
(
'
/purchase
'
)
void
router
.
replace
(
'
/purchase
'
)
}
}
...
@@ -102,12 +108,19 @@ onMounted(async () => {
...
@@ -102,12 +108,19 @@ onMounted(async () => {
}
}
const
resumeToken
=
readParam
(
'
wechat_resume_token
'
)
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
(
const
redirectURL
=
new
URL
(
normalizeRedirectPath
(
readParam
(
'
redirect
'
)),
normalizeRedirectPath
(
readParam
(
'
redirect
'
)),
window
.
location
.
origin
,
window
.
location
.
origin
,
)
)
if
(
!
resumeToken
)
{
if
(
!
resumeToken
&&
!
openid
)
{
errorMessage
.
value
=
t
(
'
auth.wechatPayment.callbackMissingResumeToken
'
)
errorMessage
.
value
=
t
(
'
auth.wechatPayment.callbackMissingResumeToken
'
)
return
return
}
}
...
@@ -115,7 +128,18 @@ onMounted(async () => {
...
@@ -115,7 +128,18 @@ onMounted(async () => {
const
query
:
Record
<
string
,
string
>
=
{
const
query
:
Record
<
string
,
string
>
=
{
...
Object
.
fromEntries
(
redirectURL
.
searchParams
.
entries
()),
...
Object
.
fromEntries
(
redirectURL
.
searchParams
.
entries
()),
wechat_resume
:
'
1
'
,
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
({
await
router
.
replace
({
...
...
frontend/src/views/auth/__tests__/LinuxDoCallbackView.spec.ts
View file @
ddf80f5e
...
@@ -336,6 +336,33 @@ describe('LinuxDoCallbackView', () => {
...
@@ -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
()
=>
{
it
(
'
persists a pending auth session when the oauth flow still needs account creation
'
,
async
()
=>
{
exchangePendingOAuthCompletion
.
mockResolvedValue
({
exchangePendingOAuthCompletion
.
mockResolvedValue
({
error
:
'
email_required
'
,
error
:
'
email_required
'
,
...
@@ -409,6 +436,50 @@ describe('LinuxDoCallbackView', () => {
...
@@ -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
()
=>
{
it
(
'
collects email, password, and verify code for pending oauth account creation and submits adoption decisions
'
,
async
()
=>
{
getPublicSettings
.
mockResolvedValue
({
getPublicSettings
.
mockResolvedValue
({
invitation_code_enabled
:
true
,
invitation_code_enabled
:
true
,
...
...
frontend/src/views/auth/__tests__/OidcCallbackView.spec.ts
View file @
ddf80f5e
...
@@ -385,6 +385,50 @@ describe('OidcCallbackView', () => {
...
@@ -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
()
=>
{
it
(
'
collects email, password, and verify code for pending oauth account creation and submits adoption decisions
'
,
async
()
=>
{
getPublicSettings
.
mockResolvedValue
({
getPublicSettings
.
mockResolvedValue
({
oidc_oauth_provider_name
:
'
ExampleID
'
,
oidc_oauth_provider_name
:
'
ExampleID
'
,
...
...
frontend/src/views/auth/__tests__/WechatCallbackView.spec.ts
View file @
ddf80f5e
...
@@ -517,6 +517,50 @@ describe('WechatCallbackView', () => {
...
@@ -517,6 +517,50 @@ describe('WechatCallbackView', () => {
expect
(
replaceMock
).
toHaveBeenCalledWith
(
'
/subscriptions
'
)
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
()
=>
{
it
(
'
offers existing-account email collection during invitation flow
'
,
async
()
=>
{
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
exchangePendingOAuthCompletionMock
.
mockResolvedValue
({
error
:
'
invitation_required
'
,
error
:
'
invitation_required
'
,
...
@@ -577,6 +621,34 @@ describe('WechatCallbackView', () => {
...
@@ -577,6 +621,34 @@ describe('WechatCallbackView', () => {
expect
(
locationState
.
current
.
href
).
toContain
(
'
mode=open
'
)
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
()
=>
{
it
(
'
collects email, password, and verify code for pending oauth account creation and submits adoption decisions
'
,
async
()
=>
{
getPublicSettingsMock
.
mockResolvedValue
({
getPublicSettingsMock
.
mockResolvedValue
({
invitation_code_enabled
:
true
,
invitation_code_enabled
:
true
,
...
...
frontend/src/views/auth/__tests__/WechatPaymentCallbackView.spec.ts
View file @
ddf80f5e
...
@@ -79,6 +79,29 @@ describe('WechatPaymentCallbackView', () => {
...
@@ -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
()
=>
{
it
(
'
shows an error when the callback payload is missing the resume token
'
,
async
()
=>
{
locationState
.
current
.
hash
=
'
#payment_type=wxpay
'
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'
...
@@ -101,7 +101,11 @@ import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useI18n
}
from
'
vue-i18n
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
{
useRoute
,
useRouter
}
from
'
vue-router
'
import
OrderStatusBadge
from
'
@/components/payment/OrderStatusBadge.vue
'
import
OrderStatusBadge
from
'
@/components/payment/OrderStatusBadge.vue
'
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
{
usePaymentStore
}
from
'
@/stores/payment
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
{
paymentAPI
}
from
'
@/api/payment
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
import
type
{
PaymentOrder
}
from
'
@/types/payment
'
...
@@ -177,6 +181,54 @@ function isPendingStatus(status: string | null | undefined): boolean {
...
@@ -177,6 +181,54 @@ function isPendingStatus(status: string | null | undefined): boolean {
return
PENDING_STATUSES
.
has
(
normalizeOrderStatus
(
status
))
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
>
{
async
function
resolveOrderFromResumeToken
(
resumeToken
:
string
):
Promise
<
PaymentOrder
|
null
>
{
try
{
try
{
const
result
=
await
paymentAPI
.
resolveOrderPublicByResumeToken
(
resumeToken
)
const
result
=
await
paymentAPI
.
resolveOrderPublicByResumeToken
(
resumeToken
)
...
@@ -186,6 +238,15 @@ async function resolveOrderFromResumeToken(resumeToken: string): Promise<Payment
...
@@ -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
{
function
clearStatusRefreshTimer
():
void
{
if
(
statusRefreshTimer
!==
null
)
{
if
(
statusRefreshTimer
!==
null
)
{
clearTimeout
(
statusRefreshTimer
)
clearTimeout
(
statusRefreshTimer
)
...
@@ -193,6 +254,18 @@ function clearStatusRefreshTimer(): void {
...
@@ -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
{
function
scheduleStatusRefresh
(
refreshOrder
:
(()
=>
Promise
<
PaymentOrder
|
null
>
)
|
null
):
void
{
clearStatusRefreshTimer
()
clearStatusRefreshTimer
()
if
(
!
refreshOrder
||
!
isPending
.
value
||
refreshAttempts
.
value
>=
STATUS_REFRESH_MAX_ATTEMPTS
)
{
if
(
!
refreshOrder
||
!
isPending
.
value
||
refreshAttempts
.
value
>=
STATUS_REFRESH_MAX_ATTEMPTS
)
{
...
@@ -204,6 +277,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
...
@@ -204,6 +277,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
const
refreshedOrder
=
await
refreshOrder
()
const
refreshedOrder
=
await
refreshOrder
()
if
(
refreshedOrder
)
{
if
(
refreshedOrder
)
{
order
.
value
=
refreshedOrder
order
.
value
=
refreshedOrder
clearRecoverySnapshotForTerminalStatus
(
refreshedOrder
.
status
)
}
}
if
(
isPendingStatus
(
order
.
value
?.
status
))
{
if
(
isPendingStatus
(
order
.
value
?.
status
))
{
...
@@ -213,29 +287,22 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
...
@@ -213,29 +287,22 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
}
}
onMounted
(
async
()
=>
{
onMounted
(
async
()
=>
{
const
resumeToken
=
typeof
route
.
query
.
resume_token
===
'
string
'
const
resumeToken
=
readRouteQueryString
(
'
resume_token
'
)
?
route
.
query
.
resume_token
const
routeOrderId
=
Number
(
readRouteQueryString
(
'
order_id
'
))
||
0
:
''
let
outTradeNo
=
readRouteQueryString
(
'
out_trade_no
'
)
const
routeOrderId
=
Number
(
route
.
query
.
order_id
)
||
0
const
outTradeNo
=
String
(
route
.
query
.
out_trade_no
||
''
)
let
orderId
=
0
let
orderId
=
0
let
resumeTokenLookupFailed
=
false
if
(
resumeToken
&&
typeof
window
!==
'
undefined
'
)
{
const
restored
=
re
adPayment
RecoverySnapshot
(
const
restored
=
re
store
RecoverySnapshot
(
{
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)
,
resumeToken
,
{
resumeToken
}
,
routeOrderId
,
)
routeOutTradeNo
:
outTradeNo
,
if
(
restored
?.
orderId
)
{
})
orderId
=
restored
.
orderId
if
(
restored
?
.
orderId
)
{
}
orderId
=
restored
.
orderId
}
}
if
(
!
outTradeNo
&&
restored
?.
outTradeNo
)
{
if
(
!
order
.
value
&&
resumeToken
&&
orderId
)
{
outTradeNo
=
restored
.
outTradeNo
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
// Fall through to signed resume-token recovery below.
}
}
}
if
(
resumeToken
)
{
if
(
resumeToken
)
{
...
@@ -245,14 +312,20 @@ onMounted(async () => {
...
@@ -245,14 +312,20 @@ onMounted(async () => {
if
(
!
orderId
)
{
if
(
!
orderId
)
{
orderId
=
resolvedOrder
.
id
orderId
=
resolvedOrder
.
id
}
}
}
else
if
(
routeOrderId
>
0
)
{
resumeTokenLookupFailed
=
true
orderId
=
routeOrderId
}
else
{
resumeTokenLookupFailed
=
true
}
}
}
}
else
if
(
routeOrderId
>
0
)
{
if
(
!
resumeToken
)
{
orderId
=
routeOrderId
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
{
try
{
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
order
.
value
=
await
paymentStore
.
pollOrderStatus
(
orderId
)
}
catch
(
_err
:
unknown
)
{
}
catch
(
_err
:
unknown
)
{
...
@@ -260,9 +333,17 @@ onMounted(async () => {
...
@@ -260,9 +333,17 @@ onMounted(async () => {
}
}
}
}
const
hasLegacyFallbackContext
=
typeof
route
.
query
.
trade_status
===
'
string
'
if
(
!
order
.
value
&&
shouldUsePublicOutTradeNo
&&
(
!
resumeToken
||
resumeTokenLookupFailed
))
{
&&
route
.
query
.
trade_status
.
trim
()
!==
''
const
legacyOrder
=
await
resolveOrderFromOutTradeNo
(
outTradeNo
)
if
(
!
order
.
value
&&
!
resumeToken
&&
!
orderId
&&
outTradeNo
&&
hasLegacyFallbackContext
)
{
if
(
legacyOrder
)
{
order
.
value
=
legacyOrder
if
(
!
orderId
)
{
orderId
=
legacyOrder
.
id
}
}
}
if
(
!
order
.
value
&&
!
orderId
&&
outTradeNo
&&
hasLegacyFallbackContext
)
{
returnInfo
.
value
=
{
returnInfo
.
value
=
{
outTradeNo
,
outTradeNo
,
money
:
String
(
route
.
query
.
money
||
''
),
money
:
String
(
route
.
query
.
money
||
''
),
...
@@ -273,11 +354,22 @@ onMounted(async () => {
...
@@ -273,11 +354,22 @@ onMounted(async () => {
const
refreshOrder
=
async
():
Promise
<
PaymentOrder
|
null
>
=>
{
const
refreshOrder
=
async
():
Promise
<
PaymentOrder
|
null
>
=>
{
if
(
resumeToken
)
{
if
(
resumeToken
)
{
return
await
resolveOrderFromResumeToken
(
resumeToken
)
const
resolvedOrder
=
await
resolveOrderFromResumeToken
(
resumeToken
)
if
(
resolvedOrder
)
{
return
resolvedOrder
}
}
}
if
(
orderId
)
{
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
return
null
...
@@ -285,6 +377,10 @@ onMounted(async () => {
...
@@ -285,6 +377,10 @@ onMounted(async () => {
if
(
isPendingStatus
(
order
.
value
?.
status
))
{
if
(
isPendingStatus
(
order
.
value
?.
status
))
{
scheduleStatusRefresh
(
refreshOrder
)
scheduleStatusRefresh
(
refreshOrder
)
}
else
if
(
order
.
value
)
{
clearRecoverySnapshotForTerminalStatus
(
order
.
value
.
status
)
}
else
if
(
returnInfo
.
value
)
{
clearRecoverySnapshot
()
}
}
loading
.
value
=
false
loading
.
value
=
false
})
})
...
...
frontend/src/views/user/PaymentView.vue
View file @
ddf80f5e
...
@@ -276,7 +276,7 @@ import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
...
@@ -276,7 +276,7 @@ import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
Icon
from
'
@/components/icons/Icon.vue
'
import
type
{
PaymentMethodOption
}
from
'
@/components/payment/PaymentMethodSelector.vue
'
import
type
{
PaymentMethodOption
}
from
'
@/components/payment/PaymentMethodSelector.vue
'
import
{
buildPaymentErrorToastMessage
,
describePaymentScenarioError
}
from
'
./paymentUx
'
import
{
buildPaymentErrorToastMessage
,
describePaymentScenarioError
}
from
'
./paymentUx
'
import
{
parseWechatResumeRoute
,
stripWechatResumeQuery
}
from
'
./paymentWechatResume
'
import
{
hasWechatResumeQuery
,
parseWechatResumeRoute
,
stripWechatResumeQuery
}
from
'
./paymentWechatResume
'
const
{
t
}
=
useI18n
()
const
{
t
}
=
useI18n
()
const
route
=
useRoute
()
const
route
=
useRoute
()
...
@@ -329,6 +329,7 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
...
@@ -329,6 +329,7 @@ function emptyPaymentState(): PaymentRecoverySnapshot {
expiresAt
:
''
,
expiresAt
:
''
,
paymentType
:
''
,
paymentType
:
''
,
payUrl
:
''
,
payUrl
:
''
,
outTradeNo
:
''
,
clientSecret
:
''
,
clientSecret
:
''
,
payAmount
:
0
,
payAmount
:
0
,
orderType
:
''
,
orderType
:
''
,
...
@@ -391,6 +392,60 @@ function resetPayment() {
...
@@ -391,6 +392,60 @@ function resetPayment() {
removeRecoverySnapshot
()
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
()
{
function
onPaymentDone
()
{
const
wasSubscription
=
paymentState
.
value
.
orderType
===
'
subscription
'
const
wasSubscription
=
paymentState
.
value
.
orderType
===
'
subscription
'
resetPayment
()
resetPayment
()
...
@@ -658,7 +713,12 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
...
@@ -658,7 +713,12 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
}
)
}
)
if
(
decision
.
kind
===
'
wechat_oauth
'
&&
decision
.
oauth
?.
authorize_url
)
{
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
return
}
}
...
@@ -680,12 +740,23 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
...
@@ -680,12 +740,23 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
return
return
}
}
if
(
decision
.
kind
===
'
wechat_jsapi
'
&&
decision
.
jsapi
)
{
if
(
decision
.
kind
===
'
wechat_jsapi
'
&&
decision
.
jsapi
)
{
const
jsapiResult
=
await
invokeWechatJsapiPayment
(
decision
.
jsapi
as
Record
<
string
,
unknown
>
)
try
{
const
errMsg
=
String
(
jsapiResult
.
err_msg
||
''
).
toLowerCase
()
const
jsapiResult
=
await
invokeWechatJsapiPayment
(
decision
.
jsapi
as
Record
<
string
,
unknown
>
)
if
(
errMsg
.
includes
(
'
cancel
'
))
{
const
errMsg
=
String
(
jsapiResult
.
err_msg
||
''
).
toLowerCase
()
appStore
.
showInfo
(
t
(
'
payment.qr.cancelled
'
))
if
(
errMsg
.
includes
(
'
cancel
'
))
{
}
else
if
(
errMsg
&&
!
errMsg
.
includes
(
'
ok
'
))
{
appStore
.
showInfo
(
t
(
'
payment.qr.cancelled
'
))
applyScenarioError
({
reason
:
'
WECHAT_JSAPI_FAILED
'
,
message
:
errMsg
}
,
visibleMethod
)
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
return
}
}
...
@@ -789,9 +860,14 @@ onMounted(async () => {
...
@@ -789,9 +860,14 @@ onMounted(async () => {
selectedMethod
.
value
=
sorted
[
0
]
selectedMethod
.
value
=
sorted
[
0
]
}
}
if
(
typeof
window
!==
'
undefined
'
)
{
if
(
typeof
window
!==
'
undefined
'
)
{
if
(
hasWechatResumeQuery
(
route
.
query
))
{
removeRecoverySnapshot
()
}
const
routeResumeToken
=
typeof
route
.
query
.
resume_token
===
'
string
'
const
routeResumeToken
=
typeof
route
.
query
.
resume_token
===
'
string
'
?
route
.
query
.
resume_token
?
route
.
query
.
resume_token
:
undefined
:
typeof
route
.
query
.
wechat_resume_token
===
'
string
'
?
route
.
query
.
wechat_resume_token
:
undefined
const
restored
=
readPaymentRecoverySnapshot
(
const
restored
=
readPaymentRecoverySnapshot
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
),
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
),
{
resumeToken
:
routeResumeToken
}
,
{
resumeToken
:
routeResumeToken
}
,
...
...
frontend/src/views/user/__tests__/PaymentResultView.spec.ts
View file @
ddf80f5e
...
@@ -7,7 +7,7 @@ const routeState = vi.hoisted(() => ({
...
@@ -7,7 +7,7 @@ const routeState = vi.hoisted(() => ({
const
routerPush
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
routerPush
=
vi
.
hoisted
(()
=>
vi
.
fn
())
const
pollOrderStatus
=
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
())
const
resolveOrderPublicByResumeToken
=
vi
.
hoisted
(()
=>
vi
.
fn
())
vi
.
mock
(
'
vue-router
'
,
async
()
=>
{
vi
.
mock
(
'
vue-router
'
,
async
()
=>
{
...
@@ -37,7 +37,7 @@ vi.mock('@/stores/payment', () => ({
...
@@ -37,7 +37,7 @@ vi.mock('@/stores/payment', () => ({
vi
.
mock
(
'
@/api/payment
'
,
()
=>
({
vi
.
mock
(
'
@/api/payment
'
,
()
=>
({
paymentAPI
:
{
paymentAPI
:
{
verifyOrder
,
verifyOrder
Public
,
resolveOrderPublicByResumeToken
,
resolveOrderPublicByResumeToken
,
},
},
}))
}))
...
@@ -60,12 +60,28 @@ const orderFactory = (status: string) => ({
...
@@ -60,12 +60,28 @@ const orderFactory = (status: string) => ({
refund_amount
:
0
,
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
'
,
()
=>
{
describe
(
'
PaymentResultView
'
,
()
=>
{
beforeEach
(()
=>
{
beforeEach
(()
=>
{
routeState
.
query
=
{}
routeState
.
query
=
{}
routerPush
.
mockReset
()
routerPush
.
mockReset
()
pollOrderStatus
.
mockReset
()
pollOrderStatus
.
mockReset
()
verifyOrder
.
mockReset
()
verifyOrder
Public
.
mockReset
()
resolveOrderPublicByResumeToken
.
mockReset
()
resolveOrderPublicByResumeToken
.
mockReset
()
window
.
localStorage
.
clear
()
window
.
localStorage
.
clear
()
})
})
...
@@ -87,6 +103,7 @@ describe('PaymentResultView', () => {
...
@@ -87,6 +103,7 @@ describe('PaymentResultView', () => {
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
outTradeNo
:
'
sub2_20260420abcd1234
'
,
clientSecret
:
''
,
clientSecret
:
''
,
payAmount
:
88
,
payAmount
:
88
,
orderType
:
'
balance
'
,
orderType
:
'
balance
'
,
...
@@ -94,7 +111,9 @@ describe('PaymentResultView', () => {
...
@@ -94,7 +111,9 @@ describe('PaymentResultView', () => {
resumeToken
:
'
resume-42
'
,
resumeToken
:
'
resume-42
'
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
}))
}))
pollOrderStatus
.
mockResolvedValue
(
orderFactory
(
'
PENDING
'
))
resolveOrderPublicByResumeToken
.
mockResolvedValue
({
data
:
orderFactory
(
'
PENDING
'
),
})
const
wrapper
=
mount
(
PaymentResultView
,
{
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
global
:
{
...
@@ -106,7 +125,8 @@ describe('PaymentResultView', () => {
...
@@ -106,7 +125,8 @@ describe('PaymentResultView', () => {
await
flushPromises
()
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
()).
toContain
(
'
payment.result.processing
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.failed
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.failed
'
)
...
@@ -125,6 +145,7 @@ describe('PaymentResultView', () => {
...
@@ -125,6 +145,7 @@ describe('PaymentResultView', () => {
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
expiresAt
:
'
2099-01-01T00:10:00.000Z
'
,
paymentType
:
'
alipay
'
,
paymentType
:
'
alipay
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
payUrl
:
'
https://pay.example.com/session/42
'
,
outTradeNo
:
'
sub2_20260420abcd1234
'
,
clientSecret
:
''
,
clientSecret
:
''
,
payAmount
:
88
,
payAmount
:
88
,
orderType
:
'
balance
'
,
orderType
:
'
balance
'
,
...
@@ -132,12 +153,6 @@ describe('PaymentResultView', () => {
...
@@ -132,12 +153,6 @@ describe('PaymentResultView', () => {
resumeToken
:
'
resume-authoritative
'
,
resumeToken
:
'
resume-authoritative
'
,
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
createdAt
:
Date
.
UTC
(
2099
,
0
,
1
,
0
,
0
,
0
),
}))
}))
pollOrderStatus
.
mockResolvedValue
({
...
orderFactory
(
'
PENDING
'
),
amount
:
88
,
pay_amount
:
88
,
fee_rate
:
0
,
})
resolveOrderPublicByResumeToken
.
mockResolvedValue
({
resolveOrderPublicByResumeToken
.
mockResolvedValue
({
data
:
{
data
:
{
...
orderFactory
(
'
PAID
'
),
...
orderFactory
(
'
PAID
'
),
...
@@ -157,11 +172,12 @@ describe('PaymentResultView', () => {
...
@@ -157,11 +172,12 @@ describe('PaymentResultView', () => {
await
flushPromises
()
await
flushPromises
()
expect
(
pollOrderStatus
).
toHaveBeenCalled
With
(
42
)
expect
(
pollOrderStatus
).
not
.
toHaveBeenCalled
(
)
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-authoritative
'
)
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-authoritative
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
103.00
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
103.00
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
100.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
()
=>
{
it
(
'
refreshes a pending resume-token result until the order becomes paid
'
,
async
()
=>
{
...
@@ -169,6 +185,10 @@ describe('PaymentResultView', () => {
...
@@ -169,6 +185,10 @@ describe('PaymentResultView', () => {
routeState
.
query
=
{
routeState
.
query
=
{
resume_token
:
'
resume-77
'
,
resume_token
:
'
resume-77
'
,
}
}
window
.
localStorage
.
setItem
(
PAYMENT_RECOVERY_STORAGE_KEY
,
JSON
.
stringify
(
recoverySnapshotFactory
(
'
resume-77
'
)),
)
resolveOrderPublicByResumeToken
resolveOrderPublicByResumeToken
.
mockResolvedValueOnce
({
.
mockResolvedValueOnce
({
data
:
orderFactory
(
'
PENDING
'
),
data
:
orderFactory
(
'
PENDING
'
),
...
@@ -189,6 +209,7 @@ describe('PaymentResultView', () => {
...
@@ -189,6 +209,7 @@ describe('PaymentResultView', () => {
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledTimes
(
1
)
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledTimes
(
1
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.processing
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.processing
'
)
expect
(
window
.
localStorage
.
getItem
(
PAYMENT_RECOVERY_STORAGE_KEY
)).
not
.
toBeNull
()
await
vi
.
advanceTimersByTimeAsync
(
2000
)
await
vi
.
advanceTimersByTimeAsync
(
2000
)
await
flushPromises
()
await
flushPromises
()
...
@@ -196,17 +217,59 @@ describe('PaymentResultView', () => {
...
@@ -196,17 +217,59 @@ describe('PaymentResultView', () => {
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledTimes
(
2
)
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledTimes
(
2
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
toContain
(
'
payment.result.success
'
)
expect
(
wrapper
.
text
()).
not
.
toContain
(
'
payment.result.failed
'
)
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
=
{
routeState
.
query
=
{
resume_token
:
'
resume-fail
'
,
resume_token
:
'
resume-fail
'
,
out_trade_no
:
'
legacy-should-not-run
'
,
out_trade_no
:
'
legacy-should-not-run
'
,
trade_status
:
'
TRADE_SUCCESS
'
,
trade_status
:
'
TRADE_SUCCESS
'
,
}
}
resolveOrderPublicByResumeToken
.
mockRejectedValueOnce
(
new
Error
(
'
resume failed
'
))
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
:
{
global
:
{
stubs
:
{
stubs
:
{
OrderStatusBadge
:
true
,
OrderStatusBadge
:
true
,
...
@@ -217,16 +280,47 @@ describe('PaymentResultView', () => {
...
@@ -217,16 +280,47 @@ describe('PaymentResultView', () => {
await
flushPromises
()
await
flushPromises
()
expect
(
resolveOrderPublicByResumeToken
).
toHaveBeenCalledWith
(
'
resume-fail
'
)
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
=
{
routeState
.
query
=
{
out_trade_no
:
'
legacy-123
'
,
out_trade_no
:
'
legacy-123
'
,
trade_status
:
'
TRADE_SUCCESS
'
,
trade_status
:
'
TRADE_SUCCESS
'
,
}
}
verifyOrderPublic
.
mockResolvedValue
({
data
:
orderFactory
(
'
PAID
'
),
})
mount
(
PaymentResultView
,
{
const
wrapper
=
mount
(
PaymentResultView
,
{
global
:
{
global
:
{
stubs
:
{
stubs
:
{
OrderStatusBadge
:
true
,
OrderStatusBadge
:
true
,
...
@@ -236,7 +330,9 @@ describe('PaymentResultView', () => {
...
@@ -236,7 +330,9 @@ describe('PaymentResultView', () => {
await
flushPromises
()
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
()
=>
{
it
(
'
does not use public out_trade_no verification for bare order numbers without legacy return markers
'
,
async
()
=>
{
...
@@ -254,7 +350,7 @@ describe('PaymentResultView', () => {
...
@@ -254,7 +350,7 @@ describe('PaymentResultView', () => {
await
flushPromises
()
await
flushPromises
()
expect
(
verifyOrder
).
not
.
toHaveBeenCalled
()
expect
(
verifyOrder
Public
).
not
.
toHaveBeenCalled
()
})
})
it
(
'
resolves order by resume token when local recovery snapshot is missing
'
,
async
()
=>
{
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', () => {
...
@@ -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
'
,
()
=>
{
it
(
'
maps missing WeixinJSBridge to a JSAPI-specific prompt
'
,
()
=>
{
expect
(
describePaymentScenarioError
(
expect
(
describePaymentScenarioError
(
new
Error
(
'
WeixinJSBridge is unavailable
'
),
new
Error
(
'
WeixinJSBridge is unavailable
'
),
...
...
frontend/src/views/user/__tests__/paymentWechatResume.spec.ts
View file @
ddf80f5e
...
@@ -14,8 +14,9 @@ describe('parseWechatResumeRoute', () => {
...
@@ -14,8 +14,9 @@ describe('parseWechatResumeRoute', () => {
},
[],
88
)).
toEqual
({
},
[],
88
)).
toEqual
({
wechatResumeToken
:
'
resume-token-123
'
,
wechatResumeToken
:
'
resume-token-123
'
,
paymentType
:
'
wxpay
'
,
paymentType
:
'
wxpay
'
,
orderType
:
'
balance
'
,
orderType
:
'
subscription
'
,
orderAmount
:
0
,
orderAmount
:
0
,
planId
:
7
,
})
})
})
})
...
...
frontend/src/views/user/paymentWechatResume.ts
View file @
ddf80f5e
...
@@ -19,22 +19,38 @@ function readQueryString(query: LocationQuery, key: string): string {
...
@@ -19,22 +19,38 @@ function readQueryString(query: LocationQuery, key: string): string {
return
typeof
value
===
'
string
'
?
value
:
''
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
(
export
function
parseWechatResumeRoute
(
query
:
LocationQuery
,
query
:
LocationQuery
,
plans
:
SubscriptionPlan
[],
plans
:
SubscriptionPlan
[],
fallbackBalanceAmount
:
number
,
fallbackBalanceAmount
:
number
,
):
ParsedWechatResumeRoute
|
null
{
):
ParsedWechatResumeRoute
|
null
{
if
(
readQueryString
(
query
,
'
wechat_resume
'
)
!==
'
1
'
)
{
if
(
!
hasWechatResumeQuery
(
query
)
)
{
return
null
return
null
}
}
const
wechatResumeToken
=
readQueryString
(
query
,
'
wechat_resume_token
'
)
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
)
{
if
(
wechatResumeToken
)
{
return
{
return
{
wechatResumeToken
,
wechatResumeToken
,
paymentType
:
'
wxpay
'
,
paymentType
,
orderType
:
'
balance
'
,
orderType
,
orderAmount
:
0
,
orderAmount
:
0
,
planId
:
hasPlanId
?
planId
:
undefined
,
}
}
}
}
...
@@ -43,9 +59,6 @@ export function parseWechatResumeRoute(
...
@@ -43,9 +59,6 @@ export function parseWechatResumeRoute(
return
null
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
rawAmount
=
Number
.
parseFloat
(
readQueryString
(
query
,
'
amount
'
))
const
orderAmount
=
Number
.
isFinite
(
rawAmount
)
&&
rawAmount
>
0
const
orderAmount
=
Number
.
isFinite
(
rawAmount
)
&&
rawAmount
>
0
?
rawAmount
?
rawAmount
...
@@ -58,7 +71,7 @@ export function parseWechatResumeRoute(
...
@@ -58,7 +71,7 @@ export function parseWechatResumeRoute(
paymentType
,
paymentType
,
orderType
,
orderType
,
orderAmount
,
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