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" /> - +