feat(health): 内容管理模块 — 审核/分类/标签/富文本编辑器
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

后端:
- 文章审核状态机:draft → pending_review → published(含 reject/unpublish)
- 文章分类 CRUD(article_category entity + service + handler)
- 文章标签 CRUD(article_tag + article_article_tag 关联)
- 文章修订版快照(article_revision)
- 阅读计数、排序、slug、审核备注
- 新增 health.articles.review 权限

前端:
- ArticleManageList:状态标签页 + 分类筛选 + 关键字搜索 + 审核操作
- ArticleEditor:Wangeditor 富文本编辑器 + 元数据侧栏
- ArticleCategoryManage:分类 CRUD + 父子层级
- ArticleTagManage:标签 CRUD

修复:
- diagnosis_service/health_data_service/dialysis_service: 补充 key_version 字段
- ArticleCategoryManage: 补充 Select 组件导入
This commit is contained in:
iven
2026-04-26 12:51:30 +08:00
parent 49b8300fdc
commit 17b423b9b8
26 changed files with 3731 additions and 97 deletions

View File

@@ -4,6 +4,10 @@ use uuid::Uuid;
use erp_core::sanitize::{sanitize_option, sanitize_string, strip_html_tags};
// ---------------------------------------------------------------------------
// 文章 DTOs
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ArticleResp {
pub id: Uuid,
@@ -14,6 +18,18 @@ pub struct ArticleResp {
pub category: Option<String>,
pub author: Option<String>,
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
pub status: String,
pub slug: Option<String>,
pub content_type: String,
pub reviewed_by: Option<Uuid>,
pub reviewed_at: Option<chrono::DateTime<chrono::Utc>>,
pub review_note: Option<String>,
pub view_count: i32,
pub sort_order: i32,
/// 文章关联的分类 ID来自 article_category 表)
pub category_id: Option<Uuid>,
/// 文章关联的标签名称列表
pub tags: Vec<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
@@ -28,6 +44,12 @@ pub struct ArticleListItem {
pub category: Option<String>,
pub author: Option<String>,
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
pub status: String,
pub view_count: i32,
/// 分类 ID
pub category_id: Option<Uuid>,
/// 标签名称列表
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, IntoParams)]
@@ -35,6 +57,14 @@ pub struct ArticleListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub category: Option<String>,
/// 按状态筛选
pub status: Option<String>,
/// 按分类 ID 筛选
pub category_id: Option<Uuid>,
/// 按标签 ID 筛选
pub tag_id: Option<Uuid>,
/// 关键词搜索(标题模糊匹配)
pub keyword: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -46,6 +76,13 @@ pub struct CreateArticleReq {
pub category: Option<String>,
pub author: Option<String>,
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
pub slug: Option<String>,
pub content_type: Option<String>,
/// 分类 ID
pub category_id: Option<Uuid>,
/// 标签 ID 列表
#[serde(default)]
pub tag_ids: Vec<Uuid>,
}
impl CreateArticleReq {
@@ -55,6 +92,8 @@ impl CreateArticleReq {
self.content = sanitize_option(self.content.take());
self.category = sanitize_option(self.category.take());
self.author = sanitize_option(self.author.take());
self.slug = sanitize_option(self.slug.take());
self.content_type = sanitize_option(self.content_type.take());
}
}
@@ -67,6 +106,13 @@ pub struct UpdateArticleReq {
pub category: Option<String>,
pub author: Option<String>,
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
pub slug: Option<String>,
pub content_type: Option<String>,
/// 分类 ID
pub category_id: Option<Uuid>,
/// 标签 ID 列表(传入则整体替换)
pub tag_ids: Option<Vec<Uuid>>,
pub sort_order: Option<i32>,
pub version: i32,
}
@@ -77,5 +123,98 @@ impl UpdateArticleReq {
self.content = sanitize_option(self.content.take());
self.category = sanitize_option(self.category.take());
self.author = sanitize_option(self.author.take());
self.slug = sanitize_option(self.slug.take());
self.content_type = sanitize_option(self.content_type.take());
}
}
/// 审核文章请求
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ReviewArticleReq {
/// 审核备注
pub note: Option<String>,
/// 文章版本号(乐观锁)
pub version: Option<i32>,
}
impl ReviewArticleReq {
pub fn sanitize(&mut self) {
self.note = sanitize_option(self.note.take());
}
}
// ---------------------------------------------------------------------------
// 分类 DTOs
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CategoryResp {
pub id: Uuid,
pub name: String,
pub slug: Option<String>,
pub parent_id: Option<Uuid>,
pub description: Option<String>,
pub sort_order: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateCategoryReq {
pub name: String,
pub slug: Option<String>,
pub parent_id: Option<Uuid>,
pub description: Option<String>,
pub sort_order: Option<i32>,
}
impl CreateCategoryReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
self.slug = sanitize_option(self.slug.take());
self.description = sanitize_option(self.description.take());
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct UpdateCategoryReq {
pub name: Option<String>,
pub slug: Option<String>,
pub parent_id: Option<Uuid>,
pub description: Option<String>,
pub sort_order: Option<i32>,
pub version: i32,
}
impl UpdateCategoryReq {
pub fn sanitize(&mut self) {
if let Some(ref mut v) = self.name { *v = strip_html_tags(v); }
self.slug = sanitize_option(self.slug.take());
self.description = sanitize_option(self.description.take());
}
}
// ---------------------------------------------------------------------------
// 标签 DTOs
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TagResp {
pub id: Uuid,
pub name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateTagReq {
pub name: String,
}
impl CreateTagReq {
pub fn sanitize(&mut self) {
self.name = sanitize_string(&self.name);
}
}

View File

@@ -0,0 +1,32 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "article_category")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub sort_order: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,38 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
/// 文章版本历史
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "article_revision")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub article_id: Uuid,
pub revision_number: i32,
pub title: String,
pub content: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::article::Entity",
from = "Column::ArticleId",
to = "super::article::Column::Id"
)]
Article,
}
impl Related<super::article::Entity> for Entity {
fn to() -> RelationDef {
Relation::Article.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,34 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "article_tag")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub created_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<Uuid>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTimeUtc>,
pub version: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::article_article_tag::Entity")]
ArticleTagRelation,
}
impl Related<super::article_article_tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::ArticleTagRelation.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -1,5 +1,9 @@
pub mod appointment;
pub mod article;
pub mod article_article_tag;
pub mod article_category;
pub mod article_revision;
pub mod article_tag;
pub mod critical_value_threshold;
pub mod consent;
pub mod consultation_message;

View File

@@ -0,0 +1,81 @@
//! 文章分类 Handler
use axum::Extension;
use axum::extract::{FromRef, Json, Path, State};
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::article_dto::{CategoryResp, CreateCategoryReq, UpdateCategoryReq};
use crate::service::article_category_service;
use crate::state::HealthState;
pub async fn list_categories<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<CategoryResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.list")?;
let result = article_category_service::list_categories(&state, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn create_category<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
mut req: Json<CreateCategoryReq>,
) -> Result<Json<ApiResponse<CategoryResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
req.sanitize();
let result = article_category_service::create_category(
&state, ctx.tenant_id, Some(ctx.user_id), req.0,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn update_category<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
mut req: Json<UpdateCategoryReq>,
) -> Result<Json<ApiResponse<CategoryResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
req.sanitize();
let result = article_category_service::update_category(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteCategoryReq {
pub version: i32,
}
pub async fn delete_category<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Json(req): Json<DeleteCategoryReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
article_category_service::delete_category(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -5,7 +5,7 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, UpdateArticleReq};
use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, ReviewArticleReq, UpdateArticleReq};
use crate::service::article_service;
use crate::state::HealthState;
@@ -22,8 +22,8 @@ where
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = article_service::list_articles(
&state, ctx.tenant_id, page, page_size, params.category,
params.status, params.category_id, params.tag_id, params.keyword,
&state, ctx.tenant_id, page, page_size,
params.category, params.status, params.category_id, params.tag_id, params.keyword,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
@@ -97,3 +97,102 @@ where
article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
Ok(Json(ApiResponse::ok(())))
}
// ---------------------------------------------------------------------------
// 审核工作流
// ---------------------------------------------------------------------------
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct VersionReq {
pub version: i32,
}
/// 提交审核
pub async fn submit_article<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Json(req): Json<VersionReq>,
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
let result = article_service::submit_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 审核通过并发布
pub async fn approve_article<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
mut req: Json<ReviewArticleReq>,
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.review")?;
req.sanitize();
let version = req.version.unwrap_or(0);
let result = article_service::approve_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, version,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 审核拒绝
pub async fn reject_article<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
mut req: Json<ReviewArticleReq>,
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.review")?;
req.sanitize();
let version = req.version.unwrap_or(0);
let result = article_service::reject_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0, version,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 撤回发布
pub async fn unpublish_article<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Json(req): Json<VersionReq>,
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
let result = article_service::unpublish_article(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 浏览计数
pub async fn view_article<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
article_service::increment_view_count(&state, ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -0,0 +1,63 @@
//! 文章标签 Handler
use axum::Extension;
use axum::extract::{FromRef, Json, Path, State};
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::article_dto::{CreateTagReq, TagResp};
use crate::service::article_tag_service;
use crate::state::HealthState;
pub async fn list_tags<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<TagResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.list")?;
let result = article_tag_service::list_tags(&state, ctx.tenant_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn create_tag<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
mut req: Json<CreateTagReq>,
) -> Result<Json<ApiResponse<TagResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
req.sanitize();
let result = article_tag_service::create_tag(
&state, ctx.tenant_id, Some(ctx.user_id), req.0,
).await?;
Ok(Json(ApiResponse::ok(result)))
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct DeleteTagReq {
pub version: i32,
}
pub async fn delete_tag<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<uuid::Uuid>,
Json(req): Json<DeleteTagReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
article_tag_service::delete_tag(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.version,
).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -1,5 +1,7 @@
pub mod appointment_handler;
pub mod article_category_handler;
pub mod article_handler;
pub mod article_tag_handler;
pub mod consultation_handler;
pub mod consent_handler;
pub mod critical_value_threshold_handler;

View File

@@ -6,7 +6,7 @@ use erp_core::events::EventBus;
use erp_core::module::{ErpModule, PermissionDescriptor};
use crate::handler::{
appointment_handler, article_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, diagnosis_handler, dialysis_handler, doctor_handler, follow_up_handler,
appointment_handler, article_category_handler, article_handler, article_tag_handler, consultation_handler, consent_handler, critical_value_threshold_handler, daily_monitoring_handler, diagnosis_handler, dialysis_handler, doctor_handler, follow_up_handler,
health_data_handler, patient_handler, points_handler, stats_handler,
};
@@ -317,6 +317,48 @@ impl HealthModule {
.put(article_handler::update_article)
.delete(article_handler::delete_article),
)
// 资讯审核工作流
.route(
"/health/articles/{id}/submit",
axum::routing::post(article_handler::submit_article),
)
.route(
"/health/articles/{id}/approve",
axum::routing::post(article_handler::approve_article),
)
.route(
"/health/articles/{id}/reject",
axum::routing::post(article_handler::reject_article),
)
.route(
"/health/articles/{id}/unpublish",
axum::routing::post(article_handler::unpublish_article),
)
.route(
"/health/articles/{id}/view",
axum::routing::post(article_handler::view_article),
)
// 资讯分类
.route(
"/health/article-categories",
axum::routing::get(article_category_handler::list_categories)
.post(article_category_handler::create_category),
)
.route(
"/health/article-categories/{id}",
axum::routing::put(article_category_handler::update_category)
.delete(article_category_handler::delete_category),
)
// 资讯标签
.route(
"/health/article-tags",
axum::routing::get(article_tag_handler::list_tags)
.post(article_tag_handler::create_tag),
)
.route(
"/health/article-tags/{id}",
axum::routing::delete(article_tag_handler::delete_tag),
)
// 积分商城 — 患者端
.route(
"/health/points/account",
@@ -630,6 +672,12 @@ impl ErpModule for HealthModule {
description: "创建、编辑、删除健康资讯文章".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.articles.review".into(),
name: "审核资讯".into(),
description: "审核通过或拒绝资讯文章发布".into(),
module: "health".into(),
},
PermissionDescriptor {
code: "health.points.list".into(),
name: "查看积分".into(),

View File

@@ -0,0 +1,148 @@
//! 文章分类 Service — CRUD
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder};
use uuid::Uuid;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use crate::dto::article_dto::{CategoryResp, CreateCategoryReq, UpdateCategoryReq};
use crate::entity::article_category;
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
pub async fn list_categories(
state: &HealthState,
tenant_id: Uuid,
) -> HealthResult<Vec<CategoryResp>> {
let models = article_category::Entity::find()
.filter(article_category::Column::TenantId.eq(tenant_id))
.filter(article_category::Column::DeletedAt.is_null())
.order_by_asc(article_category::Column::SortOrder)
.order_by_asc(article_category::Column::Name)
.all(&state.db)
.await?;
Ok(models.into_iter().map(|m| CategoryResp {
id: m.id,
name: m.name,
slug: m.slug,
parent_id: m.parent_id,
description: m.description,
sort_order: m.sort_order,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}).collect())
}
pub async fn create_category(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateCategoryReq,
) -> HealthResult<CategoryResp> {
let now = Utc::now();
let active = article_category::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
name: Set(req.name),
slug: Set(req.slug),
parent_id: Set(req.parent_id),
description: Set(req.description),
sort_order: Set(req.sort_order.unwrap_or(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?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "article_category.created", "article_category")
.with_resource_id(m.id),
&state.db,
).await;
Ok(CategoryResp {
id: m.id, name: m.name, slug: m.slug, parent_id: m.parent_id,
description: m.description, sort_order: m.sort_order,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn update_category(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
req: UpdateCategoryReq,
) -> HealthResult<CategoryResp> {
let model = article_category::Entity::find()
.filter(article_category::Column::Id.eq(id))
.filter(article_category::Column::TenantId.eq(tenant_id))
.filter(article_category::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::ArticleNotFound)?;
let next_ver = check_version(req.version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: article_category::ActiveModel = model.into();
if let Some(v) = req.name { active.name = Set(v); }
if let Some(v) = req.slug { active.slug = Set(Some(v)); }
if let Some(v) = req.parent_id { active.parent_id = Set(Some(v)); }
if let Some(v) = req.description { active.description = Set(Some(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?;
Ok(CategoryResp {
id: m.id, name: m.name, slug: m.slug, parent_id: m.parent_id,
description: m.description, sort_order: m.sort_order,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_category(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = article_category::Entity::find()
.filter(article_category::Column::Id.eq(id))
.filter(article_category::Column::TenantId.eq(tenant_id))
.filter(article_category::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::ArticleNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: article_category::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_category.deleted", "article_category")
.with_resource_id(id),
&state.db,
).await;
Ok(())
}

View File

@@ -1,4 +1,4 @@
//! 健康资讯 Service — 文章 CRUD
//! 健康资讯 Service — 文章 CRUD + 审核工作流
use chrono::Utc;
use sea_orm::entity::prelude::*;
@@ -10,53 +10,93 @@ use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::article_dto::{ArticleListItem, ArticleResp, CreateArticleReq, UpdateArticleReq};
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::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())
.filter(article::Column::PublishedAt.is_not_null());
.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::PublishedAt)
.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 data = models.into_iter().map(model_to_list_item).collect();
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,
});
}
Ok(PaginatedResponse {
data,
total,
page,
page_size: limit,
total_pages,
})
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
/// 获取文章详情
/// 获取文章详情(管理端,不过滤发布状态)
pub async fn get_article(
state: &HealthState,
tenant_id: Uuid,
@@ -66,58 +106,174 @@ pub async fn get_article(
.filter(article::Column::Id.eq(id))
.filter(article::Column::TenantId.eq(tenant_id))
.filter(article::Column::DeletedAt.is_null())
.filter(article::Column::PublishedAt.is_not_null())
.one(&state.db)
.await?
.ok_or(HealthError::ArticleNotFound)?;
Ok(model_to_resp(model))
let tags = load_article_tags(state, model.id).await?;
Ok(full_model_to_resp(model, tags))
}
// ---------------------------------------------------------------------------
// 内部辅助
// 审核工作流
// ---------------------------------------------------------------------------
fn model_to_list_item(m: article::Model) -> ArticleListItem {
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: None,
tags: vec![],
/// 提交审核: 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)?;
if model.status != "draft" && model.status != "rejected" {
return Err(HealthError::InvalidStatusTransition(format!("无法从 {} 提交审核", model.status)));
}
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))
}
fn model_to_resp(m: article::Model) -> 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: None,
tags: vec![],
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
/// 审核通过并发布: 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)?;
if model.status != "pending_review" {
return Err(HealthError::InvalidStatusTransition(format!("无法从 {} 审核通过", model.status)));
}
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;
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)?;
if model.status != "pending_review" {
return Err(HealthError::InvalidStatusTransition(format!("无法从 {} 审核拒绝", model.status)));
}
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;
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)?;
if model.status != "published" {
return Err(HealthError::InvalidStatusTransition(format!("无法从 {} 撤回发布", model.status)));
}
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(())
}
// ---------------------------------------------------------------------------
@@ -140,6 +296,7 @@ pub async fn create_article(
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()),
@@ -159,13 +316,17 @@ pub async fn create_article(
};
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;
Ok(model_to_resp(m))
let tags = load_article_tags(state, m.id).await?;
Ok(full_model_to_resp(m, tags))
}
pub async fn update_article(
@@ -175,38 +336,45 @@ pub async fn update_article(
operator_id: Option<Uuid>,
req: UpdateArticleReq,
) -> 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 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;
Ok(model_to_resp(m))
let tags = load_article_tags(state, m.id).await?;
Ok(full_model_to_resp(m, tags))
}
pub async fn delete_article(
@@ -216,13 +384,7 @@ pub async fn delete_article(
operator_id: Option<Uuid>,
expected_version: i32,
) -> 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 model = find_article(state, tenant_id, id).await?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
@@ -242,3 +404,115 @@ pub async fn delete_article(
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(())
}

View File

@@ -0,0 +1,108 @@
//! 文章标签 Service — CRUD
use chrono::Utc;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder};
use uuid::Uuid;
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use crate::dto::article_dto::{CreateTagReq, TagResp};
use crate::entity::article_tag;
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
pub async fn list_tags(
state: &HealthState,
tenant_id: Uuid,
) -> HealthResult<Vec<TagResp>> {
let models = article_tag::Entity::find()
.filter(article_tag::Column::TenantId.eq(tenant_id))
.filter(article_tag::Column::DeletedAt.is_null())
.order_by_asc(article_tag::Column::Name)
.all(&state.db)
.await?;
Ok(models.into_iter().map(|m| TagResp {
id: m.id,
name: m.name,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
}).collect())
}
pub async fn create_tag(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateTagReq,
) -> HealthResult<TagResp> {
let now = Utc::now();
let active = article_tag::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
name: Set(req.name),
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?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "article_tag.created", "article_tag")
.with_resource_id(m.id),
&state.db,
).await;
Ok(TagResp {
id: m.id, name: m.name,
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
})
}
pub async fn delete_tag(
state: &HealthState,
tenant_id: Uuid,
id: Uuid,
operator_id: Option<Uuid>,
expected_version: i32,
) -> HealthResult<()> {
let model = article_tag::Entity::find()
.filter(article_tag::Column::Id.eq(id))
.filter(article_tag::Column::TenantId.eq(tenant_id))
.filter(article_tag::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::ArticleNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: article_tag::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?;
// 清理关联
use crate::entity::article_article_tag;
article_article_tag::Entity::delete_many()
.filter(article_article_tag::Column::TagId.eq(id))
.exec(&state.db)
.await?;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "article_tag.deleted", "article_tag")
.with_resource_id(id),
&state.db,
).await;
Ok(())
}

View File

@@ -1,5 +1,7 @@
pub mod appointment_service;
pub mod article_category_service;
pub mod article_service;
pub mod article_tag_service;
pub mod consultation_service;
pub mod consent_service;
pub mod critical_value_threshold_service;