提交qiming-mcp-proxy
This commit is contained in:
1121
qiming-mcp-proxy/document-parser/src/handlers/document_handler.rs
Normal file
1121
qiming-mcp-proxy/document-parser/src/handlers/document_handler.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
use crate::app_state::AppState;
|
||||
use crate::models::HttpResult;
|
||||
use axum::Json;
|
||||
use utoipa;
|
||||
|
||||
/// 健康检查
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
responses(
|
||||
(status = 200, description = "服务健康", body = HttpResult<String>)
|
||||
),
|
||||
tag = "health"
|
||||
)]
|
||||
pub async fn health_check() -> Json<HttpResult<String>> {
|
||||
Json(HttpResult::success("health".to_string()))
|
||||
}
|
||||
|
||||
/// 就绪检查
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/ready",
|
||||
responses(
|
||||
(status = 200, description = "服务就绪", body = HttpResult<String>),
|
||||
(status = 500, description = "服务未就绪", body = HttpResult<String>)
|
||||
),
|
||||
tag = "health"
|
||||
)]
|
||||
pub async fn ready_check(state: axum::extract::State<AppState>) -> Json<HttpResult<String>> {
|
||||
match state.health_check().await {
|
||||
Ok(_) => Json(HttpResult::success("ready".to_string())),
|
||||
Err(e) => Json(HttpResult::<String>::error(
|
||||
"E001".to_string(),
|
||||
format!("not ready: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
1084
qiming-mcp-proxy/document-parser/src/handlers/markdown_handler.rs
Normal file
1084
qiming-mcp-proxy/document-parser/src/handlers/markdown_handler.rs
Normal file
File diff suppressed because it is too large
Load Diff
20
qiming-mcp-proxy/document-parser/src/handlers/mod.rs
Normal file
20
qiming-mcp-proxy/document-parser/src/handlers/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
// 处理器模块
|
||||
pub mod document_handler;
|
||||
pub mod health_handler;
|
||||
pub mod markdown_handler;
|
||||
pub mod monitoring_handler;
|
||||
pub mod private_oss_handler;
|
||||
pub mod response;
|
||||
pub mod task_handler;
|
||||
pub mod toc_handler;
|
||||
pub mod validation;
|
||||
|
||||
pub use document_handler::*;
|
||||
pub use health_handler::{health_check, ready_check};
|
||||
pub use markdown_handler::*;
|
||||
pub use monitoring_handler::*;
|
||||
pub use private_oss_handler::*;
|
||||
pub use response::*;
|
||||
pub use task_handler::*;
|
||||
pub use toc_handler::*;
|
||||
pub use validation::*;
|
||||
@@ -0,0 +1,356 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{app_state::AppState, error::AppError, models::HttpResult};
|
||||
|
||||
/// 健康检查查询参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct HealthCheckQuery {
|
||||
/// 组件名称,如果指定则只检查该组件
|
||||
pub component: Option<String>,
|
||||
/// 是否包含详细信息
|
||||
pub detailed: Option<bool>,
|
||||
}
|
||||
|
||||
/// 指标查询参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MetricsQuery {
|
||||
/// 指标格式:json 或 prometheus
|
||||
pub format: Option<String>,
|
||||
/// 指标名称过滤
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// 系统信息响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SystemInfoResponse {
|
||||
pub service_name: String,
|
||||
pub service_version: String,
|
||||
pub environment: String,
|
||||
pub uptime_seconds: u64,
|
||||
pub build_info: BuildInfo,
|
||||
pub runtime_info: RuntimeInfo,
|
||||
}
|
||||
|
||||
/// 构建信息
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BuildInfo {
|
||||
pub version: String,
|
||||
pub git_commit: String,
|
||||
pub build_date: String,
|
||||
pub rust_version: String,
|
||||
}
|
||||
|
||||
/// 运行时信息
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RuntimeInfo {
|
||||
pub platform: String,
|
||||
pub architecture: String,
|
||||
pub cpu_count: usize,
|
||||
pub memory_total_mb: u64,
|
||||
pub memory_used_mb: u64,
|
||||
}
|
||||
|
||||
/// 健康检查端点
|
||||
#[instrument(skip(_state))]
|
||||
pub async fn health_check(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Query(_query): Query<HealthCheckQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
info!("Health check request");
|
||||
|
||||
// 简化的健康检查实现
|
||||
let simple_status = SimpleHealthStatus {
|
||||
status: "healthy".to_string(),
|
||||
healthy_count: 1,
|
||||
unhealthy_count: 0,
|
||||
degraded_count: 0,
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
};
|
||||
|
||||
Ok((StatusCode::OK, Json(HttpResult::success(simple_status))).into_response())
|
||||
}
|
||||
|
||||
/// 简化的健康状态响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SimpleHealthStatus {
|
||||
pub status: String,
|
||||
pub healthy_count: usize,
|
||||
pub unhealthy_count: usize,
|
||||
pub degraded_count: usize,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// 就绪检查端点(Kubernetes readiness probe)
|
||||
#[instrument(skip(_state))]
|
||||
pub async fn readiness_check(State(_state): State<Arc<AppState>>) -> Result<Response, AppError> {
|
||||
info!("Readiness check request");
|
||||
Ok((StatusCode::OK, "Ready").into_response())
|
||||
}
|
||||
|
||||
/// 存活检查端点(Kubernetes liveness probe)
|
||||
#[instrument(skip(_state))]
|
||||
pub async fn liveness_check(State(_state): State<Arc<AppState>>) -> Result<Response, AppError> {
|
||||
// 存活检查只需要确认服务进程正在运行
|
||||
Ok((StatusCode::OK, "Alive").into_response())
|
||||
}
|
||||
|
||||
/// 指标端点
|
||||
#[instrument(skip(_state))]
|
||||
pub async fn metrics(
|
||||
State(_state): State<Arc<AppState>>,
|
||||
Query(query): Query<MetricsQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
info!("Indicator request");
|
||||
|
||||
let format = query.format.as_deref().unwrap_or("prometheus");
|
||||
|
||||
match format {
|
||||
"prometheus" => {
|
||||
let metrics_data = "# Placeholder metrics\n";
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[("content-type", "text/plain; version=0.0.4")],
|
||||
metrics_data,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
"json" => {
|
||||
let metrics_data = r#"{"placeholder": "metrics"}"#;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[("content-type", "application/json")],
|
||||
metrics_data,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
_ => Ok(HttpResult::<()>::error::<()>(
|
||||
"INVALID_FORMAT".to_string(),
|
||||
"支持的格式: prometheus, json".to_string(),
|
||||
)
|
||||
.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统信息端点
|
||||
#[instrument(skip(_state))]
|
||||
pub async fn system_info(State(_state): State<Arc<AppState>>) -> Result<Response, AppError> {
|
||||
info!("System information request");
|
||||
|
||||
// 获取内存信息
|
||||
let (memory_total_mb, memory_used_mb) = get_memory_info().await;
|
||||
|
||||
let system_info = SystemInfoResponse {
|
||||
service_name: "document-parser".to_string(),
|
||||
service_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
environment: std::env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string()),
|
||||
uptime_seconds: 0, // 简化实现
|
||||
build_info: BuildInfo {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
git_commit: std::env::var("GIT_COMMIT").unwrap_or_else(|_| "unknown".to_string()),
|
||||
build_date: std::env::var("BUILD_DATE").unwrap_or_else(|_| "unknown".to_string()),
|
||||
rust_version: std::env::var("RUST_VERSION").unwrap_or_else(|_| "unknown".to_string()),
|
||||
},
|
||||
runtime_info: RuntimeInfo {
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
architecture: std::env::consts::ARCH.to_string(),
|
||||
cpu_count: num_cpus::get(),
|
||||
memory_total_mb,
|
||||
memory_used_mb,
|
||||
},
|
||||
};
|
||||
|
||||
Ok(HttpResult::success(system_info).into_response())
|
||||
}
|
||||
|
||||
/// 配置信息端点
|
||||
#[instrument(skip(state))]
|
||||
pub async fn config_info(State(state): State<Arc<AppState>>) -> Result<Response, AppError> {
|
||||
info!("Configuration information request");
|
||||
|
||||
// 创建安全的配置摘要(隐藏敏感信息)
|
||||
let config_summary = create_config_summary(&state.config);
|
||||
|
||||
Ok(HttpResult::success(config_summary).into_response())
|
||||
}
|
||||
|
||||
/// 创建配置摘要(隐藏敏感信息)
|
||||
fn create_config_summary(config: &crate::config::AppConfig) -> HashMap<String, serde_json::Value> {
|
||||
let mut summary = HashMap::new();
|
||||
|
||||
// 服务器配置
|
||||
summary.insert(
|
||||
"server".to_string(),
|
||||
serde_json::json!({
|
||||
"host": config.server.host,
|
||||
"port": config.server.port,
|
||||
}),
|
||||
);
|
||||
|
||||
// 日志配置
|
||||
summary.insert(
|
||||
"log".to_string(),
|
||||
serde_json::json!({
|
||||
"level": config.log.level,
|
||||
"path": config.log.path,
|
||||
}),
|
||||
);
|
||||
|
||||
// 文档解析配置
|
||||
summary.insert(
|
||||
"document_parser".to_string(),
|
||||
serde_json::json!({
|
||||
"max_concurrent": config.document_parser.max_concurrent,
|
||||
"queue_size": config.document_parser.queue_size,
|
||||
"max_file_size": config.file_size_config.max_file_size.bytes(),
|
||||
"download_timeout": config.document_parser.download_timeout,
|
||||
"processing_timeout": config.document_parser.processing_timeout,
|
||||
}),
|
||||
);
|
||||
|
||||
// MinerU配置(隐藏敏感路径)
|
||||
summary.insert(
|
||||
"mineru".to_string(),
|
||||
serde_json::json!({
|
||||
"backend": config.mineru.backend,
|
||||
"max_concurrent": config.mineru.max_concurrent,
|
||||
"queue_size": config.mineru.queue_size,
|
||||
"timeout": config.mineru.timeout,
|
||||
}),
|
||||
);
|
||||
|
||||
// MarkItDown配置
|
||||
summary.insert(
|
||||
"markitdown".to_string(),
|
||||
serde_json::json!({
|
||||
"max_file_size": config.file_size_config.max_file_size.bytes(),
|
||||
"timeout": config.markitdown.timeout,
|
||||
"enable_plugins": config.markitdown.enable_plugins,
|
||||
"features": config.markitdown.features,
|
||||
}),
|
||||
);
|
||||
|
||||
// 存储配置(隐藏敏感信息)
|
||||
summary.insert(
|
||||
"storage".to_string(),
|
||||
serde_json::json!({
|
||||
"sled": {
|
||||
"cache_capacity": config.storage.sled.cache_capacity,
|
||||
},
|
||||
"oss": {
|
||||
"endpoint": config.storage.oss.endpoint,
|
||||
"public_bucket": config.storage.oss.public_bucket,
|
||||
"private_bucket": config.storage.oss.private_bucket,
|
||||
// 隐藏访问密钥
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
/// 获取内存信息
|
||||
async fn get_memory_info() -> (u64, u64) {
|
||||
tokio::task::spawn_blocking(|| {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::process::Command;
|
||||
if let Ok(output) = Command::new("vm_stat").output() {
|
||||
if let Ok(output_str) = String::from_utf8(output.stdout) {
|
||||
let mut free_pages = 0u64;
|
||||
let mut active_pages = 0u64;
|
||||
let mut inactive_pages = 0u64;
|
||||
let mut wired_pages = 0u64;
|
||||
|
||||
for line in output_str.lines() {
|
||||
if line.contains("Pages free:") {
|
||||
if let Some(pages) = extract_pages(line) {
|
||||
free_pages = pages;
|
||||
}
|
||||
} else if line.contains("Pages active:") {
|
||||
if let Some(pages) = extract_pages(line) {
|
||||
active_pages = pages;
|
||||
}
|
||||
} else if line.contains("Pages inactive:") {
|
||||
if let Some(pages) = extract_pages(line) {
|
||||
inactive_pages = pages;
|
||||
}
|
||||
} else if line.contains("Pages wired down:") {
|
||||
if let Some(pages) = extract_pages(line) {
|
||||
wired_pages = pages;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let page_size = 4096u64;
|
||||
let total =
|
||||
(free_pages + active_pages + inactive_pages + wired_pages) * page_size;
|
||||
let used = (active_pages + inactive_pages + wired_pages) * page_size;
|
||||
|
||||
return (total / 1024 / 1024, used / 1024 / 1024);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(meminfo) = std::fs::read_to_string("/proc/meminfo") {
|
||||
let mut total = 0u64;
|
||||
let mut available = 0u64;
|
||||
|
||||
for line in meminfo.lines() {
|
||||
if line.starts_with("MemTotal:") {
|
||||
if let Some(kb) = extract_kb_value(line) {
|
||||
total = kb;
|
||||
}
|
||||
} else if line.starts_with("MemAvailable:") {
|
||||
if let Some(kb) = extract_kb_value(line) {
|
||||
available = kb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let used = total - available;
|
||||
return (total / 1024, used / 1024);
|
||||
}
|
||||
}
|
||||
|
||||
// 默认值
|
||||
(0, 0)
|
||||
})
|
||||
.await
|
||||
.unwrap_or((0, 0))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn extract_pages(line: &str) -> Option<u64> {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
let page_str = parts[2].trim_end_matches('.');
|
||||
page_str.parse().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn extract_kb_value(line: &str) -> Option<u64> {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
parts[1].parse().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
use axum::{
|
||||
extract::{Multipart, Query, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::handlers::response::ApiResponse;
|
||||
|
||||
/// 文件上传响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct FileUploadResponse {
|
||||
pub oss_file_name: String,
|
||||
pub oss_bucket: String,
|
||||
pub download_url: String,
|
||||
pub expires_in_hours: u64,
|
||||
pub file_size: Option<u64>,
|
||||
pub original_filename: Option<String>,
|
||||
}
|
||||
|
||||
/// 下载URL响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DownloadUrlResponse {
|
||||
pub download_url: String,
|
||||
pub oss_file_name: String,
|
||||
pub oss_bucket: String,
|
||||
pub expires_in_hours: u64,
|
||||
}
|
||||
|
||||
/// 获取下载URL请求参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct GetDownloadUrlParams {
|
||||
pub file_name: String,
|
||||
pub bucket: Option<String>,
|
||||
}
|
||||
|
||||
/// 获取上传签名URL请求参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct GetUploadSignUrlParams {
|
||||
pub file_name: String,
|
||||
pub content_type: Option<String>,
|
||||
pub bucket: Option<String>,
|
||||
}
|
||||
|
||||
/// 获取下载签名URL请求参数(4小时有效)
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct GetDownloadSignUrlParams {
|
||||
pub file_name: String,
|
||||
pub bucket: Option<String>,
|
||||
}
|
||||
|
||||
/// 删除文件请求参数
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct DeleteFileParams {
|
||||
pub file_name: String,
|
||||
pub bucket: Option<String>,
|
||||
}
|
||||
|
||||
/// 上传签名URL响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct UploadSignUrlResponse {
|
||||
pub upload_url: String,
|
||||
pub oss_file_name: String,
|
||||
pub oss_bucket: String,
|
||||
pub expires_in_hours: u64,
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
/// 下载签名URL响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DownloadSignUrlResponse {
|
||||
pub download_url: String,
|
||||
pub oss_file_name: String,
|
||||
pub oss_bucket: String,
|
||||
pub expires_in_hours: u64,
|
||||
}
|
||||
|
||||
/// 删除文件响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DeleteFileResponse {
|
||||
pub oss_file_name: String,
|
||||
pub oss_bucket: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 上传文件到OSS
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/oss/upload",
|
||||
request_body(content = String, description = "文件内容", content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 200, description = "上传成功", body = FileUploadResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 413, description = "文件过大"),
|
||||
(status = 500, description = "服务器内部错误")
|
||||
),
|
||||
tag = "oss"
|
||||
)]
|
||||
pub async fn upload_file_to_oss(
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> impl IntoResponse {
|
||||
info!("OSS file upload request");
|
||||
|
||||
// 检查OSS客户端是否可用
|
||||
let oss_client = match &state.private_oss_client {
|
||||
Some(client) => client,
|
||||
None => {
|
||||
error!("The OSS client is not configured");
|
||||
return ApiResponse::internal_error::<FileUploadResponse>("OSS客户端未配置")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mut file_path: Option<String> = None;
|
||||
let mut original_filename: Option<String> = None;
|
||||
let mut temp_files = Vec::new();
|
||||
|
||||
// 处理multipart数据
|
||||
while let Some(field) = multipart.next_field().await.unwrap_or(None) {
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
|
||||
if field_name == "file" {
|
||||
let filename = field.file_name().map(|s| s.to_string());
|
||||
original_filename = filename.clone();
|
||||
|
||||
let data = match field.bytes().await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("Failed to read file data: {}", e);
|
||||
return ApiResponse::validation_error::<FileUploadResponse>("文件数据读取失败")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 创建临时文件
|
||||
let temp_file = match tempfile::NamedTempFile::new() {
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
error!("Failed to create temporary file: {}", e);
|
||||
return ApiResponse::internal_error::<FileUploadResponse>("临时文件创建失败")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = std::fs::write(temp_file.path(), &data) {
|
||||
error!("Failed to write to temporary file: {}", e);
|
||||
return ApiResponse::internal_error::<FileUploadResponse>("文件写入失败")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
file_path = Some(temp_file.path().to_string_lossy().to_string());
|
||||
temp_files.push(temp_file);
|
||||
}
|
||||
}
|
||||
|
||||
let file_path = match file_path {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
warn!("Uploaded file not found");
|
||||
return ApiResponse::validation_error::<FileUploadResponse>("未提供文件")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取文件大小
|
||||
let file_size = std::fs::metadata(&file_path).map(|m| m.len()).ok();
|
||||
|
||||
// 生成对象键名
|
||||
let object_key = if let Some(ref filename) = original_filename {
|
||||
// 使用原始文件名,添加时间戳避免冲突
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let clean_filename = oss_client::utils::sanitize_filename(filename);
|
||||
format!("uploads/{timestamp}_{clean_filename}")
|
||||
} else {
|
||||
// 生成唯一文件名
|
||||
format!(
|
||||
"uploads/{}",
|
||||
oss_client::utils::generate_random_filename(None)
|
||||
)
|
||||
};
|
||||
|
||||
// 上传到OSS
|
||||
match oss_client.upload_file(&file_path, &object_key).await {
|
||||
Ok(oss_url) => {
|
||||
info!("File upload to OSS successful: object_key={}", object_key);
|
||||
|
||||
// 生成下载URL(4小时有效)
|
||||
let download_url = match oss_client
|
||||
.generate_download_url(&object_key, Some(Duration::from_secs(4 * 3600)))
|
||||
{
|
||||
Ok(url) => url,
|
||||
Err(_) => oss_url.clone(), // 如果生成签名URL失败,使用原始URL
|
||||
};
|
||||
|
||||
let response = FileUploadResponse {
|
||||
oss_file_name: object_key,
|
||||
oss_bucket: oss_client.get_config().bucket.clone(),
|
||||
download_url,
|
||||
expires_in_hours: 4,
|
||||
file_size,
|
||||
original_filename,
|
||||
};
|
||||
|
||||
ApiResponse::success(response).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("File upload to OSS failed: {}", e);
|
||||
ApiResponse::internal_error::<FileUploadResponse>(&format!("上传失败: {e}"))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取上传签名URL
|
||||
///
|
||||
/// ⚠️ **重要警告**: 使用相同的文件名上传会完全覆盖OSS中的现有文件,此操作不可逆!
|
||||
/// 建议在文件名中添加时间戳或UUID来避免意外覆盖,例如:document_20240101_120000.pdf
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/oss/upload-sign-url",
|
||||
params(
|
||||
("file_name" = String, Query, description = "文件名(⚠️警告:相同文件名会覆盖现有文件!建议添加时间戳避免覆盖)"),
|
||||
("content_type" = Option<String>, Query, description = "文件内容类型,默认为 application/octet-stream"),
|
||||
("bucket" = Option<String>, Query, description = "存储桶名称(可选)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取成功,返回4小时有效的上传签名URL", body = UploadSignUrlResponse),
|
||||
(status = 400, description = "请求参数错误(如文件名为空)"),
|
||||
(status = 500, description = "服务器内部错误(如OSS客户端未配置)")
|
||||
),
|
||||
tag = "oss"
|
||||
)]
|
||||
pub async fn get_upload_sign_url(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<GetUploadSignUrlParams>,
|
||||
) -> impl IntoResponse {
|
||||
info!(
|
||||
"Get the upload signature URL request: file_name={}, content_type={:?}, bucket={:?}",
|
||||
params.file_name, params.content_type, params.bucket
|
||||
);
|
||||
|
||||
// 验证文件名
|
||||
if params.file_name.trim().is_empty() {
|
||||
return ApiResponse::validation_error::<UploadSignUrlResponse>("文件名不能为空")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 检查OSS客户端是否可用
|
||||
let oss_client = match &state.private_oss_client {
|
||||
Some(client) => client,
|
||||
None => {
|
||||
error!("The OSS client is not configured");
|
||||
return ApiResponse::internal_error::<UploadSignUrlResponse>("OSS客户端未配置")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 默认内容类型
|
||||
let content_type = params
|
||||
.content_type
|
||||
.as_deref()
|
||||
.unwrap_or("application/octet-stream");
|
||||
|
||||
// 4小时有效期
|
||||
let expires_in = Duration::from_secs(4 * 3600);
|
||||
|
||||
// 构建完整的对象键名,包含edu前缀
|
||||
let object_key = if params.file_name.starts_with("edu/") {
|
||||
// 如果已经包含前缀,直接使用
|
||||
params.file_name.clone()
|
||||
} else {
|
||||
// 添加edu前缀
|
||||
format!("edu/{}", params.file_name.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
// 生成上传签名URL
|
||||
match oss_client.generate_upload_url(&object_key, expires_in, Some(content_type)) {
|
||||
Ok(upload_url) => {
|
||||
info!(
|
||||
"Successfully generated upload signature URL: object_key={}, content_type={}",
|
||||
object_key, content_type
|
||||
);
|
||||
|
||||
let response = UploadSignUrlResponse {
|
||||
upload_url,
|
||||
oss_file_name: object_key,
|
||||
oss_bucket: oss_client.get_config().bucket.clone(),
|
||||
expires_in_hours: 4,
|
||||
content_type: content_type.to_string(),
|
||||
};
|
||||
|
||||
ApiResponse::success(response).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to generate upload signature URL: file_name={}, error={}",
|
||||
params.file_name, e
|
||||
);
|
||||
ApiResponse::internal_error::<UploadSignUrlResponse>(&format!(
|
||||
"生成上传签名URL失败: {e}"
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取下载签名URL(4小时有效)
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/oss/download-sign-url",
|
||||
params(
|
||||
("file_name" = String, Query, description = "文件名"),
|
||||
("bucket" = Option<String>, Query, description = "存储桶名称")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取成功", body = DownloadSignUrlResponse),
|
||||
(status = 400, description = "请求参数错误"),
|
||||
(status = 404, description = "文件不存在"),
|
||||
(status = 500, description = "服务器内部错误")
|
||||
),
|
||||
tag = "oss"
|
||||
)]
|
||||
pub async fn get_download_sign_url(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<GetDownloadSignUrlParams>,
|
||||
) -> impl IntoResponse {
|
||||
info!(
|
||||
"Get download signature URL request: file_name={}, bucket={:?}",
|
||||
params.file_name, params.bucket
|
||||
);
|
||||
|
||||
// 验证文件名
|
||||
if params.file_name.trim().is_empty() {
|
||||
return ApiResponse::validation_error::<DownloadSignUrlResponse>("文件名不能为空")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 检查OSS客户端是否可用
|
||||
let oss_client = match &state.private_oss_client {
|
||||
Some(client) => client,
|
||||
None => {
|
||||
error!("The OSS client is not configured");
|
||||
return ApiResponse::internal_error::<DownloadSignUrlResponse>("OSS客户端未配置")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 4小时有效期
|
||||
let expires_in = Duration::from_secs(4 * 3600);
|
||||
|
||||
// 构建完整的对象键名,如果没有前缀则添加edu前缀
|
||||
let object_key = if params.file_name.starts_with("edu/") {
|
||||
// 如果已经包含前缀,直接使用
|
||||
params.file_name.clone()
|
||||
} else {
|
||||
// 添加edu前缀
|
||||
format!("edu/{}", params.file_name.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
// 生成下载签名URL
|
||||
match oss_client.generate_download_url(&object_key, Some(expires_in)) {
|
||||
Ok(download_url) => {
|
||||
info!(
|
||||
"Successfully generated download signature URL: object_key={}",
|
||||
object_key
|
||||
);
|
||||
|
||||
let response = DownloadSignUrlResponse {
|
||||
download_url,
|
||||
oss_file_name: object_key,
|
||||
oss_bucket: oss_client.get_config().bucket.clone(),
|
||||
expires_in_hours: 4,
|
||||
};
|
||||
|
||||
ApiResponse::success(response).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to generate download signature URL: file_name={}, error={}",
|
||||
params.file_name, e
|
||||
);
|
||||
ApiResponse::internal_error::<DownloadSignUrlResponse>(&format!(
|
||||
"生成下载签名URL失败: {e}"
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除OSS文件
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/oss/delete",
|
||||
params(
|
||||
("file_name" = String, Query, description = "要删除的文件名"),
|
||||
("bucket" = Option<String>, Query, description = "存储桶名称(可选)")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "删除成功", body = DeleteFileResponse),
|
||||
(status = 400, description = "请求参数错误(如文件名为空)"),
|
||||
(status = 404, description = "文件不存在"),
|
||||
(status = 500, description = "服务器内部错误(如OSS客户端未配置)")
|
||||
),
|
||||
tag = "oss"
|
||||
)]
|
||||
pub async fn delete_file_from_oss(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<DeleteFileParams>,
|
||||
) -> impl IntoResponse {
|
||||
info!(
|
||||
"Delete OSS file request: file_name={}, bucket={:?}",
|
||||
params.file_name, params.bucket
|
||||
);
|
||||
|
||||
// 验证文件名
|
||||
if params.file_name.trim().is_empty() {
|
||||
return ApiResponse::validation_error::<DeleteFileResponse>("文件名不能为空")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 检查OSS客户端是否可用
|
||||
let oss_client = match &state.private_oss_client {
|
||||
Some(client) => client,
|
||||
None => {
|
||||
error!("The OSS client is not configured");
|
||||
return ApiResponse::internal_error::<DeleteFileResponse>("OSS客户端未配置")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 构建完整的对象键名,如果没有前缀则添加edu前缀
|
||||
let object_key = if params.file_name.starts_with("edu/") {
|
||||
// 如果已经包含前缀,直接使用
|
||||
params.file_name.clone()
|
||||
} else {
|
||||
// 添加edu前缀
|
||||
format!("edu/{}", params.file_name.trim_start_matches('/'))
|
||||
};
|
||||
|
||||
// 先检查文件是否存在
|
||||
match oss_client.file_exists(&object_key).await {
|
||||
Ok(exists) => {
|
||||
if !exists {
|
||||
warn!("The file to be deleted does not exist: {}", object_key);
|
||||
return ApiResponse::not_found::<DeleteFileResponse>("文件不存在").into_response();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to check file existence: file_name={}, error={}",
|
||||
params.file_name, e
|
||||
);
|
||||
return ApiResponse::internal_error::<DeleteFileResponse>(&format!(
|
||||
"检查文件存在性失败: {e}"
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
match oss_client.delete_file(&object_key).await {
|
||||
Ok(_) => {
|
||||
info!("OSS file deleted successfully: object_key={}", object_key);
|
||||
|
||||
let response = DeleteFileResponse {
|
||||
oss_file_name: object_key,
|
||||
oss_bucket: oss_client.get_config().bucket.clone(),
|
||||
message: "文件删除成功".to_string(),
|
||||
};
|
||||
|
||||
ApiResponse::success(response).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to delete OSS file: file_name={}, error={}",
|
||||
params.file_name, e
|
||||
);
|
||||
ApiResponse::internal_error::<DeleteFileResponse>(&format!("删除文件失败: {e}"))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
340
qiming-mcp-proxy/document-parser/src/handlers/response.rs
Normal file
340
qiming-mcp-proxy/document-parser/src/handlers/response.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::HttpResult;
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 标准API响应构建器
|
||||
pub struct ApiResponse;
|
||||
|
||||
impl ApiResponse {
|
||||
/// 成功响应
|
||||
pub fn success<T: Serialize>(data: T) -> impl IntoResponse {
|
||||
Json(HttpResult::success(data))
|
||||
}
|
||||
|
||||
/// 成功响应(带状态码)
|
||||
pub fn success_with_status<T: Serialize>(data: T, status: StatusCode) -> impl IntoResponse {
|
||||
(status, HttpResult::success(data))
|
||||
}
|
||||
|
||||
/// 错误响应
|
||||
pub fn error<T>(error_code: String, message: String) -> Json<HttpResult<T>> {
|
||||
Json(HttpResult::<T>::error(error_code, message))
|
||||
}
|
||||
|
||||
/// 错误响应(带状态码)
|
||||
pub fn error_with_status<T>(
|
||||
error_code: String,
|
||||
message: String,
|
||||
status: StatusCode,
|
||||
) -> impl IntoResponse
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
(status, HttpResult::<T>::error::<T>(error_code, message))
|
||||
}
|
||||
|
||||
/// 从AppError创建响应
|
||||
pub fn from_app_error<T>(error: AppError) -> impl IntoResponse
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let status = match &error {
|
||||
AppError::Validation(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::File(_) | AppError::UnsupportedFormat(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::Task(_) => StatusCode::NOT_FOUND,
|
||||
AppError::Network(_) | AppError::Timeout(_) => StatusCode::REQUEST_TIMEOUT,
|
||||
AppError::Parse(_) | AppError::MinerU(_) | AppError::MarkItDown(_) => {
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
}
|
||||
AppError::Database(_) | AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AppError::Oss(_) => StatusCode::BAD_GATEWAY,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
(status, error.to_http_result::<T>())
|
||||
}
|
||||
|
||||
/// 创建分页响应
|
||||
pub fn paginated<T: Serialize>(
|
||||
data: Vec<T>,
|
||||
total: usize,
|
||||
page: usize,
|
||||
page_size: usize,
|
||||
) -> Json<HttpResult<PaginatedResponse<T>>> {
|
||||
let total_pages = total.div_ceil(page_size);
|
||||
let response = PaginatedResponse {
|
||||
data,
|
||||
pagination: PaginationInfo {
|
||||
total,
|
||||
page,
|
||||
page_size,
|
||||
total_pages,
|
||||
has_next: page < total_pages,
|
||||
has_prev: page > 1,
|
||||
},
|
||||
};
|
||||
Json(HttpResult::success(response))
|
||||
}
|
||||
|
||||
/// 创建空响应
|
||||
pub fn empty() -> Json<HttpResult<()>> {
|
||||
Json(HttpResult::success(()))
|
||||
}
|
||||
|
||||
/// 创建消息响应
|
||||
pub fn message(message: String) -> Json<HttpResult<MessageResponse>> {
|
||||
Json(HttpResult::success(MessageResponse { message }))
|
||||
}
|
||||
|
||||
/// 创建统计响应
|
||||
pub fn stats(stats: HashMap<String, serde_json::Value>) -> Json<HttpResult<StatsResponse>> {
|
||||
Json(HttpResult::success(StatsResponse { stats }))
|
||||
}
|
||||
|
||||
/// 验证错误响应
|
||||
pub fn validation_error<T>(message: &str) -> Json<HttpResult<T>> {
|
||||
Json(HttpResult::<T>::error(
|
||||
"VALIDATION_ERROR".to_string(),
|
||||
message.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 内部错误响应
|
||||
pub fn internal_error<T>(message: &str) -> Json<HttpResult<T>> {
|
||||
Json(HttpResult::<T>::error(
|
||||
"INTERNAL_ERROR".to_string(),
|
||||
message.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 未找到错误响应
|
||||
pub fn not_found<T>(message: &str) -> Json<HttpResult<T>> {
|
||||
Json(HttpResult::<T>::error(
|
||||
"NOT_FOUND".to_string(),
|
||||
message.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 请求错误响应
|
||||
pub fn bad_request<T>(message: &str) -> Json<HttpResult<T>> {
|
||||
Json(HttpResult::<T>::error(
|
||||
"BAD_REQUEST".to_string(),
|
||||
message.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// 分页响应结构
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PaginatedResponse<T> {
|
||||
pub data: Vec<T>,
|
||||
pub pagination: PaginationInfo,
|
||||
}
|
||||
|
||||
/// 分页信息
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct PaginationInfo {
|
||||
pub total: usize,
|
||||
pub page: usize,
|
||||
pub page_size: usize,
|
||||
pub total_pages: usize,
|
||||
pub has_next: bool,
|
||||
pub has_prev: bool,
|
||||
}
|
||||
|
||||
/// 消息响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct MessageResponse {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 统计响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct StatsResponse {
|
||||
pub stats: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 健康检查响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HealthResponse {
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
pub timestamp: String,
|
||||
pub services: HashMap<String, ServiceHealth>,
|
||||
}
|
||||
|
||||
/// 服务健康状态
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct ServiceHealth {
|
||||
pub status: String,
|
||||
pub message: Option<String>,
|
||||
pub response_time_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 文件上传响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UploadResponse {
|
||||
pub task_id: String,
|
||||
pub message: String,
|
||||
pub file_info: FileInfo,
|
||||
}
|
||||
|
||||
/// 文件信息
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct FileInfo {
|
||||
pub filename: String,
|
||||
pub size: u64,
|
||||
pub format: String,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
/// URL下载响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct DownloadResponse {
|
||||
pub task_id: String,
|
||||
pub message: String,
|
||||
pub url_info: UrlInfo,
|
||||
}
|
||||
|
||||
/// URL信息
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UrlInfo {
|
||||
pub url: String,
|
||||
pub format: String,
|
||||
pub estimated_size: Option<u64>,
|
||||
}
|
||||
|
||||
/// 简化的任务状态枚举(用于 API 响应)
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone, PartialEq, Eq)]
|
||||
pub enum SimpleTaskStatus {
|
||||
Pending,
|
||||
Processing,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SimpleTaskStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SimpleTaskStatus::Pending => write!(f, "Pending"),
|
||||
SimpleTaskStatus::Processing => write!(f, "Processing"),
|
||||
SimpleTaskStatus::Completed => write!(f, "Completed"),
|
||||
SimpleTaskStatus::Failed => write!(f, "Failed"),
|
||||
SimpleTaskStatus::Cancelled => write!(f, "Cancelled"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&crate::models::TaskStatus> for SimpleTaskStatus {
|
||||
fn from(status: &crate::models::TaskStatus) -> Self {
|
||||
match status {
|
||||
crate::models::TaskStatus::Pending { .. } => SimpleTaskStatus::Pending,
|
||||
crate::models::TaskStatus::Processing { .. } => SimpleTaskStatus::Processing,
|
||||
crate::models::TaskStatus::Completed { .. } => SimpleTaskStatus::Completed,
|
||||
crate::models::TaskStatus::Failed { .. } => SimpleTaskStatus::Failed,
|
||||
crate::models::TaskStatus::Cancelled { .. } => SimpleTaskStatus::Cancelled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 任务操作响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct TaskOperationResponse {
|
||||
pub task_id: String,
|
||||
pub operation: String,
|
||||
pub message: String,
|
||||
pub timestamp: String,
|
||||
// 移除 task 字段以避免循环引用
|
||||
// pub task: Option<crate::models::DocumentTask>,
|
||||
pub complete: bool,
|
||||
pub status: SimpleTaskStatus,
|
||||
}
|
||||
|
||||
/// 批量操作响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BatchOperationResponse {
|
||||
pub total: usize,
|
||||
pub successful: usize,
|
||||
pub failed: usize,
|
||||
pub errors: Vec<BatchError>,
|
||||
}
|
||||
|
||||
/// 批量操作错误
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct BatchError {
|
||||
pub item_id: String,
|
||||
pub error_code: String,
|
||||
pub error_message: String,
|
||||
}
|
||||
|
||||
/// 响应头工具
|
||||
pub struct ResponseHeaders;
|
||||
|
||||
impl ResponseHeaders {
|
||||
/// 添加CORS头
|
||||
pub fn cors() -> axum::http::HeaderMap {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
"*".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::ACCESS_CONTROL_ALLOW_METHODS,
|
||||
"GET, POST, PUT, DELETE, OPTIONS".parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
axum::http::header::ACCESS_CONTROL_ALLOW_HEADERS,
|
||||
"Content-Type, Authorization, X-Requested-With"
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
/// 添加缓存头
|
||||
pub fn cache_control(max_age: u32) -> axum::http::HeaderMap {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
format!("public, max-age={max_age}").parse().unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
/// 添加内容类型头
|
||||
pub fn content_type(content_type: &str) -> axum::http::HeaderMap {
|
||||
let mut headers = axum::http::HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
content_type.parse().unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
}
|
||||
|
||||
use axum::{extract::Request, middleware::Next};
|
||||
/// 响应时间中间件
|
||||
use std::time::Instant;
|
||||
|
||||
pub async fn response_time_middleware(request: Request, next: Next) -> Response {
|
||||
let start = Instant::now();
|
||||
let mut response = next.run(request).await;
|
||||
let duration = start.elapsed();
|
||||
|
||||
response.headers_mut().insert(
|
||||
"X-Response-Time",
|
||||
format!("{:.2}ms", duration.as_secs_f64() * 1000.0)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
1130
qiming-mcp-proxy/document-parser/src/handlers/task_handler.rs
Normal file
1130
qiming-mcp-proxy/document-parser/src/handlers/task_handler.rs
Normal file
File diff suppressed because it is too large
Load Diff
235
qiming-mcp-proxy/document-parser/src/handlers/toc_handler.rs
Normal file
235
qiming-mcp-proxy/document-parser/src/handlers/toc_handler.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use crate::app_state::AppState;
|
||||
use crate::models::{HttpResult, StructuredSection};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// 目录响应结构
|
||||
///
|
||||
/// 表示文档目录处理完成后的结果,包含任务ID、目录结构和统计信息。
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TocResponse {
|
||||
/// 文档处理任务的唯一标识符
|
||||
/// 用于关联请求和响应,支持异步处理和状态查询
|
||||
pub task_id: String,
|
||||
|
||||
/// 文档的目录结构
|
||||
/// 包含所有章节和子章节的层级结构,支持无限嵌套
|
||||
pub toc: Vec<StructuredSection>,
|
||||
|
||||
/// 目录中章节的总数量
|
||||
/// 用于统计分析和分页显示
|
||||
pub total_sections: usize,
|
||||
}
|
||||
|
||||
/// 章节响应结构
|
||||
///
|
||||
/// 表示单个章节的详细信息,用于章节内容的展示和编辑。
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct SectionResponse {
|
||||
/// 章节的唯一标识符
|
||||
/// 用于章节的定位、引用和更新操作
|
||||
pub section_id: String,
|
||||
|
||||
/// 章节的标题或名称
|
||||
/// 显示在目录和导航中的章节标题
|
||||
pub title: String,
|
||||
|
||||
/// 章节的正文内容
|
||||
/// 包含章节的完整文本内容,支持Markdown格式
|
||||
pub content: String,
|
||||
|
||||
/// 章节的层级深度
|
||||
/// 1表示顶级章节,2表示二级章节,以此类推
|
||||
pub level: u8,
|
||||
|
||||
/// 是否包含子章节
|
||||
/// 用于判断章节是否可以展开显示子章节
|
||||
pub has_children: bool,
|
||||
}
|
||||
|
||||
/// 章节列表响应结构
|
||||
///
|
||||
/// 表示文档所有章节的完整信息,包含文档元数据和章节结构。
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct SectionsResponse {
|
||||
/// 文档处理任务的唯一标识符
|
||||
/// 用于关联请求和响应,支持异步处理和状态查询
|
||||
pub task_id: String,
|
||||
|
||||
/// 文档的标题或名称
|
||||
/// 显示在界面中的文档标题
|
||||
pub document_title: String,
|
||||
|
||||
/// 文档的完整目录结构
|
||||
/// 包含所有章节和子章节的层级结构,支持无限嵌套
|
||||
pub toc: Vec<StructuredSection>,
|
||||
|
||||
/// 文档中章节的总数量
|
||||
/// 用于统计分析和分页显示
|
||||
pub total_sections: usize,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks/{task_id}/toc",
|
||||
params(
|
||||
("task_id" = String, Path, description = "任务ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取文档目录成功", body = HttpResult<TocResponse>),
|
||||
(status = 404, description = "任务不存在或未生成结构化文档", body = HttpResult<TocResponse>),
|
||||
(status = 500, description = "服务器内部错误", body = HttpResult<TocResponse>)
|
||||
),
|
||||
tag = "toc"
|
||||
)]
|
||||
pub async fn get_document_toc(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
) -> Json<HttpResult<TocResponse>> {
|
||||
match state.task_service.get_task(&task_id).await {
|
||||
Ok(Some(task)) => {
|
||||
if let Some(doc) = task.structured_document {
|
||||
let resp = TocResponse {
|
||||
task_id: task_id.clone(),
|
||||
toc: doc.toc.clone(),
|
||||
total_sections: doc.total_sections,
|
||||
};
|
||||
Json(HttpResult::success(resp))
|
||||
} else {
|
||||
Json(HttpResult::<TocResponse>::error(
|
||||
"T009".to_string(),
|
||||
"任务尚未生成结构化文档".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Ok(None) => Json(HttpResult::<TocResponse>::error(
|
||||
"T002".to_string(),
|
||||
format!("任务不存在: {task_id}"),
|
||||
)),
|
||||
Err(e) => Json(HttpResult::<TocResponse>::error(
|
||||
"T003".to_string(),
|
||||
format!("查询任务失败: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks/{task_id}/sections/{section_id}",
|
||||
params(
|
||||
("task_id" = String, Path, description = "任务ID"),
|
||||
("section_id" = String, Path, description = "章节ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取章节内容成功", body = HttpResult<SectionResponse>),
|
||||
(status = 404, description = "任务或章节不存在", body = HttpResult<SectionResponse>),
|
||||
(status = 500, description = "服务器内部错误", body = HttpResult<SectionResponse>)
|
||||
),
|
||||
tag = "toc"
|
||||
)]
|
||||
pub async fn get_section_content(
|
||||
State(state): State<AppState>,
|
||||
Path((task_id, section_id)): Path<(String, String)>,
|
||||
) -> Json<HttpResult<SectionResponse>> {
|
||||
match state.task_service.get_task(&task_id).await {
|
||||
Ok(Some(task)) => {
|
||||
if let Some(doc) = task.structured_document {
|
||||
// 遍历 toc 查找 section 元信息
|
||||
fn find<'a>(
|
||||
items: &'a [StructuredSection],
|
||||
id: &str,
|
||||
) -> Option<&'a StructuredSection> {
|
||||
for it in items {
|
||||
if it.id == id {
|
||||
return Some(it);
|
||||
}
|
||||
for child in &it.children {
|
||||
if let Some(found) = find(std::slice::from_ref(child.as_ref()), id) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
if let Some(toc_item) = find(&doc.toc, §ion_id) {
|
||||
let content = toc_item.content.clone();
|
||||
let resp = SectionResponse {
|
||||
section_id: section_id.clone(),
|
||||
title: toc_item.title.clone(),
|
||||
content,
|
||||
level: toc_item.level,
|
||||
has_children: !toc_item.children.is_empty(),
|
||||
};
|
||||
Json(HttpResult::success(resp))
|
||||
} else {
|
||||
Json(HttpResult::<SectionResponse>::error(
|
||||
"T010".to_string(),
|
||||
format!("章节不存在: {section_id}"),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Json(HttpResult::<SectionResponse>::error(
|
||||
"T009".to_string(),
|
||||
"任务尚未生成结构化文档".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Ok(None) => Json(HttpResult::<SectionResponse>::error(
|
||||
"T002".to_string(),
|
||||
format!("任务不存在: {task_id}"),
|
||||
)),
|
||||
Err(e) => Json(HttpResult::<SectionResponse>::error(
|
||||
"T003".to_string(),
|
||||
format!("查询任务失败: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/tasks/{task_id}/sections",
|
||||
params(
|
||||
("task_id" = String, Path, description = "任务ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "获取所有章节成功", body = HttpResult<SectionsResponse>),
|
||||
(status = 404, description = "任务不存在或未生成结构化文档", body = HttpResult<SectionsResponse>),
|
||||
(status = 500, description = "服务器内部错误", body = HttpResult<SectionsResponse>)
|
||||
),
|
||||
tag = "toc"
|
||||
)]
|
||||
pub async fn get_all_sections(
|
||||
State(state): State<AppState>,
|
||||
Path(task_id): Path<String>,
|
||||
) -> Json<HttpResult<SectionsResponse>> {
|
||||
match state.task_service.get_task(&task_id).await {
|
||||
Ok(Some(task)) => {
|
||||
if let Some(doc) = task.structured_document {
|
||||
let resp = SectionsResponse {
|
||||
task_id: task_id.clone(),
|
||||
document_title: doc.document_title.clone(),
|
||||
toc: doc.toc.clone(),
|
||||
total_sections: doc.total_sections,
|
||||
};
|
||||
Json(HttpResult::success(resp))
|
||||
} else {
|
||||
Json(HttpResult::<SectionsResponse>::error(
|
||||
"T009".to_string(),
|
||||
"任务尚未生成结构化文档".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Ok(None) => Json(HttpResult::<SectionsResponse>::error(
|
||||
"T002".to_string(),
|
||||
format!("任务不存在: {task_id}"),
|
||||
)),
|
||||
Err(e) => Json(HttpResult::<SectionsResponse>::error(
|
||||
"T003".to_string(),
|
||||
format!("查询任务失败: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
300
qiming-mcp-proxy/document-parser/src/handlers/validation.rs
Normal file
300
qiming-mcp-proxy/document-parser/src/handlers/validation.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::DocumentFormat;
|
||||
use std::collections::HashSet;
|
||||
use url::Url;
|
||||
|
||||
/// 请求验证器
|
||||
pub struct RequestValidator;
|
||||
|
||||
impl RequestValidator {
|
||||
/// 验证文件大小
|
||||
pub fn validate_file_size(size: u64, max_size: u64) -> Result<(), AppError> {
|
||||
if size == 0 {
|
||||
return Err(AppError::Validation("文件大小不能为0".to_string()));
|
||||
}
|
||||
if size > max_size {
|
||||
return Err(AppError::Validation(format!(
|
||||
"文件大小超过限制: {size} > {max_size} 字节"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证文件扩展名
|
||||
pub fn validate_file_extension(
|
||||
filename: &str,
|
||||
allowed_extensions: &[String],
|
||||
) -> Result<String, AppError> {
|
||||
let extension = std::path::Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_lowercase())
|
||||
.ok_or_else(|| AppError::Validation("无法确定文件扩展名".to_string()))?;
|
||||
|
||||
if !allowed_extensions.contains(&extension) {
|
||||
return Err(AppError::Validation(format!(
|
||||
"不支持的文件格式: .{extension}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(extension)
|
||||
}
|
||||
|
||||
/// 验证URL格式
|
||||
pub fn validate_url(url_str: &str) -> Result<Url, AppError> {
|
||||
let url =
|
||||
Url::parse(url_str).map_err(|e| AppError::Validation(format!("无效的URL格式: {e}")))?;
|
||||
|
||||
// 检查协议
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err(AppError::Validation("只支持HTTP和HTTPS协议".to_string()));
|
||||
}
|
||||
|
||||
// 检查主机
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| AppError::Validation("URL缺少主机名".to_string()))?;
|
||||
|
||||
// 防止访问本地地址
|
||||
if Self::is_local_address(host) {
|
||||
return Err(AppError::Validation("不允许访问本地地址".to_string()));
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// 验证URL格式(仅验证,不返回解析后的URL)
|
||||
pub fn validate_url_format(url_str: &str) -> Result<(), AppError> {
|
||||
let _url =
|
||||
Url::parse(url_str).map_err(|e| AppError::Validation(format!("无效的URL格式: {e}")))?;
|
||||
|
||||
// 检查协议
|
||||
if !url_str.starts_with("http://") && !url_str.starts_with("https://") {
|
||||
return Err(AppError::Validation("只支持HTTP和HTTPS协议".to_string()));
|
||||
}
|
||||
|
||||
// 检查是否包含主机名(简单检查)
|
||||
if !url_str.contains("://") || url_str.split("://").nth(1).unwrap_or("").is_empty() {
|
||||
return Err(AppError::Validation("URL缺少主机名".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证OSS路径
|
||||
pub fn validate_oss_path(oss_path: &str) -> Result<(), AppError> {
|
||||
if oss_path.is_empty() {
|
||||
return Err(AppError::Validation("OSS路径不能为空".to_string()));
|
||||
}
|
||||
|
||||
// 检查路径格式
|
||||
if oss_path.starts_with('/') || oss_path.contains("../") || oss_path.contains("..\\") {
|
||||
return Err(AppError::Validation("OSS路径格式无效".to_string()));
|
||||
}
|
||||
|
||||
// 检查路径长度
|
||||
if oss_path.len() > 1024 {
|
||||
return Err(AppError::Validation("OSS路径过长".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证任务ID格式
|
||||
pub fn validate_task_id(task_id: &str) -> Result<(), AppError> {
|
||||
if task_id.is_empty() {
|
||||
return Err(AppError::Validation("任务ID不能为空".to_string()));
|
||||
}
|
||||
|
||||
// 检查UUID格式
|
||||
if uuid::Uuid::parse_str(task_id).is_err() {
|
||||
return Err(AppError::Validation("任务ID格式无效".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证分页参数
|
||||
pub fn validate_pagination(
|
||||
page: Option<usize>,
|
||||
page_size: Option<usize>,
|
||||
) -> Result<(usize, usize), AppError> {
|
||||
let page = page.unwrap_or(1);
|
||||
let page_size = page_size.unwrap_or(10);
|
||||
|
||||
if page == 0 {
|
||||
return Err(AppError::Validation("页码必须大于0".to_string()));
|
||||
}
|
||||
|
||||
if page_size == 0 || page_size > 100 {
|
||||
return Err(AppError::Validation("每页大小必须在1-100之间".to_string()));
|
||||
}
|
||||
|
||||
Ok((page, page_size))
|
||||
}
|
||||
|
||||
/// 验证排序参数
|
||||
pub fn validate_sort_params(
|
||||
sort_by: Option<&str>,
|
||||
sort_order: Option<&str>,
|
||||
) -> Result<(String, String), AppError> {
|
||||
let allowed_sort_fields =
|
||||
HashSet::from(["created_at", "updated_at", "progress", "file_size"]);
|
||||
|
||||
let sort_by = sort_by.unwrap_or("created_at");
|
||||
if !allowed_sort_fields.contains(sort_by) {
|
||||
return Err(AppError::Validation(format!("不支持的排序字段: {sort_by}")));
|
||||
}
|
||||
|
||||
let sort_order = sort_order.unwrap_or("desc");
|
||||
if !matches!(sort_order, "asc" | "desc") {
|
||||
return Err(AppError::Validation("排序方向必须是asc或desc".to_string()));
|
||||
}
|
||||
|
||||
Ok((sort_by.to_string(), sort_order.to_string()))
|
||||
}
|
||||
|
||||
/// 验证文档格式
|
||||
pub fn validate_document_format(format: &DocumentFormat) -> Result<(), AppError> {
|
||||
// 这里可以添加特定格式的验证逻辑
|
||||
match format {
|
||||
DocumentFormat::PDF
|
||||
| DocumentFormat::Word
|
||||
| DocumentFormat::Excel
|
||||
| DocumentFormat::PowerPoint
|
||||
| DocumentFormat::Image
|
||||
| DocumentFormat::Audio
|
||||
| DocumentFormat::HTML
|
||||
| DocumentFormat::Text
|
||||
| DocumentFormat::Txt
|
||||
| DocumentFormat::Md
|
||||
| DocumentFormat::Other(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证Markdown内容
|
||||
pub fn validate_markdown_content(content: &str) -> Result<(), AppError> {
|
||||
if content.is_empty() {
|
||||
return Err(AppError::Validation("Markdown内容不能为空".to_string()));
|
||||
}
|
||||
|
||||
// 检查内容长度(最大10MB)
|
||||
const MAX_CONTENT_SIZE: usize = 10 * 1024 * 1024;
|
||||
if content.len() > MAX_CONTENT_SIZE {
|
||||
return Err(AppError::Validation(format!(
|
||||
"Markdown内容过长: {} > {} 字节",
|
||||
content.len(),
|
||||
MAX_CONTENT_SIZE
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证TOC配置
|
||||
pub fn validate_toc_config(
|
||||
enable_toc: Option<bool>,
|
||||
max_toc_depth: Option<usize>,
|
||||
) -> Result<(bool, usize), AppError> {
|
||||
let enable_toc = enable_toc.unwrap_or(true);
|
||||
let max_depth = max_toc_depth.unwrap_or(3);
|
||||
|
||||
if enable_toc && (max_depth == 0 || max_depth > 10) {
|
||||
return Err(AppError::Validation(
|
||||
"TOC最大深度必须在1-10之间".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok((enable_toc, max_depth))
|
||||
}
|
||||
|
||||
/// 验证分页参数
|
||||
pub fn validate_pagination_params(page: usize, page_size: usize) -> Result<(), AppError> {
|
||||
if page == 0 {
|
||||
return Err(AppError::Validation("页码必须大于0".to_string()));
|
||||
}
|
||||
if page_size == 0 || page_size > 100 {
|
||||
return Err(AppError::Validation("每页大小必须在1-100之间".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查是否为本地地址
|
||||
fn is_local_address(host: &str) -> bool {
|
||||
//todo: 找rust生态的库,看能否用更简单的方式实现"检查是否为本地地址"
|
||||
matches!(
|
||||
host,
|
||||
"localhost"
|
||||
| "127.0.0.1"
|
||||
| "::1"
|
||||
| "0.0.0.0"
|
||||
| "10.0.0.0"
|
||||
| "172.16.0.0"
|
||||
| "192.168.0.0"
|
||||
) || host.starts_with("10.")
|
||||
|| host.starts_with("172.16.")
|
||||
|| host.starts_with("172.17.")
|
||||
|| host.starts_with("172.18.")
|
||||
|| host.starts_with("172.19.")
|
||||
|| host.starts_with("172.20.")
|
||||
|| host.starts_with("172.21.")
|
||||
|| host.starts_with("172.22.")
|
||||
|| host.starts_with("172.23.")
|
||||
|| host.starts_with("172.24.")
|
||||
|| host.starts_with("172.25.")
|
||||
|| host.starts_with("172.26.")
|
||||
|| host.starts_with("172.27.")
|
||||
|| host.starts_with("172.28.")
|
||||
|| host.starts_with("172.29.")
|
||||
|| host.starts_with("172.30.")
|
||||
|| host.starts_with("172.31.")
|
||||
|| host.starts_with("192.168.")
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件名清理工具
|
||||
pub struct FileNameSanitizer;
|
||||
|
||||
impl FileNameSanitizer {
|
||||
/// 清理文件名
|
||||
pub fn sanitize(filename: &str) -> Result<String, AppError> {
|
||||
if filename.is_empty() {
|
||||
return Err(AppError::Validation("文件名不能为空".to_string()));
|
||||
}
|
||||
|
||||
// 移除危险字符
|
||||
let sanitized = filename
|
||||
.chars()
|
||||
.filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
|
||||
.collect::<String>();
|
||||
|
||||
if sanitized.is_empty() {
|
||||
return Err(AppError::Validation("文件名包含过多非法字符".to_string()));
|
||||
}
|
||||
|
||||
// 检查长度
|
||||
if sanitized.len() > 255 {
|
||||
return Err(AppError::Validation("文件名过长".to_string()));
|
||||
}
|
||||
|
||||
// 避免保留名称
|
||||
let reserved_names = HashSet::from([
|
||||
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
|
||||
"COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
]);
|
||||
|
||||
let name_without_ext = std::path::Path::new(&sanitized)
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(&sanitized)
|
||||
.to_uppercase();
|
||||
|
||||
if reserved_names.contains(name_without_ext.as_str()) {
|
||||
return Err(AppError::Validation(
|
||||
"文件名不能使用系统保留名称".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(sanitized)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user