Merge pull request #3912 from superman2003/fix/audit-concurrency-and-recovery

fix: harden billing concurrency and payment recovery
This commit is contained in:
Wesley Liddick
2026-07-10 11:38:45 +08:00
committed by GitHub
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
}
// 简易模式下限制访问某些页面
+127
View File
@@ -2,6 +2,63 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAppStore } from '@/stores/app'
import { getPublicSettings } from '@/api/auth'
import type { PublicSettings } from '@/types'
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function createPublicSettings(overrides: Partial<PublicSettings> = {}): PublicSettings {
return {
registration_enabled: false,
email_verify_enabled: false,
force_email_on_third_party_signup: false,
registration_email_suffix_whitelist: [],
promo_code_enabled: true,
password_reset_enabled: false,
invitation_code_enabled: false,
turnstile_enabled: false,
turnstile_site_key: '',
site_name: 'Test Site',
site_logo: '',
site_subtitle: '',
api_base_url: '',
contact_info: '',
doc_url: '',
home_content: '',
hide_ccs_import_button: false,
payment_enabled: false,
risk_control_enabled: false,
table_default_page_size: 20,
table_page_size_options: [10, 20, 50, 100],
custom_menu_items: [],
custom_endpoints: [],
linuxdo_oauth_enabled: false,
wechat_oauth_enabled: false,
oidc_oauth_enabled: false,
oidc_oauth_provider_name: 'OIDC',
github_oauth_enabled: false,
google_oauth_enabled: false,
backend_mode_enabled: false,
version: '1.0.0',
balance_low_notify_enabled: false,
account_quota_notify_enabled: false,
balance_low_notify_threshold: 0,
channel_monitor_enabled: true,
channel_monitor_default_interval_seconds: 60,
available_channels_enabled: false,
service_quota_enabled: false,
affiliate_enabled: false,
...overrides,
}
}
// Mock API 模块
vi.mock('@/api/admin/system', () => ({
@@ -17,6 +74,7 @@ describe('useAppStore', () => {
setActivePinia(createPinia())
vi.useFakeTimers()
localStorage.clear()
vi.mocked(getPublicSettings).mockReset()
// 清除 window.__APP_CONFIG__
delete (window as any).__APP_CONFIG__
})
@@ -263,6 +321,75 @@ describe('useAppStore', () => {
// --- 公开设置 ---
describe('公开设置加载', () => {
it('并发调用复用并等待同一个请求,包括 force 调用', async () => {
const deferred = createDeferred<PublicSettings>()
vi.mocked(getPublicSettings).mockReturnValue(deferred.promise)
const settings = createPublicSettings({ payment_enabled: true })
const store = useAppStore()
const first = store.fetchPublicSettings()
const second = store.fetchPublicSettings()
const forced = store.fetchPublicSettings(true)
expect(getPublicSettings).toHaveBeenCalledTimes(1)
const settled = vi.fn()
void first.then(settled)
void second.then(settled)
void forced.then(settled)
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
deferred.resolve(settings)
await expect(Promise.all([first, second, forced])).resolves.toEqual([
settings,
settings,
settings,
])
expect(store.publicSettingsLoaded).toBe(true)
expect(store.cachedPublicSettings?.payment_enabled).toBe(true)
})
it('force 在无活动请求时绕过缓存,刷新期间的普通调用等待刷新结果', async () => {
const initial = createPublicSettings({ site_name: 'Initial Site' })
vi.mocked(getPublicSettings).mockResolvedValueOnce(initial)
const store = useAppStore()
await store.fetchPublicSettings()
const deferred = createDeferred<PublicSettings>()
const updated = createPublicSettings({ site_name: 'Updated Site' })
vi.mocked(getPublicSettings).mockReturnValueOnce(deferred.promise)
const refresh = store.fetchPublicSettings(true)
const duringRefresh = store.fetchPublicSettings()
expect(getPublicSettings).toHaveBeenCalledTimes(2)
deferred.resolve(updated)
await expect(Promise.all([refresh, duringRefresh])).resolves.toEqual([updated, updated])
expect(store.siteName).toBe('Updated Site')
await expect(store.fetchPublicSettings()).resolves.toEqual(updated)
expect(getPublicSettings).toHaveBeenCalledTimes(2)
})
it('并发请求失败时所有调用得到 null,且不会标记设置已加载', async () => {
const deferred = createDeferred<PublicSettings>()
vi.mocked(getPublicSettings).mockReturnValue(deferred.promise)
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const store = useAppStore()
const first = store.fetchPublicSettings()
const second = store.fetchPublicSettings()
deferred.reject(new Error('network unavailable'))
await expect(Promise.all([first, second])).resolves.toEqual([null, null])
expect(getPublicSettings).toHaveBeenCalledTimes(1)
expect(store.publicSettingsLoaded).toBe(false)
expect(store.cachedPublicSettings).toBeNull()
consoleError.mockRestore()
})
it('从 window.__APP_CONFIG__ 初始化', () => {
const windowAny = window as any
windowAny.__APP_CONFIG__ = {
+34 -15
View File
@@ -33,6 +33,7 @@ export const useAppStore = defineStore('app', () => {
const apiBaseUrl = ref<string>('')
const docUrl = ref<string>('')
const cachedPublicSettings = ref<PublicSettings | null>(null)
let publicSettingsRequest: Promise<PublicSettings | null> | null = null
// Version cache state
const versionLoaded = ref<boolean>(false)
@@ -306,19 +307,25 @@ export const useAppStore = defineStore('app', () => {
* Fetch public settings (uses cache unless force=true)
* @param force - Force refresh from API
*/
async function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
function fetchPublicSettings(force = false): Promise<PublicSettings | null> {
// An active request always wins over cache/force semantics so every caller observes
// the same refresh result and no older request can overwrite a newer one.
if (publicSettingsRequest) {
return publicSettingsRequest
}
// Check for injected config from server (eliminates flash)
if (!publicSettingsLoaded.value && !force && window.__APP_CONFIG__) {
applySettings(window.__APP_CONFIG__)
return window.__APP_CONFIG__
return Promise.resolve(window.__APP_CONFIG__)
}
// Return cached data if available and not forcing refresh
if (publicSettingsLoaded.value && !force) {
if (cachedPublicSettings.value) {
return { ...cachedPublicSettings.value }
return Promise.resolve({ ...cachedPublicSettings.value })
}
return {
return Promise.resolve({
registration_enabled: false,
email_verify_enabled: false,
force_email_on_third_party_signup: false,
@@ -362,25 +369,37 @@ export const useAppStore = defineStore('app', () => {
service_quota_enabled: false,
affiliate_enabled: false,
allow_user_view_error_requests: false,
}
}
// Prevent duplicate requests
if (publicSettingsLoading.value) {
return null
})
}
publicSettingsLoading.value = true
let apiRequest: Promise<PublicSettings>
try {
const data = await fetchPublicSettingsAPI()
applySettings(data)
return data
apiRequest = fetchPublicSettingsAPI()
} catch (error) {
console.error('Failed to fetch public settings:', error)
return null
} finally {
publicSettingsLoading.value = false
return Promise.resolve(null)
}
const request = apiRequest
.then((data) => {
applySettings(data)
return data
})
.catch((error) => {
console.error('Failed to fetch public settings:', error)
return null
})
.finally(() => {
if (publicSettingsRequest === request) {
publicSettingsRequest = null
publicSettingsLoading.value = false
}
})
publicSettingsRequest = request
return request
}
/**