mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 00:24:38 +00:00
fix: harden billing concurrency and payment recovery
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user