chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
226
crates/zclaw-saas/src/telemetry/service.rs
Normal file
226
crates/zclaw-saas/src/telemetry/service.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! 遥测服务逻辑
|
||||
|
||||
use sqlx::PgPool;
|
||||
use crate::error::SaasResult;
|
||||
use super::types::*;
|
||||
|
||||
/// 批量写入遥测记录
|
||||
pub async fn ingest_telemetry(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
device_id: &str,
|
||||
app_version: &str,
|
||||
entries: &[TelemetryEntry],
|
||||
) -> SaasResult<TelemetryReportResponse> {
|
||||
let mut accepted = 0usize;
|
||||
let mut rejected = 0usize;
|
||||
|
||||
for entry in entries {
|
||||
// 基本验证
|
||||
if entry.input_tokens < 0 || entry.output_tokens < 0 {
|
||||
rejected += 1;
|
||||
continue;
|
||||
}
|
||||
if entry.model_id.is_empty() {
|
||||
rejected += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO telemetry_reports
|
||||
(id, account_id, device_id, app_version, model_id, input_tokens, output_tokens,
|
||||
latency_ms, success, error_type, connection_mode, reported_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(account_id)
|
||||
.bind(device_id)
|
||||
.bind(app_version)
|
||||
.bind(&entry.model_id)
|
||||
.bind(entry.input_tokens)
|
||||
.bind(entry.output_tokens)
|
||||
.bind(entry.latency_ms)
|
||||
.bind(entry.success)
|
||||
.bind(&entry.error_type)
|
||||
.bind(&entry.connection_mode)
|
||||
.bind(&entry.timestamp)
|
||||
.bind(&now)
|
||||
.execute(db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => accepted += 1,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to insert telemetry entry: {}", e);
|
||||
rejected += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TelemetryReportResponse { accepted, rejected })
|
||||
}
|
||||
|
||||
/// 按模型聚合用量统计
|
||||
pub async fn get_model_stats(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
query: &TelemetryStatsQuery,
|
||||
) -> SaasResult<Vec<ModelUsageStat>> {
|
||||
let mut param_idx: i32 = 1;
|
||||
let mut where_clauses = vec![format!("account_id = ${}", param_idx)];
|
||||
let mut params: Vec<String> = vec![account_id.to_string()];
|
||||
param_idx += 1;
|
||||
|
||||
if let Some(ref from) = query.from {
|
||||
where_clauses.push(format!("reported_at >= ${}", param_idx));
|
||||
params.push(from.clone());
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(ref to) = query.to {
|
||||
where_clauses.push(format!("reported_at <= ${}", param_idx));
|
||||
params.push(to.clone());
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(ref model) = query.model_id {
|
||||
where_clauses.push(format!("model_id = ${}", param_idx));
|
||||
params.push(model.clone());
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if let Some(ref mode) = query.connection_mode {
|
||||
where_clauses.push(format!("connection_mode = ${}", param_idx));
|
||||
params.push(mode.clone());
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
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,
|
||||
AVG(latency_ms) as avg_latency_ms,
|
||||
(COUNT(*) FILTER (WHERE success = true))::float / NULLIF(COUNT(*), 0) as success_rate
|
||||
FROM telemetry_reports
|
||||
WHERE {}
|
||||
GROUP BY model_id
|
||||
ORDER BY request_count DESC
|
||||
LIMIT 50",
|
||||
where_sql
|
||||
);
|
||||
|
||||
let mut query_builder = sqlx::query_as::<_, (String, i64, i64, i64, Option<f64>, Option<f64>)>(&sql);
|
||||
for p in ¶ms {
|
||||
query_builder = query_builder.bind(p);
|
||||
}
|
||||
|
||||
let rows = query_builder.fetch_all(db).await?;
|
||||
|
||||
let stats: Vec<ModelUsageStat> = rows
|
||||
.into_iter()
|
||||
.map(|(model_id, request_count, input_tokens, output_tokens, avg_latency_ms, success_rate)| {
|
||||
ModelUsageStat {
|
||||
model_id,
|
||||
request_count,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
avg_latency_ms,
|
||||
success_rate: success_rate.unwrap_or(0.0),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// 写入审计日志摘要(批量写入 operation_logs)
|
||||
pub async fn ingest_audit_summary(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
device_id: &str,
|
||||
entries: &[AuditSummaryEntry],
|
||||
) -> SaasResult<usize> {
|
||||
let mut written = 0usize;
|
||||
|
||||
for entry in entries {
|
||||
if entry.action.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 审计详情仅包含操作类型和目标,不包含用户内容
|
||||
let details = serde_json::json!({
|
||||
"source": "desktop",
|
||||
"device_id": device_id,
|
||||
"result": entry.result,
|
||||
});
|
||||
|
||||
let result = sqlx::query(
|
||||
"INSERT INTO operation_logs (account_id, action, target_type, target_id, details, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(&entry.action)
|
||||
.bind("desktop_audit")
|
||||
.bind(&entry.target)
|
||||
.bind(&details)
|
||||
.bind(&entry.timestamp)
|
||||
.execute(db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => written += 1,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to insert audit summary entry: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(written)
|
||||
}/// 按天聚合用量统计
|
||||
pub async fn get_daily_stats(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
query: &TelemetryStatsQuery,
|
||||
) -> SaasResult<Vec<DailyUsageStat>> {
|
||||
let days = query.days.unwrap_or(30).min(90).max(1);
|
||||
|
||||
let sql = format!(
|
||||
"SELECT
|
||||
SUBSTRING(reported_at, 1, 10) 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,
|
||||
COUNT(DISTINCT device_id)::bigint as unique_devices
|
||||
FROM telemetry_reports
|
||||
WHERE account_id = $1
|
||||
AND reported_at >= to_char(CURRENT_DATE - INTERVAL '{} days', 'YYYY-MM-DD')
|
||||
GROUP BY SUBSTRING(reported_at, 1, 10)
|
||||
ORDER BY day DESC",
|
||||
days
|
||||
);
|
||||
|
||||
let rows: Vec<(String, i64, i64, i64, i64)> =
|
||||
sqlx::query_as(&sql).bind(account_id).fetch_all(db).await?;
|
||||
|
||||
let stats: Vec<DailyUsageStat> = rows
|
||||
.into_iter()
|
||||
.map(|(day, request_count, input_tokens, output_tokens, unique_devices)| {
|
||||
DailyUsageStat {
|
||||
day,
|
||||
request_count,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
unique_devices,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
Reference in New Issue
Block a user