fix: harden billing concurrency and payment recovery

This commit is contained in:
superman2003
2026-07-10 10:56:49 +08:00
parent 12d811bd76
commit fc66a30ffc
25 changed files with 1353 additions and 198 deletions
@@ -0,0 +1,177 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
type NavigationGuard = (
to: Record<string, any>,
from: Record<string, any>,
next: ReturnType<typeof vi.fn>
) => Promise<void>
const routerHarness = vi.hoisted(() => ({
guard: null as NavigationGuard | null,
}))
const authStore = vi.hoisted(() => ({
checkAuth: vi.fn(),
isAuthenticated: true,
isAdmin: false,
isSimpleMode: false,
hasPendingAuthSession: false,
}))
const appStore = vi.hoisted(() => ({
siteName: 'Sub2API',
backendModeEnabled: false,
publicSettingsLoaded: false,
cachedPublicSettings: null as null | {
payment_enabled?: boolean
risk_control_enabled?: boolean
custom_menu_items?: []
},
fetchPublicSettings: vi.fn(),
}))
vi.mock('vue-router', () => ({
createWebHistory: vi.fn(() => ({})),
createRouter: vi.fn(() => ({
beforeEach: vi.fn((guard: NavigationGuard) => {
routerHarness.guard = guard
}),
afterEach: vi.fn(),
onError: vi.fn(),
})),
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => authStore,
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => appStore,
}))
vi.mock('@/stores/adminSettings', () => ({
useAdminSettingsStore: () => ({ customMenuItems: [] }),
}))
vi.mock('@/stores/adminCompliance', () => ({
useAdminComplianceStore: () => ({
initialized: true,
fetchStatus: vi.fn(),
requireAcknowledgement: vi.fn(),
}),
}))
vi.mock('@/composables/useNavigationLoading', () => ({
useNavigationLoadingState: () => ({
startNavigation: vi.fn(),
endNavigation: vi.fn(),
isLoading: { value: false },
}),
}))
vi.mock('@/composables/useRoutePrefetch', () => ({
useRoutePrefetch: () => ({
triggerPrefetch: vi.fn(),
cancelPendingPrefetch: vi.fn(),
resetPrefetchState: vi.fn(),
}),
}))
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
function runGuard(meta: Record<string, unknown>, path: string) {
if (!routerHarness.guard) {
throw new Error('router guard was not registered')
}
const next = vi.fn()
const navigation = routerHarness.guard(
{
path,
fullPath: path,
name: 'FeatureRoute',
params: {},
meta: { requiresAuth: true, ...meta },
},
{},
next
)
return { navigation, next }
}
describe('feature route guard', () => {
beforeAll(async () => {
await import('@/router')
})
beforeEach(() => {
authStore.isAuthenticated = true
authStore.isAdmin = false
authStore.isSimpleMode = false
appStore.publicSettingsLoaded = false
appStore.cachedPublicSettings = null
appStore.fetchPublicSettings.mockReset()
})
it('waits for the first public-settings request before deciding payment access', async () => {
const deferred = createDeferred<{ payment_enabled: boolean }>()
appStore.fetchPublicSettings.mockImplementation(async () => {
const settings = await deferred.promise
appStore.cachedPublicSettings = settings
appStore.publicSettingsLoaded = true
return settings
})
const { navigation, next } = runGuard({ requiresPayment: true }, '/purchase')
await vi.waitFor(() => expect(appStore.fetchPublicSettings).toHaveBeenCalledTimes(1))
expect(next).not.toHaveBeenCalled()
deferred.resolve({ payment_enabled: true })
await navigation
expect(next).toHaveBeenCalledOnce()
expect(next).toHaveBeenCalledWith()
})
it.each([
['payment', { requiresPayment: true }, '/purchase'],
['risk control', { requiresRiskControl: true }, '/admin/risk-control'],
])('does not treat a failed %s settings load as explicitly disabled', async (_name, meta, path) => {
authStore.isAdmin = meta.requiresRiskControl === true
appStore.fetchPublicSettings.mockResolvedValue(null)
const { navigation, next } = runGuard(meta, path)
await navigation
expect(appStore.publicSettingsLoaded).toBe(false)
expect(next).toHaveBeenCalledOnce()
expect(next).toHaveBeenCalledWith()
})
it.each([
['payment', { requiresPayment: true }, { payment_enabled: false }, '/dashboard'],
[
'risk control',
{ requiresRiskControl: true },
{ risk_control_enabled: false },
'/admin/settings',
],
])('redirects when loaded settings explicitly disable %s', async (_name, meta, settings, target) => {
authStore.isAdmin = meta.requiresRiskControl === true
appStore.cachedPublicSettings = settings
appStore.publicSettingsLoaded = true
const { navigation, next } = runGuard(meta, '/feature')
await navigation
expect(appStore.fetchPublicSettings).not.toHaveBeenCalled()
expect(next).toHaveBeenCalledOnce()
expect(next).toHaveBeenCalledWith(target)
})
})
+16 -13
View File
@@ -837,21 +837,24 @@ router.beforeEach(async (to, _from, next) => {
}
}
// Check payment requirement (internal payment system only)
if (to.meta.requiresPayment) {
const paymentEnabled = appStore.cachedPublicSettings?.payment_enabled
if (!paymentEnabled) {
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
return
}
// Only an explicit value from successfully loaded settings can disable a route.
// A transient settings failure is unknown state, not a confirmed feature toggle.
if (
to.meta.requiresPayment &&
appStore.publicSettingsLoaded &&
appStore.cachedPublicSettings?.payment_enabled === false
) {
next(authStore.isAdmin ? '/admin/dashboard' : '/dashboard')
return
}
if (to.meta.requiresRiskControl) {
const riskControlEnabled = appStore.cachedPublicSettings?.risk_control_enabled === true
if (!riskControlEnabled) {
next(authStore.isAdmin ? '/admin/settings' : '/dashboard')
return
}
if (
to.meta.requiresRiskControl &&
appStore.publicSettingsLoaded &&
appStore.cachedPublicSettings?.risk_control_enabled === false
) {
next(authStore.isAdmin ? '/admin/settings' : '/dashboard')
return
}
// 简易模式下限制访问某些页面