chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View 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(),
})))
}

View 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))
}

View 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 &params {
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)
}

View 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,
}