- Twitter like/retweet: return explicit unavailable error instead of sending doomed Bearer token requests (would 403 on Twitter API v2) - DataMasking: pre-compile regex patterns with LazyLock (was compiling 6 patterns on every mask() call) - Prompt version: fix get_version handler ignoring version path param, add service::get_version_by_number for correct per-version retrieval
348 lines
15 KiB
Rust
348 lines
15 KiB
Rust
//! 提示词模板服务层
|
|
|
|
use sqlx::PgPool;
|
|
use crate::error::{SaasError, SaasResult};
|
|
use crate::common::PaginatedResponse;
|
|
use crate::common::normalize_pagination;
|
|
use crate::models::{PromptTemplateRow, PromptVersionRow, PromptSyncStatusRow};
|
|
use super::types::*;
|
|
|
|
/// 创建提示词模板 + 初始版本
|
|
pub async fn create_template(
|
|
db: &PgPool,
|
|
name: &str,
|
|
category: &str,
|
|
description: Option<&str>,
|
|
source: &str,
|
|
system_prompt: &str,
|
|
user_prompt_template: Option<&str>,
|
|
variables: Option<serde_json::Value>,
|
|
min_app_version: Option<&str>,
|
|
) -> SaasResult<PromptTemplateInfo> {
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let version_id = uuid::Uuid::new_v4().to_string();
|
|
let now = chrono::Utc::now();
|
|
let vars_json = variables.unwrap_or(serde_json::json!([])).to_string();
|
|
|
|
// 插入模板
|
|
sqlx::query(
|
|
"INSERT INTO prompt_templates (id, name, category, description, source, current_version, status, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, 1, 'active', $6, $6)"
|
|
)
|
|
.bind(&id).bind(name).bind(category).bind(description).bind(source).bind(&now)
|
|
.execute(db).await.map_err(|e| {
|
|
if e.to_string().contains("unique") {
|
|
SaasError::AlreadyExists(format!("提示词模板 '{}' 已存在", name))
|
|
} else {
|
|
SaasError::Database(e)
|
|
}
|
|
})?;
|
|
|
|
// 插入 v1
|
|
sqlx::query(
|
|
"INSERT INTO prompt_versions (id, template_id, version, system_prompt, user_prompt_template, variables, min_app_version, created_at)
|
|
VALUES ($1, $2, 1, $3, $4, $5, $6, $7)"
|
|
)
|
|
.bind(&version_id).bind(&id).bind(system_prompt).bind(user_prompt_template).bind(&vars_json).bind(min_app_version).bind(&now)
|
|
.execute(db).await?;
|
|
|
|
get_template(db, &id).await
|
|
}
|
|
|
|
/// 获取单个模板
|
|
pub async fn get_template(db: &PgPool, id: &str) -> SaasResult<PromptTemplateInfo> {
|
|
let row: Option<PromptTemplateRow> =
|
|
sqlx::query_as(
|
|
"SELECT id, name, category, description, source, current_version, status, created_at::TEXT, updated_at::TEXT
|
|
FROM prompt_templates WHERE id = $1"
|
|
).bind(id).fetch_optional(db).await?;
|
|
|
|
let r = row.ok_or_else(|| SaasError::NotFound(format!("提示词模板 {} 不存在", id)))?;
|
|
|
|
Ok(PromptTemplateInfo { id: r.id, name: r.name, category: r.category, description: r.description, source: r.source, current_version: r.current_version, status: r.status, created_at: r.created_at, updated_at: r.updated_at })
|
|
}
|
|
|
|
/// 按名称获取模板
|
|
pub async fn get_template_by_name(db: &PgPool, name: &str) -> SaasResult<PromptTemplateInfo> {
|
|
let row: Option<PromptTemplateRow> =
|
|
sqlx::query_as(
|
|
"SELECT id, name, category, description, source, current_version, status, created_at::TEXT, updated_at::TEXT
|
|
FROM prompt_templates WHERE name = $1"
|
|
).bind(name).fetch_optional(db).await?;
|
|
|
|
let r = row.ok_or_else(|| SaasError::NotFound(format!("提示词模板 '{}' 不存在", name)))?;
|
|
|
|
Ok(PromptTemplateInfo { id: r.id, name: r.name, category: r.category, description: r.description, source: r.source, current_version: r.current_version, status: r.status, created_at: r.created_at, updated_at: r.updated_at })
|
|
}
|
|
|
|
/// 列表模板
|
|
/// Static SQL with conditional filter pattern: ($N IS NULL OR col = $N).
|
|
pub async fn list_templates(
|
|
db: &PgPool,
|
|
query: &PromptListQuery,
|
|
) -> SaasResult<PaginatedResponse<PromptTemplateInfo>> {
|
|
let (page, page_size, offset) = normalize_pagination(query.page, query.page_size);
|
|
|
|
let count_sql = "SELECT COUNT(*) FROM prompt_templates WHERE ($1 IS NULL OR category = $1) AND ($2 IS NULL OR source = $2) AND ($3 IS NULL OR status = $3)";
|
|
let data_sql = "SELECT id, name, category, description, source, current_version, status, created_at::TEXT, updated_at::TEXT \
|
|
FROM prompt_templates WHERE ($1 IS NULL OR category = $1) AND ($2 IS NULL OR source = $2) AND ($3 IS NULL OR status = $3) ORDER BY updated_at DESC LIMIT $4 OFFSET $5";
|
|
|
|
let total: i64 = sqlx::query_scalar(count_sql)
|
|
.bind(&query.category)
|
|
.bind(&query.source)
|
|
.bind(&query.status)
|
|
.fetch_one(db).await?;
|
|
|
|
let rows: Vec<PromptTemplateRow> = sqlx::query_as(data_sql)
|
|
.bind(&query.category)
|
|
.bind(&query.source)
|
|
.bind(&query.status)
|
|
.bind(page_size as i64)
|
|
.bind(offset as i64)
|
|
.fetch_all(db).await?;
|
|
|
|
let items: Vec<PromptTemplateInfo> = rows.into_iter().map(|r| {
|
|
PromptTemplateInfo { id: r.id, name: r.name, category: r.category, description: r.description, source: r.source, current_version: r.current_version, status: r.status, created_at: r.created_at, updated_at: r.updated_at }
|
|
}).collect();
|
|
|
|
Ok(PaginatedResponse { items, total, page, page_size })
|
|
}
|
|
|
|
/// 更新模板元数据(不修改内容)
|
|
pub async fn update_template(
|
|
db: &PgPool,
|
|
id: &str,
|
|
description: Option<&str>,
|
|
status: Option<&str>,
|
|
) -> SaasResult<PromptTemplateInfo> {
|
|
let now = chrono::Utc::now();
|
|
|
|
if let Some(desc) = description {
|
|
sqlx::query("UPDATE prompt_templates SET description = $1, updated_at = $2 WHERE id = $3")
|
|
.bind(desc).bind(&now).bind(id).execute(db).await?;
|
|
}
|
|
if let Some(st) = status {
|
|
let valid = ["active", "deprecated", "archived"];
|
|
if !valid.contains(&st) {
|
|
return Err(SaasError::InvalidInput(format!("无效状态: {},允许: {}", st, valid.join(", "))));
|
|
}
|
|
sqlx::query("UPDATE prompt_templates SET status = $1, updated_at = $2 WHERE id = $3")
|
|
.bind(st).bind(&now).bind(id).execute(db).await?;
|
|
}
|
|
|
|
get_template(db, id).await
|
|
}
|
|
|
|
/// 发布新版本
|
|
pub async fn create_version(
|
|
db: &PgPool,
|
|
template_id: &str,
|
|
system_prompt: &str,
|
|
user_prompt_template: Option<&str>,
|
|
variables: Option<serde_json::Value>,
|
|
changelog: Option<&str>,
|
|
min_app_version: Option<&str>,
|
|
) -> SaasResult<PromptVersionInfo> {
|
|
let tmpl = get_template(db, template_id).await?;
|
|
|
|
let new_version = tmpl.current_version + 1;
|
|
let version_id = uuid::Uuid::new_v4().to_string();
|
|
let now = chrono::Utc::now();
|
|
let vars_json = variables.unwrap_or(serde_json::json!([])).to_string();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO prompt_versions (id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
|
|
)
|
|
.bind(&version_id).bind(template_id).bind(new_version)
|
|
.bind(system_prompt).bind(user_prompt_template).bind(&vars_json).bind(changelog).bind(min_app_version).bind(&now)
|
|
.execute(db).await?;
|
|
|
|
// 更新模板的 current_version
|
|
sqlx::query("UPDATE prompt_templates SET current_version = $1, updated_at = $2 WHERE id = $3")
|
|
.bind(new_version).bind(&now).bind(template_id)
|
|
.execute(db).await?;
|
|
|
|
get_version(db, &version_id).await
|
|
}
|
|
|
|
/// 获取特定版本
|
|
pub async fn get_version(db: &PgPool, version_id: &str) -> SaasResult<PromptVersionInfo> {
|
|
let row: Option<PromptVersionRow> =
|
|
sqlx::query_as(
|
|
"SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at::TEXT
|
|
FROM prompt_versions WHERE id = $1"
|
|
).bind(version_id).fetch_optional(db).await?;
|
|
|
|
let r = row.ok_or_else(|| SaasError::NotFound(format!("提示词版本 {} 不存在", version_id)))?;
|
|
|
|
let variables: serde_json::Value = serde_json::from_str(&r.variables).unwrap_or(serde_json::json!([]));
|
|
|
|
Ok(PromptVersionInfo { id: r.id, template_id: r.template_id, version: r.version, system_prompt: r.system_prompt, user_prompt_template: r.user_prompt_template, variables, changelog: r.changelog, min_app_version: r.min_app_version, created_at: r.created_at })
|
|
}
|
|
|
|
/// 获取模板的当前版本内容
|
|
pub async fn get_current_version(db: &PgPool, template_name: &str) -> SaasResult<PromptVersionInfo> {
|
|
let tmpl = get_template_by_name(db, template_name).await?;
|
|
|
|
let row: Option<PromptVersionRow> =
|
|
sqlx::query_as(
|
|
"SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at::TEXT
|
|
FROM prompt_versions WHERE template_id = $1 AND version = $2"
|
|
).bind(&tmpl.id).bind(tmpl.current_version).fetch_optional(db).await?;
|
|
|
|
let r = row.ok_or_else(|| SaasError::NotFound(format!("提示词 '{}' 的版本 {} 不存在", template_name, tmpl.current_version)))?;
|
|
|
|
let variables: serde_json::Value = serde_json::from_str(&r.variables).unwrap_or(serde_json::json!([]));
|
|
|
|
Ok(PromptVersionInfo { id: r.id, template_id: r.template_id, version: r.version, system_prompt: r.system_prompt, user_prompt_template: r.user_prompt_template, variables, changelog: r.changelog, min_app_version: r.min_app_version, created_at: r.created_at })
|
|
}
|
|
|
|
/// 获取模板的指定版本号的内容
|
|
pub async fn get_version_by_number(db: &PgPool, template_name: &str, version_number: i32) -> SaasResult<PromptVersionInfo> {
|
|
let tmpl = get_template_by_name(db, template_name).await?;
|
|
|
|
let row: Option<PromptVersionRow> =
|
|
sqlx::query_as(
|
|
"SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at::TEXT
|
|
FROM prompt_versions WHERE template_id = $1 AND version = $2"
|
|
).bind(&tmpl.id).bind(version_number).fetch_optional(db).await?;
|
|
|
|
let r = row.ok_or_else(|| SaasError::NotFound(format!("提示词 '{}' 的版本 {} 不存在", template_name, version_number)))?;
|
|
|
|
let variables: serde_json::Value = serde_json::from_str(&r.variables).unwrap_or(serde_json::json!([]));
|
|
|
|
Ok(PromptVersionInfo { id: r.id, template_id: r.template_id, version: r.version, system_prompt: r.system_prompt, user_prompt_template: r.user_prompt_template, variables, changelog: r.changelog, min_app_version: r.min_app_version, created_at: r.created_at })
|
|
}
|
|
|
|
/// 列出模板的所有版本
|
|
pub async fn list_versions(
|
|
db: &PgPool,
|
|
template_id: &str,
|
|
) -> SaasResult<Vec<PromptVersionInfo>> {
|
|
let rows: Vec<PromptVersionRow> =
|
|
sqlx::query_as(
|
|
"SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at::TEXT
|
|
FROM prompt_versions WHERE template_id = $1 ORDER BY version DESC"
|
|
).bind(template_id).fetch_all(db).await?;
|
|
|
|
Ok(rows.into_iter().map(|r| {
|
|
let variables = serde_json::from_str(&r.variables).unwrap_or(serde_json::json!([]));
|
|
PromptVersionInfo { id: r.id, template_id: r.template_id, version: r.version, system_prompt: r.system_prompt, user_prompt_template: r.user_prompt_template, variables, changelog: r.changelog, min_app_version: r.min_app_version, created_at: r.created_at }
|
|
}).collect())
|
|
}
|
|
|
|
/// 回退到指定版本
|
|
pub async fn rollback_version(
|
|
db: &PgPool,
|
|
template_id: &str,
|
|
target_version: i32,
|
|
) -> SaasResult<PromptTemplateInfo> {
|
|
// 验证目标版本存在
|
|
let exists: (bool,) = sqlx::query_as(
|
|
"SELECT EXISTS(SELECT 1 FROM prompt_versions WHERE template_id = $1 AND version = $2)"
|
|
).bind(template_id).bind(target_version).fetch_one(db).await?;
|
|
|
|
if !exists.0 {
|
|
return Err(SaasError::NotFound(format!("版本 {} 不存在", target_version)));
|
|
}
|
|
|
|
let now = chrono::Utc::now();
|
|
sqlx::query("UPDATE prompt_templates SET current_version = $1, updated_at = $2 WHERE id = $3")
|
|
.bind(target_version).bind(&now).bind(template_id)
|
|
.execute(db).await?;
|
|
|
|
get_template(db, template_id).await
|
|
}
|
|
|
|
/// OTA 批量检查更新
|
|
pub async fn check_updates(
|
|
db: &PgPool,
|
|
device_id: &str,
|
|
client_versions: &std::collections::HashMap<String, i32>,
|
|
) -> SaasResult<PromptCheckResponse> {
|
|
let mut updates = Vec::new();
|
|
|
|
for (name, client_ver) in client_versions {
|
|
let tmpl = match get_template_by_name(db, name).await {
|
|
Ok(t) if t.status == "active" => t,
|
|
_ => continue,
|
|
};
|
|
|
|
if tmpl.current_version > *client_ver {
|
|
// 获取最新版本内容
|
|
if let Ok(ver) = get_current_version(db, name).await {
|
|
updates.push(PromptUpdatePayload {
|
|
name: tmpl.name.clone(),
|
|
version: ver.version,
|
|
system_prompt: ver.system_prompt,
|
|
user_prompt_template: ver.user_prompt_template,
|
|
variables: ver.variables,
|
|
source: tmpl.source.clone(),
|
|
min_app_version: ver.min_app_version,
|
|
changelog: ver.changelog,
|
|
});
|
|
|
|
// 更新同步状态
|
|
let now = chrono::Utc::now();
|
|
sqlx::query(
|
|
"INSERT INTO prompt_sync_status (device_id, template_id, synced_version, synced_at)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (device_id, template_id) DO UPDATE SET synced_version = $3, synced_at = $4"
|
|
)
|
|
.bind(device_id).bind(&tmpl.id).bind(ver.version).bind(&now)
|
|
.execute(db).await.ok(); // 非关键,忽略错误
|
|
}
|
|
}
|
|
}
|
|
|
|
// 首次连接:返回所有 active 的 builtin 模板
|
|
if client_versions.is_empty() {
|
|
let all_templates = list_templates(db, &PromptListQuery {
|
|
source: Some("builtin".into()),
|
|
status: Some("active".into()),
|
|
category: None,
|
|
page: None,
|
|
page_size: Some(100),
|
|
}).await?;
|
|
|
|
for tmpl in &all_templates.items {
|
|
if let Ok(ver) = get_current_version(db, &tmpl.name).await {
|
|
updates.push(PromptUpdatePayload {
|
|
name: tmpl.name.clone(),
|
|
version: ver.version,
|
|
system_prompt: ver.system_prompt,
|
|
user_prompt_template: ver.user_prompt_template,
|
|
variables: ver.variables,
|
|
source: tmpl.source.clone(),
|
|
min_app_version: ver.min_app_version,
|
|
changelog: ver.changelog,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(PromptCheckResponse {
|
|
updates,
|
|
server_time: chrono::Utc::now().to_rfc3339(),
|
|
})
|
|
}
|
|
|
|
/// 查询设备的提示词同步状态
|
|
pub async fn get_sync_status(
|
|
db: &PgPool,
|
|
device_id: &str,
|
|
) -> SaasResult<Vec<PromptSyncStatusRow>> {
|
|
let rows = sqlx::query_as::<_, PromptSyncStatusRow>(
|
|
"SELECT device_id, template_id, synced_version, synced_at::TEXT \
|
|
FROM prompt_sync_status \
|
|
WHERE device_id = $1 \
|
|
ORDER BY synced_at DESC \
|
|
LIMIT 50"
|
|
)
|
|
.bind(device_id)
|
|
.fetch_all(db)
|
|
.await?;
|
|
Ok(rows)
|
|
}
|