Compare commits

...

4 Commits

Author SHA1 Message Date
iven
876308596a feat(ai): 补全 Prompt CRUD + 分析历史 + 用量统计 handler 和路由
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 替换 list_analysis/get_analysis 空壳为真实查询
- 新增 list_prompts/create_prompt/activate_prompt/rollback_prompt
- 新增 usage_overview/usage_by_type
- 注册 6 个新路由到 AiModule
2026-04-25 23:21:11 +08:00
iven
48d1a84c77 feat(ai): UsageService 补全 get_overview/get_by_type 聚合方法 2026-04-25 22:53:56 +08:00
iven
2a8c707f6d feat(ai): AnalysisService 补全 list/get 查询方法 2026-04-25 22:52:50 +08:00
iven
b2b64ec15d feat(ai): PromptService 补全 list/update/activate/rollback 方法 2026-04-25 22:51:47 +08:00
5 changed files with 397 additions and 12 deletions

View File

@@ -270,29 +270,180 @@ pub struct ListAnalysisQuery {
}
pub async fn list_analysis<S>(
State(_state): State<AiState>,
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Query(_params): Query<ListAnalysisQuery>,
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
Query(params): Query<ListAnalysisQuery>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.analysis.list")?;
Ok(Json(ApiResponse::ok(())))
let pagination = erp_core::types::Pagination {
page: params.page,
page_size: params.page_size,
};
let (items, total) = state
.analysis
.list_analysis(ctx.tenant_id, params.patient_id, params.analysis_type, &pagination)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items,
"total": total,
"page": pagination.page.unwrap_or(1),
"page_size": pagination.limit(),
}))))
}
pub async fn get_analysis<S>(
State(_state): State<AiState>,
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(_id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<crate::entity::ai_analysis::Model>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.analysis.list")?;
Ok(Json(ApiResponse::ok(())))
let analysis = state.analysis.get_analysis(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(analysis)))
}
// === Prompt 管理 ===
#[derive(Debug, Deserialize)]
pub struct ListPromptsQuery {
pub category: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
pub async fn list_prompts<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<ListPromptsQuery>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.list")?;
let pagination = erp_core::types::Pagination {
page: params.page,
page_size: params.page_size,
};
let (items, total) = state
.prompt
.list_prompts(ctx.tenant_id, params.category, &pagination)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items,
"total": total,
"page": pagination.page.unwrap_or(1),
"page_size": pagination.limit(),
}))))
}
#[derive(Debug, Deserialize)]
pub struct CreatePromptBody {
pub name: String,
pub description: Option<String>,
pub system_prompt: String,
pub user_prompt_template: String,
pub model_config: serde_json::Value,
pub category: String,
}
pub async fn create_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Json(body): Json<CreatePromptBody>,
) -> Result<Json<ApiResponse<crate::entity::ai_prompt::Model>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
let prompt = state
.prompt
.create_prompt(
ctx.tenant_id,
ctx.user_id,
body.name,
body.system_prompt,
body.user_prompt_template,
body.model_config,
body.category,
)
.await?;
Ok(Json(ApiResponse::ok(prompt)))
}
pub async fn activate_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<crate::entity::ai_prompt::Model>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
let prompt = state.prompt.activate_prompt(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(prompt)))
}
pub async fn rollback_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<crate::entity::ai_prompt::Model>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
let prompt = state.prompt.rollback_prompt(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(prompt)))
}
// === 用量统计 ===
pub async fn usage_overview<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.usage.list")?;
let overview = state.usage.get_overview(ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"total_count": overview.total_count,
}))))
}
pub async fn usage_by_type<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<serde_json::Value>>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.usage.list")?;
let types = state.usage.get_by_type(ctx.tenant_id).await?;
let result: Vec<serde_json::Value> = types
.into_iter()
.map(|t| {
serde_json::json!({
"analysis_type": t.analysis_type,
"count": t.count,
})
})
.collect();
Ok(Json(ApiResponse::ok(result)))
}
// === SSE 流构建辅助 ===

View File

@@ -104,5 +104,29 @@ impl AiModule {
"/ai/analysis/{id}",
axum::routing::get(crate::handler::get_analysis),
)
.route(
"/ai/prompts",
axum::routing::get(crate::handler::list_prompts),
)
.route(
"/ai/prompts",
axum::routing::post(crate::handler::create_prompt),
)
.route(
"/ai/prompts/{id}/activate",
axum::routing::post(crate::handler::activate_prompt),
)
.route(
"/ai/prompts/{id}/rollback",
axum::routing::post(crate::handler::rollback_prompt),
)
.route(
"/ai/usage/overview",
axum::routing::get(crate::handler::usage_overview),
)
.route(
"/ai/usage/by-type",
axum::routing::get(crate::handler::usage_by_type),
)
}
}

View File

@@ -1,8 +1,9 @@
use futures::Stream;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set};
use sha2::{Digest, Sha256};
use std::pin::Pin;
use uuid::Uuid;
use erp_core::types::Pagination;
use crate::dto::{AnalysisType, GenerateRequest};
use crate::entity::ai_analysis;
@@ -142,6 +143,51 @@ impl AnalysisService {
hex::encode(hasher.finalize())
}
/// 分页查询分析记录
pub async fn list_analysis(
&self,
tenant_id: Uuid,
patient_id: Option<Uuid>,
analysis_type: Option<String>,
pagination: &Pagination,
) -> AiResult<(Vec<ai_analysis::Model>, u64)> {
let mut query = ai_analysis::Entity::find()
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
.filter(ai_analysis::Column::DeletedAt.is_null());
if let Some(pid) = patient_id {
query = query.filter(ai_analysis::Column::PatientId.eq(pid));
}
if let Some(at) = &analysis_type {
query = query.filter(ai_analysis::Column::AnalysisType.eq(at.as_str()));
}
let total = query.clone().count(&self.db).await?;
let items = query
.order_by_desc(ai_analysis::Column::CreatedAt)
.offset(pagination.offset())
.limit(pagination.limit())
.all(&self.db)
.await?;
Ok((items, total))
}
/// 获取单条分析记录
pub async fn get_analysis(
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_analysis::Model> {
let model = ai_analysis::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::AnalysisNotFound(id.to_string()))?;
if model.tenant_id != tenant_id {
return Err(AiError::AnalysisNotFound(id.to_string()));
}
Ok(model)
}
async fn create_analysis_record(
&self,
id: Uuid,

View File

@@ -1,8 +1,12 @@
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use sea_orm::ActiveModelTrait;
use sea_orm::QuerySelect;
use sea_orm::Set;
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
use uuid::Uuid;
use crate::entity::ai_prompt;
use crate::error::{AiError, AiResult};
use erp_core::types::Pagination;
pub struct PromptService {
pub db: sea_orm::DatabaseConnection,
@@ -64,4 +68,122 @@ impl PromptService {
};
Ok(active.insert(&self.db).await?)
}
/// 分页列出 Prompt 模板
pub async fn list_prompts(
&self,
tenant_id: Uuid,
category: Option<String>,
pagination: &Pagination,
) -> AiResult<(Vec<ai_prompt::Model>, u64)> {
let mut query = ai_prompt::Entity::find()
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::DeletedAt.is_null());
if let Some(cat) = &category {
query = query.filter(ai_prompt::Column::Category.eq(cat.as_str()));
}
let total = query.clone().count(&self.db).await?;
let items = query
.order_by_desc(ai_prompt::Column::UpdatedAt)
.offset(pagination.offset())
.limit(pagination.limit())
.all(&self.db)
.await?;
Ok((items, total))
}
/// 更新 Prompt创建新版本
pub async fn update_prompt(
&self,
id: Uuid,
tenant_id: Uuid,
user_id: Uuid,
system_prompt: Option<String>,
user_prompt_template: Option<String>,
model_config: Option<serde_json::Value>,
description: Option<String>,
) -> AiResult<ai_prompt::Model> {
let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
if entity.tenant_id != tenant_id {
return Err(AiError::Validation("跨租户操作".into()));
}
let new_id = Uuid::now_v7();
let now = chrono::Utc::now();
let active = ai_prompt::ActiveModel {
id: Set(new_id),
tenant_id: Set(tenant_id),
name: Set(entity.name.clone()),
description: Set(description.unwrap_or(entity.description.clone())),
system_prompt: Set(system_prompt.unwrap_or(entity.system_prompt.clone())),
user_prompt_template: Set(user_prompt_template.unwrap_or(entity.user_prompt_template.clone())),
variables_schema: Set(entity.variables_schema.clone()),
model_config: Set(model_config.unwrap_or(entity.model_config.clone())),
version: Set(entity.version + 1),
is_active: Set(entity.is_active),
category: Set(entity.category.clone()),
tags: Set(entity.tags.clone()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(Some(user_id)),
updated_by: Set(Some(user_id)),
deleted_at: Set(None),
version_lock: Set(1),
};
Ok(active.insert(&self.db).await?)
}
/// 激活指定 Prompt停用同 name+category 的其他版本)
pub async fn activate_prompt(
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_prompt::Model> {
let entity = ai_prompt::Entity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| AiError::PromptNotFound(id.to_string()))?;
if entity.tenant_id != tenant_id {
return Err(AiError::Validation("跨租户操作".into()));
}
// 停用同 name + category 的其他激活版本
let siblings = ai_prompt::Entity::find()
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::Name.eq(&entity.name))
.filter(ai_prompt::Column::Category.eq(&entity.category))
.filter(ai_prompt::Column::IsActive.eq(true))
.filter(ai_prompt::Column::DeletedAt.is_null())
.all(&self.db)
.await?;
for sibling in siblings {
let mut active: ai_prompt::ActiveModel = sibling.into();
active.is_active = Set(false);
active.updated_at = Set(chrono::Utc::now());
active.update(&self.db).await?;
}
// 激活目标
let mut active: ai_prompt::ActiveModel = entity.into();
active.is_active = Set(true);
active.updated_at = Set(chrono::Utc::now());
Ok(active.update(&self.db).await?)
}
/// 回滚(= 激活指定旧版本)
pub async fn rollback_prompt(
&self,
id: Uuid,
tenant_id: Uuid,
) -> AiResult<ai_prompt::Model> {
self.activate_prompt(id, tenant_id).await
}
}

View File

@@ -1,7 +1,7 @@
use sea_orm::ActiveModelTrait;
use sea_orm::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, QueryFilter, QuerySelect, Set};
use uuid::Uuid;
use crate::entity::ai_analysis;
use crate::entity::ai_usage;
use crate::error::AiResult;
@@ -42,4 +42,46 @@ impl UsageService {
};
Ok(active.insert(&self.db).await?)
}
/// 用量概览
pub async fn get_overview(&self, tenant_id: Uuid) -> AiResult<UsageOverview> {
let result = ai_analysis::Entity::find()
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
.filter(ai_analysis::Column::Status.eq("completed"))
.filter(ai_analysis::Column::DeletedAt.is_null())
.select_only()
.column_as(ai_analysis::Column::Id.count(), "total_count")
.into_model::<UsageOverview>()
.one(&self.db)
.await?
.unwrap_or(UsageOverview { total_count: 0 });
Ok(result)
}
/// 按分析类型统计
pub async fn get_by_type(&self, tenant_id: Uuid) -> AiResult<Vec<TypeCount>> {
let result = ai_analysis::Entity::find()
.filter(ai_analysis::Column::TenantId.eq(tenant_id))
.filter(ai_analysis::Column::Status.eq("completed"))
.filter(ai_analysis::Column::DeletedAt.is_null())
.select_only()
.column(ai_analysis::Column::AnalysisType)
.column_as(ai_analysis::Column::Id.count(), "count")
.group_by(ai_analysis::Column::AnalysisType)
.into_model::<TypeCount>()
.all(&self.db)
.await?;
Ok(result)
}
}
#[derive(Debug, FromQueryResult)]
pub struct UsageOverview {
pub total_count: i64,
}
#[derive(Debug, FromQueryResult)]
pub struct TypeCount {
pub analysis_type: String,
pub count: i64,
}