- erp-health: article/banner/consultation/media 服务层优化 - erp-ai: analysis/insight/prompt 服务增强 - erp-auth: auth/role/token 服务改进 - erp-workflow: executor 执行引擎修复 - erp-plugin: 服务层改进 - 新增媒体上传文件样例
733 lines
22 KiB
Rust
733 lines
22 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;
|
||
|
||
/// 文章列表(管理端,支持状态/分类/标签/关键词筛选)
|
||
#[allow(clippy::too_many_arguments)]
|
||
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));
|
||
|
||
// 批量加载所有文章的标签,避免 N+1 查询
|
||
let article_ids: Vec<Uuid> = models.iter().map(|m| m.id).collect();
|
||
let tags_map = batch_load_article_tags(state, &article_ids).await?;
|
||
|
||
let mut data = Vec::with_capacity(models.len());
|
||
for m in models {
|
||
let tags = tags_map.get(&m.id).cloned().unwrap_or_default();
|
||
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_public_article(state: &HealthState, id: Uuid) -> HealthResult<ArticleResp> {
|
||
let model = article::Entity::find()
|
||
.filter(article::Column::Id.eq(id))
|
||
.filter(article::Column::DeletedAt.is_null())
|
||
.filter(article::Column::Status.eq("published"))
|
||
.one(&state.db)
|
||
.await?
|
||
.ok_or(HealthError::ArticleNotFound)?;
|
||
|
||
let tags = load_article_tags(state, model.id).await?;
|
||
Ok(full_model_to_resp(model, tags))
|
||
}
|
||
|
||
/// 获取文章详情(管理端可查看任意状态,非管理端仅已发布)
|
||
pub async fn get_article(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
id: Uuid,
|
||
is_admin: bool,
|
||
) -> HealthResult<ArticleResp> {
|
||
let mut query = article::Entity::find()
|
||
.filter(article::Column::Id.eq(id))
|
||
.filter(article::Column::TenantId.eq(tenant_id))
|
||
.filter(article::Column::DeletedAt.is_null());
|
||
if !is_admin {
|
||
query = query.filter(article::Column::Status.eq("published"));
|
||
}
|
||
let model = query
|
||
.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/published → 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.to_string(), "title": m.title,
|
||
"author_id": m.created_by.map(|id| id.to_string()).unwrap_or_default(),
|
||
})),
|
||
),
|
||
&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.take().unwrap_or(0) + 1);
|
||
active.updated_at = Set(Utc::now());
|
||
active.version = Set(active.version.unwrap() + 1);
|
||
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, tenant_id, 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, tenant_id, 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())
|
||
}
|
||
|
||
/// 批量加载多篇文章的标签,避免 N+1 查询。
|
||
/// 返回 HashMap<article_id, Vec<tag_name>>。
|
||
async fn batch_load_article_tags(
|
||
state: &HealthState,
|
||
article_ids: &[Uuid],
|
||
) -> HealthResult<std::collections::HashMap<Uuid, Vec<String>>> {
|
||
use std::collections::HashMap;
|
||
|
||
if article_ids.is_empty() {
|
||
return Ok(HashMap::new());
|
||
}
|
||
|
||
// 1. 一次查询所有文章-标签关联
|
||
let ids: Vec<Uuid> = article_ids.to_vec();
|
||
let relations = article_article_tag::Entity::find()
|
||
.filter(article_article_tag::Column::ArticleId.is_in(ids))
|
||
.all(&state.db)
|
||
.await?;
|
||
|
||
if relations.is_empty() {
|
||
return Ok(HashMap::new());
|
||
}
|
||
|
||
// 2. 收集所有 tag_id,按 article_id 分组
|
||
let tag_ids: Vec<Uuid> = relations.iter().map(|r| r.tag_id).collect();
|
||
let mut article_to_tag_ids: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
|
||
for r in &relations {
|
||
article_to_tag_ids
|
||
.entry(r.article_id)
|
||
.or_default()
|
||
.push(r.tag_id);
|
||
}
|
||
|
||
// 3. 一次查询所有标签实体
|
||
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?;
|
||
|
||
let tag_name_map: HashMap<Uuid, String> = tags.into_iter().map(|t| (t.id, t.name)).collect();
|
||
|
||
// 4. 组装结果
|
||
let mut result = HashMap::new();
|
||
for (article_id, tids) in article_to_tag_ids {
|
||
let names: Vec<String> = tids
|
||
.into_iter()
|
||
.filter_map(|tid| tag_name_map.get(&tid).cloned())
|
||
.collect();
|
||
result.insert(article_id, names);
|
||
}
|
||
|
||
Ok(result)
|
||
}
|
||
|
||
async fn save_article_tags(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
article_id: Uuid,
|
||
tag_ids: &[Uuid],
|
||
) -> HealthResult<()> {
|
||
let now = chrono::Utc::now();
|
||
for tid in tag_ids {
|
||
let active = article_article_tag::ActiveModel {
|
||
article_id: Set(article_id),
|
||
tag_id: Set(*tid),
|
||
tenant_id: Set(tenant_id),
|
||
deleted_at: Set(None),
|
||
created_at: Set(now),
|
||
updated_at: Set(now),
|
||
};
|
||
active.insert(&state.db).await?;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn replace_article_tags(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
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, tenant_id, 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(())
|
||
}
|
||
|
||
pub async fn list_revisions(
|
||
state: &HealthState,
|
||
tenant_id: Uuid,
|
||
article_id: Uuid,
|
||
page: u64,
|
||
page_size: u64,
|
||
) -> HealthResult<PaginatedResponse<crate::dto::article_dto::ArticleRevisionResp>> {
|
||
use crate::entity::article_revision;
|
||
|
||
let page = page.max(1);
|
||
let page_size = page_size.clamp(1, 100);
|
||
|
||
let condition = article_revision::Column::ArticleId
|
||
.eq(article_id)
|
||
.and(article_revision::Column::TenantId.eq(tenant_id));
|
||
|
||
let total = article_revision::Entity::find()
|
||
.filter(condition.clone())
|
||
.count(&state.db)
|
||
.await?;
|
||
|
||
let items = article_revision::Entity::find()
|
||
.filter(condition)
|
||
.order_by_desc(article_revision::Column::RevisionNumber)
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
.all(&state.db)
|
||
.await?;
|
||
|
||
let data = items
|
||
.into_iter()
|
||
.map(|m| crate::dto::article_dto::ArticleRevisionResp {
|
||
id: m.id,
|
||
article_id: m.article_id,
|
||
revision_number: m.revision_number,
|
||
title: m.title,
|
||
summary: m.summary,
|
||
created_by: m.created_by,
|
||
created_at: m.created_at,
|
||
})
|
||
.collect();
|
||
|
||
Ok(PaginatedResponse {
|
||
data,
|
||
total,
|
||
page,
|
||
page_size,
|
||
total_pages: ((total as f64) / (page_size as f64)).ceil() as u64,
|
||
})
|
||
}
|