chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
104
crates/zclaw-saas/src/agent_template/handlers.rs
Normal file
104
crates/zclaw-saas/src/agent_template/handlers.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
//! Agent 配置模板 HTTP 处理器
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::error::SaasResult;
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::{log_operation, check_permission};
|
||||
use super::types::*;
|
||||
use super::service;
|
||||
|
||||
/// GET /api/v1/agent-templates — 列出 Agent 模板
|
||||
pub async fn list_templates(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Query(query): Query<AgentTemplateListQuery>,
|
||||
) -> SaasResult<Json<crate::common::PaginatedResponse<AgentTemplateInfo>>> {
|
||||
check_permission(&ctx, "model:read")?;
|
||||
Ok(Json(service::list_templates(&state.db, &query).await?))
|
||||
}
|
||||
|
||||
/// POST /api/v1/agent-templates — 创建 Agent 模板
|
||||
pub async fn create_template(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<CreateAgentTemplateRequest>,
|
||||
) -> SaasResult<Json<AgentTemplateInfo>> {
|
||||
check_permission(&ctx, "model:manage")?;
|
||||
|
||||
let category = req.category.as_deref().unwrap_or("general");
|
||||
let source = req.source.as_deref().unwrap_or("custom");
|
||||
let visibility = req.visibility.as_deref().unwrap_or("public");
|
||||
let tools = req.tools.as_deref().unwrap_or(&[]);
|
||||
let capabilities = req.capabilities.as_deref().unwrap_or(&[]);
|
||||
|
||||
let result = service::create_template(
|
||||
&state.db, &req.name, req.description.as_deref(),
|
||||
category, source, req.model.as_deref(),
|
||||
req.system_prompt.as_deref(),
|
||||
tools, capabilities,
|
||||
req.temperature, req.max_tokens, visibility,
|
||||
).await?;
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "agent_template.create", "agent_template", &result.id,
|
||||
Some(serde_json::json!({"name": req.name})), ctx.client_ip.as_deref()).await?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// GET /api/v1/agent-templates/:id — 获取单个 Agent 模板
|
||||
pub async fn get_template(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(id): Path<String>,
|
||||
) -> SaasResult<Json<AgentTemplateInfo>> {
|
||||
check_permission(&ctx, "model:read")?;
|
||||
Ok(Json(service::get_template(&state.db, &id).await?))
|
||||
}
|
||||
|
||||
/// POST /api/v1/agent-templates/:id — 更新 Agent 模板
|
||||
pub async fn update_template(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateAgentTemplateRequest>,
|
||||
) -> SaasResult<Json<AgentTemplateInfo>> {
|
||||
check_permission(&ctx, "model:manage")?;
|
||||
|
||||
let result = service::update_template(
|
||||
&state.db, &id,
|
||||
req.description.as_deref(),
|
||||
req.model.as_deref(),
|
||||
req.system_prompt.as_deref(),
|
||||
req.tools.as_deref(),
|
||||
req.capabilities.as_deref(),
|
||||
req.temperature,
|
||||
req.max_tokens,
|
||||
req.visibility.as_deref(),
|
||||
req.status.as_deref(),
|
||||
).await?;
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "agent_template.update", "agent_template", &id,
|
||||
None, ctx.client_ip.as_deref()).await?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/agent-templates/:id — 归档 Agent 模板
|
||||
pub async fn archive_template(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(id): Path<String>,
|
||||
) -> SaasResult<Json<AgentTemplateInfo>> {
|
||||
check_permission(&ctx, "model:manage")?;
|
||||
|
||||
let result = service::archive_template(&state.db, &id).await?;
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "agent_template.archive", "agent_template", &id,
|
||||
None, ctx.client_ip.as_deref()).await?;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
17
crates/zclaw-saas/src/agent_template/mod.rs
Normal file
17
crates/zclaw-saas/src/agent_template/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
//! Agent 配置模板管理模块
|
||||
|
||||
pub mod types;
|
||||
pub mod service;
|
||||
pub mod handlers;
|
||||
|
||||
use axum::routing::{delete, get, post};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Agent 模板管理路由 (需要认证)
|
||||
pub fn routes() -> axum::Router<AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/v1/agent-templates", get(handlers::list_templates).post(handlers::create_template))
|
||||
.route("/api/v1/agent-templates/:id", get(handlers::get_template))
|
||||
.route("/api/v1/agent-templates/:id", post(handlers::update_template))
|
||||
.route("/api/v1/agent-templates/:id", delete(handlers::archive_template))
|
||||
}
|
||||
272
crates/zclaw-saas/src/agent_template/service.rs
Normal file
272
crates/zclaw-saas/src/agent_template/service.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
//! Agent 配置模板业务逻辑
|
||||
|
||||
use sqlx::PgPool;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use super::types::*;
|
||||
|
||||
fn row_to_template(
|
||||
row: (String, String, Option<String>, String, String, Option<String>, Option<String>,
|
||||
String, String, Option<f64>, Option<i32>, String, String, i32, String, String),
|
||||
) -> AgentTemplateInfo {
|
||||
AgentTemplateInfo {
|
||||
id: row.0, name: row.1, description: row.2, category: row.3, source: row.4,
|
||||
model: row.5, system_prompt: row.6, tools: serde_json::from_str(&row.7).unwrap_or_default(),
|
||||
capabilities: serde_json::from_str(&row.8).unwrap_or_default(),
|
||||
temperature: row.9, max_tokens: row.10, visibility: row.11, status: row.12,
|
||||
current_version: row.13, created_at: row.14, updated_at: row.15,
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 Agent 模板
|
||||
pub async fn create_template(
|
||||
db: &PgPool,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
category: &str,
|
||||
source: &str,
|
||||
model: Option<&str>,
|
||||
system_prompt: Option<&str>,
|
||||
tools: &[String],
|
||||
capabilities: &[String],
|
||||
temperature: Option<f64>,
|
||||
max_tokens: Option<i32>,
|
||||
visibility: &str,
|
||||
) -> SaasResult<AgentTemplateInfo> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let tools_json = serde_json::to_string(tools).unwrap_or_else(|_| "[]".to_string());
|
||||
let caps_json = serde_json::to_string(capabilities).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO agent_templates (id, name, description, category, source, model, system_prompt,
|
||||
tools, capabilities, temperature, max_tokens, visibility, status, current_version, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'active', 1, $13, $13)"
|
||||
)
|
||||
.bind(&id).bind(name).bind(description).bind(category).bind(source)
|
||||
.bind(model).bind(system_prompt).bind(&tools_json).bind(&caps_json)
|
||||
.bind(temperature).bind(max_tokens).bind(visibility).bind(&now)
|
||||
.execute(db).await.map_err(|e| {
|
||||
if e.to_string().contains("unique") {
|
||||
SaasError::AlreadyExists(format!("Agent 模板 '{}' 已存在", name))
|
||||
} else {
|
||||
SaasError::Database(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
get_template(db, &id).await
|
||||
}
|
||||
|
||||
/// 获取单个模板
|
||||
pub async fn get_template(db: &PgPool, id: &str) -> SaasResult<AgentTemplateInfo> {
|
||||
let row: Option<_> = sqlx::query_as(
|
||||
"SELECT id, name, description, category, source, model, system_prompt,
|
||||
tools, capabilities, temperature, max_tokens, visibility, status,
|
||||
current_version, created_at, updated_at
|
||||
FROM agent_templates WHERE id = $1"
|
||||
).bind(id).fetch_optional(db).await?;
|
||||
|
||||
row.map(row_to_template)
|
||||
.ok_or_else(|| SaasError::NotFound(format!("Agent 模板 {} 不存在", id)))
|
||||
}
|
||||
|
||||
/// 列出模板(分页 + 过滤)
|
||||
/// 使用动态参数化查询,安全拼接 WHERE 条件。
|
||||
pub async fn list_templates(
|
||||
db: &PgPool,
|
||||
query: &AgentTemplateListQuery,
|
||||
) -> SaasResult<crate::common::PaginatedResponse<AgentTemplateInfo>> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let page_size = query.page_size.unwrap_or(20).min(100);
|
||||
let offset = ((page - 1) * page_size) as i64;
|
||||
|
||||
// 动态构建参数化 WHERE 子句
|
||||
let mut conditions: Vec<String> = vec!["1=1".to_string()];
|
||||
let mut param_idx = 1u32;
|
||||
let mut cat_bind: Option<String> = None;
|
||||
let mut src_bind: Option<String> = None;
|
||||
let mut vis_bind: Option<String> = None;
|
||||
let mut st_bind: Option<String> = None;
|
||||
|
||||
if let Some(ref cat) = query.category {
|
||||
param_idx += 1;
|
||||
conditions.push(format!("category = ${}", param_idx));
|
||||
cat_bind = Some(cat.clone());
|
||||
}
|
||||
if let Some(ref src) = query.source {
|
||||
param_idx += 1;
|
||||
conditions.push(format!("source = ${}", param_idx));
|
||||
src_bind = Some(src.clone());
|
||||
}
|
||||
if let Some(ref vis) = query.visibility {
|
||||
param_idx += 1;
|
||||
conditions.push(format!("visibility = ${}", param_idx));
|
||||
vis_bind = Some(vis.clone());
|
||||
}
|
||||
if let Some(ref st) = query.status {
|
||||
param_idx += 1;
|
||||
conditions.push(format!("status = ${}", param_idx));
|
||||
st_bind = Some(st.clone());
|
||||
}
|
||||
|
||||
let where_clause = conditions.join(" AND ");
|
||||
|
||||
// COUNT 查询: WHERE 参数绑定 ($1..$N)
|
||||
let count_idx = param_idx;
|
||||
let count_sql = format!(
|
||||
"SELECT COUNT(*) FROM agent_templates WHERE {}",
|
||||
where_clause
|
||||
);
|
||||
let count_limit_idx = count_idx + 1;
|
||||
let count_offset_idx = count_limit_idx + 1;
|
||||
let data_sql = format!(
|
||||
"SELECT id, name, description, category, source, model, system_prompt,
|
||||
tools, capabilities, temperature, max_tokens, visibility, status,
|
||||
current_version, created_at, updated_at
|
||||
FROM agent_templates WHERE {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
|
||||
where_clause, count_limit_idx, count_offset_idx
|
||||
);
|
||||
|
||||
// 构建 COUNT 查询并绑定参数
|
||||
let mut count_q = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||
if let Some(ref v) = cat_bind { count_q = count_q.bind(v); }
|
||||
if let Some(ref v) = src_bind { count_q = count_q.bind(v); }
|
||||
if let Some(ref v) = vis_bind { count_q = count_q.bind(v); }
|
||||
if let Some(ref v) = st_bind { count_q = count_q.bind(v); }
|
||||
let total: i64 = count_q.fetch_one(db).await?;
|
||||
|
||||
// 构建数据查询并绑定参数
|
||||
let mut data_q = sqlx::query_as::<_, (
|
||||
String, String, Option<String>, String, String, Option<String>, Option<String>,
|
||||
String, String, Option<f64>, Option<i32>, String, String, i32, String, String
|
||||
)>(&data_sql);
|
||||
if let Some(ref v) = cat_bind { data_q = data_q.bind(v); }
|
||||
if let Some(ref v) = src_bind { data_q = data_q.bind(v); }
|
||||
if let Some(ref v) = vis_bind { data_q = data_q.bind(v); }
|
||||
if let Some(ref v) = st_bind { data_q = data_q.bind(v); }
|
||||
data_q = data_q.bind(page_size as i64).bind(offset);
|
||||
|
||||
let rows = data_q.fetch_all(db).await?;
|
||||
let items = rows.into_iter().map(row_to_template).collect();
|
||||
|
||||
Ok(crate::common::PaginatedResponse { items, total, page, page_size })
|
||||
}
|
||||
|
||||
/// 更新模板
|
||||
/// 使用动态参数化查询,安全拼接 SET 子句。
|
||||
pub async fn update_template(
|
||||
db: &PgPool,
|
||||
id: &str,
|
||||
description: Option<&str>,
|
||||
model: Option<&str>,
|
||||
system_prompt: Option<&str>,
|
||||
tools: Option<&[String]>,
|
||||
capabilities: Option<&[String]>,
|
||||
temperature: Option<f64>,
|
||||
max_tokens: Option<i32>,
|
||||
visibility: Option<&str>,
|
||||
status: Option<&str>,
|
||||
) -> SaasResult<AgentTemplateInfo> {
|
||||
// 确认存在
|
||||
get_template(db, id).await?;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let mut set_clauses: Vec<String> = vec![];
|
||||
let mut param_idx = 1u32;
|
||||
|
||||
// 收集需要绑定的值(按顺序)
|
||||
let mut desc_val: Option<String> = None;
|
||||
let mut model_val: Option<String> = None;
|
||||
let mut sp_val: Option<String> = None;
|
||||
let mut tools_val: Option<String> = None;
|
||||
let mut caps_val: Option<String> = None;
|
||||
let mut temp_val: Option<f64> = None;
|
||||
let mut mt_val: Option<i32> = None;
|
||||
let mut vis_val: Option<String> = None;
|
||||
let mut st_val: Option<String> = None;
|
||||
|
||||
if let Some(desc) = description {
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("description = ${}", param_idx));
|
||||
desc_val = Some(desc.to_string());
|
||||
}
|
||||
if let Some(m) = model {
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("model = ${}", param_idx));
|
||||
model_val = Some(m.to_string());
|
||||
}
|
||||
if let Some(sp) = system_prompt {
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("system_prompt = ${}", param_idx));
|
||||
sp_val = Some(sp.to_string());
|
||||
}
|
||||
if let Some(t) = tools {
|
||||
let json = serde_json::to_string(t).unwrap_or_else(|_| "[]".to_string());
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("tools = ${}", param_idx));
|
||||
tools_val = Some(json);
|
||||
}
|
||||
if let Some(c) = capabilities {
|
||||
let json = serde_json::to_string(c).unwrap_or_else(|_| "[]".to_string());
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("capabilities = ${}", param_idx));
|
||||
caps_val = Some(json);
|
||||
}
|
||||
if let Some(t) = temperature {
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("temperature = ${}", param_idx));
|
||||
temp_val = Some(t);
|
||||
}
|
||||
if let Some(m) = max_tokens {
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("max_tokens = ${}", param_idx));
|
||||
mt_val = Some(m);
|
||||
}
|
||||
if let Some(v) = visibility {
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("visibility = ${}", param_idx));
|
||||
vis_val = Some(v.to_string());
|
||||
}
|
||||
if let Some(s) = status {
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("status = ${}", param_idx));
|
||||
st_val = Some(s.to_string());
|
||||
}
|
||||
|
||||
if set_clauses.is_empty() {
|
||||
return get_template(db, id).await;
|
||||
}
|
||||
|
||||
// updated_at
|
||||
param_idx += 1;
|
||||
set_clauses.push(format!("updated_at = ${}", param_idx));
|
||||
|
||||
// WHERE id = $N
|
||||
let id_idx = param_idx + 1;
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE agent_templates SET {} WHERE id = ${}",
|
||||
set_clauses.join(", "), id_idx
|
||||
);
|
||||
|
||||
let mut q = sqlx::query(&sql);
|
||||
if let Some(ref v) = desc_val { q = q.bind(v); }
|
||||
if let Some(ref v) = model_val { q = q.bind(v); }
|
||||
if let Some(ref v) = sp_val { q = q.bind(v); }
|
||||
if let Some(ref v) = tools_val { q = q.bind(v); }
|
||||
if let Some(ref v) = caps_val { q = q.bind(v); }
|
||||
if let Some(v) = temp_val { q = q.bind(v); }
|
||||
if let Some(v) = mt_val { q = q.bind(v); }
|
||||
if let Some(ref v) = vis_val { q = q.bind(v); }
|
||||
if let Some(ref v) = st_val { q = q.bind(v); }
|
||||
q = q.bind(&now);
|
||||
q = q.bind(id);
|
||||
|
||||
q.execute(db).await?;
|
||||
|
||||
get_template(db, id).await
|
||||
}
|
||||
|
||||
/// 归档模板
|
||||
pub async fn archive_template(db: &PgPool, id: &str) -> SaasResult<AgentTemplateInfo> {
|
||||
update_template(db, id, None, None, None, None, None, None, None, None, Some("archived")).await
|
||||
}
|
||||
65
crates/zclaw-saas/src/agent_template/types.rs
Normal file
65
crates/zclaw-saas/src/agent_template/types.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! Agent 配置模板类型定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// --- Agent Template ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentTemplateInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub category: String,
|
||||
pub source: String,
|
||||
pub model: Option<String>,
|
||||
pub system_prompt: Option<String>,
|
||||
pub tools: Vec<String>,
|
||||
pub capabilities: Vec<String>,
|
||||
pub temperature: Option<f64>,
|
||||
pub max_tokens: Option<i32>,
|
||||
pub visibility: String,
|
||||
pub status: String,
|
||||
pub current_version: i32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAgentTemplateRequest {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub system_prompt: Option<String>,
|
||||
pub tools: Option<Vec<String>>,
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
pub temperature: Option<f64>,
|
||||
pub max_tokens: Option<i32>,
|
||||
pub visibility: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateAgentTemplateRequest {
|
||||
pub description: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub system_prompt: Option<String>,
|
||||
pub tools: Option<Vec<String>>,
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
pub temperature: Option<f64>,
|
||||
pub max_tokens: Option<i32>,
|
||||
pub visibility: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AgentTemplateListQuery {
|
||||
pub category: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u32>,
|
||||
}
|
||||
Reference in New Issue
Block a user