feat(ai): Prompt 管理 Phase 2 — analysis_type 后端选择键 + 筛选修复

- 新增 ai_prompt.analysis_type 列作为后端按链路选择 Prompt 的唯一键
- name 回归显示标识符用途,不再承担选择键角色
- 迁移 000164: 新增 analysis_type 列 + 从 name 回填 + 索引
- 迁移 000165: 修复旧数据从 category 错误回填的问题
- AiPromptList 页面重构: 分析类型/调用链路列、详情抽屉、新建表单
- DrawerForm 组件新增 onValuesChange 回调支持跨字段联动
- 新建表单选择分析类型后自动填充标识符
- 筛选过滤器改为按 analysis_type 而非 category 过滤(后端+前端同步)
- 停用/激活/回滚/删除操作完整可用
This commit is contained in:
iven
2026-05-26 17:04:26 +08:00
parent 3972db4f98
commit 3c7b48b6f6
10 changed files with 549 additions and 165 deletions

View File

@@ -16,6 +16,8 @@ pub struct Model {
pub version: i32,
pub is_active: bool,
pub category: String,
/// 后端选择键:与 AnalysisType::prompt_name() 对应handler 按此字段查找激活 Prompt
pub analysis_type: String,
pub tags: Option<serde_json::Value>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,

View File

@@ -95,7 +95,7 @@ where
let prompt = state
.prompt
.get_active_prompt(ctx.tenant_id, "lab_report_interpretation")
.get_active_prompt(ctx.tenant_id, AnalysisType::LabReport.prompt_name())
.await?;
let model_config = &prompt.model_config;
@@ -190,7 +190,7 @@ where
let prompt = state
.prompt
.get_active_prompt(ctx.tenant_id, "health_trend_analysis")
.get_active_prompt(ctx.tenant_id, AnalysisType::Trends.prompt_name())
.await?;
let model_config = &prompt.model_config;
@@ -262,7 +262,7 @@ where
let prompt = state
.prompt
.get_active_prompt(ctx.tenant_id, "personalized_checkup_plan")
.get_active_prompt(ctx.tenant_id, AnalysisType::CheckupPlan.prompt_name())
.await?;
let model_config = &prompt.model_config;
@@ -341,7 +341,7 @@ where
let prompt = state
.prompt
.get_active_prompt(ctx.tenant_id, "report_summary_generation")
.get_active_prompt(ctx.tenant_id, AnalysisType::ReportSummary.prompt_name())
.await?;
let model_config = &prompt.model_config;
@@ -417,7 +417,7 @@ where
let prompt = state
.prompt
.get_active_prompt(ctx.tenant_id, "follow_up_summary_generation")
.get_active_prompt(ctx.tenant_id, AnalysisType::FollowUpSummary.prompt_name())
.await?;
let model_config = &prompt.model_config;
@@ -577,6 +577,7 @@ where
#[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct ListPromptsQuery {
pub category: Option<String>,
pub analysis_type: Option<String>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
@@ -605,7 +606,11 @@ where
};
let (items, total) = state
.prompt
.list_prompts(ctx.tenant_id, params.category, &pagination)
.list_prompts(
ctx.tenant_id,
params.analysis_type.or(params.category),
&pagination,
)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"data": items,
@@ -623,6 +628,7 @@ pub struct CreatePromptBody {
pub user_prompt_template: String,
pub model_config: serde_json::Value,
pub category: String,
pub analysis_type: String,
}
#[utoipa::path(
@@ -655,6 +661,7 @@ where
body.user_prompt_template,
body.model_config,
body.category,
body.analysis_type,
)
.await?;
Ok(Json(ApiResponse::ok(prompt)))
@@ -702,6 +709,48 @@ where
Ok(Json(ApiResponse::ok(prompt)))
}
#[utoipa::path(
post,
path = "/ai/prompts/{id}/deactivate",
responses((status = 200, description = "停用 Prompt 模板")),
tag = "AI Prompt",
security(("bearer_auth" = [])),
)]
pub async fn deactivate_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.deactivate_prompt(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(prompt)))
}
#[utoipa::path(
delete,
path = "/ai/prompts/{id}",
responses((status = 200, description = "删除 Prompt 模板")),
tag = "AI Prompt",
security(("bearer_auth" = [])),
)]
pub async fn delete_prompt<S>(
State(state): State<AiState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<()>>, erp_core::error::AppError>
where
AiState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "ai.prompt.manage")?;
state.prompt.delete_prompt(id, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(())))
}
// === 用量统计 ===
#[utoipa::path(

View File

@@ -511,6 +511,14 @@ impl AiModule {
"/ai/prompts/{id}/rollback",
axum::routing::post(crate::handler::rollback_prompt),
)
.route(
"/ai/prompts/{id}/deactivate",
axum::routing::post(crate::handler::deactivate_prompt),
)
.route(
"/ai/prompts/{id}",
axum::routing::delete(crate::handler::delete_prompt),
)
.route(
"/ai/usage/overview",
axum::routing::get(crate::handler::usage_overview),

View File

@@ -17,20 +17,20 @@ impl PromptService {
Self { db }
}
/// 获取当前激活的 Prompt 模板
/// 获取当前激活的 Prompt 模板(按 analysis_type 查找)
pub async fn get_active_prompt(
&self,
tenant_id: Uuid,
name: &str,
analysis_type: &str,
) -> AiResult<ai_prompt::Model> {
ai_prompt::Entity::find()
.filter(ai_prompt::Column::TenantId.eq(tenant_id))
.filter(ai_prompt::Column::Name.eq(name))
.filter(ai_prompt::Column::AnalysisType.eq(analysis_type))
.filter(ai_prompt::Column::IsActive.eq(true))
.filter(ai_prompt::Column::DeletedAt.is_null())
.one(&self.db)
.await?
.ok_or_else(|| AiError::PromptNotFound(name.into()))
.ok_or_else(|| AiError::PromptNotFound(analysis_type.into()))
}
/// 新建 Prompt
@@ -44,6 +44,7 @@ impl PromptService {
user_prompt_template: String,
model_config: serde_json::Value,
category: String,
analysis_type: String,
) -> AiResult<ai_prompt::Model> {
let id = Uuid::now_v7();
let now = chrono::Utc::now();
@@ -59,6 +60,7 @@ impl PromptService {
version: Set(1),
is_active: Set(true),
category: Set(category),
analysis_type: Set(analysis_type),
tags: Set(None),
created_at: Set(now),
updated_at: Set(now),
@@ -74,15 +76,15 @@ impl PromptService {
pub async fn list_prompts(
&self,
tenant_id: Uuid,
category: Option<String>,
analysis_type: 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()));
if let Some(at) = &analysis_type {
query = query.filter(ai_prompt::Column::AnalysisType.eq(at.as_str()));
}
let total = query.clone().count(&self.db).await?;
@@ -132,6 +134,7 @@ impl PromptService {
version: Set(entity.version + 1),
is_active: Set(entity.is_active),
category: Set(entity.category.clone()),
analysis_type: Set(entity.analysis_type.clone()),
tags: Set(entity.tags.clone()),
created_at: Set(now),
updated_at: Set(now),
@@ -143,7 +146,7 @@ impl PromptService {
Ok(active.insert(&self.db).await?)
}
/// 激活指定 Prompt停用同 name+category 的其他版本)
/// 激活指定 Prompt停用同 analysis_type 的其他版本,原子操作
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)
@@ -154,25 +157,23 @@ impl PromptService {
return Err(AiError::Validation("跨租户操作".into()));
}
// 停用同 name + category 的其他激活版本
let siblings = ai_prompt::Entity::find()
// 原子操作:停用同 analysis_type 的其他版本
ai_prompt::Entity::update_many()
.col_expr(
ai_prompt::Column::IsActive,
sea_orm::sea_query::Expr::value(false),
)
.col_expr(
ai_prompt::Column::UpdatedAt,
sea_orm::sea_query::Expr::value(chrono::Utc::now()),
)
.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())
.filter(ai_prompt::Column::AnalysisType.eq(&entity.analysis_type))
.filter(ai_prompt::Column::Id.ne(id))
.all(&self.db)
.filter(ai_prompt::Column::DeletedAt.is_null())
.exec(&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.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
active.update(&self.db).await?;
}
// 激活目标
let mut active: ai_prompt::ActiveModel = entity.into();
active.is_active = Set(true);
@@ -185,4 +186,41 @@ impl PromptService {
pub async fn rollback_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<ai_prompt::Model> {
self.activate_prompt(id, tenant_id).await
}
/// 停用 Prompt
pub async fn deactivate_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()));
}
let mut active: ai_prompt::ActiveModel = entity.into();
active.is_active = Set(false);
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
Ok(active.update(&self.db).await?)
}
/// 删除 Prompt软删除
pub async fn delete_prompt(&self, id: Uuid, tenant_id: Uuid) -> AiResult<()> {
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 mut active: ai_prompt::ActiveModel = entity.into();
active.deleted_at = Set(Some(chrono::Utc::now()));
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.take().unwrap_or(0) + 1);
active.update(&self.db).await?;
Ok(())
}
}

View File

@@ -170,6 +170,8 @@ mod m20260522_000160_article_add_is_public;
mod m20260522_000161_patient_points_manage_perm;
mod m20260522_000162_seed_patient_miniprogram_permissions;
mod m20260526_000163_points_rule_unique_event_type;
mod m20260526_000164_ai_prompt_add_analysis_type;
mod m20260526_000165_ai_prompt_fix_analysis_type;
pub struct Migrator;
@@ -347,6 +349,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260522_000161_patient_points_manage_perm::Migration),
Box::new(m20260522_000162_seed_patient_miniprogram_permissions::Migration),
Box::new(m20260526_000163_points_rule_unique_event_type::Migration),
Box::new(m20260526_000164_ai_prompt_add_analysis_type::Migration),
Box::new(m20260526_000165_ai_prompt_fix_analysis_type::Migration),
]
}
}

View File

@@ -0,0 +1,47 @@
use sea_orm_migration::prelude::*;
/// ai_prompt 新增 analysis_type 列作为后端选择键name 回归显示名
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
// 1. 新增 analysis_type 列(先允许 NULL 以便回填)
db.execute_unprepared(
"ALTER TABLE ai_prompt ADD COLUMN IF NOT EXISTS analysis_type VARCHAR(64)",
)
.await?;
// 2. 用 name 值回填 analysis_typename 在旧数据中就是后端选择键)
db.execute_unprepared(
"UPDATE ai_prompt SET analysis_type = name WHERE analysis_type IS NULL AND deleted_at IS NULL",
)
.await?;
// 3. 设置 NOT NULL 约束
db.execute_unprepared("ALTER TABLE ai_prompt ALTER COLUMN analysis_type SET NOT NULL")
.await?;
// 4. 为 analysis_type 创建索引(后端按此列查询激活 Prompt
db.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_ai_prompt_analysis_type ON ai_prompt (tenant_id, analysis_type, is_active) WHERE deleted_at IS NULL",
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared("DROP INDEX IF EXISTS idx_ai_prompt_analysis_type")
.await?;
db.execute_unprepared("ALTER TABLE ai_prompt DROP COLUMN IF EXISTS analysis_type")
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,23 @@
use sea_orm_migration::prelude::*;
/// 修复 ai_prompt.analysis_type 回填数据:从 name真正的后端选择键而非 category泛化标签
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let db = manager.get_connection();
db.execute_unprepared(
"UPDATE ai_prompt SET analysis_type = name WHERE analysis_type != name AND deleted_at IS NULL",
)
.await?;
Ok(())
}
async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> {
Ok(())
}
}