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 utoipa::{IntoParams, ToSchema};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use erp_core::sanitize::{sanitize_option, sanitize_string, strip_html_tags};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct ArticleResp {
|
pub struct ArticleResp {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
@@ -34,3 +36,46 @@ pub struct ArticleListParams {
|
|||||||
pub page_size: Option<u64>,
|
pub page_size: Option<u64>,
|
||||||
pub category: Option<String>,
|
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::rbac::require_permission;
|
||||||
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
|
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::service::article_service;
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
|
|
||||||
@@ -41,3 +41,52 @@ where
|
|||||||
let result = article_service::get_article(&state, ctx.tenant_id, id).await?;
|
let result = article_service::get_article(&state, ctx.tenant_id, id).await?;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
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(
|
.route(
|
||||||
"/health/articles",
|
"/health/articles",
|
||||||
axum::routing::get(article_handler::list_articles),
|
axum::routing::get(article_handler::list_articles)
|
||||||
|
.post(article_handler::create_article),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/health/articles/{id}",
|
"/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::entity::prelude::*;
|
||||||
use sea_orm::{QueryOrder, QuerySelect};
|
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
|
||||||
use uuid::Uuid;
|
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 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::entity::article;
|
||||||
use crate::error::{HealthError, HealthResult};
|
use crate::error::{HealthError, HealthResult};
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
@@ -101,3 +105,113 @@ fn model_to_resp(m: article::Model) -> ArticleResp {
|
|||||||
version: m.version,
|
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