//! 健康资讯 Service — 文章 CRUD + 审核工作流 use chrono::Utc; use sea_orm::entity::prelude::*; use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; use uuid::Uuid; use erp_core::audit::AuditLog; use erp_core::audit_service; use erp_core::error::check_version; use erp_core::events::DomainEvent; use erp_core::types::PaginatedResponse; use crate::dto::article_dto::{ArticleListItem, ArticleResp, CreateArticleReq, ReviewArticleReq, UpdateArticleReq}; use crate::entity::article; use crate::entity::article_article_tag; use crate::entity::article_tag; use crate::error::{HealthError, HealthResult}; use crate::service::validation; use crate::state::HealthState; /// 文章列表(管理端,支持状态/分类/标签/关键词筛选) pub async fn list_articles( state: &HealthState, tenant_id: Uuid, page: u64, page_size: u64, category: Option, status: Option, category_id: Option, tag_id: Option, keyword: Option, ) -> HealthResult> { let limit = page_size.min(100); let offset = page.saturating_sub(1) * limit; let mut query = article::Entity::find() .filter(article::Column::TenantId.eq(tenant_id)) .filter(article::Column::DeletedAt.is_null()); if let Some(ref cat) = category { query = query.filter(article::Column::Category.eq(cat)); } if let Some(ref s) = status { query = query.filter(article::Column::Status.eq(s)); } if let Some(cid) = category_id { query = query.filter(article::Column::CategoryId.eq(cid)); } if let Some(ref kw) = keyword { query = query.filter(article::Column::Title.contains(kw)); } // 按标签筛选需要子查询 if let Some(tid) = tag_id { let article_ids: Vec = article_article_tag::Entity::find() .filter(article_article_tag::Column::TagId.eq(tid)) .all(&state.db) .await? .into_iter() .map(|r| r.article_id) .collect(); if article_ids.is_empty() { return Ok(PaginatedResponse { data: vec![], total: 0, page, page_size: limit, total_pages: 0 }); } query = query.filter(article::Column::Id.is_in(article_ids)); } let total = query.clone().count(&state.db).await?; let models = query .order_by_desc(article::Column::SortOrder) .order_by_desc(article::Column::CreatedAt) .offset(offset) .limit(limit) .all(&state.db) .await?; let total_pages = total.div_ceil(limit.max(1)); let mut data = Vec::with_capacity(models.len()); for m in models { let tags = load_article_tags(state, m.id).await?; data.push(ArticleListItem { id: m.id, title: m.title, summary: m.summary, cover_image: m.cover_image, category: m.category, author: m.author, published_at: m.published_at, status: m.status, view_count: m.view_count, category_id: m.category_id, tags, version: m.version, }); } Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages }) } /// 获取文章详情(管理端,不过滤发布状态) pub async fn get_article( state: &HealthState, tenant_id: Uuid, id: Uuid, ) -> HealthResult { let model = article::Entity::find() .filter(article::Column::Id.eq(id)) .filter(article::Column::TenantId.eq(tenant_id)) .filter(article::Column::DeletedAt.is_null()) .one(&state.db) .await? .ok_or(HealthError::ArticleNotFound)?; let tags = load_article_tags(state, model.id).await?; Ok(full_model_to_resp(model, tags)) } // --------------------------------------------------------------------------- // 审核工作流 // --------------------------------------------------------------------------- /// 提交审核: draft/rejected → pending_review pub async fn submit_article( state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option, expected_version: i32, ) -> HealthResult { let model = find_article(state, tenant_id, id).await?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; validation::validate_article_status_transition(&model.status, "pending_review")?; let mut active: article::ActiveModel = model.into(); active.status = Set("pending_review".into()); active.updated_at = Set(Utc::now()); active.updated_by = Set(operator_id); active.version = Set(next_ver); let m = active.update(&state.db).await?; audit_service::record( AuditLog::new(tenant_id, operator_id, "article.submitted", "article") .with_resource_id(m.id), &state.db, ).await; let tags = load_article_tags(state, m.id).await?; Ok(full_model_to_resp(m, tags)) } /// 审核通过并发布: pending_review → published pub async fn approve_article( state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option, req: ReviewArticleReq, expected_version: i32, ) -> HealthResult { let model = find_article(state, tenant_id, id).await?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; validation::validate_article_status_transition(&model.status, "published")?; let now = Utc::now(); let mut active: article::ActiveModel = model.into(); active.status = Set("published".into()); active.published_at = Set(Some(now)); active.reviewed_by = Set(operator_id); active.reviewed_at = Set(Some(now)); active.review_note = Set(req.note); active.updated_at = Set(now); active.updated_by = Set(operator_id); active.version = Set(next_ver); let m = active.update(&state.db).await?; audit_service::record( AuditLog::new(tenant_id, operator_id, "article.published", "article") .with_resource_id(m.id), &state.db, ).await; state.event_bus.publish( DomainEvent::new(crate::event::ARTICLE_PUBLISHED, tenant_id, erp_core::events::build_event_payload(serde_json::json!({ "article_id": m.id, "title": m.title, "category": m.category, }))), &state.db, ).await; let tags = load_article_tags(state, m.id).await?; Ok(full_model_to_resp(m, tags)) } /// 审核拒绝: pending_review → rejected pub async fn reject_article( state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option, req: ReviewArticleReq, expected_version: i32, ) -> HealthResult { let model = find_article(state, tenant_id, id).await?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; validation::validate_article_status_transition(&model.status, "rejected")?; let now = Utc::now(); let mut active: article::ActiveModel = model.into(); active.status = Set("rejected".into()); active.reviewed_by = Set(operator_id); active.reviewed_at = Set(Some(now)); active.review_note = Set(req.note); active.updated_at = Set(now); active.updated_by = Set(operator_id); active.version = Set(next_ver); let m = active.update(&state.db).await?; audit_service::record( AuditLog::new(tenant_id, operator_id, "article.rejected", "article") .with_resource_id(m.id), &state.db, ).await; state.event_bus.publish( DomainEvent::new(crate::event::ARTICLE_REJECTED, tenant_id, erp_core::events::build_event_payload(serde_json::json!({ "article_id": m.id, "title": m.title, }))), &state.db, ).await; let tags = load_article_tags(state, m.id).await?; Ok(full_model_to_resp(m, tags)) } /// 撤回发布: published → draft pub async fn unpublish_article( state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option, expected_version: i32, ) -> HealthResult { let model = find_article(state, tenant_id, id).await?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; validation::validate_article_status_transition(&model.status, "draft")?; let mut active: article::ActiveModel = model.into(); active.status = Set("draft".into()); active.published_at = Set(None); active.updated_at = Set(Utc::now()); active.updated_by = Set(operator_id); active.version = Set(next_ver); let m = active.update(&state.db).await?; audit_service::record( AuditLog::new(tenant_id, operator_id, "article.unpublished", "article") .with_resource_id(m.id), &state.db, ).await; let tags = load_article_tags(state, m.id).await?; Ok(full_model_to_resp(m, tags)) } /// 增加浏览计数 pub async fn increment_view_count( state: &HealthState, tenant_id: Uuid, id: Uuid, ) -> HealthResult<()> { let model = find_article(state, tenant_id, id).await?; let mut active: article::ActiveModel = model.into(); active.view_count = Set(active.view_count.unwrap() + 1); active.updated_at = Set(Utc::now()); active.update(&state.db).await?; Ok(()) } // --------------------------------------------------------------------------- // 文章管理(写入) // --------------------------------------------------------------------------- pub async fn create_article( state: &HealthState, tenant_id: Uuid, operator_id: Option, req: CreateArticleReq, ) -> HealthResult { let now = Utc::now(); let active = article::ActiveModel { id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), title: Set(req.title), summary: Set(req.summary), content: Set(req.content.unwrap_or_default()), cover_image: Set(req.cover_image), category: Set(req.category), category_id: Set(req.category_id), author: Set(req.author), published_at: Set(req.published_at), status: Set("draft".into()), slug: Set(req.slug), content_type: Set(req.content_type.unwrap_or_else(|| "rich_text".into())), reviewed_by: Set(None), reviewed_at: Set(None), review_note: Set(None), view_count: Set(0), sort_order: Set(0), created_at: Set(now), updated_at: Set(now), created_by: Set(operator_id), updated_by: Set(operator_id), deleted_at: Set(None), version: Set(1), }; let m = active.insert(&state.db).await?; // 保存标签关联 save_article_tags(state, m.id, &req.tag_ids).await?; audit_service::record( AuditLog::new(tenant_id, operator_id, "article.created", "article") .with_resource_id(m.id), &state.db, ).await; let tags = load_article_tags(state, m.id).await?; Ok(full_model_to_resp(m, tags)) } pub async fn update_article( state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option, req: UpdateArticleReq, ) -> HealthResult { let model = find_article(state, tenant_id, id).await?; let next_ver = check_version(req.version, model.version) .map_err(|_| HealthError::VersionMismatch)?; // 保存版本历史 save_revision(state, tenant_id, &model, operator_id).await?; let mut active: article::ActiveModel = model.into(); if let Some(v) = req.title { active.title = Set(v); } if let Some(v) = req.summary { active.summary = Set(Some(v)); } if let Some(v) = req.content { active.content = Set(v); } if let Some(v) = req.cover_image { active.cover_image = Set(Some(v)); } if let Some(v) = req.category { active.category = Set(Some(v)); } if let Some(v) = req.category_id { active.category_id = Set(Some(v)); } if let Some(v) = req.author { active.author = Set(Some(v)); } if let Some(v) = req.published_at { active.published_at = Set(Some(v)); } if let Some(v) = req.slug { active.slug = Set(Some(v)); } if let Some(v) = req.content_type { active.content_type = Set(v); } if let Some(v) = req.sort_order { active.sort_order = Set(v); } active.updated_at = Set(Utc::now()); active.updated_by = Set(operator_id); active.version = Set(next_ver); let m = active.update(&state.db).await?; // 替换标签关联 if let Some(tag_ids) = req.tag_ids { replace_article_tags(state, m.id, &tag_ids).await?; } audit_service::record( AuditLog::new(tenant_id, operator_id, "article.updated", "article") .with_resource_id(m.id), &state.db, ).await; let tags = load_article_tags(state, m.id).await?; Ok(full_model_to_resp(m, tags)) } pub async fn delete_article( state: &HealthState, tenant_id: Uuid, id: Uuid, operator_id: Option, expected_version: i32, ) -> HealthResult<()> { let model = find_article(state, tenant_id, id).await?; let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; let mut active: article::ActiveModel = model.into(); active.deleted_at = Set(Some(Utc::now())); active.updated_at = Set(Utc::now()); active.updated_by = Set(operator_id); active.version = Set(next_ver); active.update(&state.db).await?; audit_service::record( AuditLog::new(tenant_id, operator_id, "article.deleted", "article") .with_resource_id(id), &state.db, ).await; Ok(()) } // --------------------------------------------------------------------------- // 内部辅助 // --------------------------------------------------------------------------- async fn find_article(state: &HealthState, tenant_id: Uuid, id: Uuid) -> HealthResult { article::Entity::find() .filter(article::Column::Id.eq(id)) .filter(article::Column::TenantId.eq(tenant_id)) .filter(article::Column::DeletedAt.is_null()) .one(&state.db) .await? .ok_or(HealthError::ArticleNotFound) } fn full_model_to_resp(m: article::Model, tags: Vec) -> ArticleResp { ArticleResp { id: m.id, title: m.title, summary: m.summary, content: Some(m.content), cover_image: m.cover_image, category: m.category, author: m.author, published_at: m.published_at, status: m.status, slug: m.slug, content_type: m.content_type, reviewed_by: m.reviewed_by, reviewed_at: m.reviewed_at, review_note: m.review_note, view_count: m.view_count, sort_order: m.sort_order, category_id: m.category_id, tags, created_at: m.created_at, updated_at: m.updated_at, version: m.version, } } async fn load_article_tags(state: &HealthState, article_id: Uuid) -> HealthResult> { let relations = article_article_tag::Entity::find() .filter(article_article_tag::Column::ArticleId.eq(article_id)) .all(&state.db) .await?; let tag_ids: Vec = relations.into_iter().map(|r| r.tag_id).collect(); if tag_ids.is_empty() { return Ok(vec![]); } let tags = article_tag::Entity::find() .filter(article_tag::Column::Id.is_in(tag_ids)) .filter(article_tag::Column::DeletedAt.is_null()) .all(&state.db) .await?; Ok(tags.into_iter().map(|t| t.name).collect()) } async fn save_article_tags(state: &HealthState, article_id: Uuid, tag_ids: &[Uuid]) -> HealthResult<()> { for tid in tag_ids { let active = article_article_tag::ActiveModel { article_id: Set(article_id), tag_id: Set(*tid), }; active.insert(&state.db).await?; } Ok(()) } async fn replace_article_tags(state: &HealthState, article_id: Uuid, tag_ids: &[Uuid]) -> HealthResult<()> { article_article_tag::Entity::delete_many() .filter(article_article_tag::Column::ArticleId.eq(article_id)) .exec(&state.db) .await?; save_article_tags(state, article_id, tag_ids).await } async fn save_revision( state: &HealthState, tenant_id: Uuid, model: &article::Model, operator_id: Option, ) -> HealthResult<()> { use crate::entity::article_revision; // 获取当前最大版本号 let max_rev = article_revision::Entity::find() .filter(article_revision::Column::ArticleId.eq(model.id)) .order_by_desc(article_revision::Column::RevisionNumber) .one(&state.db) .await?; let next_rev = max_rev.map(|r| r.revision_number + 1).unwrap_or(1); let active = article_revision::ActiveModel { id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), article_id: Set(model.id), revision_number: Set(next_rev), title: Set(model.title.clone()), content: Set(model.content.clone()), summary: Set(model.summary.clone()), created_by: Set(operator_id), created_at: Set(Utc::now()), }; active.insert(&state.db).await?; Ok(()) }