feat(health): 内容管理模块 — 审核/分类/标签/富文本编辑器
后端: - 文章审核状态机: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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
32
crates/erp-health/src/entity/article_category.rs
Normal file
32
crates/erp-health/src/entity/article_category.rs
Normal 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 {}
|
||||
38
crates/erp-health/src/entity/article_revision.rs
Normal file
38
crates/erp-health/src/entity/article_revision.rs
Normal 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 {}
|
||||
34
crates/erp-health/src/entity/article_tag.rs
Normal file
34
crates/erp-health/src/entity/article_tag.rs
Normal 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 {}
|
||||
@@ -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;
|
||||
|
||||
81
crates/erp-health/src/handler/article_category_handler.rs
Normal file
81
crates/erp-health/src/handler/article_category_handler.rs
Normal 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(())))
|
||||
}
|
||||
@@ -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(())))
|
||||
}
|
||||
|
||||
63
crates/erp-health/src/handler/article_tag_handler.rs
Normal file
63
crates/erp-health/src/handler/article_tag_handler.rs
Normal 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(())))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
148
crates/erp-health/src/service/article_category_service.rs
Normal file
148
crates/erp-health/src/service/article_category_service.rs
Normal 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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
108
crates/erp-health/src/service/article_tag_service.rs
Normal file
108
crates/erp-health/src/service/article_tag_service.rs
Normal 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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user