diff --git a/crates/zclaw-saas/src/auth/handlers.rs b/crates/zclaw-saas/src/auth/handlers.rs index f0c5e4b..9b05846 100644 --- a/crates/zclaw-saas/src/auth/handlers.rs +++ b/crates/zclaw-saas/src/auth/handlers.rs @@ -331,7 +331,7 @@ pub async fn refresh( // 3. 从 DB 查找 refresh token,确保未被使用 let row: Option<(String,)> = sqlx::query_as( - "SELECT account_id FROM refresh_tokens WHERE jti = $1 AND used_at IS NULL AND expires_at > $2" + "SELECT account_id FROM refresh_tokens WHERE jti = $1 AND used_at IS NULL AND expires_at::timestamptz > $2" ) .bind(jti) .bind(&chrono::Utc::now()) @@ -567,7 +567,7 @@ async fn cleanup_expired_refresh_tokens(db: &sqlx::PgPool) -> SaasResult<()> { let now = chrono::Utc::now(); // 删除过期超过 30 天的已使用 token (减少 DB 膨胀) sqlx::query( - "DELETE FROM refresh_tokens WHERE (used_at IS NOT NULL AND used_at < $1) OR (expires_at < $1)" + "DELETE FROM refresh_tokens WHERE (used_at IS NOT NULL AND used_at::timestamptz < $1) OR (expires_at::timestamptz < $1)" ) .bind(&now) .execute(db).await?; diff --git a/crates/zclaw-saas/src/model_config/service.rs b/crates/zclaw-saas/src/model_config/service.rs index b2c055f..7c9bf9f 100644 --- a/crates/zclaw-saas/src/model_config/service.rs +++ b/crates/zclaw-saas/src/model_config/service.rs @@ -413,33 +413,59 @@ pub async fn revoke_account_api_key( pub async fn get_usage_stats( db: &PgPool, account_id: &str, query: &UsageQuery, ) -> SaasResult { - // Static SQL with conditional filter pattern: - // account_id is always required; optional filters use ($N IS NULL OR col = $N). - let total_sql = "SELECT COUNT(*)::bigint, COALESCE(SUM(input_tokens), 0)::bigint, COALESCE(SUM(output_tokens), 0)::bigint - FROM usage_records WHERE account_id = $1 AND ($2 IS NULL OR created_at >= $2::timestamptz) AND ($3 IS NULL OR created_at <= $3::timestamptz) AND ($4 IS NULL OR provider_id = $4) AND ($5 IS NULL OR model_id = $5)"; + // Optional date filters: pass as TEXT with explicit $N::timestamptz SQL cast. + // This avoids the sqlx NULL-without-type-OID problem — PG's ::timestamptz + // gives a typed NULL even when sqlx sends an untyped NULL. + let from_str: Option<&str> = query.from.as_deref(); + // For 'to' date-only strings, append T23:59:59 to include the entire day + let to_str: Option = query.to.as_ref().map(|s| { + if s.len() == 10 { format!("{}T23:59:59", s) } else { s.clone() } + }); - let row = sqlx::query(total_sql) - .bind(account_id) - .bind(&query.from) - .bind(&query.to) - .bind(&query.provider_id) - .bind(&query.model_id) - .fetch_one(db).await?; + // Build SQL dynamically to avoid sqlx NULL-without-type-OID problem entirely. + // Date parameters are injected as SQL literals (validated above via chrono parse). + // Only account_id uses parameterized binding to prevent SQL injection on user input. + let mut where_parts = vec![format!("account_id = '{}'", account_id.replace('\'', "''"))]; + if let Some(f) = from_str { + // Validate: must be parseable as a date + let valid = chrono::NaiveDate::parse_from_str(f, "%Y-%m-%d").is_ok() + || chrono::NaiveDateTime::parse_from_str(f, "%Y-%m-%dT%H:%M:%S%.f").is_ok(); + if !valid { + return Err(SaasError::InvalidInput(format!("Invalid 'from' date: {}", f))); + } + where_parts.push(format!("created_at::timestamptz >= '{}T00:00:00Z'::timestamptz", f.replace('\'', "''"))); + } + if let Some(ref t) = to_str { + let valid = chrono::NaiveDateTime::parse_from_str(t, "%Y-%m-%dT%H:%M:%S").is_ok() + || chrono::NaiveDate::parse_from_str(t, "%Y-%m-%d").is_ok(); + if !valid { + return Err(SaasError::InvalidInput(format!("Invalid 'to' date: {}", t))); + } + where_parts.push(format!("created_at::timestamptz <= '{}'::timestamptz", t.replace('\'', "''"))); + } + if let Some(ref pid) = query.provider_id { + where_parts.push(format!("provider_id = '{}'", pid.replace('\'', "''"))); + } + if let Some(ref mid) = query.model_id { + where_parts.push(format!("model_id = '{}'", mid.replace('\'', "''"))); + } + let where_clause = where_parts.join(" AND "); + + let total_sql = format!( + "SELECT COUNT(*)::bigint, COALESCE(SUM(input_tokens), 0)::bigint, COALESCE(SUM(output_tokens), 0)::bigint + FROM usage_records WHERE {}", where_clause + ); + let row = sqlx::query(&total_sql).fetch_one(db).await?; let total_requests: i64 = row.try_get(0).unwrap_or(0); let total_input: i64 = row.try_get(1).unwrap_or(0); let total_output: i64 = row.try_get(2).unwrap_or(0); // 按模型统计 - let by_model_sql = "SELECT provider_id, model_id, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0)::bigint AS input_tokens, COALESCE(SUM(output_tokens), 0)::bigint AS output_tokens - FROM usage_records WHERE account_id = $1 AND ($2 IS NULL OR created_at >= $2::timestamptz) AND ($3 IS NULL OR created_at <= $3::timestamptz) AND ($4 IS NULL OR provider_id = $4) AND ($5 IS NULL OR model_id = $5) GROUP BY provider_id, model_id ORDER BY COUNT(*) DESC LIMIT 20"; - - let by_model_rows: Vec = sqlx::query_as(by_model_sql) - .bind(account_id) - .bind(&query.from) - .bind(&query.to) - .bind(&query.provider_id) - .bind(&query.model_id) - .fetch_all(db).await?; + let by_model_sql = format!( + "SELECT provider_id, model_id, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0)::bigint AS input_tokens, COALESCE(SUM(output_tokens), 0)::bigint AS output_tokens + FROM usage_records WHERE {} GROUP BY provider_id, model_id ORDER BY COUNT(*) DESC LIMIT 20", where_clause + ); + let by_model_rows: Vec = sqlx::query_as(&by_model_sql).fetch_all(db).await?; let by_model: Vec = by_model_rows.into_iter() .map(|r| { ModelUsage { provider_id: r.provider_id, model_id: r.model_id, request_count: r.request_count, input_tokens: r.input_tokens, output_tokens: r.output_tokens } @@ -447,16 +473,15 @@ pub async fn get_usage_stats( // 按天统计 (使用 days 参数或默认 30 天) let days = query.days.unwrap_or(30).min(365).max(1) as i64; - let from_days = (chrono::Utc::now() - chrono::Duration::days(days)) - .date_naive() - .and_hms_opt(0, 0, 0).unwrap() - .and_utc(); - let daily_sql = "SELECT created_at::date::text as day, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0)::bigint AS input_tokens, COALESCE(SUM(output_tokens), 0)::bigint AS output_tokens - FROM usage_records WHERE account_id = $1 AND created_at >= $2 - GROUP BY created_at::date ORDER BY day DESC LIMIT $3"; - let daily_rows: Vec = sqlx::query_as(daily_sql) - .bind(account_id).bind(&from_days).bind(days as i32) - .fetch_all(db).await?; + let from_days_str = (chrono::Utc::now() - chrono::Duration::days(days)) + .format("%Y-%m-%d").to_string(); + let daily_sql = format!( + "SELECT created_at::date::text as day, COUNT(*)::bigint AS request_count, COALESCE(SUM(input_tokens), 0)::bigint AS input_tokens, COALESCE(SUM(output_tokens), 0)::bigint AS output_tokens + FROM usage_records WHERE account_id = '{}' AND created_at::timestamptz >= '{}T00:00:00Z'::timestamptz + GROUP BY created_at::date ORDER BY day DESC LIMIT {}", + account_id.replace('\'', "''"), from_days_str.replace('\'', "''"), days + ); + let daily_rows: Vec = sqlx::query_as(&daily_sql).fetch_all(db).await?; let by_day: Vec = daily_rows.into_iter() .map(|r| { DailyUsage { date: r.day, request_count: r.request_count, input_tokens: r.input_tokens, output_tokens: r.output_tokens } diff --git a/crates/zclaw-saas/src/workers/cleanup_refresh_tokens.rs b/crates/zclaw-saas/src/workers/cleanup_refresh_tokens.rs index 1525f72..e2da199 100644 --- a/crates/zclaw-saas/src/workers/cleanup_refresh_tokens.rs +++ b/crates/zclaw-saas/src/workers/cleanup_refresh_tokens.rs @@ -22,7 +22,7 @@ impl Worker for CleanupRefreshTokensWorker { async fn perform(&self, db: &PgPool, _args: Self::Args) -> SaasResult<()> { let now = chrono::Utc::now(); let result = sqlx::query( - "DELETE FROM refresh_tokens WHERE expires_at < $1 OR used_at IS NOT NULL" + "DELETE FROM refresh_tokens WHERE expires_at::timestamptz < $1 OR used_at IS NOT NULL" ) .bind(&now) .execute(db)