From e94f5bc00cccf3f8fcc999606c77ae5c1a652c66 Mon Sep 17 00:00:00 2001 From: iven Date: Wed, 27 May 2026 00:17:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E6=96=87=E6=A1=A3=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20handler=20=E2=80=94=20CRUD=20+=20Multipart=20?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list_documents: 分页列表(按知识库过滤) - get_document: 文档详情 - create_manual_document: 手动输入文档 - upload_document: Multipart 文件上传(20MB 限制 + 自动解析) - delete_document: 软删除(级联减计数) - 5 条路由注册到 /ai/knowledge-bases/{kb_id}/documents + /ai/documents/* Phase 2 Task 10 --- crates/erp-ai/src/handler/document_handler.rs | 254 ++++++++++++++++++ crates/erp-ai/src/handler/mod.rs | 1 + crates/erp-ai/src/module.rs | 21 ++ 3 files changed, 276 insertions(+) create mode 100644 crates/erp-ai/src/handler/document_handler.rs diff --git a/crates/erp-ai/src/handler/document_handler.rs b/crates/erp-ai/src/handler/document_handler.rs new file mode 100644 index 0000000..5b8c3ac --- /dev/null +++ b/crates/erp-ai/src/handler/document_handler.rs @@ -0,0 +1,254 @@ +use axum::Json; +use axum::extract::{Extension, FromRef, Multipart, Path, State}; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; +use serde::Deserialize; + +use crate::service::document::{CreateDocumentReq, ListDocumentsQuery, UploadDocumentParams}; +use crate::state::AiState; + +#[derive(Debug, Deserialize)] +pub struct ListDocumentsParams { + pub kb_id: uuid::Uuid, + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +#[utoipa::path( + get, + path = "/ai/knowledge-bases/{kb_id}/documents", + responses((status = 200, description = "文档列表")), + tag = "知识库文档", + security(("bearer_auth" = [])), +)] +pub async fn list_documents( + State(state): State, + Extension(ctx): Extension, + Path(kb_id): Path, + axum::extract::Query(params): axum::extract::Query, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.knowledge.list")?; + + let query = ListDocumentsQuery { + status: params.status, + page: params.page, + page_size: params.page_size, + }; + let (items, total) = state + .document + .list_documents(ctx.tenant_id, kb_id, &query) + .await?; + + Ok(Json(ApiResponse::ok(serde_json::json!({ + "data": items, + "total": total, + "page": query.page.unwrap_or(1), + "page_size": query.page_size.unwrap_or(20), + })))) +} + +#[derive(Debug, Deserialize)] +pub struct ListDocumentsParamsNoKb { + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +#[utoipa::path( + get, + path = "/ai/documents/{id}", + responses((status = 200, description = "文档详情")), + tag = "知识库文档", + security(("bearer_auth" = [])), +)] +pub async fn get_document( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.knowledge.list")?; + let doc = state.document.get_document(ctx.tenant_id, id).await?; + Ok(Json(ApiResponse::ok( + serde_json::to_value(&doc).unwrap_or_default(), + ))) +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateDocumentBody { + pub kb_id: uuid::Uuid, + pub title: String, + pub doc_type: Option, + pub source_type: Option, + pub source_url: Option, + pub content: Option, +} + +#[utoipa::path( + post, + path = "/ai/documents/manual", + request_body = CreateDocumentBody, + responses((status = 200, description = "创建手动文档")), + tag = "知识库文档", + security(("bearer_auth" = [])), +)] +pub async fn create_manual_document( + State(state): State, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.knowledge.manage")?; + + if body.title.trim().is_empty() { + return Err(erp_core::error::AppError::Validation( + "文档标题不能为空".into(), + )); + } + + let req = CreateDocumentReq { + title: body.title, + doc_type: body.doc_type, + source_type: body.source_type, + source_url: body.source_url, + content: body.content, + }; + + let id = state + .document + .create_manual_document(ctx.tenant_id, ctx.user_id, body.kb_id, req) + .await?; + + Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id })))) +} + +const MAX_FILE_SIZE: usize = 20 * 1024 * 1024; // 20MB + +#[utoipa::path( + post, + path = "/ai/documents/upload", + responses((status = 200, description = "上传文档文件")), + tag = "知识库文档", + security(("bearer_auth" = [])), +)] +pub async fn upload_document( + State(state): State, + Extension(ctx): Extension, + mut multipart: Multipart, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.knowledge.manage")?; + + let mut kb_id: Option = None; + let mut title: Option = None; + let mut file_data: Option<(String, String, Vec)> = None; // (filename, mime, bytes) + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| erp_core::error::AppError::Validation(format!("读取上传字段失败: {}", e)))? + { + let field_name = field.name().unwrap_or("").to_string(); + + match field_name.as_str() { + "kb_id" => { + let text = field.text().await.map_err(|e| { + erp_core::error::AppError::Validation(format!("读取 kb_id 失败: {}", e)) + })?; + kb_id = + Some(uuid::Uuid::parse_str(&text).map_err(|_| { + erp_core::error::AppError::Validation("kb_id 格式错误".into()) + })?); + } + "title" => { + let text = field.text().await.map_err(|e| { + erp_core::error::AppError::Validation(format!("读取 title 失败: {}", e)) + })?; + title = Some(text); + } + "file" => { + let file_name = field.file_name().unwrap_or("unknown").to_string(); + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + let bytes = field.bytes().await.map_err(|e| { + erp_core::error::AppError::Validation(format!("读取文件失败: {}", e)) + })?; + + if bytes.len() > MAX_FILE_SIZE { + return Err(erp_core::error::AppError::Validation(format!( + "文件大小超过限制 (最大 {}MB)", + MAX_FILE_SIZE / 1024 / 1024 + ))); + } + + file_data = Some((file_name, content_type, bytes.to_vec())); + } + _ => {} + } + } + + let kb_id = + kb_id.ok_or_else(|| erp_core::error::AppError::Validation("缺少 kb_id 字段".into()))?; + let (file_name, mime_type, bytes) = + file_data.ok_or_else(|| erp_core::error::AppError::Validation("缺少 file 字段".into()))?; + + let doc_title = title.unwrap_or_else(|| file_name.clone()); + + // 解析文档内容 + let content = crate::service::document::parser::parse_document(&file_name, &mime_type, &bytes) + .map_err(|e| erp_core::error::AppError::Validation(format!("文档解析失败: {}", e)))?; + + let params = UploadDocumentParams { + file_name, + file_size: bytes.len() as i64, + mime_type, + content, + }; + + let id = state + .document + .create_upload_document(ctx.tenant_id, ctx.user_id, kb_id, doc_title, params) + .await?; + + Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id })))) +} + +#[utoipa::path( + delete, + path = "/ai/knowledge-bases/{kb_id}/documents/{id}", + responses((status = 200, description = "删除文档")), + tag = "知识库文档", + security(("bearer_auth" = [])), +)] +pub async fn delete_document( + State(state): State, + Extension(ctx): Extension, + Path((kb_id, id)): Path<(uuid::Uuid, uuid::Uuid)>, +) -> Result>, erp_core::error::AppError> +where + AiState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "ai.knowledge.manage")?; + state + .document + .delete_document(ctx.tenant_id, kb_id, id) + .await?; + Ok(Json(ApiResponse::ok(serde_json::json!({ "id": id })))) +} diff --git a/crates/erp-ai/src/handler/mod.rs b/crates/erp-ai/src/handler/mod.rs index 38312f2..69876d2 100644 --- a/crates/erp-ai/src/handler/mod.rs +++ b/crates/erp-ai/src/handler/mod.rs @@ -14,6 +14,7 @@ use crate::state::AiState; pub mod chat_handler; pub mod config_handler; +pub mod document_handler; pub mod insight_handler; pub mod knowledge_handler; pub mod knowledge_v2_handler; diff --git a/crates/erp-ai/src/module.rs b/crates/erp-ai/src/module.rs index 502f739..48ea554 100644 --- a/crates/erp-ai/src/module.rs +++ b/crates/erp-ai/src/module.rs @@ -609,6 +609,27 @@ impl AiModule { "/ai/knowledge-bases/{id}", axum::routing::delete(crate::handler::knowledge_v2_handler::delete_knowledge_base), ) + // 文档管理路由 + .route( + "/ai/knowledge-bases/{kb_id}/documents", + axum::routing::get(crate::handler::document_handler::list_documents), + ) + .route( + "/ai/documents/manual", + axum::routing::post(crate::handler::document_handler::create_manual_document), + ) + .route( + "/ai/documents/upload", + axum::routing::post(crate::handler::document_handler::upload_document), + ) + .route( + "/ai/documents/{id}", + axum::routing::get(crate::handler::document_handler::get_document), + ) + .route( + "/ai/knowledge-bases/{kb_id}/documents/{id}", + axum::routing::delete(crate::handler::document_handler::delete_document), + ) .route( "/ai/dialysis/risk-assessment", axum::routing::post(crate::handler::assess_dialysis_risk),