Files
zclaw_openfang/crates/zclaw-saas/src/workers/aggregate_usage.rs
iven bd6cf8e05f
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(saas): add ::bigint cast to all SUM() aggregates for PG NUMERIC compat
PostgreSQL SUM() on bigint returns NUMERIC, causing sqlx decode errors
when Rust expects i64/Option<i64>. Root cause: key_pool.rs
select_best_key() token_count SUM was missing ::bigint, causing
DATABASE_ERROR on every relay request.

Fixed in 4 files:
- relay/key_pool.rs: SUM(token_count) — root cause of relay failure
- relay/service.rs: SUM(remaining_rpm) in sort_candidates_by_quota
- account/handlers.rs: SUM(input/output_tokens) in dashboard stats
- workers/aggregate_usage.rs: SUM(input/output_tokens) in aggregation
2026-04-09 22:16:27 +08:00

124 lines
3.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 计费用量聚合 Worker
//!
//! 从 usage_records 聚合当月用量到 billing_usage_quotas 表。
//! 由 Scheduler 每小时触发,或在 relay 请求完成时直接派发。
use async_trait::async_trait;
use chrono::{Datelike, Timelike};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use crate::error::SaasResult;
use super::Worker;
/// 用量聚合参数
#[derive(Debug, Serialize, Deserialize)]
pub struct AggregateUsageArgs {
/// 聚合的目标账户 IDNone = 聚合所有活跃账户)
pub account_id: Option<String>,
}
pub struct AggregateUsageWorker;
#[async_trait]
impl Worker for AggregateUsageWorker {
type Args = AggregateUsageArgs;
fn name(&self) -> &str {
"aggregate_usage"
}
async fn perform(&self, db: &PgPool, args: Self::Args) -> SaasResult<()> {
match args.account_id {
Some(account_id) => {
aggregate_single_account(db, &account_id).await?;
}
None => {
aggregate_all_accounts(db).await?;
}
}
Ok(())
}
}
/// 聚合单个账户的当月用量
async fn aggregate_single_account(db: &PgPool, account_id: &str) -> SaasResult<()> {
// 获取或创建用量记录(确保存在)
let usage = crate::billing::service::get_or_create_usage(db, account_id).await?;
// 从 usage_records 聚合当月实际 token 用量
let now = chrono::Utc::now();
let period_start = now
.with_day(1).unwrap_or(now)
.with_hour(0).unwrap_or(now)
.with_minute(0).unwrap_or(now)
.with_second(0).unwrap_or(now)
.with_nanosecond(0).unwrap_or(now);
let aggregated: Option<(i64, i64, i64)> = sqlx::query_as(
"SELECT COALESCE(SUM(input_tokens), 0)::bigint, \
COALESCE(SUM(output_tokens), 0)::bigint, \
COUNT(*) \
FROM usage_records \
WHERE account_id = $1 AND created_at >= $2 AND status = 'success'"
)
.bind(account_id)
.bind(period_start)
.fetch_optional(db)
.await?;
if let Some((input_tokens, output_tokens, request_count)) = aggregated {
sqlx::query(
"UPDATE billing_usage_quotas \
SET input_tokens = $1, \
output_tokens = $2, \
relay_requests = GREATEST(relay_requests, $3::int), \
updated_at = NOW() \
WHERE id = $4"
)
.bind(input_tokens)
.bind(output_tokens)
.bind(request_count as i32)
.bind(&usage.id)
.execute(db)
.await?;
tracing::debug!(
"Aggregated usage for account {}: in={}, out={}, reqs={}",
account_id, input_tokens, output_tokens, request_count
);
}
Ok(())
}
/// 聚合所有活跃账户
async fn aggregate_all_accounts(db: &PgPool) -> SaasResult<()> {
let account_ids: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT account_id FROM billing_subscriptions \
WHERE status IN ('trial', 'active', 'past_due') \
UNION \
SELECT DISTINCT account_id FROM billing_usage_quotas \
WHERE period_start >= date_trunc('month', NOW())"
)
.fetch_all(db)
.await?;
let total = account_ids.len();
let mut errors = 0;
for account_id in &account_ids {
if let Err(e) = aggregate_single_account(db, account_id).await {
tracing::warn!("Failed to aggregate usage for {}: {}", account_id, e);
errors += 1;
}
}
tracing::info!(
"Usage aggregation complete: {} accounts, {} errors",
total, errors
);
Ok(())
}