- erp-core 添加 build_event_payload(),自动注入 schema_version + occurred_at - erp-health 12 个 service(25 处)、erp-auth(1 处)、erp-workflow(2 处) 全部迁移到统一信封格式
528 lines
17 KiB
Rust
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(())
|
|
}
|