diff --git a/apps/aether-gateway/src/control/auth/gate.rs b/apps/aether-gateway/src/control/auth/gate.rs
index 6e79bf6e6..a24f29ff6 100644
--- a/apps/aether-gateway/src/control/auth/gate.rs
+++ b/apps/aether-gateway/src/control/auth/gate.rs
@@ -131,7 +131,13 @@ async fn execution_plan_balance_capacity_rejection_inner(
validate_execution_plan_pricing_configuration_for_plan(state, plan, report_context).await?;
return Ok(None);
}
- let Some(available_usd) = available_balance_capacity_usd(state, auth_context).await? else {
+ let wallet_only = report_context
+ .and_then(|context| context.get("plan_wallet_fallback"))
+ .and_then(serde_json::Value::as_bool)
+ .unwrap_or(false);
+ let Some(available_usd) =
+ available_balance_capacity_usd(state, auth_context, wallet_only).await?
+ else {
validate_execution_plan_pricing_configuration_for_plan(state, plan, report_context).await?;
return Ok(None);
};
@@ -177,11 +183,16 @@ async fn validate_execution_plan_pricing_configuration_for_plan(
async fn available_balance_capacity_usd(
state: &AppState,
auth_context: &GatewayControlAuthContext,
+ wallet_only: bool,
) -> Result
-
-
-
-
- 额度不足时继续使用钱包余额
-
-
-
-
+
+ 套餐额度不足时是否使用钱包余额,由用户在个人设置中选择,默认关闭。
+
@@ -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)
diff --git a/frontend/src/views/user/Settings.vue b/frontend/src/views/user/Settings.vue
index bc5c4591b..41b00e199 100644
--- a/frontend/src/views/user/Settings.vue
+++ b/frontend/src/views/user/Settings.vue
@@ -503,6 +503,35 @@
+
+
+ 额度使用
+
+
+
+
+
+ 默认关闭,套餐额度不足时不会自动扣除钱包余额。开启后优先使用套餐额度,额度耗尽、不可用或处于恢复期时使用钱包余额。
+
+
+
+
+
+
通知设置
@@ -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(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({
diff --git a/frontend/src/views/user/WalletCenter.vue b/frontend/src/views/user/WalletCenter.vue
index 4014b6e05..f7465827d 100644
--- a/frontend/src/views/user/WalletCenter.vue
+++ b/frontend/src/views/user/WalletCenter.vue
@@ -47,7 +47,7 @@
已用 {{ formatCurrency(dailyQuotaUsed) }} / 每日 {{ formatCurrency(dailyQuotaTotal) }}
- {{ dailyQuota?.allow_wallet_overage ? '套餐不足时继续扣钱包余额' : '套餐额度不足时会拒绝请求' }}
+ {{ dailyQuota?.allow_wallet_overage ? '已开启套餐不足时使用钱包余额' : '套餐额度不足时会拒绝请求,可在个人设置中开启使用余额' }}
{
expect(meApiMock.updatePreferences).not.toHaveBeenCalled()
})
})
+
+describe('Settings wallet fallback preferences', () => {
+ function walletSwitch(root: HTMLElement): HTMLButtonElement {
+ const control = root.querySelector('#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>()
+ 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((_, 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()
+ })
+})