提交qiming-mcp-proxy

This commit is contained in:
Codex
2026-06-01 13:03:20 +08:00
parent 9e9486b7c2
commit afb3d9f4e6
394 changed files with 124494 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View 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::*;

View File

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

View File

@@ -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);
// 生成下载URL4小时有效
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()
}
}
}
/// 获取下载签名URL4小时有效
#[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()
}
}
}

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

File diff suppressed because it is too large Load Diff

View 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, &section_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}"),
)),
}
}

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