From d79c76a105d9ff3d55711a378bc08ee90ca7a828 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 17 Oct 2026 20:15:21 +0800 Subject: [PATCH] feat(accounts): add optional weekly quota limit for reset credits Skip new automatic redemptions above the configured remaining weekly quota limit, leaving it unset by default and preserving pending retries. Keep temporarily ineligible credits scheduled and expose the setting beside the redemption lead time. Co-authored-by: Codex --- .../gateway-admin/src/model/settings.rs | 7 + .../accounts/reset_credit_automation.rs | 105 ++++++- .../tests/use_case/accounts/mod.rs | 15 +- .../accounts/reset_credit_automation.rs | 262 +++++++++++++++++- .../tests/use_case/freeze_recovery.rs | 1 + .../tests/use_case/observability/mod.rs | 1 + .../gateway-admin/tests/use_case/settings.rs | 1 + .../crates/gateway-api/src/admin/settings.rs | 6 + backend/crates/gateway-api/tests/admin/mod.rs | 2 + .../gateway-api/tests/admin/settings.rs | 45 ++- .../gateway-plugin/sdk/docs/capabilities.md | 4 +- .../sdk/src/call/services/settings.rs | 4 + .../src/postgres/runtime_settings.rs | 28 +- .../tests/postgres/runtime_settings.rs | 40 +++ backend/migrations/.frozen-sha256 | 1 + .../0035_reset_credit_weekly_threshold.sql | 4 + docs/api.md | 11 +- docs/architecture.md | 4 +- frontend/src/api/modules/settings/index.ts | 1 + .../ResetCreditAutomation.vue | 3 + .../settings/components/ResetCreditCard.vue | 4 + .../settings/composables/useSettingsForm.ts | 12 +- frontend/src/views/settings/index.vue | 6 +- 23 files changed, 539 insertions(+), 28 deletions(-) create mode 100644 backend/migrations/0035_reset_credit_weekly_threshold.sql diff --git a/backend/crates/gateway-admin/src/model/settings.rs b/backend/crates/gateway-admin/src/model/settings.rs index 48a9bbf6d..1aecfcbaf 100644 --- a/backend/crates/gateway-admin/src/model/settings.rs +++ b/backend/crates/gateway-admin/src/model/settings.rs @@ -76,6 +76,7 @@ pub struct RuntimeSettingsValues { pub account_warmup_schedule_time: String, pub account_warmup_model: Option, pub reset_credit_lead_minutes: u32, + pub reset_credit_max_weekly_remaining_percent: Option, } /// 完整运行设置事实 @@ -193,6 +194,12 @@ impl RuntimeSettingsValues { if !(1..=1440).contains(&self.reset_credit_lead_minutes) { return Err("reset_credit_lead_minutes"); } + if self + .reset_credit_max_weekly_remaining_percent + .is_some_and(|percent| percent > 100) + { + return Err("reset_credit_max_weekly_remaining_percent"); + } self.request_location .validate() .map_err(|_| "request_location")?; diff --git a/backend/crates/gateway-admin/src/use_case/accounts/reset_credit_automation.rs b/backend/crates/gateway-admin/src/use_case/accounts/reset_credit_automation.rs index 8c62360f1..62e788041 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts/reset_credit_automation.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts/reset_credit_automation.rs @@ -59,14 +59,15 @@ use chrono::{DateTime, Utc}; use rand_core::{OsRng, RngCore as _}; impl DefaultAccountsService { - async fn automation_lead_minutes(&self) -> Result { + async fn automation_settings( + &self, + ) -> Result { Ok(self .settings .load_runtime_settings() .await .map_err(|e| map_store_error(e, "runtime settings"))? - .values - .reset_credit_lead_minutes) + .values) } pub(super) async fn automation_status( @@ -74,7 +75,7 @@ impl DefaultAccountsService { account_id: &ProviderAccountId, ) -> Result { self.require_reset_credit_automation(account_id).await?; - let lead_minutes = self.automation_lead_minutes().await?; + let lead_minutes = self.automation_settings().await?.reset_credit_lead_minutes; let record = self .accounts .reset_credit_automation(account_id.as_str()) @@ -160,7 +161,7 @@ impl DefaultAccountsService { record.state.next_discovery_at = now + chrono::Duration::seconds(i64::from(OsRng.next_u32() % 60)); } - let lead = self.automation_lead_minutes().await?; + let lead = self.automation_settings().await?.reset_credit_lead_minutes; reschedule(&mut record, lead, now); self.save_automation(&mut record, Some(context)).await?; let settings = self @@ -244,7 +245,11 @@ impl DefaultAccountsService { } let now = Utc::now(); self.observe_reset_credits(&mut record, credits, now)?; - reschedule(&mut record, self.automation_lead_minutes().await?, now); + reschedule( + &mut record, + self.automation_settings().await?.reset_credit_lead_minutes, + now, + ); self.save_automation(&mut record, None).await } @@ -281,6 +286,60 @@ impl DefaultAccountsService { self.save_automation(record, None).await } + /// 暂不满足条件不丢弃卡片,也不创建未决消费,允许到期前再次判断 + async fn defer_automation( + &self, + record: &mut ResetCreditAutomation, + lead: u32, + reason: &str, + ) -> Result<(), AdminError> { + let now = Utc::now(); + let retry = now + chrono::Duration::seconds(30 + i64::from(OsRng.next_u32() % 30)); + record.state.retry_at = Some( + record + .state + .credits + .iter() + .map(|credit| credit.expires_at) + .min() + .map_or(retry, |expiry| retry.min(expiry)), + ); + record.state.last_result = Some(reason.to_owned()); + record.state.failures = 0; + reschedule(record, lead, now); + self.save_automation(record, None).await + } + + async fn weekly_reset_credit_gate( + &self, + account_id: &ProviderAccountId, + max_remaining: u32, + ) -> Result, AdminError> { + let (_, provider) = self.provider_for_account(account_id).await?; + let quota = provider + .quota(ProviderQuotaRequest { + account_id: account_id.clone(), + refresh: true, + rolling_usage: None, + }) + .await + .map_err(|error| map_provider_error(error, "reset-credit weekly quota"))?; + // 复用账号周窗口归属,不把短期、月度或模型专属桶当作剩余周额度 + let remaining = quota + .usage_windows() + .find(|(_, period)| *period == AccountUsagePeriod::Weekly) + .and_then(|(window, _)| window.used_percent) + .filter(|used| used.is_finite()) + .map(|used| 100.0 - used.clamp(0.0, 100.0)); + Ok(match remaining { + Some(remaining) if remaining > f64::from(max_remaining) => { + Some("weekly_quota_above_threshold") + } + Some(_) => None, + None => Some("weekly_quota_unavailable"), + }) + } + pub(super) async fn reconcile_automation( &self, account_id: &ProviderAccountId, @@ -309,7 +368,8 @@ impl DefaultAccountsService { } } let now = Utc::now(); - let lead = self.automation_lead_minutes().await?; + let settings = self.automation_settings().await?; + let lead = settings.reset_credit_lead_minutes; record .state .credits @@ -368,6 +428,32 @@ impl DefaultAccountsService { .find(|credit| consume_at(account_id.as_str(), credit, lead) <= Utc::now()) .cloned() { + if let Some(max_remaining) = settings.reset_credit_max_weekly_remaining_percent { + // 已发出的未决请求跳过此检查,继续沿原幂等键确认结果 + match self + .weekly_reset_credit_gate(account_id, max_remaining) + .await + { + Ok(Some(reason)) => { + return self.defer_automation(&mut record, lead, reason).await; + } + Ok(None) => {} + Err(error) => { + self.defer_automation(&mut record, lead, "weekly_quota_unavailable") + .await?; + return Err(error); + } + } + } + // 刷新额度期间卡片可能到期,不能把已过期卡片持久化为待消费 + if credit.expires_at <= Utc::now() { + record + .state + .credits + .retain(|credit| credit.expires_at > Utc::now()); + reschedule(&mut record, lead, Utc::now()); + return self.save_automation(&mut record, None).await; + } record.state.pending = Some(PendingResetCredit { credit, redeem_request_id: uuid::Uuid::new_v4().to_string(), @@ -393,6 +479,11 @@ impl DefaultAccountsService { Ok(result) => { let confirmed = matches!(result.code.as_str(), "reset" | "already_redeemed"); record.state.pending = None; + if result.code == "nothing_to_reset" { + return self + .defer_automation(&mut record, lead, "nothing_to_reset") + .await; + } record .state .credits diff --git a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs index fbd2e85e1..6ec3f0728 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts/mod.rs @@ -1137,7 +1137,10 @@ impl AccountStore for FakeAccountStore { } } -struct StaticSettingsStore; +#[derive(Default)] +struct StaticSettingsStore { + reset_credit_max_weekly_remaining_percent: Option, +} #[async_trait] impl SettingsStore for StaticSettingsStore { @@ -1197,6 +1200,8 @@ impl SettingsStore for StaticSettingsStore { account_warmup_schedule_time: "08:00".to_owned(), account_warmup_model: None, reset_credit_lead_minutes: 10, + reset_credit_max_weekly_remaining_percent: self + .reset_credit_max_weekly_remaining_percent, }, }) } @@ -1892,7 +1897,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() { store.set_accounts(vec![openai_account, xai_account]); let services = super::AdminHarness::new() .accounts(store.clone()) - .settings(Arc::new(StaticSettingsStore)) + .settings(Arc::new(StaticSettingsStore::default())) .provider(openai) .provider(xai) .probe(Arc::new(SuccessfulAccountProbe)) @@ -2100,7 +2105,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop store.set_accounts(vec![account_record("openai"), xai_account]); let services = super::AdminHarness::new() .accounts(store.clone()) - .settings(Arc::new(StaticSettingsStore)) + .settings(Arc::new(StaticSettingsStore::default())) .provider(openai) .provider(failing) .probe(Arc::new(SuccessfulAccountProbe)) @@ -3457,7 +3462,7 @@ async fn accounts_service_with_probe( ) -> AdminServices { super::AdminHarness::new() .accounts(store) - .settings(Arc::new(StaticSettingsStore)) + .settings(Arc::new(StaticSettingsStore::default())) .provider(provider) .probe(probe) .build() @@ -3701,7 +3706,7 @@ async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavai let services = super::AdminHarness::new() .accounts(store) .account_runtime(runtime.clone()) - .settings(Arc::new(StaticSettingsStore)) + .settings(Arc::new(StaticSettingsStore::default())) .provider(FakeProviderAdmin::new("openai", events())) .build() .await; diff --git a/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs b/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs index 3eb66b7c3..f5a7cb086 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts/reset_credit_automation.rs @@ -309,7 +309,7 @@ async fn automation_restores_pending_idempotency_after_service_restart() { .state .retry_at = Some(Utc::now() - TimeDelta::seconds(1)); drop(services); - let restarted = accounts_service(provider.clone(), store.clone()).await; + let restarted = threshold_services(provider.clone(), store.clone(), Some(0)).await; restarted .accounts() .reconcile_reset_credit_automation(account_id) @@ -707,3 +707,263 @@ async fn disabled_automation_only_blocks_manual_consume_until_pending_credit_exp .unwrap(); assert_eq!(provider.reset_credit_commands().len(), 1); } + +async fn threshold_services( + provider: Arc, + store: Arc, + max_remaining: Option, +) -> AdminServices { + super::super::AdminHarness::new() + .accounts(store) + .settings(Arc::new(StaticSettingsStore { + reset_credit_max_weekly_remaining_percent: max_remaining, + })) + .provider(provider) + .probe(Arc::new(SuccessfulAccountProbe)) + .build() + .await +} + +fn weekly_quota(used_percent: f64) -> ProviderQuota { + ProviderQuota { + windows: vec![ProviderQuotaWindow { + key: "week".into(), + group: "shortTerm".into(), + label: "周额度".into(), + limit_id: None, + limit_name: None, + role: None, + local_usage_attribution: QuotaLocalUsageAttribution::AccountWide, + window_seconds: Some(7 * 86_400), + used_percent: Some(used_percent), + reset_at: Some(Utc::now() + TimeDelta::days(1)), + limit_reached: false, + local_usage: None, + provider_data: None, + }], + ..empty_quota() + } +} + +fn schedule_due_credit(provider: &FakeProviderAdmin, store: &FakeAccountStore) { + enable_capability(provider); + let expiry = Utc::now() + TimeDelta::minutes(5); + provider.reset_credits.lock().unwrap().push(card(expiry)); + store + .automations + .lock() + .unwrap() + .insert("acct_test".into(), plan(expiry)); +} + +fn advance_retry(store: &FakeAccountStore) { + store + .automations + .lock() + .unwrap() + .get_mut("acct_test") + .unwrap() + .state + .retry_at = Some(Utc::now() - TimeDelta::seconds(1)); +} + +#[tokio::test] +async fn automation_weekly_threshold_is_optional_inclusive_and_uses_remaining_quota() { + for (threshold, used, consume) in [ + (None, 0.0, true), + (Some(70), 29.9, false), + (Some(70), 30.0, true), + (Some(0), 99.9, false), + (Some(0), 100.0, true), + (Some(100), 0.0, true), + ] { + let provider = FakeProviderAdmin::new("openai", events()); + let store = FakeAccountStore::new("openai", events()); + schedule_due_credit(&provider, &store); + provider.set_quota(weekly_quota(used)); + let services = threshold_services(provider.clone(), store.clone(), threshold).await; + services + .accounts() + .reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap()) + .await + .unwrap(); + assert_eq!( + !provider.reset_credit_commands().is_empty(), + consume, + "threshold={threshold:?}, used={used}" + ); + let quota_requests = provider.quota_requests(); + // 消费成功已有一次回读;未设置上限时不增加消费前查询 + assert_eq!( + quota_requests.len(), + usize::from(threshold.is_some()) + usize::from(consume) + ); + assert!(quota_requests.iter().all(|request| request.refresh)); + let record = store + .reset_credit_automation("acct_test") + .await + .unwrap() + .unwrap(); + assert!(record.state.pending.is_none()); + assert_eq!(record.state.credits.is_empty(), consume); + if !consume { + assert_eq!( + record.state.last_result.as_deref(), + Some("weekly_quota_above_threshold") + ); + assert!(record.state.retry_at.unwrap() > Utc::now()); + assert!(record.state.retry_at.unwrap() <= record.state.credits[0].expires_at); + } + } +} + +#[tokio::test] +async fn automation_retains_skipped_credit_and_refreshes_quota_on_next_attempt() { + let provider = FakeProviderAdmin::new("openai", events()); + let store = FakeAccountStore::new("openai", events()); + schedule_due_credit(&provider, &store); + provider.set_quota(weekly_quota(10.0)); + let services = threshold_services(provider.clone(), store.clone(), Some(70)).await; + let id = ProviderAccountId::new("acct_test").unwrap(); + services + .accounts() + .reconcile_reset_credit_automation(id.clone()) + .await + .unwrap(); + services + .accounts() + .reconcile_reset_credit_automation(id.clone()) + .await + .unwrap(); + assert!(provider.reset_credit_commands().is_empty()); + assert_eq!(provider.quota_requests().len(), 1); + provider.set_quota(weekly_quota(40.0)); + advance_retry(&store); + services + .accounts() + .reconcile_reset_credit_automation(id) + .await + .unwrap(); + assert_eq!(provider.reset_credit_commands().len(), 1); + let record = store + .reset_credit_automation("acct_test") + .await + .unwrap() + .unwrap(); + assert!(record.state.credits.is_empty()); + assert!(record.state.retry_at.is_none()); +} + +#[tokio::test] +async fn automation_defers_missing_invalid_or_failed_weekly_quota() { + let weekly = weekly_quota(90.0).windows.remove(0); + let mut short = weekly.clone(); + short.window_seconds = Some(18_000); + let mut model = weekly.clone(); + model.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable; + let mut month = weekly.clone(); + month.group = "monthly".into(); + month.window_seconds = Some(30 * 86_400); + let mut missing_percent = weekly.clone(); + missing_percent.used_percent = None; + for (quota, failure) in [ + (empty_quota(), None), + ( + ProviderQuota { + windows: vec![short, model, month], + ..empty_quota() + }, + None, + ), + ( + ProviderQuota { + windows: vec![missing_percent], + ..empty_quota() + }, + None, + ), + (weekly_quota(f64::NAN), None), + ( + weekly_quota(90.0), + Some(ProviderAdminErrorKind::Unavailable), + ), + ] { + let provider = FakeProviderAdmin::new("openai", events()); + let store = FakeAccountStore::new("openai", events()); + schedule_due_credit(&provider, &store); + provider.set_quota(quota); + *provider.quota_failure.lock().unwrap() = failure; + let services = threshold_services(provider.clone(), store.clone(), Some(70)).await; + let result = services + .accounts() + .reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap()) + .await; + assert_eq!(result.is_err(), failure.is_some()); + assert!(provider.reset_credit_commands().is_empty()); + let record = store + .reset_credit_automation("acct_test") + .await + .unwrap() + .unwrap(); + assert_eq!( + record.state.last_result.as_deref(), + Some("weekly_quota_unavailable") + ); + assert!(record.state.pending.is_none()); + assert_eq!(record.state.credits.len(), 1); + assert!(record.state.retry_at.is_some()); + } +} + +#[tokio::test] +async fn automation_nothing_to_reset_retains_credit_and_retries_with_a_new_key() { + let provider = FakeProviderAdmin::new("openai", events()); + let store = FakeAccountStore::new("openai", events()); + schedule_due_credit(&provider, &store); + *provider.reset_credit_result.lock().unwrap() = "nothing_to_reset".into(); + let services = accounts_service(provider.clone(), store.clone()).await; + let id = ProviderAccountId::new("acct_test").unwrap(); + services + .accounts() + .reconcile_reset_credit_automation(id.clone()) + .await + .unwrap(); + let record = store + .reset_credit_automation("acct_test") + .await + .unwrap() + .unwrap(); + assert_eq!( + record.state.last_result.as_deref(), + Some("nothing_to_reset") + ); + assert!(record.state.pending.is_none()); + assert_eq!(record.state.credits.len(), 1); + services + .accounts() + .reconcile_reset_credit_automation(id.clone()) + .await + .unwrap(); + assert_eq!(provider.reset_credit_commands().len(), 1); + advance_retry(&store); + *provider.reset_credit_result.lock().unwrap() = "reset".into(); + services + .accounts() + .reconcile_reset_credit_automation(id) + .await + .unwrap(); + let commands = provider.reset_credit_commands(); + assert_eq!(commands.len(), 2); + assert_eq!(commands[0].credit_id, commands[1].credit_id); + assert_ne!(commands[0].redeem_request_id, commands[1].redeem_request_id); + assert!( + store + .reset_credit_automation("acct_test") + .await + .unwrap() + .unwrap() + .state + .credits + .is_empty() + ); +} diff --git a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs index 41b32ee83..2a9686e31 100644 --- a/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs +++ b/backend/crates/gateway-admin/tests/use_case/freeze_recovery.rs @@ -63,6 +63,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti account_warmup_schedule_time: "08:00".to_owned(), account_warmup_model: None, reset_credit_lead_minutes: 10, + reset_credit_max_weekly_remaining_percent: None, }, } } diff --git a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs index 87fcec6bc..d3119e9e9 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs @@ -1141,6 +1141,7 @@ impl SettingsStore for FixtureSettingsStore { account_warmup_schedule_time: "08:00".to_owned(), account_warmup_model: None, reset_credit_lead_minutes: 10, + reset_credit_max_weekly_remaining_percent: None, }, }) } diff --git a/backend/crates/gateway-admin/tests/use_case/settings.rs b/backend/crates/gateway-admin/tests/use_case/settings.rs index a3b02d39d..0d5283c6c 100644 --- a/backend/crates/gateway-admin/tests/use_case/settings.rs +++ b/backend/crates/gateway-admin/tests/use_case/settings.rs @@ -356,6 +356,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() { account_warmup_schedule_time: "08:00".to_owned(), account_warmup_model: None, reset_credit_lead_minutes: 10, + reset_credit_max_weekly_remaining_percent: None, }, }, ) diff --git a/backend/crates/gateway-api/src/admin/settings.rs b/backend/crates/gateway-api/src/admin/settings.rs index 2219ce96e..6353e604e 100644 --- a/backend/crates/gateway-api/src/admin/settings.rs +++ b/backend/crates/gateway-api/src/admin/settings.rs @@ -67,6 +67,7 @@ pub struct RuntimeSettingsFields { pub account_warmup_schedule_time: String, pub account_warmup_model: Option, pub reset_credit_lead_minutes: u32, + pub reset_credit_max_weekly_remaining_percent: Option, } /// 运行配置投影与设置页字段的聚合响应 @@ -787,6 +788,8 @@ impl RuntimeSettingsFields { account_warmup_schedule_time: self.account_warmup_schedule_time, account_warmup_model: self.account_warmup_model, reset_credit_lead_minutes: self.reset_credit_lead_minutes, + reset_credit_max_weekly_remaining_percent: self + .reset_credit_max_weekly_remaining_percent, }; values.validate().map_err(settings_validation_error)?; Ok(values) @@ -828,6 +831,8 @@ impl From for RuntimeSett account_warmup_schedule_time: settings.account_warmup_schedule_time, account_warmup_model: settings.account_warmup_model, reset_credit_lead_minutes: settings.reset_credit_lead_minutes, + reset_credit_max_weekly_remaining_percent: settings + .reset_credit_max_weekly_remaining_percent, } } } @@ -844,6 +849,7 @@ fn settings_validation_error(field: &'static str) -> WireValidationError { "account_warmup_enabled" => "accountWarmupEnabled", "account_warmup_model" => "accountWarmupModel", "reset_credit_lead_minutes" => "resetCreditLeadMinutes", + "reset_credit_max_weekly_remaining_percent" => "resetCreditMaxWeeklyRemainingPercent", "account_warmup_schedule_time" => "accountWarmupScheduleTime", "audit_retention_days" => "auditRetentionDays", "concurrency_wait_timeout_seconds" => "concurrencyWaitTimeoutSeconds", diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index 895eacf42..7869a8616 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -647,6 +647,7 @@ impl SettingsStore for MemorySettingsStore { account_warmup_schedule_time: "08:00".to_owned(), account_warmup_model: None, reset_credit_lead_minutes: 10, + reset_credit_max_weekly_remaining_percent: None, }, }; *settings = updated.clone(); @@ -1826,6 +1827,7 @@ fn test_runtime_settings() -> RuntimeSettings { account_warmup_schedule_time: "08:00".to_owned(), account_warmup_model: None, reset_credit_lead_minutes: 10, + reset_credit_max_weekly_remaining_percent: None, }, } } diff --git a/backend/crates/gateway-api/tests/admin/settings.rs b/backend/crates/gateway-api/tests/admin/settings.rs index afde7b1b9..be77debad 100644 --- a/backend/crates/gateway-api/tests/admin/settings.rs +++ b/backend/crates/gateway-api/tests/admin/settings.rs @@ -75,7 +75,8 @@ fn update_body() -> Value { "accountWarmupEnabled": false, "accountWarmupScheduleTime": "08:00", "accountWarmupModel": null, - "resetCreditLeadMinutes": 10 + "resetCreditLeadMinutes": 10, + "resetCreditMaxWeeklyRemainingPercent": null }) } @@ -410,6 +411,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { account_warmup_schedule_time: "08:00".to_owned(), account_warmup_model: None, reset_credit_lead_minutes: 10, + reset_credit_max_weekly_remaining_percent: None, }, }; @@ -462,6 +464,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() { "accountWarmupScheduleTime": "08:00", "accountWarmupModel": null, "resetCreditLeadMinutes": 10, + "resetCreditMaxWeeklyRemainingPercent": null, "updatedAt": "2026-08-02T10:30:00Z", "updatedAtDisplay": "2026-08-02 18:30:00" }) @@ -539,6 +542,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() { account_warmup_schedule_time: "08:00".to_owned(), account_warmup_model: None, reset_credit_lead_minutes: 10, + reset_credit_max_weekly_remaining_percent: None, }, }; @@ -1642,3 +1646,42 @@ fn reset_credit_lead_minutes_requires_an_integer_within_one_day() { } } } + +#[test] +fn reset_credit_weekly_threshold_accepts_unset_and_zero_to_one_hundred() { + let mut omitted = update_body(); + omitted + .as_object_mut() + .unwrap() + .remove("resetCreditMaxWeeklyRemainingPercent"); + let request: UpdateRuntimeSettingsRequest = serde_json::from_value(omitted).unwrap(); + request.validate().unwrap(); + assert!( + request + .values + .reset_credit_max_weekly_remaining_percent + .is_none() + ); + for value in [json!(null), json!(0), json!(70), json!(100)] { + let mut body = update_body(); + body["resetCreditMaxWeeklyRemainingPercent"] = value.clone(); + let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap(); + request.validate().unwrap(); + assert_eq!( + serde_json::to_value(request.values.reset_credit_max_weekly_remaining_percent).unwrap(), + value + ); + } + let mut body = update_body(); + body["resetCreditMaxWeeklyRemainingPercent"] = json!(101); + let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap(); + assert_eq!( + request.validate().unwrap_err().field(), + "resetCreditMaxWeeklyRemainingPercent" + ); + for value in [json!(-1), json!(70.5), json!("70")] { + let mut body = update_body(); + body["resetCreditMaxWeeklyRemainingPercent"] = value; + assert!(serde_json::from_value::(body).is_err()); + } +} diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index 00caf0897..d828e14d8 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -281,8 +281,8 @@ HTTP 总入口统一包裹原生路由,`dispatch_http` 复用同一路由与 `accounts::GetResetCreditAutomation` 接收账号 ID,返回 `ResetCreditAutomationStatus`:开关、全局提前分钟数、 下次发现与使用时间、最近结果;时间为 RFC 3339 字符串,无计划时为 `None`。 `accounts::SetResetCreditAutomation` 接收 `(account_id, enabled)`,返回保存后的同一状态;审计主体与调用标识由宿主提供。 -两者复用原生账号校验和自动重置用例,不立即查询或消费上游卡片。提前时间通过 `settings::Load` / `Replace` 的 -`reset_credit_lead_minutes` 读取和设置,范围见[主动额度重置卡](../../../../../docs/api.md#主动额度重置卡) +两者复用原生账号校验和自动重置用例,不立即查询或消费上游卡片。提前时间与剩余周额度上限通过 `settings::Load` / `Replace` 的 +`reset_credit_lead_minutes` 和可选的 `reset_credit_max_weekly_remaining_percent` 读取和设置,范围见[主动额度重置卡](../../../../../docs/api.md#主动额度重置卡) ```rust,ignore use gateway_plugin_sdk::call::services::accounts::{GetResetCreditAutomation, SetResetCreditAutomation}; diff --git a/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs b/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs index 5ae70a05d..85eb73d5b 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/services/settings.rs @@ -125,6 +125,7 @@ pub struct RuntimeSettings { pub account_warmup_schedule_time: String, pub account_warmup_model: Option, pub reset_credit_lead_minutes: u32, + pub reset_credit_max_weekly_remaining_percent: Option, pub request_profiles: ProviderRequestProfiles, pub config_revision: Revision, pub model_mappings: ModelMappings, @@ -165,6 +166,7 @@ pub struct ReplaceRuntimeSettings { pub account_warmup_schedule_time: String, pub account_warmup_model: Option, pub reset_credit_lead_minutes: u32, + pub reset_credit_max_weekly_remaining_percent: Option, /// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新 pub expected_revision: Revision, /// 只覆盖提交的 Provider;未提交项保留当前持久值 @@ -368,6 +370,8 @@ impl From for ReplaceRuntimeSettings { account_warmup_schedule_time: settings.account_warmup_schedule_time, account_warmup_model: settings.account_warmup_model, reset_credit_lead_minutes: settings.reset_credit_lead_minutes, + reset_credit_max_weekly_remaining_percent: settings + .reset_credit_max_weekly_remaining_percent, expected_revision: settings.config_revision, request_profile_updates: settings .request_profiles diff --git a/backend/crates/gateway-store/src/postgres/runtime_settings.rs b/backend/crates/gateway-store/src/postgres/runtime_settings.rs index 9afcbc04a..1d44ecaef 100644 --- a/backend/crates/gateway-store/src/postgres/runtime_settings.rs +++ b/backend/crates/gateway-store/src/postgres/runtime_settings.rs @@ -159,7 +159,7 @@ pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResul account_auto_freeze_window_seconds, account_auto_freeze_duration_seconds, account_auto_freeze_probe_enabled, account_auto_freeze_probe_model, account_auto_freeze_adaptive_concurrency, - account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes + account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes, reset_credit_max_weekly_remaining_percent from runtime_settings where id = 1", ) .fetch_optional(pool) @@ -337,7 +337,7 @@ pub(crate) async fn load_runtime_settings_in_transaction( account_auto_freeze_window_seconds, account_auto_freeze_duration_seconds, account_auto_freeze_probe_enabled, account_auto_freeze_probe_model, account_auto_freeze_adaptive_concurrency, - account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes + account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes, reset_credit_max_weekly_remaining_percent from runtime_settings where id = 1", ) .fetch_optional(&mut **transaction) @@ -356,12 +356,12 @@ pub(crate) async fn update_runtime_settings_in_transaction( update: &RuntimeSettingsUpdate, ) -> StoreResult { update.validate()?; - let previous_lead: i32 = sqlx::query_scalar( - "select reset_credit_lead_minutes from runtime_settings where id = 1 for update", + let previous_reset_settings: (i32, Option) = sqlx::query_as( + "select reset_credit_lead_minutes, reset_credit_max_weekly_remaining_percent from runtime_settings where id = 1 for update", ) .fetch_one(&mut **transaction) .await - .map_err(|source| postgres_unavailable("load reset-credit lead time", source))?; + .map_err(|source| postgres_unavailable("load reset-credit settings", source))?; let refresh_margin_seconds = i64::try_from(update.values.refresh_margin_seconds).map_err(|_| invalid_numeric())?; let request_profile_updates = update @@ -419,6 +419,7 @@ pub(crate) async fn update_runtime_settings_in_transaction( openai_session_affinity_ttl_hours = $33, codex_privacy_policy_json = $34, reset_credit_lead_minutes = $35, + reset_credit_max_weekly_remaining_percent = $36, updated_at = now() where id = 1 returning config_revision", @@ -469,6 +470,7 @@ pub(crate) async fn update_runtime_settings_in_transaction( .bind(i64::from(update.values.openai_session_affinity_ttl_hours)) .bind(sqlx::types::Json(&update.values.codex_privacy_policy)) .bind(i64::from(update.values.reset_credit_lead_minutes)) + .bind(update.values.reset_credit_max_weekly_remaining_percent.map(i64::from)) .fetch_optional(&mut **transaction) .await .map_err(|source| postgres_unavailable("update runtime settings in transaction", source))? @@ -477,8 +479,14 @@ pub(crate) async fn update_runtime_settings_in_transaction( entity: "runtime settings", id: "1".to_owned(), })?; - // 提前量变化只唤醒本地计划重算,不触发账号全量上游查询 - if i64::from(previous_lead) != i64::from(update.values.reset_credit_lead_minutes) { + // 自动使用配置变化只唤醒本地计划重算,不触发账号全量上游查询 + if i64::from(previous_reset_settings.0) != i64::from(update.values.reset_credit_lead_minutes) + || previous_reset_settings.1.map(i64::from) + != update + .values + .reset_credit_max_weekly_remaining_percent + .map(i64::from) + { sqlx::query("update provider_accounts set reset_credit_next_run_at = least(reset_credit_next_run_at, now()), reset_credit_automation_json = jsonb_set(reset_credit_automation_json, '{revision}', to_jsonb((reset_credit_automation_json ->> 'revision')::bigint + 1)) where reset_credit_automation_json ->> 'enabled' = 'true' ") .execute(&mut **transaction).await .map_err(|source| postgres_unavailable("reschedule reset-credit automation", source))?; @@ -566,6 +574,7 @@ struct RuntimeSettingsRow { account_warmup_schedule_time: String, account_warmup_model: Option, reset_credit_lead_minutes: i32, + reset_credit_max_weekly_remaining_percent: Option, } fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult { @@ -635,6 +644,11 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult RuntimeSettin account_warmup_schedule_time: "08:00".to_owned(), account_warmup_model: None, reset_credit_lead_minutes: 10, + reset_credit_max_weekly_remaining_percent: None, }, } } @@ -213,6 +214,7 @@ async fn privacy_steps_migration_preserves_rules_and_other_settings() { .unwrap(); expected_row["codex_privacy_policy_json"] = expected.clone(); expected_row["reset_credit_lead_minutes"] = serde_json::json!(10); + expected_row["reset_credit_max_weekly_remaining_percent"] = serde_json::Value::Null; // 完整升级还会清理自动回切字段,其余运行设置必须原样保留 expected_row["smart_scheduling_json"] .as_object_mut() @@ -1485,3 +1487,41 @@ async fn removing_switchback_preserves_custom_weights_and_replaces_database_defa ); database.close().await; } + +#[tokio::test] +async fn reset_credit_weekly_threshold_upgrade_defaults_unset_and_round_trips() { + let Some(database) = TestDatabase::create_through("reset_weekly_threshold", 34).await else { + return; + }; + sqlx::query("update runtime_settings set reset_credit_lead_minutes = 20 where id = 1") + .execute(&database.pool) + .await + .unwrap(); + super::TEST_MIGRATOR.run(&database.pool).await.unwrap(); + let repository = PgRuntimeSettingsRepository::new(database.pool.clone()); + let settings = repository.load_runtime_settings().await.unwrap(); + assert_eq!(settings.values.reset_credit_lead_minutes, 20); + assert_eq!( + settings.values.reset_credit_max_weekly_remaining_percent, + None + ); + for threshold in [Some(70), Some(0), Some(100), None] { + let mut update = settings_with_margin(600); + update.values.reset_credit_max_weekly_remaining_percent = threshold; + repository.update_runtime_settings(update).await.unwrap(); + assert_eq!( + repository + .load_runtime_settings() + .await + .unwrap() + .values + .reset_credit_max_weekly_remaining_percent, + threshold + ); + } + for threshold in [-1, 101] { + assert!(sqlx::query("update runtime_settings set reset_credit_max_weekly_remaining_percent = $1 where id = 1") + .bind(threshold).execute(&database.pool).await.is_err()); + } + database.close().await; +} diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index 6b75da534..6741ed5bc 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -32,3 +32,4 @@ b49832e205405de7ef078f3b65c0c19f2c030b8984d42411598738b6b6c146bf 0031_proxy_and a0e409402611dbc76bb71393b8eda7c717756c18974dbabca305fdcb4b115f31 0032_usage_and_scheduling.sql 6acd13f237cd163fdf9b52ec9c003ca75b723980e5db5c52c9776413cfe85c4b 0033_proxy_websocket_capacity.sql 0bcd3b00ad7038d9a62e955ff607032f072b08c9f529cf43f794a9820965bdbd 0034_reset_credit_automation.sql +3e8ba22f9611c31df3e1159f6a5c237c2dd0020751ae83c029de8b23623ab068 0035_reset_credit_weekly_threshold.sql diff --git a/backend/migrations/0035_reset_credit_weekly_threshold.sql b/backend/migrations/0035_reset_credit_weekly_threshold.sql new file mode 100644 index 000000000..9d77e35df --- /dev/null +++ b/backend/migrations/0035_reset_credit_weekly_threshold.sql @@ -0,0 +1,4 @@ +-- 自动使用重置卡的剩余周额度上限,空值表示不限制 +ALTER TABLE runtime_settings + ADD COLUMN reset_credit_max_weekly_remaining_percent integer + CHECK (reset_credit_max_weekly_remaining_percent BETWEEN 0 AND 100); diff --git a/docs/api.md b/docs/api.md index 59519698d..0a6618aff 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1283,6 +1283,10 @@ revision 变化时丢弃结果 GET 和 POST 均返回 `enabled`、`leadMinutes`、`nextDiscoveryAtDisplay`、`nextConsumeAtDisplay`、`lastResult`, 时间展示沿用部署时区,无计划时为 `null`。运行设置 `resetCreditLeadMinutes` 默认 10,范围 1~1440 分钟 +`resetCreditMaxWeeklyRemainingPercent` 是可选的全局剩余周额度上限,范围 0~100 的整数,默认 `null` 表示不限制。 +设置后,每次新的自动消费前刷新账号周额度;剩余比例大于上限或无法确认周额度时,保留卡片并在到期前错峰重查, +等于上限时允许使用。仅采用账号级周窗口,不回退到短期、月度或模型专属额度;手动使用不受此上限影响 + 开启后在一分钟内错峰执行首次发现,此后按部署时区每天随机发现一次,普通发现失败也等待下一日。 已知且有明确到期时间的可用卡片在提前窗口内错峰执行,消费前重新查询;已在其他地方使用或已到期的卡片 会移出计划,查询失败不直接消费。上游返回 `already_redeemed` 后结束该次操作,不切换另一张卡重试。 @@ -1290,7 +1294,10 @@ GET 和 POST 均返回 `enabled`、`leadMinutes`、`nextDiscoveryAtDisplay`、`n `lastResult` 的 `pending` 表示消费结果待确认;重试沿用已持久化的幂等键,重启或关闭再开启也不换键。 `expired_unknown` 表示未确认操作的卡片已经到期,停止重试;`reset`、`already_redeemed` 表示对应终态, -`discovery_failed` 表示最近查询失败,`not_redeemed` 表示上游明确未完成消费。自动操作未决且对应卡片尚未到期时,手动消费返回 `409` +`discovery_failed` 表示最近查询失败,`not_redeemed` 表示上游明确未完成消费。 +`weekly_quota_above_threshold` 表示剩余周额度高于上限,`weekly_quota_unavailable` 表示暂时无法确认周额度; +`nothing_to_reset` 表示当前没有可重置窗口,这三种状态均保留卡片并稍后重查,其中 `nothing_to_reset` 的下一次消费使用新幂等键。 +结果未知的未决操作继续使用原幂等键确认,不重新应用周额度上限;自动操作未决且对应卡片尚未到期时,手动消费返回 `409` ## 6. 账号分组 @@ -1473,7 +1480,7 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly | [历史保留](#历史保留) | `usageRetentionDays`、`opsEventRetentionDays`、`auditRetentionDays` | | [账号自动冻结](#账号自动冻结) | `accountAutoFreezeEnabled`、`accountAutoFreezeThreshold`、`accountAutoFreezeWindowSeconds`、`accountAutoFreezeDurationSeconds`、`accountAutoFreezeProbeEnabled`、`accountAutoFreezeProbeModel`、`accountAutoFreezeAdaptiveConcurrency` | | [账号预热](#账号预热) | `accountWarmupEnabled`、`accountWarmupScheduleTime`、`accountWarmupModel` | -| [主动额度重置卡](#主动额度重置卡) | `resetCreditLeadMinutes` | +| [主动额度重置卡](#主动额度重置卡) | `resetCreditLeadMinutes`、`resetCreditMaxWeeklyRemainingPercent` | ### 历史保留 diff --git a/docs/architecture.md b/docs/architecture.md index 9074444bd..1ec1be91e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -900,7 +900,9 @@ Admin 仅为开启自动使用的账号保存卡片 ID、到期时间、下一 已知到期计划在提前窗口前段错峰;Worker 租约限制为单个执行者,每轮最多处理 100 个到期账号,并发复用 凭据刷新配置。周期扫描只访问本地计划,消费前重新核对上游;外部已消费的卡片移出计划,已消费响应为终态。 消费幂等键先持久化再发送,结果未确认时退避重试,到期后停止;确认结果先落库再刷新额度。 -计划写入通过版本比较拒绝过期覆盖,全局提前时间变更唤醒本地重算,关闭自动使用保留未决键供重新开启后恢复 +设置剩余周额度上限时,Admin 在新消费前通过 Provider 刷新额度,复用账号周窗口归属;超过上限或无法确认时保留卡片重查。 +上游明确返回 `nothing_to_reset` 时清除未决键并保留卡片,下一次消费使用新键;未确认操作始终沿原键完成确认。 +计划写入通过版本比较拒绝过期覆盖,全局提前时间或周额度上限变更唤醒本地重算,关闭自动使用保留未决键供重新开启后恢复 ## 10. 观测与后台任务 diff --git a/frontend/src/api/modules/settings/index.ts b/frontend/src/api/modules/settings/index.ts index 1d1b109e4..6760b552e 100644 --- a/frontend/src/api/modules/settings/index.ts +++ b/frontend/src/api/modules/settings/index.ts @@ -77,6 +77,7 @@ export interface RuntimeSettings { accountWarmupScheduleTime: string accountWarmupModel: string | null resetCreditLeadMinutes: number + resetCreditMaxWeeklyRemainingPercent: number | null updatedAt: string updatedAtDisplay: string } diff --git a/frontend/src/views/accounts/components/account-quota-panel/ResetCreditAutomation.vue b/frontend/src/views/accounts/components/account-quota-panel/ResetCreditAutomation.vue index 35f2ef525..ebbd3f8d8 100644 --- a/frontend/src/views/accounts/components/account-quota-panel/ResetCreditAutomation.vue +++ b/frontend/src/views/accounts/components/account-quota-panel/ResetCreditAutomation.vue @@ -21,6 +21,9 @@ const resultText = computed(() => { case 'pending': return status.value?.enabled ? '上次自动重置结果待确认,将继续核对' : '上次自动重置结果待确认' case 'expired_unknown': return '上次重置结果未能确认,卡片已到期' case 'discovery_failed': return '最近查询失败,将按计划重试' + case 'weekly_quota_above_threshold': return '剩余周额度高于上限,稍后检查' + case 'weekly_quota_unavailable': return '暂时无法确认周额度,稍后检查' + case 'nothing_to_reset': return '当前额度窗口不需要重置,稍后检查' case 'not_redeemed': return '上游未完成最近一次自动重置' default: return '' } diff --git a/frontend/src/views/settings/components/ResetCreditCard.vue b/frontend/src/views/settings/components/ResetCreditCard.vue index e8d11b790..dba007e33 100644 --- a/frontend/src/views/settings/components/ResetCreditCard.vue +++ b/frontend/src/views/settings/components/ResetCreditCard.vue @@ -2,6 +2,7 @@ import { ZCard, ZForm, ZFormItem, ZInput } from '@codex-proxy/ui' const leadMinutes = defineModel({ required: true }) +const maxWeeklyRemainingPercent = defineModel('maxWeeklyRemainingPercent', { required: true }) diff --git a/frontend/src/views/settings/composables/useSettingsForm.ts b/frontend/src/views/settings/composables/useSettingsForm.ts index b36dcff64..d35949d54 100644 --- a/frontend/src/views/settings/composables/useSettingsForm.ts +++ b/frontend/src/views/settings/composables/useSettingsForm.ts @@ -61,6 +61,7 @@ export function useSettingsForm() { accountWarmupScheduleTime: '08:00', accountWarmupModel: '', resetCreditLeadMinutes: 10 as number | null, + resetCreditMaxWeeklyRemainingPercent: null as number | null, }) function snapshot() { @@ -79,7 +80,7 @@ export function useSettingsForm() { mappings.value = initial.mappings } - function numericModel(key: 'resetCreditLeadMinutes' | 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'openaiGuardianReservedConcurrency' | 'maxAccountRotations' | 'openaiSessionAffinityTtlHours' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'responsesMaxDecompressedBodyMiB' | 'usageRetentionDays' | 'accountAutoFreezeThreshold' | 'accountAutoFreezeWindowSeconds' | 'accountAutoFreezeDurationSeconds') { + function numericModel(key: 'resetCreditMaxWeeklyRemainingPercent' | 'resetCreditLeadMinutes' | 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'openaiGuardianReservedConcurrency' | 'maxAccountRotations' | 'openaiSessionAffinityTtlHours' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'responsesMaxDecompressedBodyMiB' | 'usageRetentionDays' | 'accountAutoFreezeThreshold' | 'accountAutoFreezeWindowSeconds' | 'accountAutoFreezeDurationSeconds') { return computed({ get: () => (form[key] === null ? '' : String(form[key])), set: (value: string) => { @@ -103,6 +104,7 @@ export function useSettingsForm() { const maxWaitingPerAccountValue = numericModel('maxWaitingPerAccount') const responsesMaxDecompressedBodyMiBValue = numericModel('responsesMaxDecompressedBodyMiB') const resetCreditLeadMinutesValue = numericModel('resetCreditLeadMinutes') + const resetCreditMaxWeeklyRemainingPercentValue = numericModel('resetCreditMaxWeeklyRemainingPercent') const usageRetentionDaysValue = numericModel('usageRetentionDays') const concurrencyWaitTimeoutSecondsValue = numericModel('concurrencyWaitTimeoutSeconds') const accountAutoFreezeThresholdValue = numericModel('accountAutoFreezeThreshold') @@ -154,6 +156,7 @@ export function useSettingsForm() { form.accountWarmupScheduleTime = data.accountWarmupScheduleTime ?? '08:00' form.accountWarmupModel = data.accountWarmupModel ?? '' form.resetCreditLeadMinutes = data.resetCreditLeadMinutes + form.resetCreditMaxWeeklyRemainingPercent = data.resetCreditMaxWeeklyRemainingPercent mappings.value = Object.entries(data.modelMappings).map(([requestedModel, upstreamModel]) => ({ requestedModel, upstreamModel, @@ -261,6 +264,11 @@ export function useSettingsForm() { ZNotification.warning({ message: 'Responses 解压上限应为有效的正整数(MiB)' }) return } + const resetCreditMaxWeeklyRemainingPercent = form.resetCreditMaxWeeklyRemainingPercent + if (resetCreditMaxWeeklyRemainingPercent !== null && (!Number.isInteger(resetCreditMaxWeeklyRemainingPercent) || resetCreditMaxWeeklyRemainingPercent < 0 || resetCreditMaxWeeklyRemainingPercent > 100)) { + ZNotification.warning({ message: '剩余周额度上限应为 0~100 的整数,留空表示不限制' }) + return + } const resetCreditLeadMinutes = form.resetCreditLeadMinutes if (resetCreditLeadMinutes === null || !Number.isInteger(resetCreditLeadMinutes) || resetCreditLeadMinutes < 1 || resetCreditLeadMinutes > 1440) { ZNotification.warning({ message: '重置卡提前使用时间应为 1~1440 分钟的整数' }) @@ -359,6 +367,7 @@ export function useSettingsForm() { accountWarmupScheduleTime: scheduleTime, accountWarmupModel: warmupModel || null, resetCreditLeadMinutes, + resetCreditMaxWeeklyRemainingPercent, }) applySettings(result) ZNotification.success({ message: '设置已保存' }) @@ -396,6 +405,7 @@ export function useSettingsForm() { responsesMaxDecompressedBodyMiBValue, usageRetentionDaysValue, resetCreditLeadMinutesValue, + resetCreditMaxWeeklyRemainingPercentValue, accountAutoFreezeThresholdValue, accountAutoFreezeWindowSecondsValue, accountAutoFreezeDurationSecondsValue, diff --git a/frontend/src/views/settings/index.vue b/frontend/src/views/settings/index.vue index ed92436d1..2461c647a 100644 --- a/frontend/src/views/settings/index.vue +++ b/frontend/src/views/settings/index.vue @@ -75,6 +75,7 @@ const { responsesMaxDecompressedBodyMiBValue, usageRetentionDaysValue, resetCreditLeadMinutesValue, + resetCreditMaxWeeklyRemainingPercentValue, accountAutoFreezeThresholdValue, accountAutoFreezeWindowSecondsValue, accountAutoFreezeDurationSecondsValue, @@ -194,7 +195,10 @@ watch(section, (value) => { v-model:schedule-time="form.accountWarmupScheduleTime" v-model:model="form.accountWarmupModel" /> - +