feat(health): 文章管理 CRUD 补充 create/update/delete
- article_dto 新增 CreateArticleReq/UpdateArticleReq 含 sanitize - article_service 新增 create_article/update_article/delete_article 含审计日志 - article_handler 新增三个 handler 端点含权限校验 - module.rs 文章路由合并 POST/PUT/DELETE
This commit is contained in:
@@ -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<u64>,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateArticleReq {
|
||||
pub title: String,
|
||||
pub summary: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub cover_image: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub summary: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub cover_image: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
mut req: Json<CreateArticleReq>,
|
||||
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
mut req: Json<UpdateArticleReq>,
|
||||
) -> Result<Json<ApiResponse<ArticleResp>>, AppError>
|
||||
where
|
||||
HealthState: FromRef<S>,
|
||||
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<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,
|
||||
{
|
||||
require_permission(&ctx, "health.articles.manage")?;
|
||||
article_service::delete_article(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Uuid>,
|
||||
req: CreateArticleReq,
|
||||
) -> HealthResult<ArticleResp> {
|
||||
let now = Utc::now();
|
||||
|
||||
let active = article::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
tenant_id: Set(tenant_id),
|
||||
title: Set(req.title),
|
||||
summary: Set(req.summary),
|
||||
content: Set(req.content.unwrap_or_default()),
|
||||
cover_image: Set(req.cover_image),
|
||||
category: Set(req.category),
|
||||
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<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 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<Uuid>,
|
||||
) -> 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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user