refactor(saas): 架构重构 + 性能优化 — 借鉴 loco-rs 模式
Phase 0: 知识库
- docs/knowledge-base/loco-rs-patterns.md — loco-rs 10 个可借鉴模式研究
Phase 1: 数据层重构
- crates/zclaw-saas/src/models/ — 15 个 FromRow 类型化模型
- Login 3 次查询合并为 1 次 AccountLoginRow 查询
- 所有 service 文件从元组解构迁移到 FromRow 结构体
Phase 2: Worker + Scheduler 系统
- crates/zclaw-saas/src/workers/ — Worker trait + 5 个具体实现
- crates/zclaw-saas/src/scheduler.rs — TOML 声明式调度器
- crates/zclaw-saas/src/tasks/ — CLI 任务系统
Phase 3: 性能修复
- Relay N+1 查询 → 精准 SQL (relay/handlers.rs)
- Config RwLock → AtomicU32 无锁 rate limit (state.rs, middleware.rs)
- SSE std::sync::Mutex → tokio::sync::Mutex (relay/service.rs)
- /auth/refresh 阻塞清理 → Scheduler 定期执行
Phase 4: 多环境配置
- config/saas-{development,production,test}.toml
- ZCLAW_ENV 环境选择 + ZCLAW_SAAS_CONFIG 精确覆盖
- scheduler 配置集成到 TOML
This commit is contained in:
@@ -2,9 +2,12 @@
|
||||
|
||||
use sqlx::PgPool;
|
||||
use crate::error::SaasResult;
|
||||
use crate::models::{TelemetryModelStatsRow, TelemetryDailyStatsRow};
|
||||
use super::types::*;
|
||||
|
||||
/// 批量写入遥测记录
|
||||
const CHUNK_SIZE: usize = 100;
|
||||
|
||||
/// 批量写入遥测记录(分块多行 INSERT,每 chunk 100 条)
|
||||
pub async fn ingest_telemetry(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
@@ -12,54 +15,73 @@ pub async fn ingest_telemetry(
|
||||
app_version: &str,
|
||||
entries: &[TelemetryEntry],
|
||||
) -> SaasResult<TelemetryReportResponse> {
|
||||
let mut accepted = 0usize;
|
||||
// 预验证所有条目,分离有效/无效
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let mut rejected = 0usize;
|
||||
|
||||
for entry in entries {
|
||||
// 基本验证
|
||||
if entry.input_tokens < 0 || entry.output_tokens < 0 {
|
||||
let valid: Vec<&TelemetryEntry> = entries.iter().filter(|e| {
|
||||
if e.input_tokens < 0 || e.output_tokens < 0 || e.model_id.is_empty() {
|
||||
rejected += 1;
|
||||
continue;
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
if entry.model_id.is_empty() {
|
||||
rejected += 1;
|
||||
continue;
|
||||
}).collect();
|
||||
|
||||
if valid.is_empty() {
|
||||
return Ok(TelemetryReportResponse { accepted: 0, rejected });
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let mut accepted = 0usize;
|
||||
|
||||
let cols = 13;
|
||||
for chunk in valid.chunks(CHUNK_SIZE) {
|
||||
// 预分配所有参数(拥有所有权)
|
||||
let ids: Vec<String> = (0..chunk.len()).map(|_| uuid::Uuid::new_v4().to_string()).collect();
|
||||
|
||||
// 构建 VALUES 占位符
|
||||
let placeholders: Vec<String> = (0..chunk.len())
|
||||
.map(|i| {
|
||||
let base = i * cols + 1;
|
||||
format!("(${},${},${},${},${},${},${},${},${},${},${},${},${})",
|
||||
base, base+1, base+2, base+3, base+4, base+5, base+6,
|
||||
base+7, base+8, base+9, base+10, base+11, base+12)
|
||||
}).collect();
|
||||
let sql = format!(
|
||||
"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 {}",
|
||||
placeholders.join(", ")
|
||||
);
|
||||
|
||||
let mut query = sqlx::query(&sql);
|
||||
for (i, entry) in chunk.iter().enumerate() {
|
||||
query = query
|
||||
.bind(&ids[i])
|
||||
.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);
|
||||
}
|
||||
|
||||
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,
|
||||
match query.execute(&mut *tx).await {
|
||||
Ok(result) => accepted += result.rows_affected() as usize,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to insert telemetry entry: {}", e);
|
||||
rejected += 1;
|
||||
tracing::warn!("Failed to insert telemetry chunk: {}", e);
|
||||
rejected += chunk.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(TelemetryReportResponse { accepted, rejected })
|
||||
}
|
||||
|
||||
@@ -116,7 +138,7 @@ pub async fn get_model_stats(
|
||||
where_sql
|
||||
);
|
||||
|
||||
let mut query_builder = sqlx::query_as::<_, (String, i64, i64, i64, Option<f64>, Option<f64>)>(&sql);
|
||||
let mut query_builder = sqlx::query_as::<_, TelemetryModelStatsRow>(&sql);
|
||||
for p in ¶ms {
|
||||
query_builder = query_builder.bind(p);
|
||||
}
|
||||
@@ -125,14 +147,14 @@ pub async fn get_model_stats(
|
||||
|
||||
let stats: Vec<ModelUsageStat> = rows
|
||||
.into_iter()
|
||||
.map(|(model_id, request_count, input_tokens, output_tokens, avg_latency_ms, success_rate)| {
|
||||
.map(|r| {
|
||||
ModelUsageStat {
|
||||
model_id,
|
||||
request_count,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
avg_latency_ms,
|
||||
success_rate: success_rate.unwrap_or(0.0),
|
||||
model_id: r.model_id,
|
||||
request_count: r.request_count,
|
||||
input_tokens: r.input_tokens,
|
||||
output_tokens: r.output_tokens,
|
||||
avg_latency_ms: r.avg_latency_ms,
|
||||
success_rate: r.success_rate.unwrap_or(0.0),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -140,84 +162,107 @@ pub async fn get_model_stats(
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// 写入审计日志摘要(批量写入 operation_logs)
|
||||
/// 写入审计日志摘要(分块多行 INSERT,每 chunk 100 条)
|
||||
pub async fn ingest_audit_summary(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
device_id: &str,
|
||||
entries: &[AuditSummaryEntry],
|
||||
) -> SaasResult<usize> {
|
||||
// 预过滤空 action
|
||||
let valid: Vec<_> = entries.iter().filter(|e| !e.action.is_empty()).collect();
|
||||
if valid.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let mut written = 0usize;
|
||||
|
||||
for entry in entries {
|
||||
if entry.action.is_empty() {
|
||||
continue;
|
||||
// 每行 6 列参数
|
||||
let cols = 6;
|
||||
for chunk in valid.chunks(CHUNK_SIZE) {
|
||||
let mut sql = String::from(
|
||||
"INSERT INTO operation_logs (account_id, action, target_type, target_id, details, created_at) VALUES "
|
||||
);
|
||||
let placeholders: Vec<String> = (0..chunk.len())
|
||||
.map(|i| {
|
||||
let base = i * cols + 1;
|
||||
format!("(${},${},${},${},${},${})", base, base+1, base+2, base+3, base+4, base+5)
|
||||
}).collect();
|
||||
sql.push_str(&placeholders.join(", "));
|
||||
|
||||
// 预收集 details(拥有所有权),避免借用生命周期问题
|
||||
let details_list: Vec<serde_json::Value> = chunk.iter().map(|entry| {
|
||||
serde_json::json!({
|
||||
"source": "desktop",
|
||||
"device_id": device_id,
|
||||
"result": entry.result,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
let mut query = sqlx::query(&sql);
|
||||
for (i, entry) in chunk.iter().enumerate() {
|
||||
query = query
|
||||
.bind(account_id)
|
||||
.bind(&entry.action)
|
||||
.bind("desktop_audit")
|
||||
.bind(&entry.target)
|
||||
.bind(&details_list[i])
|
||||
.bind(&entry.timestamp);
|
||||
}
|
||||
|
||||
// 审计详情仅包含操作类型和目标,不包含用户内容
|
||||
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,
|
||||
match query.execute(&mut *tx).await {
|
||||
Ok(result) => written += result.rows_affected() as usize,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to insert audit summary entry: {}", e);
|
||||
tracing::warn!("Failed to insert audit summary chunk: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
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 days = query.days.unwrap_or(30).min(90).max(1) as i64;
|
||||
|
||||
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
|
||||
);
|
||||
// Rust 侧计算日期范围,避免 format!() 拼 SQL
|
||||
let from_ts = (chrono::Utc::now() - chrono::Duration::days(days))
|
||||
.date_naive()
|
||||
.and_hms_opt(0, 0, 0).unwrap()
|
||||
.and_utc()
|
||||
.to_rfc3339();
|
||||
|
||||
let rows: Vec<(String, i64, i64, i64, i64)> =
|
||||
sqlx::query_as(&sql).bind(account_id).fetch_all(db).await?;
|
||||
let sql = "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 >= $2
|
||||
GROUP BY SUBSTRING(reported_at, 1, 10)
|
||||
ORDER BY day DESC";
|
||||
|
||||
let rows: Vec<TelemetryDailyStatsRow> =
|
||||
sqlx::query_as(sql).bind(account_id).bind(&from_ts).fetch_all(db).await?;
|
||||
|
||||
let stats: Vec<DailyUsageStat> = rows
|
||||
.into_iter()
|
||||
.map(|(day, request_count, input_tokens, output_tokens, unique_devices)| {
|
||||
.map(|r| {
|
||||
DailyUsageStat {
|
||||
day,
|
||||
request_count,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
unique_devices,
|
||||
day: r.day,
|
||||
request_count: r.request_count,
|
||||
input_tokens: r.input_tokens,
|
||||
output_tokens: r.output_tokens,
|
||||
unique_devices: r.unique_devices,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Reference in New Issue
Block a user