diff --git a/crates/erp-health/src/dto/article_dto.rs b/crates/erp-health/src/dto/article_dto.rs index cde2635..0a461aa 100644 --- a/crates/erp-health/src/dto/article_dto.rs +++ b/crates/erp-health/src/dto/article_dto.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; +use erp_core::sanitize::{sanitize_option, sanitize_string, strip_html_tags}; + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ArticleResp { pub id: Uuid, @@ -34,3 +36,46 @@ pub struct ArticleListParams { pub page_size: Option, pub category: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct CreateArticleReq { + pub title: String, + pub summary: Option, + pub content: Option, + pub cover_image: Option, + pub category: Option, + pub author: Option, + pub published_at: Option>, +} + +impl CreateArticleReq { + pub fn sanitize(&mut self) { + self.title = sanitize_string(&self.title); + self.summary = sanitize_option(self.summary.take()); + self.content = sanitize_option(self.content.take()); + self.category = sanitize_option(self.category.take()); + self.author = sanitize_option(self.author.take()); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateArticleReq { + pub title: Option, + pub summary: Option, + pub content: Option, + pub cover_image: Option, + pub category: Option, + pub author: Option, + pub published_at: Option>, + pub version: i32, +} + +impl UpdateArticleReq { + pub fn sanitize(&mut self) { + if let Some(ref mut v) = self.title { *v = strip_html_tags(v); } + self.summary = sanitize_option(self.summary.take()); + self.content = sanitize_option(self.content.take()); + self.category = sanitize_option(self.category.take()); + self.author = sanitize_option(self.author.take()); + } +} diff --git a/crates/erp-health/src/handler/article_handler.rs b/crates/erp-health/src/handler/article_handler.rs index 2212852..9b80ac8 100644 --- a/crates/erp-health/src/handler/article_handler.rs +++ b/crates/erp-health/src/handler/article_handler.rs @@ -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}; +use crate::dto::article_dto::{ArticleListItem, ArticleListParams, ArticleResp, CreateArticleReq, UpdateArticleReq}; use crate::service::article_service; use crate::state::HealthState; @@ -41,3 +41,52 @@ where let result = article_service::get_article(&state, ctx.tenant_id, id).await?; Ok(Json(ApiResponse::ok(result))) } + +pub async fn create_article( + State(state): State, + Extension(ctx): Extension, + mut req: Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.articles.manage")?; + req.sanitize(); + let result = article_service::create_article( + &state, ctx.tenant_id, Some(ctx.user_id), req.0, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn update_article( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + mut req: Json, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.articles.manage")?; + req.sanitize(); + let result = article_service::update_article( + &state, ctx.tenant_id, id, Some(ctx.user_id), req.0, + ).await?; + Ok(Json(ApiResponse::ok(result))) +} + +pub async fn delete_article( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + HealthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "health.articles.manage")?; + article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?; + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-health/src/module.rs b/crates/erp-health/src/module.rs index c578da5..8f3a72b 100644 --- a/crates/erp-health/src/module.rs +++ b/crates/erp-health/src/module.rs @@ -221,11 +221,14 @@ impl HealthModule { // 健康资讯 .route( "/health/articles", - axum::routing::get(article_handler::list_articles), + axum::routing::get(article_handler::list_articles) + .post(article_handler::create_article), ) .route( "/health/articles/{id}", - axum::routing::get(article_handler::get_article), + axum::routing::get(article_handler::get_article) + .put(article_handler::update_article) + .delete(article_handler::delete_article), ) } } diff --git a/crates/erp-health/src/service/article_service.rs b/crates/erp-health/src/service/article_service.rs index 8bf41b7..5390568 100644 --- a/crates/erp-health/src/service/article_service.rs +++ b/crates/erp-health/src/service/article_service.rs @@ -1,12 +1,16 @@ -//! 健康资讯 Service — 文章列表和详情 +//! 健康资讯 Service — 文章 CRUD +use chrono::Utc; use sea_orm::entity::prelude::*; -use sea_orm::{QueryOrder, QuerySelect}; +use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect}; use uuid::Uuid; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; use erp_core::types::PaginatedResponse; -use crate::dto::article_dto::{ArticleListItem, ArticleResp}; +use crate::dto::article_dto::{ArticleListItem, ArticleResp, CreateArticleReq, UpdateArticleReq}; use crate::entity::article; use crate::error::{HealthError, HealthResult}; use crate::state::HealthState; @@ -101,3 +105,113 @@ fn model_to_resp(m: article::Model) -> ArticleResp { version: m.version, } } + +// --------------------------------------------------------------------------- +// 文章管理(写入) +// --------------------------------------------------------------------------- + +pub async fn create_article( + state: &HealthState, + tenant_id: Uuid, + operator_id: Option, + req: CreateArticleReq, +) -> HealthResult { + let now = Utc::now(); + + let active = article::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + title: Set(req.title), + summary: Set(req.summary), + content: Set(req.content.unwrap_or_default()), + cover_image: Set(req.cover_image), + category: Set(req.category), + author: Set(req.author), + published_at: Set(req.published_at), + 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.created", "article") + .with_resource_id(m.id), + &state.db, + ).await; + + Ok(model_to_resp(m)) +} + +pub async fn update_article( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, + req: UpdateArticleReq, +) -> 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 next_ver = check_version(req.version, model.version) + .map_err(|_| HealthError::VersionMismatch)?; + + 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.author { active.author = Set(Some(v)); } + if let Some(v) = req.published_at { active.published_at = Set(Some(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?; + + 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)) +} + +pub async fn delete_article( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, + operator_id: Option, +) -> 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 mut active: article::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.update(&state.db).await?; + + audit_service::record( + AuditLog::new(tenant_id, operator_id, "article.deleted", "article") + .with_resource_id(id), + &state.db, + ).await; + + Ok(()) +}