feat(accounts): automatically use expiring reset credits (#460)

* feat(accounts): automate expiring reset credits

Persist per-account plans and redemption keys, stagger daily discovery, and verify availability before consuming expiring credits.

Co-authored-by: Codex <noreply@openai.com>

* fix(ui): remove reset credit timing button background

Use the existing link variant to keep the control transparent in both idle and hover states.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-17 16:25:12 +08:00
committed by GitHub
co-authored by Codex Zyy
parent 1fadc75ba2
commit 5a81d61979
43 changed files with 2110 additions and 75 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ sha2 = "0.11"
subtle = "2.6.1"
thiserror = "2.0.18"
tracing = "0.1.44"
uuid = { version = "1.23.3", features = ["v7"] }
uuid = { version = "1.23.3", features = ["v4", "v7"] }
cron = "0.17"
chrono-tz = "0.10"
tokio = { version = "1.52", features = ["rt", "time", "macros", "sync"] }
+30
View File
@@ -21,6 +21,7 @@ pub mod freeze_recovery;
pub mod model;
pub mod ports;
pub mod public_service;
mod reset_credit_automation;
mod use_case;
pub use use_case::plugins::{PluginDistributionPorts, PluginManagementService, PluginsService};
@@ -423,6 +424,8 @@ async fn initialize_inner(
let accounts = Arc::new(DefaultAccountsService::new(
store.accounts(),
store.account_runtime(),
store.settings(),
timezone,
registry.clone(),
snapshot.clone(),
probe.clone(),
@@ -553,6 +556,33 @@ async fn initialize_inner(
.map_err(|_| AdminError::internal("导入 Worker 注册信息不合法"))?;
worker_contributions.push(WorkerContribution::Registration(registration));
worker_contributions.extend(freeze_recovery_worker_contribution(freeze_recovery)?);
let id = WorkerId::try_new(WorkerKind::ResetCreditAutomation, "admin")
.map_err(|_| AdminError::internal("重置卡 Worker ID 不合法"))?;
let schedule = WorkerSchedule::try_new(
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(60),
Duration::from_secs(90),
Duration::from_secs(30),
)
.map_err(|_| AdminError::internal("重置卡 Worker 调度配置不合法"))?;
let lease = WorkerLeaseRequest::try_new(id.clone(), Duration::from_secs(90))
.map_err(|_| AdminError::internal("重置卡 Worker 租约配置不合法"))?;
worker_contributions.push(WorkerContribution::Registration(
WorkerRegistration::try_new(
id,
WorkerRunnable::Scheduled {
schedule,
lease: Some(lease),
task: Box::new(reset_credit_automation::ResetCreditAutomationTask {
accounts,
store: store.accounts(),
settings: store.settings(),
}),
},
)
.map_err(|_| AdminError::internal("重置卡 Worker 注册失败"))?,
));
Ok(AdminBundle {
services,
worker_contributions,
@@ -22,6 +22,7 @@ pub mod provider_credentials;
pub mod proxies;
pub mod quota_forecast;
pub mod quota_forecast_sampling;
pub mod reset_credit_automation;
pub mod retention;
pub mod settings;
pub mod system;
@@ -0,0 +1,45 @@
//! 自动重置卡的持久计划与查询投影,不保存上游凭据或完整卡片
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PlannedResetCredit {
pub id: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingResetCredit {
pub credit: PlannedResetCredit,
pub redeem_request_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResetCreditAutomationState {
pub next_discovery_at: DateTime<Utc>,
pub credits: Vec<PlannedResetCredit>,
pub pending: Option<PendingResetCredit>,
pub retry_at: Option<DateTime<Utc>>,
pub failures: u32,
pub last_result: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResetCreditAutomation {
pub account_id: String,
pub enabled: bool,
/// 零表示尚未创建;每次持久状态写入都递增版本
pub revision: u64,
pub next_run_at: DateTime<Utc>,
pub state: ResetCreditAutomationState,
}
#[derive(Debug, Clone)]
pub struct ResetCreditAutomationStatus {
pub enabled: bool,
pub lead_minutes: u32,
pub next_discovery_at: Option<DateTime<Utc>>,
pub next_consume_at: Option<DateTime<Utc>>,
pub last_result: Option<String>,
}
@@ -75,6 +75,7 @@ pub struct RuntimeSettingsValues {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub reset_credit_lead_minutes: u32,
}
/// 完整运行设置事实
@@ -189,6 +190,9 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
impl RuntimeSettingsValues {
/// 校验共同事实,返回字段名供边界转换错误;SQL 类型转换仍由 Store 处理
pub fn validate(&self) -> Result<(), &'static str> {
if !(1..=1440).contains(&self.reset_credit_lead_minutes) {
return Err("reset_credit_lead_minutes");
}
self.request_location
.validate()
.map_err(|_| "request_location")?;
@@ -11,6 +11,7 @@ pub mod plugins;
pub mod pricing;
pub mod provider;
pub mod proxy;
pub mod reset_credit_automation;
pub mod retention;
pub mod store;
pub mod system;
@@ -0,0 +1,29 @@
//! 自动重置卡计划的持久化端口,配置变更沿用管理事务与审计
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use super::store::AdminStoreResult;
use crate::model::{MutationContext, reset_credit_automation::ResetCreditAutomation};
#[async_trait]
pub trait ResetCreditAutomationStore: Send + Sync {
async fn reset_credit_automation(
&self,
account_id: &str,
) -> AdminStoreResult<Option<ResetCreditAutomation>>;
/// 只读取到期的本地计划,按到期时间稳定排序
async fn due_reset_credit_automations(
&self,
now: DateTime<Utc>,
limit: u32,
) -> AdminStoreResult<Vec<String>>;
/// 按 record.revision 比较写入,成功返回新版本;配置变更传入审计上下文
async fn save_reset_credit_automation(
&self,
record: &ResetCreditAutomation,
context: Option<&MutationContext>,
) -> AdminStoreResult<u64>;
}
@@ -109,7 +109,9 @@ pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
/// 账号目录与公共账号写操作
#[async_trait]
pub trait AccountStore: Send + Sync {
pub trait AccountStore:
super::reset_credit_automation::ResetCreditAutomationStore + Send + Sync
{
/// 插件回调按授权 Provider/账号在数据库过滤后做稳定 cursor 分页
async fn list_plugin_accounts(
&self,
@@ -0,0 +1,125 @@
//! 每日错峰发现与已知卡片的临期执行,周期只读取本地到期计划
use crate::{
model::reset_credit_automation::{PlannedResetCredit, ResetCreditAutomation},
ports::store::{AccountStore, SettingsStore},
use_case::accounts::AccountsService,
};
use chrono::{DateTime, Utc};
use futures::StreamExt as _;
use gateway_core::{
account::ProviderAccountId,
task::{ScheduledTask, WorkerCycleContext, WorkerTaskError},
time::DeploymentTimeZone,
};
use sha2::{Digest as _, Sha256};
use std::{sync::Arc, time::Duration};
/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽
pub(crate) fn next_discovery(
now: DateTime<Utc>,
timezone: DeploymentTimeZone,
random: u64,
) -> Option<DateTime<Utc>> {
let start = timezone.days_after(now, 1)?;
let end = timezone.days_after(now, 2)?;
let seconds = u64::try_from((end - start).num_seconds()).ok()?;
if seconds == 0 {
return None;
}
start.checked_add_signed(chrono::Duration::seconds(
i64::try_from(random % seconds).ok()?,
))
}
/// 提前窗口的前 10% 用于账号错峰,最多一分钟,其余时间保留给网络失败恢复
pub(crate) fn consume_at(
account_id: &str,
credit: &PlannedResetCredit,
lead_minutes: u32,
) -> DateTime<Utc> {
let lead_seconds = i64::from(lead_minutes) * 60;
let mut hash = Sha256::new();
hash.update(account_id.as_bytes());
hash.update([0]);
hash.update(credit.id.as_bytes());
let digest = hash.finalize();
let offset =
i64::from(u16::from_be_bytes([digest[0], digest[1]])) % (lead_seconds / 10).clamp(1, 60);
credit.expires_at - chrono::Duration::seconds(lead_seconds - offset)
}
pub(crate) fn next_consumption(
record: &ResetCreditAutomation,
lead_minutes: u32,
) -> Option<DateTime<Utc>> {
record
.state
.credits
.iter()
.map(|credit| consume_at(&record.account_id, credit, lead_minutes))
.min()
}
pub(crate) fn reschedule(
record: &mut ResetCreditAutomation,
lead_minutes: u32,
now: DateTime<Utc>,
) {
record.next_run_at = if let Some(retry) = record.state.retry_at {
retry
} else if record.state.pending.is_some() {
now
} else {
next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| {
due.min(record.state.next_discovery_at)
})
};
}
pub(crate) struct ResetCreditAutomationTask {
pub(crate) accounts: Arc<dyn AccountsService>,
pub(crate) store: Arc<dyn AccountStore>,
pub(crate) settings: Arc<dyn SettingsStore>,
}
impl ScheduledTask for ResetCreditAutomationTask {
fn run_cycle(
&self,
context: WorkerCycleContext,
) -> futures::future::BoxFuture<'_, Result<(), WorkerTaskError>> {
Box::pin(async move {
let settings = self
.settings
.load_runtime_settings()
.await
.map_err(|_| WorkerTaskError::safe("重置卡设置读取失败"))?;
// 单轮有界,积压沿到期顺序继续;并发复用已有账号刷新配置
let due = self
.store
.due_reset_credit_automations(Utc::now(), 100)
.await
.map_err(|_| WorkerTaskError::safe("重置卡计划读取失败"))?;
let work = futures::stream::iter(due)
.map(|account_id| async move {
let id = ProviderAccountId::new(account_id.clone())
.map_err(|_| WorkerTaskError::safe("重置卡账号 ID 不合法"))?;
if let Err(error) = self.accounts.reconcile_reset_credit_automation(id).await {
tracing::warn!(
account_id,
error_kind = error.kind().as_str(),
"自动重置卡任务失败"
);
}
Ok::<_, WorkerTaskError>(())
})
.buffer_unordered(settings.values.refresh_concurrency.max(1) as usize)
.collect::<Vec<_>>();
tokio::select! {
() = context.cancellation().cancelled() => {},
_ = tokio::time::timeout(Duration::from_secs(30), work) => {},
}
Ok(())
})
}
}
@@ -1,5 +1,7 @@
//! 统一账号目录与原生 Provider 分派
mod reset_credit_automation;
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
@@ -123,6 +125,29 @@ pub trait AccountsService: Send + Sync {
Err(AdminError::invalid("当前 Provider 不支持账号头像"))
}
async fn reset_credit_automation_status(
&self,
_account_id: ProviderAccountId,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
Err(AdminError::invalid("账号不支持自动重置卡"))
}
async fn set_reset_credit_automation(
&self,
_context: &MutationContext,
_account_id: ProviderAccountId,
_enabled: bool,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
Err(AdminError::invalid("账号不支持自动重置卡"))
}
async fn reconcile_reset_credit_automation(
&self,
_account_id: ProviderAccountId,
) -> Result<(), AdminError> {
Err(AdminError::invalid("账号不支持自动重置卡"))
}
async fn reset_credits(
&self,
_context: &MutationContext,
@@ -160,6 +185,8 @@ pub trait AccountsService: Send + Sync {
pub(crate) struct DefaultAccountsService {
accounts: Arc<dyn AccountStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
settings: Arc<dyn crate::ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
@@ -172,6 +199,8 @@ impl DefaultAccountsService {
pub(crate) fn new(
accounts: Arc<dyn AccountStore>,
account_runtime: Arc<dyn AccountRuntimeStore>,
settings: Arc<dyn crate::ports::store::SettingsStore>,
timezone: gateway_core::time::DeploymentTimeZone,
providers: ProviderAdminRegistry,
snapshot: Arc<dyn SnapshotControl>,
probe: Arc<dyn AccountProbe>,
@@ -179,6 +208,8 @@ impl DefaultAccountsService {
Self {
accounts,
account_runtime,
settings,
timezone,
providers,
snapshot,
probe,
@@ -891,27 +922,48 @@ impl AccountsService for DefaultAccountsService {
.map_err(|error| map_provider_error(error, "provider profile avatar"))
}
async fn reset_credit_automation_status(
&self,
account_id: ProviderAccountId,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
self.automation_status(&account_id).await
}
async fn set_reset_credit_automation(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
enabled: bool,
) -> Result<crate::model::reset_credit_automation::ResetCreditAutomationStatus, AdminError>
{
self.configure_automation(context, &account_id, enabled)
.await
}
async fn reconcile_reset_credit_automation(
&self,
account_id: ProviderAccountId,
) -> Result<(), AdminError> {
self.reconcile_automation(&account_id).await
}
async fn reset_credits(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.reset_credits(&account_id).await {
Ok(credits) => Ok(credits),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.reset_credits(&account_id)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset credits")),
let lock = self.reset_credit_lock(&account_id).await;
let _guard = lock.lock().await;
let credits = self
.reset_credits_upstream(context, account_id.clone())
.await?;
if let Err(error) = self.remember_reset_credits(&account_id, &credits).await {
tracing::warn!(
account_id = account_id.as_str(),
error_kind = error.kind().as_str(),
"保存重置卡计划失败"
);
}
Ok(credits)
}
async fn consume_reset_credit(
@@ -919,27 +971,26 @@ impl AccountsService for DefaultAccountsService {
context: &MutationContext,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
let account_id = command.account_id.clone();
// 覆盖 credential refresh + 同键重试的完整账号级临界区,避免 401 两次调用
// 之间插入另一笔不可逆消费
let lock = self.reset_credit_lock(&account_id).await;
let lock = self.reset_credit_lock(&command.account_id).await;
let _guard = lock.lock().await;
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.consume_reset_credit(command.clone()).await {
Ok(result) => Ok(result),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.consume_reset_credit(command)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
if self
.accounts
.reset_credit_automation(command.account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
.is_some_and(|record| {
record
.state
.pending
.is_some_and(|pending| pending.credit.expires_at > Utc::now())
})
{
return Err(AdminError::new(
crate::model::AdminErrorKind::Conflict,
"自动重置结果待确认,请稍后刷新",
));
}
self.consume_reset_credit_upstream(context, command).await
}
async fn models(
@@ -0,0 +1,422 @@
//! 重置卡自动使用的账号锁、持久幂等键、查询缓存与重试编排
use super::*;
impl DefaultAccountsService {
pub(super) async fn reset_credits_upstream(
&self,
context: &MutationContext,
account_id: ProviderAccountId,
) -> Result<ProviderResetCredits, AdminError> {
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.reset_credits(&account_id).await {
Ok(credits) => Ok(credits),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.reset_credits(&account_id)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset credits")),
}
}
pub(super) async fn consume_reset_credit_upstream(
&self,
context: &MutationContext,
command: ConsumeProviderResetCredit,
) -> Result<ProviderResetCreditResult, AdminError> {
let account_id = command.account_id.clone();
let (_, provider) = self.provider_for_account(&account_id).await?;
match provider.consume_reset_credit(command.clone()).await {
Ok(result) => Ok(result),
Err(error)
if error.kind()
== crate::ports::provider::ProviderAdminErrorKind::CredentialRefreshRequired =>
{
self.refresh(context, account_id.clone()).await?;
let (_, provider) = self.provider_for_account(&account_id).await?;
provider
.consume_reset_credit(command)
.await
.map_err(map_reset_credits_error_after_refresh)
}
Err(error) => Err(map_provider_error(error, "provider reset-credit consume")),
}
}
}
use crate::{
model::{MutationActor, reset_credit_automation::*},
reset_credit_automation::{consume_at, next_consumption, next_discovery, reschedule},
};
use chrono::{DateTime, Utc};
use rand_core::{OsRng, RngCore as _};
impl DefaultAccountsService {
async fn automation_lead_minutes(&self) -> Result<u32, AdminError> {
Ok(self
.settings
.load_runtime_settings()
.await
.map_err(|e| map_store_error(e, "runtime settings"))?
.values
.reset_credit_lead_minutes)
}
pub(super) async fn automation_status(
&self,
account_id: &ProviderAccountId,
) -> Result<ResetCreditAutomationStatus, AdminError> {
self.require_reset_credit_automation(account_id).await?;
let lead_minutes = self.automation_lead_minutes().await?;
let record = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?;
Ok(ResetCreditAutomationStatus {
enabled: record.as_ref().is_some_and(|r| r.enabled),
lead_minutes,
next_discovery_at: record
.as_ref()
.filter(|r| r.enabled)
.map(|r| r.state.next_discovery_at),
next_consume_at: record.as_ref().filter(|r| r.enabled).and_then(|r| {
next_consumption(r, lead_minutes)
.or_else(|| {
r.state
.pending
.as_ref()
.map(|pending| consume_at(&r.account_id, &pending.credit, lead_minutes))
})
.map(|due| r.state.retry_at.map_or(due, |retry| due.max(retry)))
}),
last_result: record.and_then(|r| {
if r.state
.pending
.as_ref()
.is_some_and(|pending| pending.credit.expires_at <= Utc::now())
{
Some("expired_unknown".to_owned())
} else {
r.state.last_result
}
}),
})
}
async fn require_reset_credit_automation(
&self,
account_id: &ProviderAccountId,
) -> Result<bool, AdminError> {
let (stored, provider) = self.provider_for_account(account_id).await?;
let capabilities =
provider.account_capabilities(account_id, &stored.account.authentication_kind);
if !capabilities.reset_credits || !capabilities.consume_reset_credit {
return Err(AdminError::invalid("当前账号不支持自动重置卡"));
}
Ok(stored.account.enabled)
}
pub(super) async fn configure_automation(
&self,
context: &MutationContext,
account_id: &ProviderAccountId,
enabled: bool,
) -> Result<ResetCreditAutomationStatus, AdminError> {
let lock = self.reset_credit_lock(account_id).await;
let _guard = lock.lock().await;
self.require_reset_credit_automation(account_id).await?;
let now = Utc::now();
let mut record = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
.unwrap_or_else(|| ResetCreditAutomation {
account_id: account_id.as_str().to_owned(),
enabled: false,
revision: 0,
next_run_at: now,
state: ResetCreditAutomationState {
next_discovery_at: now,
credits: vec![],
pending: None,
retry_at: None,
failures: 0,
last_result: None,
},
});
if record.enabled != enabled {
record.enabled = enabled;
// 首次开启错开一分钟;重新开启保留已知到期时间及未决幂等键
if enabled && record.revision == 0 {
record.state.next_discovery_at =
now + chrono::Duration::seconds(i64::from(OsRng.next_u32() % 60));
}
let lead = self.automation_lead_minutes().await?;
reschedule(&mut record, lead, now);
self.save_automation(&mut record, Some(context)).await?;
let settings = self
.settings
.load_runtime_settings()
.await
.map_err(|e| map_store_error(e, "runtime settings"))?;
publish_committed(self.snapshot.as_ref(), settings.config_revision).await?;
}
self.automation_status(account_id).await
}
async fn save_automation(
&self,
record: &mut ResetCreditAutomation,
context: Option<&MutationContext>,
) -> Result<(), AdminError> {
record.revision = self
.accounts
.save_reset_credit_automation(record, context)
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?;
Ok(())
}
fn observe_reset_credits(
&self,
record: &mut ResetCreditAutomation,
credits: &ProviderResetCredits,
now: DateTime<Utc>,
) -> Result<(), AdminError> {
record.state.credits = credits
.credits
.iter()
.filter(|credit| credit.status.as_deref() == Some("available"))
.filter_map(|credit| {
credit
.expires_at
.filter(|expiry| *expiry > now)
.map(|expires_at| PlannedResetCredit {
id: credit.id.clone(),
expires_at,
})
})
.collect();
record.state.credits.sort_by_key(|credit| credit.expires_at);
record.state.credits.dedup_by(|a, b| a.id == b.id);
if record.state.next_discovery_at <= now
|| self
.timezone
.local(record.state.next_discovery_at)
.date_naive()
== self.timezone.local(now).date_naive()
{
record.state.next_discovery_at =
next_discovery(now, self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
}
if record.state.pending.is_none() {
record.state.retry_at = None;
record.state.failures = 0;
}
Ok(())
}
pub(super) async fn remember_reset_credits(
&self,
account_id: &ProviderAccountId,
credits: &ProviderResetCredits,
) -> Result<(), AdminError> {
let Some(mut record) = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
else {
return Ok(());
};
if !record.enabled {
return Ok(());
}
let now = Utc::now();
self.observe_reset_credits(&mut record, credits, now)?;
reschedule(&mut record, self.automation_lead_minutes().await?, now);
self.save_automation(&mut record, None).await
}
async fn automation_failed(
&self,
record: &mut ResetCreditAutomation,
lead: u32,
now: DateTime<Utc>,
) -> Result<(), AdminError> {
if record.state.pending.is_none()
&& !next_consumption(record, lead).is_some_and(|due| due <= now)
{
record.state.next_discovery_at =
next_discovery(now, self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
record.state.retry_at = None;
record.state.last_result = Some("discovery_failed".to_owned());
reschedule(record, lead, now);
return self.save_automation(record, None).await;
}
record.state.failures = record.state.failures.saturating_add(1);
let delay = (30_i64 * 2_i64.pow(record.state.failures.min(4) - 1)).min(300)
+ i64::from(OsRng.next_u32() % 30);
record.state.retry_at = Some(now + chrono::Duration::seconds(delay));
record.state.last_result = Some(
if record.state.pending.is_some() {
"pending"
} else {
"discovery_failed"
}
.to_owned(),
);
reschedule(record, lead, now);
self.save_automation(record, None).await
}
pub(super) async fn reconcile_automation(
&self,
account_id: &ProviderAccountId,
) -> Result<(), AdminError> {
let lock = self.reset_credit_lock(account_id).await;
let _guard = lock.lock().await;
let Some(mut record) = self
.accounts
.reset_credit_automation(account_id.as_str())
.await
.map_err(|e| map_store_error(e, "reset-credit automation"))?
else {
return Ok(());
};
if !record.enabled {
return Ok(());
}
match self.require_reset_credit_automation(account_id).await {
Ok(true) => {}
Ok(false) => return Ok(()),
Err(error) => {
record.next_run_at = next_discovery(Utc::now(), self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
self.save_automation(&mut record, None).await?;
return Err(error);
}
}
let now = Utc::now();
let lead = self.automation_lead_minutes().await?;
record
.state
.credits
.retain(|credit| credit.expires_at > now);
if record
.state
.pending
.as_ref()
.is_some_and(|pending| pending.credit.expires_at <= now)
{
record.state.pending = None;
record.state.retry_at = None;
record.state.last_result = Some("expired_unknown".to_owned());
}
if record.state.retry_at.is_some_and(|retry| retry > now) {
reschedule(&mut record, lead, now);
return self.save_automation(&mut record, None).await;
}
let context = MutationContext {
actor: MutationActor::System,
request_id: "reset-credit-automation".to_owned(),
};
let due_credit = next_consumption(&record, lead).is_some_and(|due| due <= now);
if record.state.pending.is_none()
&& (due_credit
|| record.state.next_discovery_at <= now
|| record.state.retry_at.is_some())
{
// 先保存下一次发现,查询被取消或进程重启也不会重复刷普通发现接口
if record.state.next_discovery_at <= now {
record.state.next_discovery_at =
next_discovery(now, self.timezone, OsRng.next_u64())
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
}
if due_credit || record.state.retry_at.is_some() {
record.state.retry_at = Some(now + chrono::Duration::seconds(30));
}
reschedule(&mut record, lead, now);
self.save_automation(&mut record, None).await?;
// 真正消费前重新核对卡片;每日发现结果也可直接承担本次核对
match self
.reset_credits_upstream(&context, account_id.clone())
.await
{
Ok(credits) => self.observe_reset_credits(&mut record, &credits, Utc::now())?,
Err(error) => {
self.automation_failed(&mut record, lead, Utc::now())
.await?;
return Err(error);
}
}
if let Some(credit) = record
.state
.credits
.iter()
.find(|credit| consume_at(account_id.as_str(), credit, lead) <= Utc::now())
.cloned()
{
record.state.pending = Some(PendingResetCredit {
credit,
redeem_request_id: uuid::Uuid::new_v4().to_string(),
});
record.state.last_result = Some("pending".to_owned());
record.state.retry_at = Some(Utc::now() + chrono::Duration::seconds(30));
reschedule(&mut record, lead, Utc::now());
// 必须在发送消费请求前落盘,进程退出或响应丢失继续使用相同幂等键
self.save_automation(&mut record, None).await?;
}
}
let Some(pending) = record.state.pending.clone() else {
reschedule(&mut record, lead, Utc::now());
return self.save_automation(&mut record, None).await;
};
let command = ConsumeProviderResetCredit {
account_id: account_id.clone(),
credit_id: Some(pending.credit.id.clone()),
redeem_request_id: uuid::Uuid::parse_str(&pending.redeem_request_id)
.map_err(|_| AdminError::internal("重置卡幂等键不合法"))?,
};
match self.consume_reset_credit_upstream(&context, command).await {
Ok(result) => {
let confirmed = matches!(result.code.as_str(), "reset" | "already_redeemed");
record.state.pending = None;
record
.state
.credits
.retain(|credit| credit.id != pending.credit.id);
record.state.retry_at = None;
record.state.failures = 0;
record.state.last_result = Some(if confirmed {
result.code
} else {
"not_redeemed".to_owned()
});
reschedule(&mut record, lead, Utc::now());
// 已确认的结果先落盘,再刷新额度;观测失败不能重放已完成的消费
self.save_automation(&mut record, None).await?;
if confirmed {
let _ = self.quota(account_id, true).await;
}
}
Err(error) => {
self.automation_failed(&mut record, lead, Utc::now())
.await?;
return Err(error);
}
}
Ok(())
}
}
@@ -1,5 +1,7 @@
//! 账号管理用例测试及共享 Provider、存储替身
mod reset_credit_automation;
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
@@ -87,6 +89,11 @@ pub(super) struct FakeProviderAdmin {
quota_refresh_account: Mutex<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
current_credential_revision: Mutex<Revision>,
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
reset_credits: Mutex<Vec<gateway_admin::model::provider_credentials::ProviderResetCredit>>,
reset_credit_failure: Mutex<Option<ProviderAdminErrorKind>>,
reset_credit_result: Mutex<String>,
reset_credit_query_started: tokio::sync::Notify,
reset_credit_query_gate: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
profile_result: Mutex<Result<ProviderProfileStatistics, ProviderAdminErrorKind>>,
subscription_result: Mutex<Result<Option<ProviderSubscription>, ProviderAdminErrorKind>>,
personal_info_barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
@@ -112,6 +119,11 @@ impl FakeProviderAdmin {
quota_refresh_account: Mutex::new(None),
current_credential_revision: Mutex::new(revision(1)),
reset_credit_commands: Mutex::new(Vec::new()),
reset_credits: Mutex::default(),
reset_credit_failure: Mutex::default(),
reset_credit_result: Mutex::new("reset".into()),
reset_credit_query_started: tokio::sync::Notify::new(),
reset_credit_query_gate: Mutex::new(None),
profile_result: Mutex::new(Ok(empty_profile_statistics())),
subscription_result: Mutex::new(Ok(None)),
personal_info_barrier: Mutex::new(None),
@@ -498,6 +510,28 @@ impl ProviderAdmin for FakeProviderAdmin {
Ok(self.quota.lock().expect("provider quota").clone())
}
async fn reset_credits(
&self,
_: &ProviderAccountId,
) -> Result<gateway_admin::model::provider_credentials::ProviderResetCredits, ProviderAdminError>
{
self.record("provider.reset_credits");
self.require_available()?;
self.reset_credit_query_started.notify_one();
let gate = self.reset_credit_query_gate.lock().unwrap().take();
if let Some(gate) = gate {
let _ = gate.await;
}
let credits = self.reset_credits.lock().unwrap().clone();
Ok(
gateway_admin::model::provider_credentials::ProviderResetCredits {
available_count: credits.len() as u64,
credits,
},
)
}
async fn consume_reset_credit(
&self,
command: ConsumeProviderResetCredit,
@@ -508,8 +542,11 @@ impl ProviderAdmin for FakeProviderAdmin {
.expect("provider reset-credit commands")
.push(command);
self.require_available()?;
if let Some(kind) = self.reset_credit_failure.lock().unwrap().take() {
return Err(ProviderAdminError::new(kind));
}
Ok(ProviderResetCreditResult {
code: "reset".to_owned(),
code: self.reset_credit_result.lock().unwrap().clone(),
credit: None,
})
}
@@ -577,6 +614,9 @@ pub(super) struct FakeAccountStore {
quota_window_queries: Mutex<Vec<AccountUsageWindowQuery>>,
quota_forecast_history: Mutex<QuotaForecastHistory>,
update_commands: Mutex<Vec<UpdateAccount>>,
automations: Mutex<
BTreeMap<String, gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
>,
pub(super) lowered_limits: Mutex<Vec<(String, u32)>>,
}
@@ -599,6 +639,7 @@ impl FakeAccountStore {
quota_forecast_history: Mutex::new(QuotaForecastHistory::default()),
update_commands: Mutex::new(Vec::new()),
lowered_limits: Mutex::new(Vec::new()),
automations: Mutex::default(),
})
}
@@ -1145,6 +1186,7 @@ impl SettingsStore for StaticSettingsStore {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
},
})
}
@@ -3667,3 +3709,53 @@ async fn account_capacity_should_batch_page_ids_and_distinguish_idle_from_unavai
}
}
}
#[async_trait]
impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore
for FakeAccountStore
{
async fn reset_credit_automation(
&self,
account_id: &str,
) -> AdminStoreResult<
Option<gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
> {
Ok(self.automations.lock().unwrap().get(account_id).cloned())
}
async fn due_reset_credit_automations(
&self,
now: chrono::DateTime<Utc>,
limit: u32,
) -> AdminStoreResult<Vec<String>> {
let records = self.automations.lock().unwrap();
let mut due = records
.values()
.filter(|r| r.enabled && r.next_run_at <= now)
.collect::<Vec<_>>();
due.sort_by_key(|r| r.next_run_at);
Ok(due
.into_iter()
.take(limit as usize)
.map(|r| r.account_id.clone())
.collect())
}
async fn save_reset_credit_automation(
&self,
record: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation,
_: Option<&MutationContext>,
) -> AdminStoreResult<u64> {
let mut records = self.automations.lock().unwrap();
if records.get(&record.account_id).map_or(0, |r| r.revision) != record.revision {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Conflict,
"automation",
"stale revision",
));
}
let mut next = record.clone();
next.revision += 1;
let revision = next.revision;
records.insert(next.account_id.clone(), next);
Ok(revision)
}
}
@@ -0,0 +1,497 @@
//! 自动重置的持久幂等恢复、低频发现与账号独立错峰
use super::*;
use gateway_admin::{
model::reset_credit_automation::*,
ports::reset_credit_automation::ResetCreditAutomationStore as _,
};
fn enable_capability(provider: &FakeProviderAdmin) {
let mut capabilities = provider.capabilities.lock().unwrap();
capabilities.reset_credits = true;
capabilities.consume_reset_credit = true;
}
fn plan(expires_at: chrono::DateTime<Utc>) -> ResetCreditAutomation {
ResetCreditAutomation {
account_id: "acct_test".into(),
enabled: true,
revision: 1,
next_run_at: Utc::now(),
state: ResetCreditAutomationState {
next_discovery_at: Utc::now() + TimeDelta::days(1),
credits: vec![PlannedResetCredit {
id: "card_1".into(),
expires_at,
}],
pending: None,
retry_at: None,
failures: 0,
last_result: None,
},
}
}
fn card(
expires_at: chrono::DateTime<Utc>,
) -> gateway_admin::model::provider_credentials::ProviderResetCredit {
gateway_admin::model::provider_credentials::ProviderResetCredit {
id: "card_1".into(),
status: Some("available".into()),
expires_at: Some(expires_at),
title: None,
reset_type: None,
}
}
#[tokio::test]
async fn automation_restores_pending_idempotency_after_service_restart() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let expiry = Utc::now() + TimeDelta::minutes(5);
provider.reset_credits.lock().unwrap().push(card(expiry));
*provider.reset_credit_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Ambiguous);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), plan(expiry));
let account_id = ProviderAccountId::new("acct_test").unwrap();
let services = accounts_service(provider.clone(), store.clone()).await;
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.expect_err("unknown consume result");
let persisted = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
let pending = persisted.state.pending.as_ref().unwrap();
let command = provider.reset_credit_commands()[0].clone();
assert_eq!(
pending.redeem_request_id,
command.redeem_request_id.to_string()
);
assert_eq!(command.redeem_request_id.get_version_num(), 4);
assert_eq!(provider.quota_requests().len(), 0);
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
assert_eq!(
provider.reset_credit_commands().len(),
1,
"wait until persisted retry time"
);
store
.automations
.lock()
.unwrap()
.get_mut("acct_test")
.unwrap()
.state
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
drop(services);
let restarted = accounts_service(provider.clone(), store.clone()).await;
restarted
.accounts()
.reconcile_reset_credit_automation(account_id)
.await
.unwrap();
assert_eq!(provider.reset_credit_commands(), [command.clone(), command]);
assert_eq!(
recorded(&events)
.iter()
.filter(|e| **e == "provider.reset_credits")
.count(),
1
);
let record = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(record.state.pending.is_none());
assert!(record.state.credits.is_empty());
assert_eq!(record.state.last_result.as_deref(), Some("reset"));
assert!(provider.quota_requests().iter().any(|q| q.refresh));
}
#[tokio::test]
async fn automation_only_queries_due_accounts_and_rechecks_credit_before_consume() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let mut record = plan(Utc::now() + TimeDelta::hours(6));
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
assert!(!recorded(&events).contains(&"provider.reset_credits"));
record.state.credits[0].expires_at = Utc::now() + TimeDelta::minutes(5);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
services
.accounts()
.reconcile_reset_credit_automation(account_id)
.await
.unwrap();
assert!(recorded(&events).contains(&"provider.reset_credits"));
assert!(
provider.reset_credit_commands().is_empty(),
"disappeared credit must never be consumed"
);
}
#[tokio::test]
async fn automation_defaults_off_and_disabled_pending_survives_reenable() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
assert!(
!services
.accounts()
.reset_credit_automation_status(account_id.clone())
.await
.unwrap()
.enabled
);
services
.accounts()
.set_reset_credit_automation(&context("enable"), account_id.clone(), true)
.await
.unwrap();
let first = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(first.state.next_discovery_at >= Utc::now() - TimeDelta::seconds(1));
let pending = PendingResetCredit {
credit: PlannedResetCredit {
id: "pending".into(),
expires_at: Utc::now() + TimeDelta::hours(1),
},
redeem_request_id: uuid::Uuid::new_v4().to_string(),
};
store
.automations
.lock()
.unwrap()
.get_mut("acct_test")
.unwrap()
.state
.pending = Some(pending.clone());
services
.accounts()
.set_reset_credit_automation(&context("disable"), account_id.clone(), false)
.await
.unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
assert!(provider.reset_credit_commands().is_empty());
services
.accounts()
.set_reset_credit_automation(&context("reenable"), account_id, true)
.await
.unwrap();
let next = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(next.state.pending, Some(pending));
assert_eq!(next.state.next_discovery_at, first.state.next_discovery_at);
}
#[tokio::test]
async fn automation_schedules_each_account_inside_the_lead_window_and_next_local_day() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events);
let now = Utc::now();
let expiry = now + TimeDelta::days(2);
provider.reset_credits.lock().unwrap().push(card(expiry));
let accounts = (0..100)
.map(|n| {
let mut account = account_record("openai");
account.id = format!("acct_{n}");
let mut record = plan(expiry);
record.account_id = account.id.clone();
record.state.next_discovery_at = now - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert(account.id.clone(), record);
account
})
.collect::<Vec<_>>();
store.set_accounts(accounts.clone());
let services = accounts_service(provider, store.clone()).await;
let timezone = gateway_core::time::DeploymentTimeZone::default();
let start = timezone.days_after(now, 1).unwrap();
let end = timezone.days_after(now, 2).unwrap();
let mut times = std::collections::BTreeSet::new();
for account in accounts {
let id = ProviderAccountId::new(account.id.clone()).unwrap();
services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await
.unwrap();
let status = services
.accounts()
.reset_credit_automation_status(id)
.await
.unwrap();
let discovery = status.next_discovery_at.unwrap();
assert!(discovery >= start && discovery < end);
times.insert(status.next_consume_at.unwrap());
}
assert!(
times.len() > 30,
"accounts must not all fire at the same instant"
);
assert!(times.iter().all(
|due| *due >= expiry - TimeDelta::minutes(10) && *due < expiry - TimeDelta::minutes(9)
));
}
#[tokio::test]
async fn automation_discards_externally_used_credit_and_stops_on_already_redeemed() {
for already_redeemed_after_query in [false, true] {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let expiry = Utc::now() + TimeDelta::minutes(5);
let mut remote_credit = card(expiry);
if !already_redeemed_after_query {
remote_credit.status = Some("redeemed".into());
}
provider.reset_credits.lock().unwrap().push(remote_credit);
*provider.reset_credit_result.lock().unwrap() = "already_redeemed".into();
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), plan(expiry));
let services = accounts_service(provider.clone(), store.clone()).await;
let account_id = ProviderAccountId::new("acct_test").unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id.clone())
.await
.unwrap();
services
.accounts()
.reconcile_reset_credit_automation(account_id)
.await
.unwrap();
assert_eq!(
provider.reset_credit_commands().len(),
usize::from(already_redeemed_after_query)
);
let stored = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(stored.state.credits.is_empty());
assert!(stored.state.pending.is_none());
if already_redeemed_after_query {
assert_eq!(
stored.state.last_result.as_deref(),
Some("already_redeemed")
);
}
}
}
#[tokio::test]
async fn automation_daily_discovery_is_not_polled_again_after_success_or_failure() {
for failed in [false, true] {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
if failed {
provider.fail_next(ProviderAdminErrorKind::Unavailable);
}
let store = FakeAccountStore::new("openai", events.clone());
let mut record = plan(Utc::now() + TimeDelta::days(3));
record.state.credits.clear();
record.state.next_discovery_at = Utc::now() - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
let services = accounts_service(provider.clone(), store.clone()).await;
let id = ProviderAccountId::new("acct_test").unwrap();
let first = services
.accounts()
.reconcile_reset_credit_automation(id.clone())
.await;
assert_eq!(first.is_err(), failed);
let scheduled = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap()
.state
.next_discovery_at;
services
.accounts()
.reconcile_reset_credit_automation(id)
.await
.unwrap();
assert_eq!(
recorded(&events)
.iter()
.filter(|e| **e == "provider.reset_credits")
.count(),
1
);
assert_eq!(
store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap()
.state
.next_discovery_at,
scheduled
);
assert!(scheduled > Utc::now());
}
}
#[tokio::test]
async fn automation_cancelled_discovery_preserves_next_random_time_across_restart() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events.clone());
let mut record = plan(Utc::now() + TimeDelta::days(3));
record.state.credits.clear();
record.state.next_discovery_at = Utc::now() - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
let (release, gate) = tokio::sync::oneshot::channel();
*provider.reset_credit_query_gate.lock().unwrap() = Some(gate);
let services = accounts_service(provider.clone(), store.clone()).await;
let task = tokio::spawn(async move {
services
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
.await
});
tokio::time::timeout(
std::time::Duration::from_secs(2),
provider.reset_credit_query_started.notified(),
)
.await
.unwrap();
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
drop(release);
let before = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert!(before.state.next_discovery_at > Utc::now());
let restarted = accounts_service(provider.clone(), store.clone()).await;
restarted
.accounts()
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
.await
.unwrap();
let after = store
.reset_credit_automation("acct_test")
.await
.unwrap()
.unwrap();
assert_eq!(
before.state.next_discovery_at,
after.state.next_discovery_at
);
assert_eq!(
recorded(&events)
.iter()
.filter(|e| **e == "provider.reset_credits")
.count(),
1
);
}
#[tokio::test]
async fn disabled_automation_only_blocks_manual_consume_until_pending_credit_expires() {
let events = events();
let provider = FakeProviderAdmin::new("openai", events.clone());
enable_capability(&provider);
let store = FakeAccountStore::new("openai", events);
let mut record = plan(Utc::now() + TimeDelta::minutes(5));
record.enabled = false;
record.state.pending = Some(PendingResetCredit {
credit: record.state.credits[0].clone(),
redeem_request_id: uuid::Uuid::new_v4().to_string(),
});
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record.clone());
let services = accounts_service(provider.clone(), store.clone()).await;
let command = ConsumeProviderResetCredit {
account_id: ProviderAccountId::new("acct_test").unwrap(),
credit_id: Some("another_card".into()),
redeem_request_id: uuid::Uuid::new_v4(),
};
services
.accounts()
.consume_reset_credit(&context("manual-with-pending"), command.clone())
.await
.expect_err("unresolved automation is still active");
assert!(provider.reset_credit_commands().is_empty());
record.state.pending.as_mut().unwrap().credit.expires_at = Utc::now() - TimeDelta::seconds(1);
store
.automations
.lock()
.unwrap()
.insert("acct_test".into(), record);
services
.accounts()
.consume_reset_credit(&context("manual-with-pending"), command)
.await
.unwrap();
assert_eq!(provider.reset_credit_commands().len(), 1);
}
@@ -62,6 +62,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
},
}
}
@@ -1162,3 +1162,31 @@ fn unavailable_system() -> SystemOperationError {
)
}
mod service_contract;
#[async_trait]
impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore
for UnavailableStore
{
async fn reset_credit_automation(
&self,
_: &str,
) -> AdminStoreResult<
Option<gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
> {
Ok(None)
}
async fn due_reset_credit_automations(
&self,
_: chrono::DateTime<chrono::Utc>,
_: u32,
) -> AdminStoreResult<Vec<String>> {
Ok(Vec::new())
}
async fn save_reset_credit_automation(
&self,
_: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation,
_: Option<&gateway_admin::model::MutationContext>,
) -> AdminStoreResult<u64> {
Err(unavailable("reset-credit automation"))
}
}
@@ -1140,6 +1140,7 @@ impl SettingsStore for FixtureSettingsStore {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
},
})
}
@@ -355,6 +355,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
},
},
)
@@ -36,6 +36,11 @@ where
"/api/admin/accounts/profile-avatar",
get(account_profile_avatar::<S>),
)
.route(
"/api/admin/accounts/reset-credits/automation",
get(account_reset_credit_automation::<S>)
.post(set_account_reset_credit_automation::<S>),
)
.route(
"/api/admin/accounts/reset-credits",
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
@@ -625,3 +630,54 @@ where
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
async fn account_reset_credit_automation<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let result = state
.admin_services()
.accounts()
.reset_credit_automation_status(query.into_id().map_err(map_wire_error)?)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditAutomationData::from((result, time))),
))
}
async fn set_account_reset_credit_automation<S>(
auth: AdminAuth,
State(state): State<S>,
AdminJson(request): AdminJson<AccountResetCreditAutomationRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let time = crate::time::TimePresenter::new(state.admin_services().timezone());
let account_id = AccountIdQuery {
account_id: request.account_id,
}
.into_id()
.map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.set_reset_credit_automation(
&auth.context().mutation_context(),
account_id,
request.enabled,
)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AccountResetCreditAutomationData::from((result, time))),
))
}
@@ -1192,3 +1192,49 @@ fn profile_activity_calendar(
weeks,
})
}
/// 账号自动使用临期重置卡开关
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AccountResetCreditAutomationRequest {
pub account_id: String,
pub enabled: bool,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountResetCreditAutomationData {
pub enabled: bool,
pub lead_minutes: u32,
pub next_discovery_at_display: Option<String>,
pub next_consume_at_display: Option<String>,
pub last_result: Option<String>,
}
impl
From<(
gateway_admin::model::reset_credit_automation::ResetCreditAutomationStatus,
crate::time::TimePresenter,
)> for AccountResetCreditAutomationData
{
fn from(
(status, time): (
gateway_admin::model::reset_credit_automation::ResetCreditAutomationStatus,
crate::time::TimePresenter,
),
) -> Self {
Self {
enabled: status.enabled,
lead_minutes: status.lead_minutes,
next_discovery_at_display: status
.next_discovery_at
.as_ref()
.map(|value| time.datetime(value)),
next_consume_at_display: status
.next_consume_at
.as_ref()
.map(|value| time.datetime(value)),
last_result: status.last_result,
}
}
}
@@ -66,6 +66,7 @@ pub struct RuntimeSettingsFields {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub reset_credit_lead_minutes: u32,
}
/// 运行配置投影与设置页字段的聚合响应
@@ -785,6 +786,7 @@ impl RuntimeSettingsFields {
account_warmup_enabled: self.account_warmup_enabled,
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,
};
values.validate().map_err(settings_validation_error)?;
Ok(values)
@@ -825,6 +827,7 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
account_warmup_enabled: settings.account_warmup_enabled,
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,
}
}
}
@@ -840,6 +843,7 @@ fn settings_validation_error(field: &'static str) -> WireValidationError {
"account_auto_freeze_window_seconds" => "accountAutoFreezeWindowSeconds",
"account_warmup_enabled" => "accountWarmupEnabled",
"account_warmup_model" => "accountWarmupModel",
"reset_credit_lead_minutes" => "resetCreditLeadMinutes",
"account_warmup_schedule_time" => "accountWarmupScheduleTime",
"audit_retention_days" => "auditRetentionDays",
"concurrency_wait_timeout_seconds" => "concurrencyWaitTimeoutSeconds",
@@ -646,6 +646,7 @@ impl SettingsStore for MemorySettingsStore {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
},
};
*settings = updated.clone();
@@ -1789,6 +1790,7 @@ fn test_runtime_settings() -> RuntimeSettings {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
},
}
}
@@ -1845,3 +1847,29 @@ fn unsupported_provider() -> ProviderAdminError {
fn unavailable_system() -> SystemOperationError {
SystemOperationError::new(SystemOperationErrorKind::Internal, "unused test operation")
}
#[async_trait]
impl gateway_admin::ports::reset_credit_automation::ResetCreditAutomationStore for UnusedStore {
async fn reset_credit_automation(
&self,
_: &str,
) -> AdminStoreResult<
Option<gateway_admin::model::reset_credit_automation::ResetCreditAutomation>,
> {
Ok(None)
}
async fn due_reset_credit_automations(
&self,
_: chrono::DateTime<chrono::Utc>,
_: u32,
) -> AdminStoreResult<Vec<String>> {
Ok(Vec::new())
}
async fn save_reset_credit_automation(
&self,
_: &gateway_admin::model::reset_credit_automation::ResetCreditAutomation,
_: Option<&gateway_admin::model::MutationContext>,
) -> AdminStoreResult<u64> {
Err(unavailable("reset-credit automation"))
}
}
@@ -74,7 +74,8 @@ fn update_body() -> Value {
"accountAutoFreezeAdaptiveConcurrency": true,
"accountWarmupEnabled": false,
"accountWarmupScheduleTime": "08:00",
"accountWarmupModel": null
"accountWarmupModel": null,
"resetCreditLeadMinutes": 10
})
}
@@ -408,6 +409,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
},
};
@@ -459,6 +461,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"accountWarmupEnabled": false,
"accountWarmupScheduleTime": "08:00",
"accountWarmupModel": null,
"resetCreditLeadMinutes": 10,
"updatedAt": "2026-08-02T10:30:00Z",
"updatedAtDisplay": "2026-08-02 18:30:00"
})
@@ -535,6 +538,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
},
};
@@ -1617,3 +1621,24 @@ async fn account_affinity_and_rotation_budget_round_trip_and_reject_invalid_valu
assert_eq!(response.status(), expected_status, "{field}");
}
}
#[test]
fn reset_credit_lead_minutes_requires_an_integer_within_one_day() {
for (value, valid) in [
(0, false),
(1, true),
(10, true),
(1440, true),
(1441, false),
] {
let mut body = update_body();
body["resetCreditLeadMinutes"] = json!(value);
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap();
let result = request.validate();
if valid {
assert!(result.is_ok());
} else {
assert_eq!(result.unwrap_err().field(), "resetCreditLeadMinutes");
}
}
}
+4 -1
View File
@@ -26,10 +26,11 @@ pub enum WorkerKind {
Backup,
AccountImport,
AccountFreezeRecovery,
ResetCreditAutomation,
}
impl WorkerKind {
pub const ALL: [Self; 11] = [
pub const ALL: [Self; 12] = [
Self::OAuthRefresh,
Self::QuotaCatalogHealth,
Self::RuntimeSnapshotReconciliation,
@@ -41,6 +42,7 @@ impl WorkerKind {
Self::Backup,
Self::AccountImport,
Self::AccountFreezeRecovery,
Self::ResetCreditAutomation,
];
#[must_use]
@@ -57,6 +59,7 @@ impl WorkerKind {
Self::Backup => "backup",
Self::AccountImport => "account_import",
Self::AccountFreezeRecovery => "account_freeze_recovery",
Self::ResetCreditAutomation => "reset_credit_automation",
}
}
}
+2 -1
View File
@@ -19,7 +19,7 @@ use gateway_core::task::{
use gateway_host::workers::{WorkerStartError, WorkerSupervisor};
use tokio::sync::Notify;
const ACTIVE_KINDS: [WorkerKind; 10] = [
const ACTIVE_KINDS: [WorkerKind; 11] = [
WorkerKind::OAuthRefresh,
WorkerKind::QuotaCatalogHealth,
WorkerKind::RuntimeSnapshotReconciliation,
@@ -30,6 +30,7 @@ const ACTIVE_KINDS: [WorkerKind; 10] = [
WorkerKind::Backup,
WorkerKind::AccountImport,
WorkerKind::AccountFreezeRecovery,
WorkerKind::ResetCreditAutomation,
];
#[derive(Clone)]
@@ -124,6 +124,7 @@ pub struct RuntimeSettings {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub reset_credit_lead_minutes: u32,
pub request_profiles: ProviderRequestProfiles,
pub config_revision: Revision,
pub model_mappings: ModelMappings,
@@ -163,6 +164,7 @@ pub struct ReplaceRuntimeSettings {
pub account_warmup_enabled: bool,
pub account_warmup_schedule_time: String,
pub account_warmup_model: Option<String>,
pub reset_credit_lead_minutes: u32,
/// 读取设置时的版本;与写入在同一事务内比较,防止覆盖并发更新
pub expected_revision: Revision,
/// 只覆盖提交的 Provider;未提交项保留当前持久值
@@ -365,6 +367,7 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
account_warmup_enabled: settings.account_warmup_enabled,
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,
expected_revision: settings.config_revision,
request_profile_updates: settings
.request_profiles
@@ -60,6 +60,7 @@ mod core_adapter;
mod mapping;
mod quota_forecast;
mod repository;
mod reset_credit_automation;
mod rows;
mod runtime;
@@ -0,0 +1,134 @@
//! 账号重置卡计划的 PostgreSQL CAS 与配置审计,不维护独立卡片库存
use super::*;
use gateway_admin::{
model::{
audit::MutationAuditOperation,
reset_credit_automation::{ResetCreditAutomation, ResetCreditAutomationState},
},
ports::reset_credit_automation::ResetCreditAutomationStore,
};
#[derive(serde::Serialize, serde::Deserialize)]
struct StoredResetCreditAutomation {
enabled: bool,
revision: u64,
state: ResetCreditAutomationState,
}
#[async_trait]
impl ResetCreditAutomationStore for PgAdminAccountStore {
async fn reset_credit_automation(
&self,
account_id: &str,
) -> AdminStoreResult<Option<ResetCreditAutomation>> {
let row = sqlx::query("select reset_credit_automation_json, reset_credit_next_run_at from provider_accounts where id = $1")
.bind(account_id).fetch_optional(&self.pool).await
.map_err(|source| automation_error("load reset-credit automation", source))?;
let Some(row) = row else {
return Ok(None);
};
let stored: Option<sqlx::types::Json<StoredResetCreditAutomation>> = row
.try_get("reset_credit_automation_json")
.map_err(|source| automation_error("decode reset-credit automation", source))?;
stored
.map(|stored| {
Ok(ResetCreditAutomation {
account_id: account_id.to_owned(),
enabled: stored.enabled,
revision: stored.revision,
next_run_at: row.try_get("reset_credit_next_run_at").map_err(|source| {
automation_error("decode reset-credit deadline", source)
})?,
state: stored.0.state,
})
})
.transpose()
}
async fn due_reset_credit_automations(
&self,
now: DateTime<Utc>,
limit: u32,
) -> AdminStoreResult<Vec<String>> {
sqlx::query_scalar("select id from provider_accounts where enabled and reset_credit_automation_json ->> 'enabled' = 'true' and reset_credit_next_run_at <= $1 order by reset_credit_next_run_at, id limit $2")
.bind(now).bind(i64::from(limit)).fetch_all(&self.pool).await
.map_err(|source| automation_error("list due reset-credit automation", source))
}
async fn save_reset_credit_automation(
&self,
record: &ResetCreditAutomation,
context: Option<&MutationContext>,
) -> AdminStoreResult<u64> {
let mut tx = self
.pool
.begin()
.await
.map_err(|source| automation_error("begin reset-credit automation", source))?;
// 配置写入统一先锁全局版本,再锁账号计划,避免和运行设置重排相互等待
let config_revision = if context.is_some() {
Some(
bump_config_revision_in_transaction(&mut tx)
.await
.map_err(|error| admin_store_error("reset-credit automation", error))?,
)
} else {
None
};
let expected = i64::try_from(record.revision).map_err(|_| invalid_automation_revision())?;
let next = expected
.checked_add(1)
.and_then(|value| u64::try_from(value).ok())
.ok_or_else(invalid_automation_revision)?;
let stored = StoredResetCreditAutomation {
enabled: record.enabled,
revision: next,
state: record.state.clone(),
};
let saved = sqlx::query("update provider_accounts set reset_credit_automation_json = $2, reset_credit_next_run_at = $3 where id = $1 and coalesce((reset_credit_automation_json ->> 'revision')::bigint, 0) = $4")
.bind(&record.account_id).bind(sqlx::types::Json(stored)).bind(record.next_run_at).bind(expected)
.execute(&mut *tx).await
.map_err(|source| automation_error("save reset-credit automation", source))?;
if saved.rows_affected() != 1 {
return Err(AdminStoreError::new(
AdminStoreErrorKind::Conflict,
"reset-credit automation",
"automation state changed",
));
}
if let (Some(context), Some(revision)) = (context, config_revision) {
append_admin_audit_event_in_transaction(
&mut tx,
mutation_audit(
context,
MutationAuditOperation::ProviderAccountUpdate,
&record.account_id,
vec!["reset_credit_automation".to_owned()],
),
revision,
)
.await
.map_err(|error| admin_store_error("reset-credit automation", error))?;
}
tx.commit()
.await
.map_err(|source| automation_error("commit reset-credit automation", source))?;
Ok(next)
}
}
fn invalid_automation_revision() -> AdminStoreError {
AdminStoreError::new(
AdminStoreErrorKind::Invalid,
"reset-credit automation",
"invalid revision",
)
}
fn automation_error(operation: &'static str, source: sqlx::Error) -> AdminStoreError {
admin_store_error(
"reset-credit automation",
postgres_unavailable(operation, source),
)
}
@@ -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
account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes
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
account_warmup_enabled, account_warmup_schedule_time, account_warmup_model, reset_credit_lead_minutes
from runtime_settings where id = 1",
)
.fetch_optional(&mut **transaction)
@@ -356,6 +356,12 @@ pub(crate) async fn update_runtime_settings_in_transaction(
update: &RuntimeSettingsUpdate,
) -> StoreResult<Revision> {
update.validate()?;
let previous_lead: i32 = sqlx::query_scalar(
"select reset_credit_lead_minutes from runtime_settings where id = 1 for update",
)
.fetch_one(&mut **transaction)
.await
.map_err(|source| postgres_unavailable("load reset-credit lead time", source))?;
let refresh_margin_seconds =
i64::try_from(update.values.refresh_margin_seconds).map_err(|_| invalid_numeric())?;
let request_profile_updates = update
@@ -412,6 +418,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
max_account_rotations = $32,
openai_session_affinity_ttl_hours = $33,
codex_privacy_policy_json = $34,
reset_credit_lead_minutes = $35,
updated_at = now()
where id = 1
returning config_revision",
@@ -461,6 +468,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
.bind(i64::from(update.values.max_account_rotations))
.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))
.fetch_optional(&mut **transaction)
.await
.map_err(|source| postgres_unavailable("update runtime settings in transaction", source))?
@@ -469,6 +477,12 @@ 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) {
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))?;
}
Revision::new(u64::try_from(next).map_err(|_| invalid_numeric())?)
}
@@ -551,6 +565,7 @@ struct RuntimeSettingsRow {
account_warmup_enabled: bool,
account_warmup_schedule_time: String,
account_warmup_model: Option<String>,
reset_credit_lead_minutes: i32,
}
fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSettings> {
@@ -618,6 +633,8 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSett
account_warmup_enabled: row.account_warmup_enabled,
account_warmup_schedule_time: row.account_warmup_schedule_time,
account_warmup_model: row.account_warmup_model,
reset_credit_lead_minutes: u32::try_from(row.reset_credit_lead_minutes)
.map_err(|_| invalid_numeric())?,
},
})
}
@@ -333,3 +333,164 @@ async fn authorization_conflict_preserves_accounts_revision_and_audit() {
assert_eq!(audit, 1);
database.close().await;
}
#[tokio::test]
async fn reset_credit_automation_persists_pending_checks_cas_and_follows_account_lifecycle() {
use gateway_admin::{
model::reset_credit_automation::*,
ports::reset_credit_automation::ResetCreditAutomationStore as _,
};
let Some(database) = TestDatabase::create("reset_credit_automation").await else {
return;
};
let repository = PgProviderAccountRepository::new(database.pool.clone());
repository
.insert_provider_account(account("acct_automation", "user-automation"))
.await
.unwrap();
let store = admin_account_store(&database.pool);
let now = Utc::now();
let mut record = ResetCreditAutomation {
account_id: "acct_automation".into(),
enabled: true,
revision: 0,
next_run_at: now + TimeDelta::hours(2),
state: ResetCreditAutomationState {
next_discovery_at: now + TimeDelta::days(1),
credits: vec![],
pending: Some(PendingResetCredit {
credit: PlannedResetCredit {
id: "card_1".into(),
expires_at: now + TimeDelta::minutes(5),
},
redeem_request_id: uuid::Uuid::new_v4().to_string(),
}),
retry_at: Some(now + TimeDelta::seconds(30)),
failures: 1,
last_result: Some("pending".into()),
},
};
let context = MutationContext {
actor: MutationActor::System,
request_id: "automation-test".into(),
};
record.revision = store
.save_reset_credit_automation(&record, Some(&context))
.await
.unwrap();
let loaded = store
.reset_credit_automation(&record.account_id)
.await
.unwrap()
.unwrap();
assert_eq!(loaded.state, record.state);
use gateway_store::postgres::{PgRuntimeSettingsRepository, RuntimeSettingsRepository as _};
let settings = PgRuntimeSettingsRepository::new(database.pool.clone());
assert_eq!(
settings
.load_runtime_settings()
.await
.unwrap()
.values
.reset_credit_lead_minutes,
10
);
let mut update = super::super::runtime_settings::settings_with_margin(3600);
settings
.update_runtime_settings(update.clone())
.await
.unwrap();
assert_eq!(
store
.reset_credit_automation(&record.account_id)
.await
.unwrap()
.unwrap(),
loaded,
"unrelated settings must not replan"
);
update.values.reset_credit_lead_minutes = 20;
settings.update_runtime_settings(update).await.unwrap();
let replanned = store
.reset_credit_automation(&record.account_id)
.await
.unwrap()
.unwrap();
assert_eq!(
replanned.state, record.state,
"replan preserves pending idempotency key"
);
assert!(replanned.revision > record.revision);
assert!(replanned.next_run_at <= Utc::now());
record = replanned;
assert_eq!(
store
.due_reset_credit_automations(now + TimeDelta::seconds(1), 1)
.await
.unwrap(),
[record.account_id.clone()]
);
repository
.update_provider_account(profile(&record.account_id, "updated profile"))
.await
.unwrap();
repository
.set_provider_account_enabled(&record.account_id, false)
.await
.unwrap();
assert_eq!(
store
.reset_credit_automation(&record.account_id)
.await
.unwrap()
.unwrap(),
record,
"account edits preserve the automation plan"
);
assert!(
store
.due_reset_credit_automations(now + TimeDelta::seconds(1), 10)
.await
.unwrap()
.is_empty()
);
repository
.set_provider_account_enabled(&record.account_id, true)
.await
.unwrap();
let stale = record.clone();
record.enabled = false;
record.revision = store
.save_reset_credit_automation(&record, None)
.await
.unwrap();
assert_eq!(
store
.save_reset_credit_automation(&stale, None)
.await
.unwrap_err()
.kind(),
gateway_admin::ports::store::AdminStoreErrorKind::Conflict
);
assert!(
store
.due_reset_credit_automations(now + TimeDelta::seconds(1), 10)
.await
.unwrap()
.is_empty()
);
sqlx::query("delete from provider_accounts where id = $1")
.bind(&record.account_id)
.execute(&database.pool)
.await
.unwrap();
assert!(
store
.reset_credit_automation(&record.account_id)
.await
.unwrap()
.is_none()
);
database.close().await;
}
@@ -48,6 +48,7 @@ pub(super) fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettin
account_warmup_enabled: false,
account_warmup_schedule_time: "08:00".to_owned(),
account_warmup_model: None,
reset_credit_lead_minutes: 10,
},
}
}
@@ -211,6 +212,7 @@ async fn privacy_steps_migration_preserves_rules_and_other_settings() {
.await
.unwrap();
expected_row["codex_privacy_policy_json"] = expected.clone();
expected_row["reset_credit_lead_minutes"] = serde_json::json!(10);
// 完整升级还会清理自动回切字段,其余运行设置必须原样保留
expected_row["smart_scheduling_json"]
.as_object_mut()
+1
View File
@@ -31,3 +31,4 @@ f10058e2b4c974b6d2ffa11be78b085504a233205253948c03357ef6fe234bca 0030_analytics
b49832e205405de7ef078f3b65c0c19f2c030b8984d42411598738b6b6c146bf 0031_proxy_and_privacy.sql
a0e409402611dbc76bb71393b8eda7c717756c18974dbabca305fdcb4b115f31 0032_usage_and_scheduling.sql
6acd13f237cd163fdf9b52ec9c003ca75b723980e5db5c52c9776413cfe85c4b 0033_proxy_websocket_capacity.sql
0bcd3b00ad7038d9a62e955ff607032f072b08c9f529cf43f794a9820965bdbd 0034_reset_credit_automation.sql
@@ -0,0 +1,22 @@
-- 主动重置卡配置与计划随账号保存,到期索引只覆盖开启自动使用的账号
ALTER TABLE runtime_settings
ADD COLUMN reset_credit_lead_minutes integer NOT NULL DEFAULT 10
CHECK (reset_credit_lead_minutes BETWEEN 1 AND 1440);
ALTER TABLE provider_accounts
ADD COLUMN reset_credit_automation_json jsonb,
ADD COLUMN reset_credit_next_run_at timestamptz,
ADD CONSTRAINT provider_accounts_reset_credit_automation_shape CHECK (
(reset_credit_automation_json IS NULL AND reset_credit_next_run_at IS NULL)
OR coalesce((
reset_credit_automation_json IS NOT NULL AND reset_credit_next_run_at IS NOT NULL
AND jsonb_typeof(reset_credit_automation_json) = 'object'
AND jsonb_typeof(reset_credit_automation_json -> 'enabled') = 'boolean'
AND jsonb_typeof(reset_credit_automation_json -> 'revision') = 'number'
AND jsonb_typeof(reset_credit_automation_json -> 'state') = 'object'
), false)
);
CREATE INDEX provider_accounts_reset_credit_due_idx
ON provider_accounts (reset_credit_next_run_at, id)
WHERE enabled AND reset_credit_automation_json ->> 'enabled' = 'true';
+18 -2
View File
@@ -713,6 +713,8 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前
| `GET` | `/api/admin/accounts/profile-avatar` | `accountId`、可选 `version` | 返回支持头像能力的 Provider 字节流,需要管理员会话;`version` 仅区分浏览器缓存 |
| `GET` | `/api/admin/accounts/reset-credits` | `accountId` | 查询支持该能力的 Provider 重置卡,不读取本地库存 |
| `POST` | `/api/admin/accounts/reset-credits` | `{ accountId, creditId?, redeemRequestId }` | 使用 UUIDv4 幂等键消费一张 Provider 重置卡 |
| `GET` | `/api/admin/accounts/reset-credits/automation` | `accountId` | 读取自动使用开关与本地计划,不查询上游 |
| `POST` | `/api/admin/accounts/reset-credits/automation` | `{ accountId, enabled }` | 设置账号自动使用开关,默认关闭 |
| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 |
| `GET` | `/api/admin/accounts/models/catalog` | `accountId` | 读取指定账号的完整 Codex 原生模型目录,返回 `{ modelCount, observedAt, catalog }` |
| `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache |
@@ -1231,8 +1233,8 @@ revision 变化时丢弃结果
### 主动额度重置卡
`GET /api/admin/accounts/reset-credits?accountId=...` 每次都查询对应 Provider;后端不把卡片列表写入
PostgreSQL 或 Redis。当前由 OpenAI Provider 为 OAuth 账号提供,账号视图分别以 `resetCredits` 和
`GET /api/admin/accounts/reset-credits?accountId=...` 每次都查询对应 Provider,不用本地计划代替库存。
当前由 OpenAI Provider 为 OAuth 账号提供,账号视图分别以 `resetCredits` 和
`consumeResetCredit` 表示查询和消费能力
查询响应:
@@ -1272,6 +1274,19 @@ PostgreSQL 或 Redis。当前由 OpenAI Provider 为 OAuth 账号提供,账号
消费期间账号身份或凭据版本变化时返回结果未知,不将旧结果套用到新绑定账号
自动使用通过 `/api/admin/accounts/reset-credits/automation` 按账号开启,默认关闭;账号本身停用时暂停执行。
GET 和 POST 均返回 `enabled`、`leadMinutes`、`nextDiscoveryAtDisplay`、`nextConsumeAtDisplay`、`lastResult`,
时间展示沿用部署时区,无计划时为 `null`。运行设置 `resetCreditLeadMinutes` 默认 10,范围 1~1440 分钟
开启后在一分钟内错峰执行首次发现,此后按部署时区每天随机发现一次,普通发现失败也等待下一日。
已知且有明确到期时间的可用卡片在提前窗口内错峰执行,消费前重新查询;已在其他地方使用或已到期的卡片
会移出计划,查询失败不直接消费。上游返回 `already_redeemed` 后结束该次操作,不切换另一张卡重试。
无卡片 ID 或到期时间时不自动消费;每天发现一次可能错过两次发现之间发放并到期的卡片
`lastResult` 的 `pending` 表示消费结果待确认;重试沿用已持久化的幂等键,重启或关闭再开启也不换键。
`expired_unknown` 表示未确认操作的卡片已经到期,停止重试;`reset`、`already_redeemed` 表示对应终态,
`discovery_failed` 表示最近查询失败,`not_redeemed` 表示上游明确未完成消费。自动操作未决且对应卡片尚未到期时,手动消费返回 `409`
## 6. 账号分组
分组是 Provider-neutral 的账号集合;一个组可包含任意 Provider 账号,一个账号也可属于多个组。
@@ -1453,6 +1468,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` |
### 历史保留
+10 -3
View File
@@ -847,7 +847,8 @@ PostgreSQL 周期对账才是正确性基础
| admission、lease、cooldown、circuit、会话亲和、continuation、失败轮次、OAuth pending、目录 cache | Redis | 可重建、可过期的协调状态 |
| 控制面统一登录会话与登录限流桶 | Redis | AuthService 唯一拥有;保存 Admin / Key 身份、绑定 ID、绝对有效期和计数,不保存原始凭据 |
| 日志、OAuth 恢复记录、在线更新状态、备份暂存 | `.runtime/` | 部署节点本地运行文件 |
| 重置卡库存与消费结果 | 对应 Provider 的上游 | 后端不建立本地卡库存;前端按账号在浏览器会话期间保留最近查询、未决消费幂等键与发送锁 |
| 重置卡库存与消费结果 | 对应 Provider 的上游 | 本地到期计划不能证明卡片仍可用;手动查询直连上游,自动消费前重新核对 |
| 重置卡自动使用配置、发现时间与未决幂等键 | PostgreSQL 账号记录 | Admin 拥有计划与重试编排;账号删除时一并清理,重启沿用已保存时间及幂等键 |
| Provider 公开模型与官方发布资料 | Provider/runtime cache | 由官方目录或发布源刷新,与 PostgreSQL 中的用户身份选择分别管理 |
| Windows 安装包临时直链 | Host 进程内短缓存 | 按需解析、严格校验、到期前丢弃;不写 PostgreSQL/Redis,也不代理包字节 |
@@ -889,8 +890,14 @@ OpenAI 订阅周期属于按需个人信息,不是额度事实。Admin 账号
刷新信息时请求,关闭后取消等待,不维护两套请求状态
主动额度重置是 OpenAI Provider 的不可逆上游操作:列表查询和消费都直接使用当前 Desktop 请求画像;
卡片不写 PostgreSQL/Redis。消费请求携带调用方生成的 UUIDv4 幂等键,同一账号的消费在进程内串行;
发送结果不明确时必须复用原键。确认成功后管理端再显式刷新卡片与 quota,不能直接改本地重置时间
完整卡片库存不落库。消费请求携带调用方生成的 UUIDv4 幂等键,同一账号的手动与自动消费共用进程内锁;
发送结果不明确时必须复用原键。确认成功后显式回读上游 quota,不能直接改本地重置时间
Admin 仅为开启自动使用的账号保存卡片 ID、到期时间、下一次发现时间和未决消费。每日发现按账号独立随机,
已知到期计划在提前窗口前段错峰;Worker 租约限制为单个执行者,每轮最多处理 100 个到期账号,并发复用
凭据刷新配置。周期扫描只访问本地计划,消费前重新核对上游;外部已消费的卡片移出计划,已消费响应为终态。
消费幂等键先持久化再发送,结果未确认时退避重试,到期后停止;确认结果先落库再刷新额度。
计划写入通过版本比较拒绝过期覆盖,全局提前时间变更唤醒本地重算,关闭自动使用保留未决键供重新开启后恢复
## 10. 观测与后台任务
+26
View File
@@ -708,3 +708,29 @@ export function getAccountDetail(data: AccountIdParam, options: RequestOptions =
...options,
})
}
export interface AccountResetCreditAutomation {
enabled: boolean
leadMinutes: number
nextDiscoveryAtDisplay: string | null
nextConsumeAtDisplay: string | null
lastResult: string | null
}
export function getAccountResetCreditAutomation(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountResetCreditAutomation>({
url: '/api/admin/accounts/reset-credits/automation',
method: 'GET',
params: data,
...options,
})
}
export function setAccountResetCreditAutomation(data: AccountIdParam & { enabled: boolean }, options: RequestOptions = {}) {
return request<AccountResetCreditAutomation>({
url: '/api/admin/accounts/reset-credits/automation',
method: 'POST',
data,
...options,
})
}
@@ -76,6 +76,7 @@ export interface RuntimeSettings {
accountWarmupEnabled: boolean
accountWarmupScheduleTime: string
accountWarmupModel: string | null
resetCreditLeadMinutes: number
updatedAt: string
updatedAtDisplay: string
}
@@ -30,7 +30,7 @@ const hasBalance = computed(() => !props.credits?.unlimited && balanceDisplay.va
</script>
<template>
<div class="flex min-w-0 flex-wrap items-baseline justify-between gap-x-4 gap-y-1 rounded-cp bg-cp-fill-quaternary px-4 py-3.5" aria-label="额度点数">
<div class="flex min-w-0 flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-4 pb-4" aria-label="额度点数">
<span class="text-cp-sm font-heavy text-cp-text">额度点数</span>
<span class="flex min-w-0 items-baseline gap-1.5">
<strong
@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { AccountResetCreditAutomation } from '@/api'
import { ZButton, ZIconButton, ZPopover, ZSwitch } from '@codex-proxy/ui'
import { CircleHelp, Clock3 } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getAccountResetCreditAutomation, setAccountResetCreditAutomation } from '@/api'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { errorMessage } from '@/utils/operation'
const props = defineProps<{ accountId: string }>()
const router = useRouter()
const status = shallowRef<AccountResetCreditAutomation>()
const loadError = shallowRef('')
const switchEnabled = shallowRef(false)
const loadAction = useAsyncAction({ errorText: false })
const saveAction = useAsyncAction()
const busy = computed(() => loadAction.loading.value || saveAction.loading.value)
const resultText = computed(() => {
switch (status.value?.lastResult) {
case 'pending': return status.value?.enabled ? '上次自动重置结果待确认,将继续核对' : '上次自动重置结果待确认'
case 'expired_unknown': return '上次重置结果未能确认,卡片已到期'
case 'discovery_failed': return '最近查询失败,将按计划重试'
case 'not_redeemed': return '上游未完成最近一次自动重置'
default: return ''
}
})
async function load() {
const accountId = props.accountId
loadError.value = ''
await loadAction.run(async () => {
try {
const result = await getAccountResetCreditAutomation({ accountId }, { silent: true })
if (props.accountId === accountId) {
status.value = result
switchEnabled.value = result.enabled
}
}
catch (error) {
if (props.accountId === accountId)
loadError.value = errorMessage(error)
}
})
}
async function toggle(enabled: boolean) {
const accountId = props.accountId
switchEnabled.value = enabled
await saveAction.run(async () => {
const result = await setAccountResetCreditAutomation({ accountId, enabled })
if (props.accountId === accountId)
status.value = result
})
if (props.accountId === accountId)
switchEnabled.value = status.value?.enabled ?? false
}
watch(() => props.accountId, () => {
status.value = undefined
switchEnabled.value = false
void load()
}, { immediate: true })
</script>
<template>
<section class="grid gap-2 rounded-cp bg-cp-fill-quaternary px-4 py-3" aria-label="自动使用重置卡">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1">
<h3 class="m-0 text-cp-sm font-heavy text-cp-text">
自动使用
</h3>
<ZPopover trigger="click" placement="bottom-start" role="dialog">
<template #reference="{ open, id }">
<ZIconButton size="small" class="size-6! p-0!" aria-label="自动使用规则" :aria-expanded="open" :aria-controls="id">
<CircleHelp class="size-3.5" />
</ZIconButton>
</template>
<div class="grid max-w-64 gap-2 p-3 text-cp-xs leading-relaxed text-cp-text-secondary">
<p>每天随机查询一次,各账号错开时间</p>
<p>临期使用前核对卡片,已在其他地方使用的会自动跳过</p>
</div>
</ZPopover>
</div>
<div class="flex items-center gap-3">
<ZButton size="small" variant="link" aria-label="设置全局提前时间" @click="router.push('/settings#reset-credits')">
<template #icon>
<Clock3 class="size-3.5 -translate-y-px" />
</template>
{{ status ? `提前 ${status.leadMinutes} 分钟` : '提前时间' }}
</ZButton>
<ZSwitch
class="reset-credit-switch"
:model-value="switchEnabled"
:disabled="busy || !status"
aria-label="自动使用临期重置卡"
@update:model-value="toggle"
/>
</div>
</div>
<p v-if="status?.enabled && status.nextConsumeAtDisplay" class="text-cp-xs text-cp-text-secondary">
下次使用 {{ status.nextConsumeAtDisplay }}
</p>
<p v-if="resultText" class="text-cp-xs text-cp-warning" role="status">
{{ resultText }}
</p>
<div v-if="loadError" class="flex items-center justify-between gap-2 text-cp-xs text-cp-error" role="alert">
<span>{{ loadError }}</span>
<ZButton size="small" variant="plain" :loading="loadAction.loading.value" @click="load">
重试
</ZButton>
</div>
</section>
</template>
<style scoped>
.reset-credit-switch :deep(input:not(:checked) + span) {
background-color: var(--cp-switch-unchecked-bg);
}
</style>
@@ -1,11 +1,12 @@
<script setup lang="ts">
import type { Account, AccountResetCredit } from '@/api'
import { ZButton, ZDialog, ZEmpty, ZIconButton } from '@codex-proxy/ui'
import { ZButton, ZDialog, ZEmpty, ZIconButton, ZTag } from '@codex-proxy/ui'
import { AlertTriangle, RefreshCw, TicketCheck } from '@lucide/vue'
import { computed, shallowRef, watch } from 'vue'
import { useAccountResetCredits } from '../../composables/useAccountResetCredits'
import AccountQuotaCredits from './Credits.vue'
import ResetCreditAutomation from './ResetCreditAutomation.vue'
import UsageLimits from './UsageLimits.vue'
const props = defineProps<{
@@ -146,7 +147,12 @@ function handleRequestConsume(creditId: string) {
</div>
<div v-else class="grid gap-4">
<UsageLimits :windows="account.quota.windows" />
<section class="overflow-hidden rounded-cp bg-cp-fill-quaternary" aria-label="当前额度">
<UsageLimits :windows="account.quota.windows" />
<AccountQuotaCredits :credits="account.quota.credits" />
</section>
<ResetCreditAutomation v-if="account.capabilities.consumeResetCredit" :key="account.id" :account-id="account.id" />
<section v-if="ambiguous" class="flex items-start gap-3 rounded-cp bg-cp-warning-container px-4 py-3.5" role="status">
<AlertTriangle class="mt-0.5 size-4 shrink-0 text-cp-warning-on-container" />
@@ -163,21 +169,14 @@ function handleRequestConsume(creditId: string) {
</ZButton>
</section>
<section class="overflow-hidden rounded-cp bg-cp-fill-quaternary" aria-label="使用限额重置">
<div
class="flex items-center gap-3 pt-4 pr-3 pl-4"
:class="showCountOnlyAction ? 'pb-4' : 'pb-1'"
>
<section class="grid gap-3" aria-label="可用重置卡">
<div class="flex items-center gap-2">
<h3 class="m-0 min-w-0 flex-1 text-cp-sm font-heavy text-cp-text">
使用限额重置
可用重置卡
</h3>
<span
class="shrink-0 rounded-cp-sm px-2 py-1 text-cp-xs leading-none font-heavy tabular-nums"
:class="hasSnapshot && availableCount > 0 ? 'bg-cp-success-container text-cp-success-on-container' : 'bg-cp-fill-tertiary text-cp-text-secondary'"
role="status"
>
<ZTag size="small" :type="hasSnapshot && availableCount > 0 ? 'success' : 'default'" role="status">
{{ countLabel }}
</span>
</ZTag>
<ZIconButton
size="small"
aria-label="刷新主动重置卡"
@@ -195,23 +194,23 @@ function handleRequestConsume(creditId: string) {
<div :aria-busy="loading || consuming">
<p
v-if="loadError"
class="mx-4 mt-0 mb-4 rounded-cp bg-cp-error-container px-4 py-3 text-cp-xs leading-normal font-emphasis text-cp-error-on-container"
class="m-0 rounded-cp bg-cp-error-container px-4 py-3 text-cp-xs leading-normal font-emphasis text-cp-error-on-container"
role="status"
>
{{ loadError }},请刷新重试
</p>
<p
v-else-if="loading && !hasSnapshot"
class="m-0 px-4 pt-1 pb-4 text-cp-xs font-emphasis text-cp-text-secondary"
class="m-0 px-4 py-4 text-cp-xs font-emphasis text-cp-text-secondary"
role="status"
>
正在查询可用重置次数…
</p>
<ul v-else-if="creditItems.length" class="m-0 list-none px-0 pt-0 pb-1">
<ul v-else-if="creditItems.length" class="m-0 grid list-none gap-2 p-0">
<li
v-for="credit in creditItems"
:key="credit.id"
class="flex items-center gap-4 px-4 py-3"
class="flex items-center gap-4 rounded-cp bg-cp-fill-quaternary px-4 py-3.5"
>
<div class="min-w-0 flex-1">
<p class="m-0 text-cp-sm leading-normal font-heavy wrap-anywhere text-cp-text">
@@ -238,13 +237,10 @@ function handleRequestConsume(creditId: string) {
:icon="TicketCheck"
size="small"
surface="none"
title="当前没有可用重置次数"
description="可刷新列表,重新读取上游状态"
title="暂无可用重置卡"
/>
</div>
</section>
<AccountQuotaCredits :credits="account.quota.credits" />
</div>
<template v-if="confirmation || showCountOnlyAction" #footer>
@@ -30,11 +30,11 @@ const items = computed(() => orderedPanelQuotaWindows(props.windows)
</script>
<template>
<section class="overflow-hidden rounded-cp bg-cp-fill-quaternary" aria-label="使用限额">
<section class="grid gap-4 p-4" aria-label="使用限额">
<div
v-for="item in items"
:key="item.key"
class="grid gap-3 px-4 py-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-center sm:gap-5"
class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-center sm:gap-5"
>
<div class="min-w-0">
<h3 class="m-0 text-cp-sm font-heavy text-cp-text">
@@ -65,7 +65,7 @@ const items = computed(() => orderedPanelQuotaWindows(props.windows)
</span>
</div>
</div>
<p v-if="items.length === 0" class="m-0 px-4 py-4 text-cp-sm font-emphasis text-cp-text-secondary">
<p v-if="items.length === 0" class="m-0 text-cp-sm font-emphasis text-cp-text-secondary">
额度待观测
</p>
</section>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { ZCard, ZForm, ZFormItem, ZInput } from '@codex-proxy/ui'
const leadMinutes = defineModel<string>({ required: true })
</script>
<template>
<ZCard id="reset-credits" title="主动重置卡" description="在账号的「额度重置」中开启自动使用">
<ZForm class="max-w-6xl sm:grid-cols-2">
<ZFormItem label="提前使用时间(分钟)" description="全局设置,对已开启自动使用的账号生效">
<ZInput v-model="leadMinutes" type="number" min="1" max="1440" step="1" aria-label="重置卡提前使用时间" />
</ZFormItem>
</ZForm>
</ZCard>
</template>
@@ -60,6 +60,7 @@ export function useSettingsForm() {
accountWarmupEnabled: false,
accountWarmupScheduleTime: '08:00',
accountWarmupModel: '',
resetCreditLeadMinutes: 10 as number | null,
})
function snapshot() {
@@ -78,7 +79,7 @@ export function useSettingsForm() {
mappings.value = initial.mappings
}
function numericModel(key: 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'openaiGuardianReservedConcurrency' | 'maxAccountRotations' | 'openaiSessionAffinityTtlHours' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'responsesMaxDecompressedBodyMiB' | 'usageRetentionDays' | 'accountAutoFreezeThreshold' | 'accountAutoFreezeWindowSeconds' | 'accountAutoFreezeDurationSeconds') {
function numericModel(key: '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) => {
@@ -101,6 +102,7 @@ export function useSettingsForm() {
const maxWaitingPerKeyValue = numericModel('maxWaitingPerKey')
const maxWaitingPerAccountValue = numericModel('maxWaitingPerAccount')
const responsesMaxDecompressedBodyMiBValue = numericModel('responsesMaxDecompressedBodyMiB')
const resetCreditLeadMinutesValue = numericModel('resetCreditLeadMinutes')
const usageRetentionDaysValue = numericModel('usageRetentionDays')
const concurrencyWaitTimeoutSecondsValue = numericModel('concurrencyWaitTimeoutSeconds')
const accountAutoFreezeThresholdValue = numericModel('accountAutoFreezeThreshold')
@@ -151,6 +153,7 @@ export function useSettingsForm() {
form.accountWarmupEnabled = data.accountWarmupEnabled
form.accountWarmupScheduleTime = data.accountWarmupScheduleTime ?? '08:00'
form.accountWarmupModel = data.accountWarmupModel ?? ''
form.resetCreditLeadMinutes = data.resetCreditLeadMinutes
mappings.value = Object.entries(data.modelMappings).map(([requestedModel, upstreamModel]) => ({
requestedModel,
upstreamModel,
@@ -258,6 +261,11 @@ export function useSettingsForm() {
ZNotification.warning({ message: 'Responses 解压上限应为有效的正整数(MiB)' })
return
}
const resetCreditLeadMinutes = form.resetCreditLeadMinutes
if (resetCreditLeadMinutes === null || !Number.isInteger(resetCreditLeadMinutes) || resetCreditLeadMinutes < 1 || resetCreditLeadMinutes > 1440) {
ZNotification.warning({ message: '重置卡提前使用时间应为 1~1440 分钟的整数' })
return
}
const usageRetentionDays = form.usageRetentionDays
if (usageRetentionDays === null || !Number.isInteger(usageRetentionDays) || usageRetentionDays < 1 || usageRetentionDays > 31) {
ZNotification.warning({ message: '历史保留天数应为 1–31 的整数' })
@@ -350,6 +358,7 @@ export function useSettingsForm() {
accountWarmupEnabled: form.accountWarmupEnabled,
accountWarmupScheduleTime: scheduleTime,
accountWarmupModel: warmupModel || null,
resetCreditLeadMinutes,
})
applySettings(result)
ZNotification.success({ message: '设置已保存' })
@@ -386,6 +395,7 @@ export function useSettingsForm() {
concurrencyWaitTimeoutSecondsValue,
responsesMaxDecompressedBodyMiBValue,
usageRetentionDaysValue,
resetCreditLeadMinutesValue,
accountAutoFreezeThresholdValue,
accountAutoFreezeWindowSecondsValue,
accountAutoFreezeDurationSecondsValue,
+11 -1
View File
@@ -2,7 +2,7 @@
import { ZButton, ZIconButton, ZPageHeader, ZSegmented, ZSelect } from '@codex-proxy/ui'
import { Save, Undo2 } from '@lucide/vue'
import { computed, reactive, shallowRef, watch } from 'vue'
import { computed, nextTick, reactive, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AccountAffinityCard from './components/AccountAffinityCard.vue'
@@ -15,6 +15,7 @@ import ModelAliasesCard from './components/ModelAliasesCard.vue'
import PrivacyPolicyCard from './components/privacy/PrivacyPolicyCard.vue'
import RequestHistoryCard from './components/RequestHistoryCard.vue'
import RequestLocationCard from './components/RequestLocationCard.vue'
import ResetCreditCard from './components/ResetCreditCard.vue'
import RotationStrategyCard from './components/RotationStrategyCard.vue'
import SettingsAccessSection from './components/SettingsAccessSection.vue'
import TokenRefreshCard from './components/TokenRefreshCard.vue'
@@ -73,6 +74,7 @@ const {
concurrencyWaitTimeoutSecondsValue,
responsesMaxDecompressedBodyMiBValue,
usageRetentionDaysValue,
resetCreditLeadMinutesValue,
accountAutoFreezeThresholdValue,
accountAutoFreezeWindowSecondsValue,
accountAutoFreezeDurationSecondsValue,
@@ -83,6 +85,13 @@ const {
loadSettings,
} = useSettingsForm()
watch(() => !loading.value && route.hash === '#reset-credits', async (ready) => {
if (ready) {
await nextTick()
document.getElementById('reset-credits')?.scrollIntoView({ block: 'start' })
}
}, { immediate: true })
const disabled = computed(() => saving.value || loading.value || detectingLocation.value || !!error.value)
watch(section, (value) => {
@@ -185,6 +194,7 @@ watch(section, (value) => {
v-model:schedule-time="form.accountWarmupScheduleTime"
v-model:model="form.accountWarmupModel"
/>
<ResetCreditCard v-model="resetCreditLeadMinutesValue" />
<RequestHistoryCard v-model="usageRetentionDaysValue" />
</template>