Files
hms/crates/erp-health/src/service/article_service.rs
iven 30a578ee00
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
fix(health): 客户试用前全局审计修复 — P0 权限旁路 + API 路径 + 事件注册
P0 阻塞修复:
- 修复 PrivateRoute 权限旁路: p.startsWith('auth.') 匹配不到任何权限码,
  改为基于实际权限码的路由级检查 (user.manage/role.manage/organization.manage)
- 修复 deviceReadings API 路径: /patients/{id}/device-readings/daily 改为
  /vital-signs/daily?patient_id=, 消除 404

P1 重要修复:
- 补全事件注册表: 新增 auth(11) + config(8) + workflow(4) + plugin(2) = 25 条
- article_article_tag 联表新增 tenant_id + deleted_at + 审计列 (迁移 107)
- vital_signs_hourly 新增 deleted_at 支持软删除过滤 (迁移 108)
- 6 个页面添加权限守卫 (AlertDashboard/AlertRuleList/DeviceManage/
  AiAnalysisList/AiUsageDashboard)
- DialysisModule 声明 auth 依赖
2026-05-04 11:02:25 +08:00

647 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 健康资讯 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));
// 批量加载所有文章的标签,避免 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_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 → 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.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) as u64)
.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,
})
}