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:
iven
2026-05-27 00:17:43 +08:00
parent 0a1f4cb9a9
commit e94f5bc00c
3 changed files with 276 additions and 0 deletions

View 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 }))))
}

View File

@@ -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;

View File

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