chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
114
crates/zclaw-saas/src/telemetry/handlers.rs
Normal file
114
crates/zclaw-saas/src/telemetry/handlers.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
//! 遥测 API 处理器
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Query, State},
|
||||
Json,
|
||||
};
|
||||
use crate::error::SaasResult;
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::log_operation;
|
||||
use crate::state::AppState;
|
||||
use super::types::*;
|
||||
|
||||
/// POST /api/v1/telemetry/report
|
||||
///
|
||||
/// 接收桌面端上报的 Token 用量统计(无内容,仅计数)。
|
||||
/// 桌面端定期批量上报本地 LLM 调用的用量数据。
|
||||
pub async fn report_telemetry(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<TelemetryReportRequest>,
|
||||
) -> SaasResult<Json<TelemetryReportResponse>> {
|
||||
// 限制单次上报条目数(防止滥用)
|
||||
let entries = if req.entries.len() > 500 {
|
||||
&req.entries[..500]
|
||||
} else {
|
||||
&req.entries
|
||||
};
|
||||
|
||||
let result = super::service::ingest_telemetry(
|
||||
&state.db,
|
||||
&ctx.account_id,
|
||||
&req.device_id,
|
||||
&req.app_version,
|
||||
entries,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 审计日志:记录遥测上报事件
|
||||
log_operation(
|
||||
&state.db,
|
||||
&ctx.account_id,
|
||||
"telemetry.report",
|
||||
"telemetry",
|
||||
&req.device_id,
|
||||
Some(serde_json::json!({"entry_count": entries.len(), "app_version": req.app_version})),
|
||||
ctx.client_ip.as_deref(),
|
||||
).await.ok(); // 非阻塞:日志写入失败不影响上报
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// GET /api/v1/telemetry/stats
|
||||
///
|
||||
/// 按模型聚合用量统计(当前用户)
|
||||
pub async fn get_telemetry_stats(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Query(query): Query<TelemetryStatsQuery>,
|
||||
) -> SaasResult<Json<Vec<ModelUsageStat>>> {
|
||||
let stats = super::service::get_model_stats(
|
||||
&state.db,
|
||||
&ctx.account_id,
|
||||
&query,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(stats))
|
||||
}
|
||||
|
||||
/// GET /api/v1/telemetry/daily
|
||||
///
|
||||
/// 按天聚合用量统计(当前用户)
|
||||
pub async fn get_daily_stats(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Query(query): Query<TelemetryStatsQuery>,
|
||||
) -> SaasResult<Json<Vec<DailyUsageStat>>> {
|
||||
let stats = super::service::get_daily_stats(
|
||||
&state.db,
|
||||
&ctx.account_id,
|
||||
&query,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(stats))
|
||||
}
|
||||
|
||||
/// POST /api/v1/telemetry/audit
|
||||
///
|
||||
/// 接收桌面端上报的审计日志摘要(仅操作类型和计数,无具体内容)。
|
||||
pub async fn report_audit_summary(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<AuditSummaryRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
let entries = if req.entries.len() > 200 {
|
||||
&req.entries[..200]
|
||||
} else {
|
||||
&req.entries
|
||||
};
|
||||
|
||||
let written = super::service::ingest_audit_summary(
|
||||
&state.db,
|
||||
&ctx.account_id,
|
||||
&req.device_id,
|
||||
entries,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"accepted": written,
|
||||
"total": entries.len(),
|
||||
})))
|
||||
}
|
||||
20
crates/zclaw-saas/src/telemetry/mod.rs
Normal file
20
crates/zclaw-saas/src/telemetry/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
//! 使用量遥测模块
|
||||
//!
|
||||
//! 接收桌面端上报的本地 LLM 调用 Token 用量统计(无内容),
|
||||
//! 提供聚合查询 API 供 Admin 面板使用。
|
||||
|
||||
pub mod types;
|
||||
pub mod service;
|
||||
pub mod handlers;
|
||||
|
||||
use axum::routing::{get, post};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 遥测路由 (需要认证)
|
||||
pub fn routes() -> axum::Router<AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/v1/telemetry/report", post(handlers::report_telemetry))
|
||||
.route("/api/v1/telemetry/stats", get(handlers::get_telemetry_stats))
|
||||
.route("/api/v1/telemetry/daily", get(handlers::get_daily_stats))
|
||||
.route("/api/v1/telemetry/audit", post(handlers::report_audit_summary))
|
||||
}
|
||||
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)
|
||||
}
|
||||
98
crates/zclaw-saas/src/telemetry/types.rs
Normal file
98
crates/zclaw-saas/src/telemetry/types.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! 遥测类型定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 审计日志摘要条目(桌面端上报,仅操作类型和计数,无内容)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuditSummaryEntry {
|
||||
/// 操作类型(如 "hand.trigger", "agent.create")
|
||||
pub action: String,
|
||||
/// 操作目标(如 Agent 名称或 Hand 名称)
|
||||
pub target: String,
|
||||
/// 操作结果: "success" | "failure" | "pending"
|
||||
pub result: String,
|
||||
/// 操作时间(ISO 8601)
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// 审计摘要上报请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuditSummaryRequest {
|
||||
/// 设备 ID
|
||||
pub device_id: String,
|
||||
/// 审计条目列表
|
||||
pub entries: Vec<AuditSummaryEntry>,
|
||||
}
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TelemetryEntry {
|
||||
/// 模型标识(如 "gpt-4o", "glm-4-flash")
|
||||
pub model_id: String,
|
||||
/// 输入 Token 数
|
||||
pub input_tokens: i64,
|
||||
/// 输出 Token 数
|
||||
pub output_tokens: i64,
|
||||
/// 调用延迟(毫秒)
|
||||
pub latency_ms: Option<i64>,
|
||||
/// 调用是否成功
|
||||
pub success: bool,
|
||||
/// 错误类型(失败时)
|
||||
pub error_type: Option<String>,
|
||||
/// 调用时间(ISO 8601)
|
||||
pub timestamp: String,
|
||||
/// 连接模式: "tauri"(本地直连)/ "saas"(通过中转)
|
||||
pub connection_mode: String,
|
||||
}
|
||||
|
||||
/// 桌面端遥测上报请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TelemetryReportRequest {
|
||||
/// 设备 ID
|
||||
pub device_id: String,
|
||||
/// 桌面端版本
|
||||
pub app_version: String,
|
||||
/// 用量条目列表(批量上报)
|
||||
pub entries: Vec<TelemetryEntry>,
|
||||
}
|
||||
|
||||
/// 遥测上报响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TelemetryReportResponse {
|
||||
pub accepted: usize,
|
||||
pub rejected: usize,
|
||||
}
|
||||
|
||||
/// 遥测统计查询参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TelemetryStatsQuery {
|
||||
/// 起始日期 (ISO 8601)
|
||||
pub from: Option<String>,
|
||||
/// 结束日期 (ISO 8601)
|
||||
pub to: Option<String>,
|
||||
/// 按模型过滤
|
||||
pub model_id: Option<String>,
|
||||
/// 按连接模式过滤
|
||||
pub connection_mode: Option<String>,
|
||||
/// 按天分组时的时间范围(天数)
|
||||
pub days: Option<i64>,
|
||||
}
|
||||
|
||||
/// 按模型聚合的用量统计
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ModelUsageStat {
|
||||
pub model_id: String,
|
||||
pub request_count: i64,
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
pub avg_latency_ms: Option<f64>,
|
||||
pub success_rate: f64,
|
||||
}
|
||||
|
||||
/// 按天的用量统计
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DailyUsageStat {
|
||||
pub day: String,
|
||||
pub request_count: i64,
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
pub unique_devices: i64,
|
||||
}
|
||||
Reference in New Issue
Block a user