feat(ai): SuggestionService — 建议记录 CRUD + 状态流转

- create_suggestions: 批量创建建议记录,关联分析 ID 和 baseline 快照
- list_by_analysis: 按 analysis_id 查询建议列表(带 tenant_id 过滤 + 软删除)
- list_pending: 查询待审批建议
- update_status: 更新状态(带乐观锁 + tenant_id 过滤)
- mark_parse_failed: 解析失败时记录日志
- AiState 新增 suggestion 字段
This commit is contained in:
iven
2026-05-01 08:09:59 +08:00
parent 3b6f72d5c0
commit b30897119b
3 changed files with 115 additions and 0 deletions

View File

@@ -3,4 +3,5 @@ pub mod auto_analysis;
pub mod local_rules;
pub mod output_parser;
pub mod prompt;
pub mod suggestion;
pub mod usage;

View File

@@ -0,0 +1,112 @@
use uuid::Uuid;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use erp_core::error::AppResult;
use crate::dto::suggestion::*;
use crate::entity::ai_suggestion;
pub struct SuggestionService;
impl SuggestionService {
/// 批量创建建议记录
pub async fn create_suggestions(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
analysis_id: Uuid,
suggestions: &[StructuredSuggestion],
risk_level: RiskLevel,
baseline_snapshot: &serde_json::Value,
created_by: Option<Uuid>,
) -> AppResult<Vec<uuid::Uuid>> {
let mut ids = Vec::new();
for s in suggestions {
let id = Uuid::now_v7();
let model = ai_suggestion::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
analysis_id: Set(analysis_id),
suggestion_type: Set(s.suggestion_type.as_str().to_string()),
risk_level: Set(risk_level.as_str().to_string()),
params: Set(s.params.clone()),
status: Set(SuggestionStatus::Pending.as_str().to_string()),
workflow_instance_id: Set(None),
action_result: Set(None),
baseline_snapshot: Set(Some(baseline_snapshot.clone())),
reanalysis_id: Set(None),
created_by: Set(created_by),
updated_by: Set(created_by),
..Default::default()
};
model.insert(db).await?;
ids.push(id);
}
Ok(ids)
}
/// 查询某次分析的所有建议(带 tenant_id 过滤 + 软删除)
pub async fn list_by_analysis(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
analysis_id: Uuid,
) -> AppResult<Vec<ai_suggestion::Model>> {
let items = ai_suggestion::Entity::find()
.filter(ai_suggestion::Column::TenantId.eq(tenant_id))
.filter(ai_suggestion::Column::AnalysisId.eq(analysis_id))
.filter(ai_suggestion::Column::DeletedAt.is_null())
.all(db)
.await?;
Ok(items)
}
/// 查询待审批建议(带 tenant_id 过滤)
pub async fn list_pending(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
) -> AppResult<Vec<ai_suggestion::Model>> {
let items = ai_suggestion::Entity::find()
.filter(ai_suggestion::Column::TenantId.eq(tenant_id))
.filter(ai_suggestion::Column::Status.eq(SuggestionStatus::Pending.as_str()))
.filter(ai_suggestion::Column::DeletedAt.is_null())
.all(db)
.await?;
Ok(items)
}
/// 更新建议状态(带乐观锁 + tenant_id 过滤)
pub async fn update_status(
db: &sea_orm::DatabaseConnection,
suggestion_id: Uuid,
tenant_id: Uuid,
new_status: SuggestionStatus,
updated_by: Option<Uuid>,
) -> AppResult<()> {
let item = ai_suggestion::Entity::find()
.filter(ai_suggestion::Column::Id.eq(suggestion_id))
.filter(ai_suggestion::Column::TenantId.eq(tenant_id))
.filter(ai_suggestion::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| {
crate::error::AiError::AnalysisNotFound("建议不存在".into())
})?;
let current_version = item.version_lock;
let mut active: ai_suggestion::ActiveModel = item.into();
active.status = Set(new_status.as_str().to_string());
active.updated_by = Set(updated_by);
active.version_lock = Set(current_version + 1);
active.update(db).await?;
Ok(())
}
/// 标记为解析失败(仅记录日志,不创建建议记录)
pub async fn mark_parse_failed(
_db: &sea_orm::DatabaseConnection,
analysis_id: Uuid,
) -> AppResult<()> {
tracing::warn!(
analysis_id = %analysis_id,
"AI 结构化输出解析失败,降级为纯文本"
);
Ok(())
}
}

View File

@@ -6,6 +6,7 @@ use sea_orm::DatabaseConnection;
use crate::service::analysis::AnalysisService;
use crate::service::prompt::PromptService;
use crate::service::suggestion::SuggestionService;
use crate::service::usage::UsageService;
#[derive(Clone)]
@@ -15,5 +16,6 @@ pub struct AiState {
pub analysis: Arc<AnalysisService>,
pub prompt: Arc<PromptService>,
pub usage: Arc<UsageService>,
pub suggestion: Arc<SuggestionService>,
pub health_provider: Arc<dyn HealthDataProvider>,
}