Files
hms/crates/erp-health/src/service/article_service.rs
iven 97bb592688 feat(core): build_event_payload 统一信封 — 28 处事件发布全部迁移
- erp-core 添加 build_event_payload(),自动注入 schema_version + occurred_at
- erp-health 12 个 service(25 处)、erp-auth(1 处)、erp-workflow(2 处)
  全部迁移到统一信封格式
2026-04-27 18:01:05 +08:00

528 lines
17 KiB
Rust

//! 健康资讯 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<String>,
status: Option<String>,
category_id: Option<Uuid>,
tag_id: Option<Uuid>,
keyword: Option<String>,
) -> HealthResult<PaginatedResponse<ArticleListItem>> {
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<Uuid> = 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<ArticleResp> {
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<Uuid>,
expected_version: i32,
) -> HealthResult<ArticleResp> {
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<Uuid>,
req: ReviewArticleReq,
expected_version: i32,
) -> HealthResult<ArticleResp> {
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<Uuid>,
req: ReviewArticleReq,
expected_version: i32,
) -> HealthResult<ArticleResp> {
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<Uuid>,
expected_version: i32,
) -> HealthResult<ArticleResp> {
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<Uuid>,
req: CreateArticleReq,
) -> HealthResult<ArticleResp> {
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<Uuid>,
req: UpdateArticleReq,
) -> HealthResult<ArticleResp> {
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<Uuid>,
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::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)
}
fn full_model_to_resp(m: article::Model, tags: Vec<String>) -> 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<Vec<String>> {
let relations = article_article_tag::Entity::find()
.filter(article_article_tag::Column::ArticleId.eq(article_id))
.all(&state.db)
.await?;
let tag_ids: Vec<Uuid> = 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<Uuid>,
) -> 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(())
}