feat(health): 文章管理 CRUD 补充 create/update/delete
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

- 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:
iven
2026-04-25 00:34:15 +08:00
parent 43e127d4f7
commit 994119ded1
4 changed files with 217 additions and 6 deletions

View File

@@ -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());
}
}

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};
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(())))
}

View File

@@ -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),
)
}
}

View File

@@ -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(())
}