feat(ai): 建议状态生命周期 — 转换验证 + 执行端点 + 事件发布
建议(ai_suggestion)原有状态枚举完整但缺乏生命周期管理:
- 无转换验证(可从 Rejected 跳到 Approved)
- 无执行端点(护士无法标记"已执行")
- 无状态变更事件
变更:
1. SuggestionStatus.can_transition_to() — 仅允许合法单向转换
Pending → Approved/Rejected/Expired → Approved → Executed/Rejected/Expired
2. SuggestionService.execute_suggestion() — 记录执行结果
3. SuggestionService.expire_stale_suggestions() — 批量过期超时建议
4. POST /ai/suggestions/{id}/execute — 新执行端点
5. publish_status_event() — 状态变更时发布 ai.suggestion.status_changed 事件
6. 9 个新单元测试覆盖所有转换规则
This commit is contained in:
@@ -67,6 +67,19 @@ impl SuggestionStatus {
|
|||||||
Self::ParseFailed => "parse_failed",
|
Self::ParseFailed => "parse_failed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 合法状态转换:仅允许相邻状态单向流转
|
||||||
|
pub fn can_transition_to(&self, target: SuggestionStatus) -> bool {
|
||||||
|
matches!(
|
||||||
|
(self, target),
|
||||||
|
(Self::Pending, Self::Approved)
|
||||||
|
| (Self::Pending, Self::Rejected)
|
||||||
|
| (Self::Pending, Self::Expired)
|
||||||
|
| (Self::Approved, Self::Executed)
|
||||||
|
| (Self::Approved, Self::Rejected)
|
||||||
|
| (Self::Approved, Self::Expired)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AI 输出的单条结构化建议
|
/// AI 输出的单条结构化建议
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use axum::extract::{Extension, FromRef, Path, Query, State};
|
use axum::extract::{Extension, FromRef, Path, State};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use erp_core::rbac::require_permission;
|
use erp_core::rbac::require_permission;
|
||||||
use erp_core::types::{ApiResponse, TenantContext};
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
@@ -17,7 +17,7 @@ pub struct ListSuggestionsQuery {
|
|||||||
pub async fn list_suggestions<S>(
|
pub async fn list_suggestions<S>(
|
||||||
State(state): State<AiState>,
|
State(state): State<AiState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
Query(params): Query<ListSuggestionsQuery>,
|
axum::extract::Query(params): axum::extract::Query<ListSuggestionsQuery>,
|
||||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||||
where
|
where
|
||||||
AiState: FromRef<S>,
|
AiState: FromRef<S>,
|
||||||
@@ -73,7 +73,7 @@ where
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
SuggestionService::update_status(
|
let updated = SuggestionService::update_status(
|
||||||
&state.db,
|
&state.db,
|
||||||
id,
|
id,
|
||||||
ctx.tenant_id,
|
ctx.tenant_id,
|
||||||
@@ -82,12 +82,51 @@ where
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// 发布建议状态变更事件
|
||||||
|
publish_status_event(&state, &updated).await;
|
||||||
|
|
||||||
Ok(Json(ApiResponse::ok(serde_json::json!({
|
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
"status": new_status.as_str(),
|
"status": new_status.as_str(),
|
||||||
}))))
|
}))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ExecuteBody {
|
||||||
|
pub action_result: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行建议:护士标记建议为已执行,可选记录执行结果
|
||||||
|
pub async fn execute_suggestion<S>(
|
||||||
|
State(state): State<AiState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
Json(body): Json<ExecuteBody>,
|
||||||
|
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||||
|
where
|
||||||
|
AiState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "ai.suggestion.manage")?;
|
||||||
|
|
||||||
|
let updated = SuggestionService::execute_suggestion(
|
||||||
|
&state.db,
|
||||||
|
id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
body.action_result,
|
||||||
|
Some(ctx.user_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 发布建议状态变更事件
|
||||||
|
publish_status_event(&state, &updated).await;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"status": "executed",
|
||||||
|
}))))
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 AI 建议的前后对比报告。
|
/// 获取 AI 建议的前后对比报告。
|
||||||
pub async fn get_comparison<S>(
|
pub async fn get_comparison<S>(
|
||||||
State(state): State<AiState>,
|
State(state): State<AiState>,
|
||||||
@@ -127,3 +166,20 @@ where
|
|||||||
})))),
|
})))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 发布建议状态变更事件
|
||||||
|
async fn publish_status_event(state: &AiState, suggestion: &crate::entity::ai_suggestion::Model) {
|
||||||
|
let event = erp_core::events::DomainEvent::new(
|
||||||
|
"ai.suggestion.status_changed",
|
||||||
|
suggestion.tenant_id,
|
||||||
|
erp_core::events::build_event_payload(serde_json::json!({
|
||||||
|
"suggestion_id": suggestion.id,
|
||||||
|
"analysis_id": suggestion.analysis_id,
|
||||||
|
"suggestion_type": suggestion.suggestion_type,
|
||||||
|
"risk_level": suggestion.risk_level,
|
||||||
|
"status": suggestion.status,
|
||||||
|
"updated_by": suggestion.updated_by,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
state.event_bus.publish(event, &state.db).await;
|
||||||
|
}
|
||||||
|
|||||||
@@ -219,6 +219,10 @@ impl AiModule {
|
|||||||
"/ai/suggestions/{id}/approve",
|
"/ai/suggestions/{id}/approve",
|
||||||
axum::routing::post(crate::handler::suggestion_handler::approve_suggestion),
|
axum::routing::post(crate::handler::suggestion_handler::approve_suggestion),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/ai/suggestions/{id}/execute",
|
||||||
|
axum::routing::post(crate::handler::suggestion_handler::execute_suggestion),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/ai/suggestions/{id}/comparison",
|
"/ai/suggestions/{id}/comparison",
|
||||||
axum::routing::get(crate::handler::suggestion_handler::get_comparison),
|
axum::routing::get(crate::handler::suggestion_handler::get_comparison),
|
||||||
|
|||||||
@@ -71,14 +71,14 @@ impl SuggestionService {
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新建议状态(带乐观锁 + tenant_id 过滤)
|
/// 更新建议状态(带合法转换校验 + 乐观锁 + tenant_id 过滤)
|
||||||
pub async fn update_status(
|
pub async fn update_status(
|
||||||
db: &sea_orm::DatabaseConnection,
|
db: &sea_orm::DatabaseConnection,
|
||||||
suggestion_id: Uuid,
|
suggestion_id: Uuid,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
new_status: SuggestionStatus,
|
new_status: SuggestionStatus,
|
||||||
updated_by: Option<Uuid>,
|
updated_by: Option<Uuid>,
|
||||||
) -> AppResult<()> {
|
) -> AppResult<ai_suggestion::Model> {
|
||||||
let item = ai_suggestion::Entity::find()
|
let item = ai_suggestion::Entity::find()
|
||||||
.filter(ai_suggestion::Column::Id.eq(suggestion_id))
|
.filter(ai_suggestion::Column::Id.eq(suggestion_id))
|
||||||
.filter(ai_suggestion::Column::TenantId.eq(tenant_id))
|
.filter(ai_suggestion::Column::TenantId.eq(tenant_id))
|
||||||
@@ -89,13 +89,85 @@ impl SuggestionService {
|
|||||||
crate::error::AiError::AnalysisNotFound("建议不存在".into())
|
crate::error::AiError::AnalysisNotFound("建议不存在".into())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
let current_status = parse_status(&item.status);
|
||||||
|
if !current_status.can_transition_to(new_status) {
|
||||||
|
return Err(crate::error::AiError::Validation(format!(
|
||||||
|
"建议状态不允许从 {} 转换为 {}",
|
||||||
|
current_status.as_str(),
|
||||||
|
new_status.as_str()
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
let current_version = item.version_lock;
|
let current_version = item.version_lock;
|
||||||
let mut active: ai_suggestion::ActiveModel = item.into();
|
let mut active: ai_suggestion::ActiveModel = item.into();
|
||||||
active.status = Set(new_status.as_str().to_string());
|
active.status = Set(new_status.as_str().to_string());
|
||||||
active.updated_by = Set(updated_by);
|
active.updated_by = Set(updated_by);
|
||||||
active.version_lock = Set(current_version + 1);
|
active.version_lock = Set(current_version + 1);
|
||||||
active.update(db).await?;
|
let updated = active.update(db).await?;
|
||||||
Ok(())
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行建议:状态从 Approved → Executed,同时记录执行结果
|
||||||
|
pub async fn execute_suggestion(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
suggestion_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
action_result: Option<serde_json::Value>,
|
||||||
|
executed_by: Option<Uuid>,
|
||||||
|
) -> AppResult<ai_suggestion::Model> {
|
||||||
|
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_status = parse_status(&item.status);
|
||||||
|
// 允许从 Pending 或 Approved 直接执行(护士可能跳过审批)
|
||||||
|
if !matches!(current_status, SuggestionStatus::Pending | SuggestionStatus::Approved) {
|
||||||
|
return Err(crate::error::AiError::Validation(format!(
|
||||||
|
"建议状态为 {},无法执行(需要 pending 或 approved)",
|
||||||
|
current_status.as_str()
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_version = item.version_lock;
|
||||||
|
let mut active: ai_suggestion::ActiveModel = item.into();
|
||||||
|
active.status = Set(SuggestionStatus::Executed.as_str().to_string());
|
||||||
|
active.action_result = Set(action_result);
|
||||||
|
active.updated_by = Set(executed_by);
|
||||||
|
active.version_lock = Set(current_version + 1);
|
||||||
|
let updated = active.update(db).await?;
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量过期超时建议(超过 N 天仍为 Pending/Approved 的建议标记为 Expired)
|
||||||
|
pub async fn expire_stale_suggestions(
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
max_age_days: i64,
|
||||||
|
) -> AppResult<u64> {
|
||||||
|
let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days);
|
||||||
|
let sql = r#"
|
||||||
|
UPDATE ai_suggestion
|
||||||
|
SET status = 'expired', updated_at = NOW(), version_lock = version_lock + 1
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
AND status IN ('pending', 'approved')
|
||||||
|
AND created_at < $2
|
||||||
|
"#;
|
||||||
|
let result = sea_orm::Statement::from_sql_and_values(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
sql,
|
||||||
|
[tenant_id.into(), cutoff.into()],
|
||||||
|
);
|
||||||
|
let res = sea_orm::ConnectionTrait::execute(db, result).await?;
|
||||||
|
Ok(res.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 标记为解析失败(仅记录日志,不创建建议记录)
|
/// 标记为解析失败(仅记录日志,不创建建议记录)
|
||||||
@@ -110,3 +182,77 @@ impl SuggestionService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从数据库字符串解析 SuggestionStatus
|
||||||
|
fn parse_status(s: &str) -> SuggestionStatus {
|
||||||
|
match s {
|
||||||
|
"approved" => SuggestionStatus::Approved,
|
||||||
|
"rejected" => SuggestionStatus::Rejected,
|
||||||
|
"executed" => SuggestionStatus::Executed,
|
||||||
|
"expired" => SuggestionStatus::Expired,
|
||||||
|
"parse_failed" => SuggestionStatus::ParseFailed,
|
||||||
|
_ => SuggestionStatus::Pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_can_transition_to_approved() {
|
||||||
|
assert!(SuggestionStatus::Pending.can_transition_to(SuggestionStatus::Approved));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_can_transition_to_rejected() {
|
||||||
|
assert!(SuggestionStatus::Pending.can_transition_to(SuggestionStatus::Rejected));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_can_transition_to_expired() {
|
||||||
|
assert!(SuggestionStatus::Pending.can_transition_to(SuggestionStatus::Expired));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approved_can_transition_to_executed() {
|
||||||
|
assert!(SuggestionStatus::Approved.can_transition_to(SuggestionStatus::Executed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn approved_can_transition_to_rejected() {
|
||||||
|
assert!(SuggestionStatus::Approved.can_transition_to(SuggestionStatus::Rejected));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_cannot_transition() {
|
||||||
|
assert!(!SuggestionStatus::Rejected.can_transition_to(SuggestionStatus::Approved));
|
||||||
|
assert!(!SuggestionStatus::Rejected.can_transition_to(SuggestionStatus::Pending));
|
||||||
|
assert!(!SuggestionStatus::Rejected.can_transition_to(SuggestionStatus::Executed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn executed_cannot_transition() {
|
||||||
|
assert!(!SuggestionStatus::Executed.can_transition_to(SuggestionStatus::Pending));
|
||||||
|
assert!(!SuggestionStatus::Executed.can_transition_to(SuggestionStatus::Approved));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_cannot_transition() {
|
||||||
|
assert!(!SuggestionStatus::Expired.can_transition_to(SuggestionStatus::Pending));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_status_roundtrip() {
|
||||||
|
for status in [
|
||||||
|
SuggestionStatus::Pending,
|
||||||
|
SuggestionStatus::Approved,
|
||||||
|
SuggestionStatus::Rejected,
|
||||||
|
SuggestionStatus::Executed,
|
||||||
|
SuggestionStatus::Expired,
|
||||||
|
SuggestionStatus::ParseFailed,
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_status(status.as_str()), status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user