mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
@@ -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"] }
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
}
|
||||
}
|
||||
+85
-34
@@ -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(())
|
||||
}
|
||||
}
|
||||
+93
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+134
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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. 观测与后台任务
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user