fix(health+server): 多专家组生产就绪度分析 — DTO 校验补全 + 审计日志用户名

五维度分析结果(DevOps 4.0/10, 医疗合规 9C/6P/1NC, 前端 Lighthouse 94/100/100):

1. Article/Category/Tag DTO 补全 #[derive(Validate)] + handler .validate() 调用(6 DTO + 8 handler)
2. 审计日志 API 新增 user_name 字段(批量关联 users 表),仪表盘显示用户名而非 UUID
3. 多专家组分析报告存档 docs/discussions/
This commit is contained in:
iven
2026-05-21 17:53:00 +08:00
parent 7ad5ddb898
commit b0f96258ee
6 changed files with 318 additions and 14 deletions

View File

@@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use validator::Validate;
use erp_core::sanitize::{
sanitize_option, sanitize_rich_html_option, sanitize_string, strip_html_tags,
@@ -72,16 +73,23 @@ pub struct ArticleListParams {
pub keyword: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateArticleReq {
#[validate(length(min = 1, max = 500, message = "文章标题长度须在1-500之间"))]
pub title: String,
#[validate(length(max = 2000, message = "摘要最多2000字"))]
pub summary: Option<String>,
pub content: Option<String>,
#[validate(length(max = 500, message = "封面URL最多500字"))]
pub cover_image: Option<String>,
#[validate(length(max = 100, message = "分类名最多100字"))]
pub category: Option<String>,
#[validate(length(max = 100, message = "作者名最多100字"))]
pub author: Option<String>,
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
#[validate(length(max = 200, message = "slug最多200字"))]
pub slug: Option<String>,
#[validate(length(max = 50, message = "内容类型最多50字"))]
pub content_type: Option<String>,
/// 分类 ID
pub category_id: Option<Uuid>,
@@ -103,16 +111,23 @@ impl CreateArticleReq {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateArticleReq {
#[validate(length(min = 1, max = 500, message = "文章标题长度须在1-500之间"))]
pub title: Option<String>,
#[validate(length(max = 2000, message = "摘要最多2000字"))]
pub summary: Option<String>,
pub content: Option<String>,
#[validate(length(max = 500, message = "封面URL最多500字"))]
pub cover_image: Option<String>,
#[validate(length(max = 100, message = "分类名最多100字"))]
pub category: Option<String>,
#[validate(length(max = 100, message = "作者名最多100字"))]
pub author: Option<String>,
pub published_at: Option<chrono::DateTime<chrono::Utc>>,
#[validate(length(max = 200, message = "slug最多200字"))]
pub slug: Option<String>,
#[validate(length(max = 50, message = "内容类型最多50字"))]
pub content_type: Option<String>,
/// 分类 ID
pub category_id: Option<Uuid>,
@@ -138,9 +153,10 @@ impl UpdateArticleReq {
}
/// 审核文章请求
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct ReviewArticleReq {
/// 审核备注
#[validate(length(max = 2000, message = "审核备注最多2000字"))]
pub note: Option<String>,
/// 文章版本号(乐观锁)
pub version: Option<i32>,
@@ -184,8 +200,9 @@ pub struct CategoryResp {
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateCategoryReq {
#[validate(length(min = 1, max = 100, message = "分类名长度须在1-100之间"))]
pub name: String,
pub slug: Option<String>,
pub parent_id: Option<Uuid>,
@@ -201,8 +218,9 @@ impl CreateCategoryReq {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateCategoryReq {
#[validate(length(min = 1, max = 100, message = "分类名长度须在1-100之间"))]
pub name: Option<String>,
pub slug: Option<String>,
pub parent_id: Option<Uuid>,
@@ -234,8 +252,9 @@ pub struct TagResp {
pub version: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct CreateTagReq {
#[validate(length(min = 1, max = 50, message = "标签名长度须在1-50之间"))]
pub name: String,
}
@@ -245,8 +264,9 @@ impl CreateTagReq {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateTagReq {
#[validate(length(min = 1, max = 50, message = "标签名长度须在1-50之间"))]
pub name: String,
pub version: i32,
}

View File

@@ -10,6 +10,8 @@ use crate::dto::article_dto::{CategoryResp, CreateCategoryReq, UpdateCategoryReq
use crate::service::article_category_service;
use crate::state::HealthState;
use validator::Validate;
pub async fn list_categories<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
@@ -33,6 +35,9 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
(*req)
.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
let result =
article_category_service::create_category(&state, ctx.tenant_id, Some(ctx.user_id), req.0)
@@ -51,6 +56,9 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
(*req)
.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
let result = article_category_service::update_category(
&state,

View File

@@ -12,6 +12,8 @@ use crate::dto::article_dto::{
use crate::service::article_service;
use crate::state::HealthState;
use validator::Validate;
pub async fn list_articles<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
@@ -107,6 +109,9 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
(*req)
.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
if req.title.trim().is_empty() {
return Err(AppError::Validation("文章标题不能为空".into()));
@@ -127,6 +132,9 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
(*req)
.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
let result =
article_service::update_article(&state, ctx.tenant_id, id, Some(ctx.user_id), req.0)
@@ -194,6 +202,9 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.review")?;
(*req)
.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
let version = req.version.unwrap_or(0);
let result = article_service::approve_article(
@@ -220,6 +231,9 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.review")?;
(*req)
.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
let version = req.version.unwrap_or(0);
let result = article_service::reject_article(

View File

@@ -10,6 +10,8 @@ use crate::dto::article_dto::{CreateTagReq, TagResp, UpdateTagReq};
use crate::service::article_tag_service;
use crate::state::HealthState;
use validator::Validate;
pub async fn list_tags<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
@@ -33,6 +35,9 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
(*req)
.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
if req.name.trim().is_empty() {
return Err(AppError::Validation("标签名称不能为空".into()));
@@ -53,6 +58,9 @@ where
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.articles.manage")?;
(*req)
.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
req.sanitize();
let result =
article_tag_service::update_tag(&state, ctx.tenant_id, id, Some(ctx.user_id), req.0)

View File

@@ -3,13 +3,12 @@ use axum::extract::{Extension, FromRef, Query, State};
use axum::response::Json;
use axum::routing::get;
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use erp_core::entity::audit_log;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
/// 审计日志查询参数。
#[derive(Debug, Deserialize)]
pub struct AuditLogQuery {
pub resource_type: Option<String>,
@@ -18,15 +17,82 @@ pub struct AuditLogQuery {
pub page_size: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct AuditLogResp {
pub id: uuid::Uuid,
pub tenant_id: uuid::Uuid,
pub user_id: Option<uuid::Uuid>,
pub user_name: Option<String>,
pub action: String,
pub resource_type: String,
pub resource_id: Option<uuid::Uuid>,
pub old_value: Option<serde_json::Value>,
pub new_value: Option<serde_json::Value>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
impl From<audit_log::Model> for AuditLogResp {
fn from(m: audit_log::Model) -> Self {
Self {
id: m.id,
tenant_id: m.tenant_id,
user_id: m.user_id,
user_name: None,
action: m.action,
resource_type: m.resource_type,
resource_id: m.resource_id,
old_value: m.old_value,
new_value: m.new_value,
ip_address: m.ip_address,
user_agent: m.user_agent,
created_at: m.created_at,
}
}
}
async fn resolve_user_names(
db: &sea_orm::DatabaseConnection,
items: &[audit_log::Model],
) -> std::collections::HashMap<uuid::Uuid, String> {
use erp_auth::entity::user;
let user_ids: Vec<uuid::Uuid> = items
.iter()
.filter_map(|i| i.user_id)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
if user_ids.is_empty() {
return std::collections::HashMap::new();
}
let users = user::Entity::find()
.filter(user::Column::Id.is_in(user_ids))
.all(db)
.await
.unwrap_or_default();
users
.into_iter()
.map(|u| {
let name = u
.display_name
.filter(|n| !n.is_empty())
.unwrap_or(u.username);
(u.id, name)
})
.collect()
}
/// GET /audit-logs
///
/// 分页查询审计日志,支持按 resource_type 和 user_id 过滤。
/// 租户隔离通过 JWT 中间件注入的 TenantContext 实现。
pub async fn list_audit_logs<S>(
State(db): State<sea_orm::DatabaseConnection>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<AuditLogQuery>,
) -> Result<Json<ApiResponse<PaginatedResponse<audit_log::Model>>>, AppError>
) -> Result<Json<ApiResponse<PaginatedResponse<AuditLogResp>>>, AppError>
where
sea_orm::DatabaseConnection: FromRef<S>,
S: Clone + Send + Sync + 'static,
@@ -58,10 +124,22 @@ where
.await
.map_err(|e| AppError::Internal(format!("查询审计日志失败: {e}")))?;
let user_map = resolve_user_names(&db, &items).await;
let resp_items: Vec<AuditLogResp> = items
.into_iter()
.map(|m| {
let user_name = m.user_id.and_then(|uid| user_map.get(&uid).cloned());
let mut resp = AuditLogResp::from(m);
resp.user_name = user_name;
resp
})
.collect();
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: items,
data: resp_items,
total,
page,
page_size,