feat(ai): 建议状态生命周期 — 转换验证 + 执行端点 + 事件发布
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

建议(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:
iven
2026-05-04 13:39:48 +08:00
parent e78eb1af07
commit d68c7be098
4 changed files with 226 additions and 7 deletions

View File

@@ -71,14 +71,14 @@ impl SuggestionService {
Ok(items)
}
/// 更新建议状态(带乐观锁 + tenant_id 过滤)
/// 更新建议状态(带合法转换校验 + 乐观锁 + 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<()> {
) -> 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))
@@ -89,13 +89,85 @@ impl SuggestionService {
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 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(())
let updated = active.update(db).await?;
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(())
}
}
/// 从数据库字符串解析 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);
}
}
}