feat(ai): 文档管理 handler — CRUD + Multipart 上传
- 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
This commit is contained in:
254
crates/erp-ai/src/handler/document_handler.rs
Normal file
254
crates/erp-ai/src/handler/document_handler.rs
Normal file
@@ -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<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/knowledge-bases/{kb_id}/documents",
|
||||
responses((status = 200, description = "文档列表")),
|
||||
tag = "知识库文档",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn list_documents<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(kb_id): Path<uuid::Uuid>,
|
||||
axum::extract::Query(params): axum::extract::Query<ListDocumentsParamsNoKb>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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<String>,
|
||||
pub page: Option<u64>,
|
||||
pub page_size: Option<u64>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ai/documents/{id}",
|
||||
responses((status = 200, description = "文档详情")),
|
||||
tag = "知识库文档",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn get_document<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(id): Path<uuid::Uuid>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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<String>,
|
||||
pub source_type: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/ai/documents/manual",
|
||||
request_body = CreateDocumentBody,
|
||||
responses((status = 200, description = "创建手动文档")),
|
||||
tag = "知识库文档",
|
||||
security(("bearer_auth" = [])),
|
||||
)]
|
||||
pub async fn create_manual_document<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(body): Json<CreateDocumentBody>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "ai.knowledge.manage")?;
|
||||
|
||||
let mut kb_id: Option<uuid::Uuid> = None;
|
||||
let mut title: Option<String> = None;
|
||||
let mut file_data: Option<(String, String, Vec<u8>)> = 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<S>(
|
||||
State(state): State<AiState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((kb_id, id)): Path<(uuid::Uuid, uuid::Uuid)>,
|
||||
) -> Result<Json<ApiResponse<serde_json::Value>>, erp_core::error::AppError>
|
||||
where
|
||||
AiState: FromRef<S>,
|
||||
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 }))))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user