fix: 三端联调测试 2 P1 + 2 P2 + 4 P3 修复
P1-07: billing get_or_create_usage 同步 max_* 列到当前计划限额 P1-08: relay handler 增加直接配额检查 (relay_requests/input/output_tokens) P2-09: relay failover 成功后记录 tokens 并标记 completed P2-10: Tauri agentStore saas-relay 模式下从 SaaS API 获取真实用量 P2-14: super_admin 合成 subscription + check_quota 放行 P3-19: 新建 ApiKeys.tsx 页面替代 ModelServices 路由 P3-15: antd destroyOnClose → destroyOnHidden (3处) P3-16: ProTable onSearch → onSubmit (2处)
This commit is contained in:
@@ -39,9 +39,23 @@ pub async fn get_subscription(
|
||||
let sub = service::get_active_subscription(&state.db, &ctx.account_id).await?;
|
||||
let usage = service::get_or_create_usage(&state.db, &ctx.account_id).await?;
|
||||
|
||||
// P2-14 修复: super_admin 无订阅时合成一个 "active" subscription
|
||||
let sub_value = if sub.is_none() && ctx.role == "super_admin" {
|
||||
Some(serde_json::json!({
|
||||
"id": format!("sub-admin-{}", &ctx.account_id.chars().take(8).collect::<String>()),
|
||||
"account_id": ctx.account_id,
|
||||
"plan_id": plan.id,
|
||||
"status": "active",
|
||||
"current_period_start": usage.period_start,
|
||||
"current_period_end": usage.period_end,
|
||||
}))
|
||||
} else {
|
||||
sub.map(|s| serde_json::to_value(s).unwrap_or_default())
|
||||
};
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"plan": plan,
|
||||
"subscription": sub,
|
||||
"subscription": sub_value,
|
||||
"usage": usage,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -114,7 +114,26 @@ pub async fn get_or_create_usage(pool: &PgPool, account_id: &str) -> SaasResult<
|
||||
.await?;
|
||||
|
||||
if let Some(usage) = existing {
|
||||
return Ok(usage);
|
||||
// P1-07 修复: 同步当前计划限额到 max_* 列(防止计划变更后数据不一致)
|
||||
let plan = get_account_plan(pool, account_id).await?;
|
||||
let limits: PlanLimits = serde_json::from_value(plan.limits.clone())
|
||||
.unwrap_or_else(|_| PlanLimits::free());
|
||||
sqlx::query(
|
||||
"UPDATE billing_usage_quotas SET max_input_tokens=$2, max_output_tokens=$3, \
|
||||
max_relay_requests=$4, max_hand_executions=$5, max_pipeline_runs=$6, updated_at=NOW() \
|
||||
WHERE id=$1"
|
||||
)
|
||||
.bind(&usage.id)
|
||||
.bind(limits.max_input_tokens_monthly)
|
||||
.bind(limits.max_output_tokens_monthly)
|
||||
.bind(limits.max_relay_requests_monthly)
|
||||
.bind(limits.max_hand_executions_monthly)
|
||||
.bind(limits.max_pipeline_runs_monthly)
|
||||
.execute(pool).await?;
|
||||
let updated = sqlx::query_as::<_, UsageQuota>(
|
||||
"SELECT * FROM billing_usage_quotas WHERE id = $1"
|
||||
).bind(&usage.id).fetch_one(pool).await?;
|
||||
return Ok(updated);
|
||||
}
|
||||
|
||||
// 获取当前计划限额
|
||||
@@ -288,8 +307,13 @@ pub async fn increment_dimension_by(
|
||||
pub async fn check_quota(
|
||||
pool: &PgPool,
|
||||
account_id: &str,
|
||||
role: &str,
|
||||
quota_type: &str,
|
||||
) -> SaasResult<QuotaCheck> {
|
||||
// P2-14 修复: super_admin 不受配额限制
|
||||
if role == "super_admin" {
|
||||
return Ok(QuotaCheck { allowed: true, reason: None, current: 0, limit: None, remaining: None });
|
||||
}
|
||||
let usage = get_or_create_usage(pool, account_id).await?;
|
||||
// 从当前 Plan 读取真实限额,而非 usage 表的 stale 冗余列
|
||||
let plan = get_account_plan(pool, account_id).await?;
|
||||
|
||||
@@ -119,13 +119,13 @@ pub async fn quota_check_middleware(
|
||||
}
|
||||
|
||||
// 从扩展中获取认证上下文
|
||||
let account_id = match req.extensions().get::<AuthContext>() {
|
||||
Some(ctx) => ctx.account_id.clone(),
|
||||
let (account_id, role) = match req.extensions().get::<AuthContext>() {
|
||||
Some(ctx) => (ctx.account_id.clone(), ctx.role.clone()),
|
||||
None => return next.run(req).await,
|
||||
};
|
||||
|
||||
// 检查 relay_requests 配额
|
||||
match crate::billing::service::check_quota(&state.db, &account_id, "relay_requests").await {
|
||||
match crate::billing::service::check_quota(&state.db, &account_id, &role, "relay_requests").await {
|
||||
Ok(check) if !check.allowed => {
|
||||
tracing::warn!(
|
||||
"Quota exceeded for account {}: {} ({}/{})",
|
||||
@@ -146,7 +146,7 @@ pub async fn quota_check_middleware(
|
||||
}
|
||||
|
||||
// P1-8 修复: 同时检查 input_tokens 配额
|
||||
match crate::billing::service::check_quota(&state.db, &account_id, "input_tokens").await {
|
||||
match crate::billing::service::check_quota(&state.db, &account_id, &role, "input_tokens").await {
|
||||
Ok(check) if !check.allowed => {
|
||||
tracing::warn!(
|
||||
"Token quota exceeded for account {}: {} ({}/{})",
|
||||
|
||||
@@ -23,6 +23,18 @@ pub async fn chat_completions(
|
||||
) -> SaasResult<Response> {
|
||||
check_permission(&ctx, "relay:use")?;
|
||||
|
||||
// P1-08 修复: 直接配额检查(不依赖中间件,防御性编程)
|
||||
for quota_type in &["relay_requests", "input_tokens", "output_tokens"] {
|
||||
let check = crate::billing::service::check_quota(
|
||||
&state.db, &ctx.account_id, &ctx.role, quota_type,
|
||||
).await?;
|
||||
if !check.allowed {
|
||||
return Err(SaasError::RateLimited(
|
||||
check.reason.unwrap_or_else(|| format!("{} 配额已用尽", quota_type))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 队列容量检查:使用内存 AtomicI64 计数器,消除 DB COUNT 查询
|
||||
let max_queue_size = {
|
||||
let config = state.config.read().await;
|
||||
|
||||
@@ -591,6 +591,17 @@ pub async fn execute_relay_with_failover(
|
||||
candidate.model_id
|
||||
);
|
||||
}
|
||||
// P2-09 修复: 非 SSE 响应在 failover 成功后记录 tokens 并标记 completed
|
||||
if let RelayResponse::Json(ref body) = response {
|
||||
let (input_tokens, output_tokens) = extract_token_usage(body);
|
||||
if input_tokens > 0 || output_tokens > 0 {
|
||||
if let Err(e) = update_task_status(db, task_id, "completed",
|
||||
Some(input_tokens), Some(output_tokens), None).await {
|
||||
tracing::warn!("Failed to update task {} tokens after failover: {}", task_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
// SSE 响应由 StreamBridge 后台任务处理,无需在此更新
|
||||
return Ok((response, candidate.provider_id.clone(), candidate.model_id.clone()));
|
||||
}
|
||||
Err(SaasError::RateLimited(msg)) => {
|
||||
|
||||
Reference in New Issue
Block a user