feat: allow wallet fallback after plan quota exhaustion

This commit is contained in:
elky
2026-10-09 21:21:53 +08:00
parent bad13237d2
commit 63520c8c27
39 changed files with 1474 additions and 190 deletions
+1
View File
@@ -21,6 +21,7 @@ export interface UserPreferences {
theme?: 'light' | 'dark' | 'auto'
language?: string
notifications_enabled?: boolean
allow_wallet_overage?: boolean
[key: string]: unknown // 允许扩展其他偏好设置
}
+1
View File
@@ -42,6 +42,7 @@ export interface UserPreferences {
theme: string
language: string
timezone?: string
allow_wallet_overage?: boolean
notifications?: {
email?: boolean
usage_alerts?: boolean
+13 -2
View File
@@ -1609,11 +1609,22 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
'GET /api/users/me/preferences': async () => {
await delay()
return createMockResponse(getCurrentProfile().preferences || { theme: 'auto', language: 'zh-CN' })
return createMockResponse({
allow_wallet_overage: false,
...(getCurrentProfile().preferences || { theme: 'auto', language: 'zh-CN' }),
})
},
'PUT /api/users/me/preferences': async () => {
'PUT /api/users/me/preferences': async (config) => {
await delay()
const payload = mockRequestObject(config)
if (typeof payload.allow_wallet_overage === 'boolean') {
const profile = getCurrentProfile()
profile.preferences = {
...(profile.preferences || { theme: 'auto', language: 'zh-CN' }),
allow_wallet_overage: payload.allow_wallet_overage,
}
}
return createMockResponse({ message: '偏好设置已更新(演示模式)' })
},
@@ -746,15 +746,9 @@
每日额度套餐本身已按类型互斥;填写后还可与其他权益类型的同名组整包互斥。
</p>
</div>
<div class="flex items-center justify-between rounded-xl border border-border/60 bg-card/50 p-3">
<div>
<Label>允许超额扣钱包</Label>
<p class="mt-1 text-xs text-muted-foreground">
额度不足时继续使用钱包余额
</p>
</div>
<Switch v-model="form.allow_wallet_overage" />
</div>
<p class="rounded-xl border border-border/60 bg-card/50 p-3 text-xs leading-5 text-muted-foreground">
套餐额度不足时是否使用钱包余额,由用户在个人设置中选择,默认关闭。
</p>
<div class="flex items-center justify-between rounded-xl border border-border/60 bg-card/50 p-3 opacity-70">
<div>
<Label>额度结转</Label>
@@ -1272,7 +1266,6 @@ interface PlanFormState {
daily_quota_usd: number
reset_timezone: string
carry_over: boolean
allow_wallet_overage: boolean
daily_quota_replacement_group: string
membership_group_enabled: boolean
grant_user_groups: string[]
@@ -1526,11 +1519,7 @@ const dailyQuotaSummaryText = computed(() =>
: '每天独立 USD 消费用量,默认不结转'
)
const dailyQuotaDetailText = computed(() =>
form.allow_wallet_overage
? '每日额度不足时会继续使用钱包余额,适合希望用户不中断请求的套餐。'
: '每日额度不足时不再继续扣钱包,适合严格封顶的月卡或体验卡。'
)
const dailyQuotaDetailText = '优先使用每日套餐额度;额度不足时是否使用钱包余额,由用户在个人设置中选择,默认关闭。'
const membershipSummaryText = computed(() =>
planMode.value === 'mixed'
@@ -1568,7 +1557,6 @@ function buildDefaultForm(): PlanFormState {
daily_quota_usd: 50,
reset_timezone: 'Asia/Shanghai',
carry_over: false,
allow_wallet_overage: false,
daily_quota_replacement_group: '',
membership_group_enabled: false,
grant_user_groups: [],
@@ -1732,7 +1720,6 @@ function formFromPlan(plan: BillingPlan): PlanFormState {
next.daily_quota_usd = Number(quota.daily_quota_usd || next.daily_quota_usd)
next.reset_timezone = quota.reset_timezone || 'Asia/Shanghai'
next.carry_over = Boolean(quota.carry_over)
next.allow_wallet_overage = Boolean(quota.allow_wallet_overage)
next.daily_quota_replacement_group = quota.replacement_group || ''
} else if (entitlement.type === 'membership_group') {
const membership = entitlement as MembershipGroupEntitlement
@@ -1813,7 +1800,6 @@ function buildEntitlements(): BillingEntitlement[] {
daily_quota_usd: Number(form.daily_quota_usd),
reset_timezone: form.reset_timezone.trim() || 'Asia/Shanghai',
carry_over: false,
allow_wallet_overage: Boolean(form.allow_wallet_overage),
}
attachReplacementGroup(entitlement, form.daily_quota_replacement_group)
entitlements.push(entitlement)
+51
View File
@@ -503,6 +503,35 @@
</div>
</div>
<div class="space-y-3">
<h4 class="font-medium text-foreground">
额度使用
</h4>
<div class="flex items-center justify-between gap-4 py-2">
<div class="flex-1">
<Label
for="allow-wallet-overage"
class="text-sm font-medium cursor-pointer"
>
套餐额度不可用时使用余额
</Label>
<p
id="allow-wallet-overage-description"
class="text-xs text-muted-foreground mt-1"
>
默认关闭,套餐额度不足时不会自动扣除钱包余额。开启后优先使用套餐额度,额度耗尽、不可用或处于恢复期时使用钱包余额。
</p>
</div>
<Switch
id="allow-wallet-overage"
:model-value="allowWalletOverage"
:disabled="!billingPreferencesLoaded || savingWalletOverage"
aria-describedby="allow-wallet-overage-description"
@update:model-value="updateWalletOverage"
/>
</div>
</div>
<div class="space-y-3">
<h4 class="font-medium text-foreground">
通知设置
@@ -744,6 +773,9 @@ const featureSettingsForm = ref({
const savingProfile = ref(false)
const savingFeatureSettings = ref(false)
const allowWalletOverage = ref(false)
const billingPreferencesLoaded = ref(false)
const savingWalletOverage = ref(false)
const changingPassword = ref(false)
const sessionsLoading = ref(false)
const sessionActionLoading = ref<string | null>(null)
@@ -1001,6 +1033,8 @@ async function handleUnbind(providerType: string) {
async function loadPreferences() {
try {
const prefs = await meApi.getPreferences()
allowWalletOverage.value = prefs.allow_wallet_overage === true
billingPreferencesLoaded.value = true
// 主题以本地 localStorage 为准(useDarkMode 在应用启动时已初始化)
// 这样可以避免刷新页面时主题被服务端旧值覆盖
@@ -1169,6 +1203,23 @@ async function handleRevokeOtherSessions() {
}
}
async function updateWalletOverage(enabled: boolean) {
if (!billingPreferencesLoaded.value || savingWalletOverage.value) return
const previous = allowWalletOverage.value
allowWalletOverage.value = enabled
savingWalletOverage.value = true
try {
await meApi.updatePreferences({ allow_wallet_overage: enabled })
success('设置已保存')
} catch (error) {
allowWalletOverage.value = previous
log.error('更新额度使用设置失败:', error)
showError('保存设置失败')
} finally {
savingWalletOverage.value = false
}
}
async function updatePreferences() {
try {
await meApi.updatePreferences({
+1 -1
View File
@@ -47,7 +47,7 @@
已用 {{ formatCurrency(dailyQuotaUsed) }} / 每日 {{ formatCurrency(dailyQuotaTotal) }}
</div>
<div class="text-xs text-muted-foreground">
{{ dailyQuota?.allow_wallet_overage ? '套餐不足时继续扣钱包余额' : '套餐额度不足时会拒绝请求' }}
{{ dailyQuota?.allow_wallet_overage ? '已开启套餐不足时使用钱包余额' : '套餐额度不足时会拒绝请求,可在个人设置中开启使用余额' }}
</div>
</div>
<div
@@ -227,3 +227,72 @@ describe('Settings language preferences', () => {
expect(meApiMock.updatePreferences).not.toHaveBeenCalled()
})
})
describe('Settings wallet fallback preferences', () => {
function walletSwitch(root: HTMLElement): HTMLButtonElement {
const control = root.querySelector<HTMLButtonElement>('#allow-wallet-overage')
if (!control) throw new Error('The wallet fallback switch was not rendered')
return control
}
it('defaults to off for existing users and waits for preferences before allowing changes', async () => {
const preferences = deferred<ReturnType<typeof serverPreferences>>()
meApiMock.getPreferences.mockReturnValueOnce(preferences.promise)
const root = mountSettings()
const control = walletSwitch(root)
expect(control.getAttribute('aria-checked')).toBe('false')
expect(control.disabled).toBe(true)
control.click()
expect(meApiMock.updatePreferences).not.toHaveBeenCalled()
preferences.resolve(serverPreferences())
await flushPromises()
expect(control.disabled).toBe(false)
expect(control.getAttribute('aria-checked')).toBe('false')
})
it('loads the saved choice and persists both disabling and enabling without changing other preferences', async () => {
meApiMock.getPreferences.mockResolvedValue({ ...serverPreferences(), allow_wallet_overage: true })
const root = mountSettings()
await flushPromises()
const control = walletSwitch(root)
expect(control.getAttribute('aria-checked')).toBe('true')
control.click()
await flushPromises()
expect(meApiMock.updatePreferences).toHaveBeenLastCalledWith({ allow_wallet_overage: false })
expect(control.getAttribute('aria-checked')).toBe('false')
control.click()
await flushPromises()
expect(meApiMock.updatePreferences).toHaveBeenLastCalledWith({ allow_wallet_overage: true })
expect(control.getAttribute('aria-checked')).toBe('true')
chooseEnglish(root)
await flushPromises()
expect(meApiMock.updatePreferences.mock.lastCall?.[0]).not.toHaveProperty('allow_wallet_overage')
})
it('prevents duplicate saves and restores the saved choice when saving fails', async () => {
let reject!: (reason: Error) => void
meApiMock.updatePreferences.mockReturnValueOnce(new Promise<void>((_, fail) => { reject = fail }))
const root = mountSettings()
await flushPromises()
const control = walletSwitch(root)
control.click()
await nextTick()
expect(control.disabled).toBe(true)
expect(control.getAttribute('aria-checked')).toBe('true')
control.click()
expect(meApiMock.updatePreferences).toHaveBeenCalledTimes(1)
reject(new Error('Save failed'))
await flushPromises()
expect(control.disabled).toBe(false)
expect(control.getAttribute('aria-checked')).toBe('false')
expect(toastMock.error).toHaveBeenCalledWith('保存设置失败')
expect(toastMock.success).not.toHaveBeenCalled()
})
})