docs(backend): 重构后端README文档,完善项目文档与开发指南

- 全面更新README,添加项目特性、架构设计与技术栈介绍
- 详细补充项目结构说明及模块设计规范
- 增加快速开始步骤,包括环境配置、数据库初始化和服务启动
- 补充主要API模块路径和认证授权使用示例
- 添加开发指南、数据库迁移与测试方法
- 集成监控、日志级别说明及性能监控内容
- 完善Docker、传统部署及Nginx配置示例
- 添加贡献指南和代码规范说明
- 新增MCP模块概述及智能对话API接口文档
- 清理和移除module_ai中旧的mcp_server相关实现代码
- 在api/v1初始化文件中注册AI模块路由
- 修改resource模块,增强资源路径安全检查和文件类型检测逻辑
- 增强资源搜索和上传服务的健壮性及安全性
This commit is contained in:
zhangtao
2025-09-08 00:45:34 +08:00
parent 8cb93d31d1
commit 01fd6c8ea4
66 changed files with 5692 additions and 5193 deletions
+9
View File
@@ -26,6 +26,8 @@ from .module_example.demo.controller import DemoRouter
from .module_application.myapp.controller import MyAppRouter
from .module_ai.mcp.controller import MCPRouter
from .module_resource.resource.controller import ResourceRouter
@@ -58,6 +60,8 @@ EXAMPLE_MODULES = [{"router": DemoRouter}]
APPLICATION_MODULES = [{"router": MyAppRouter}]
AI_MODULES = [{"router": MCPRouter}]
router = APIRouter()
for module in SYSTEM_MODULES:
@@ -88,4 +92,9 @@ for module in APPLICATION_MODULES:
for module in RESOURCE_MODULES:
router.include_router(
router=module["router"], prefix="/resource"
)
for module in AI_MODULES:
router.include_router(
router=module["router"], prefix="/ai"
)
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
"""
AI模块初始化文件
"""
@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""
AI模块初始化文件
"""
from .controller import MCPRouter
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
from fastapi import APIRouter, Depends, WebSocket
from fastapi.responses import JSONResponse, StreamingResponse
from app.common.response import StreamResponse, SuccessResponse
from app.core.dependencies import AuthPermission
from app.core.router_class import OperationLogRoute
from app.core.logger import logger
from app.api.v1.module_system.auth.schema import AuthSchema
from .service import MCPService
from .schema import ChatQuerySchema
MCPRouter = APIRouter(route_class=OperationLogRoute, prefix="/mcp", tags=["MCP智能助手"])
@MCPRouter.post("/chat", summary="智能对话", description="与MCP智能助手进行对话")
async def chat_controller(
query: ChatQuerySchema,
auth: AuthSchema = Depends(AuthPermission(permissions=["ai:mcp:chat"]))
) -> StreamingResponse:
"""智能对话接口"""
logger.info(f"用户 {auth.user.name} 发起智能对话: {query.message[:50]}...")
async def generate_response():
async for chunk in MCPService.chat_query(query.message):
yield chunk
return StreamingResponse(generate_response(), media_type="text/plain")
@MCPRouter.websocket("/ws/chat", name="WebSocket聊天")
async def websocket_chat_controller(
websocket: WebSocket,
):
"""WebSocket聊天接口
ws://127.0.0.1:8001/api/v1/ai/mcp/ws/chat
"""
await websocket.accept()
try:
while True:
data = await websocket.receive_text()
# 流式发送响应
async for chunk in MCPService.chat_query(data):
await websocket.send_text(chunk)
except Exception as e:
logger.error(f"WebSocket聊天出错: {str(e)}")
await websocket.close()
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
from pydantic import BaseModel, Field
from typing import Optional
class ChatQuerySchema(BaseModel):
"""聊天查询模型"""
message: str = Field(..., min_length=1, max_length=4000, description="聊天消息")
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from app.utils.ai_util import AIClient
class MCPService:
"""MCP服务层 - 适配FastAPI-MCP"""
@classmethod
async def chat_query(cls, message: str):
"""处理聊天查询"""
# 创建MCP客户端实例
mcp_client = AIClient()
# 处理消息
async for response in mcp_client.process(message):
yield response
@@ -1,37 +0,0 @@
from fastapi import FastAPI
from starlette.websockets import WebSocket, WebSocketDisconnect
from mcp_server.mcp_client import MCPClient
async def init_ai_websocket(app: FastAPI):
@app.websocket("/ws/chat")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
user_id = id(websocket)
user_contexts = {}
user_contexts[user_id] = [{"role": "system", "content": "你是一个有帮助的助手。"}]
client = MCPClient()
await client.connect_to_server('mcp_server/mcp_server.py')
try:
while True:
user_msg = await websocket.receive_text()
user_contexts[user_id].append({"role": "user", "content": user_msg})
await websocket.send_json({"role": "user", "content": user_msg})
assistant_reply = ""
response = client.put_query(user_msg)
await websocket.send_json({"start": True})
async for content_piece in response:
assistant_reply += content_piece
await websocket.send_json({"role": "assistant", "content": content_piece})
await websocket.send_json({"done": True})
except WebSocketDisconnect:
print("WebSocket 断开连接")
# 清理上下文
user_contexts.pop(user_id, None)
await client.cleanup()
@@ -1,215 +0,0 @@
import argparse
import asyncio
import os
import json
from typing import Optional
from contextlib import AsyncExitStack
from click import argument
from openai import AsyncOpenAI
from dotenv import load_dotenv
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
class MCPClient:
def __init__(self):
"""初始化 MCP 客户端"""
self.exit_stack = AsyncExitStack()
self.openai_api_key = os.getenv("OPENAI_API_KEY") # 读取 OpenAI API Key
self.base_url = os.getenv("OPENAI_API_URL") # 读取 BASE YRL
self.model = os.getenv("OPENAI_API_MODEL") # 读取 model
if not self.openai_api_key:
raise ValueError("❌ 未找到 OpenAI API Key,请在 .env 文件中设置 OPENAI_API_KEY")
self.client = AsyncOpenAI(api_key=self.openai_api_key, base_url=self.base_url) # 创建OpenAI client
self.session: Optional[ClientSession] = None
self.exit_stack = AsyncExitStack()
self.messages = []
async def connect_to_server(self, server_script_path: str):
"""连接到 MCP 服务器并列出可用工具"""
is_python = server_script_path.endswith('.py')
is_js = server_script_path.endswith('.js')
if not (is_python or is_js):
raise ValueError("服务器脚本必须是 .py 或 .js 文件")
# 必须设置项目根目录,否则无法获取到其他引用代码文件
project_root = os.path.abspath(os.getcwd())
python_cmd_path = os.getenv("PYTHON_PATH")
command = python_cmd_path if is_python else "node"
parser = argparse.ArgumentParser(description='命令行参数')
parser.add_argument('--env', type=str, default='', help='运行环境')
args, unknown = parser.parse_known_args()
server_params = StdioServerParameters(
command=command,
args=[server_script_path, f'--env={args.env}'],
env={"PYTHONPATH": project_root}
)
# 启动 MCP 服务器并建立通信
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
self.stdio, self.write = stdio_transport
self.session = await self.exit_stack.enter_async_context(ClientSession(self.stdio, self.write))
await self.session.initialize()
# 列出 MCP 服务器上的工具
response = await self.session.list_tools()
tools = response.tools
print("\n已连接到服务器,支持以下工具:", [tool.name for tool in tools])
async def process_query(self, query: str):
"""
使用大模型处理查询并调用可用的 MCP 工具 (Function Calling)
"""
self.messages.append({"role": "user", "content": query})
response = await self.session.list_tools()
available_tools = [{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"input_schema": tool.inputSchema
}
} for tool in response.tools]
# print(available_tools)
response = await self.client.chat.completions.create(
model=self.model,
messages=self.messages,
stream=True,
tools=available_tools
)
is_tool_call = False
tool_name = None
tool_args = ''
tool_call_id = None
content = ''
yield f'🤖AI'
async for chunk in response:
print(chunk)
if chunk.choices and chunk.choices[0].delta.tool_calls:
#调用工具
tool_call = chunk.choices[0].delta.tool_calls[0]
if tool_call.id:
is_tool_call = True
tool_name = tool_call.function.name
tool_call_id = tool_call.id
yield f'开始调用工具【{tool_call.function.name}】,参数为'
if tool_call.function:
tool_args += tool_call.function.arguments
print(f'tool_args==={tool_args}')
yield tool_call.function.arguments
elif tool_call.function:
tool_args += tool_call.function.arguments
print(f'tool_args==={tool_args}')
yield tool_call.function.arguments
elif chunk.choices and chunk.choices[0].delta.content:
# 大模型解答
content += chunk.choices[0].delta.content
yield chunk.choices[0].delta.content
elif chunk.choices and chunk.choices[0].finish_reason == 'tool_calls':
# 参数处理完毕
pass
elif chunk.choices and chunk.choices[0].finish_reason == 'stop':
self.messages.append({
"role": "assistant",
"content": content
})
pass
# 处理返回的内容
if is_tool_call:
# 如何是需要使用工具,就解析工具
# 执行工具
print(f"\n\n[Calling tool {tool_name} with args {tool_args}]\n\n")
result = await self.session.call_tool(tool_name, json.loads(tool_args))
print(result)
# 将模型返回的调用哪个工具数据和工具执行完成后的数据都存入messages中
self.messages.append({
"role": "assistant",
"content": "",
"index": 0,
"tool_calls": [{
"id": tool_call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": tool_args
}
}]
})
self.messages.append({
"role": "tool",
"content": result.content[0].text,
"tool_call_id": tool_call_id,
})
# 将上面的结果再返回给大模型用于生产最终的结果
result_response = await self.client.chat.completions.create(
model=self.model,
messages=self.messages,
stream=True,
)
result_content = ''
async for chunk in result_response:
if chunk.choices and chunk.choices[0].delta.content:
result_content += chunk.choices[0].delta.content
yield chunk.choices[0].delta.content
self.messages.append({
"role": "assistant",
'content': result_content,
})
return
async def put_query(self, query: str):
print(f"\n🤖 OpenAI: ", end="", flush=True)
response = self.process_query(query) # 发送用户输入到 OpenAI API
async for value in response:
print(value, end="", flush=True)
yield value
async def chat_loop(self):
"""运行交互式聊天循环"""
print("\n🤖 MCP 客户端已启动!输入 'quit' 退出")
while True:
try:
query = input("\n你: ").strip()
if query.lower() == 'quit':
break
print(f"\n🤖 OpenAI: ", end="", flush=True)
response = self.process_query(query) # 发送用户输入到 OpenAI API
async for value in response:
print(value, end="", flush=True)
except Exception as e:
print(f"\n⚠️ 发生错误: {str(e)}")
async def cleanup(self):
"""清理资源"""
await self.exit_stack.aclose()
async def main(server_script_path: str):
client = MCPClient()
try:
await client.connect_to_server(server_script_path)
await client.chat_loop()
finally:
await client.cleanup()
if __name__ == "__main__":
asyncio.run(main('mcp_server.py'))
@@ -1,39 +0,0 @@
from typing import Literal
from mcp.server.fastmcp import FastMCP
from tool_table import TableTool
from tool_weather import WeatherTool
# 初始化 MCP 服务器
mcp = FastMCP("FluxMcpServer")
@mcp.tool()
async def query_weather(city: str) -> str:
"""
输入指定城市的英文名称,返回今日天气查询结果。
:param city: 城市名称(需使用英文)
:return: 格式化后的天气信息
"""
data = await WeatherTool.fetch_weather(city)
return WeatherTool.format_weather(data)
@mcp.tool()
async def query_table(table_name: Literal["car_driver", "student_info"]) -> str:
"""
输入指定表名,获取表内的数据。
Args:
table_name: 表名选项:
- car_driver: 司机信息
- student_info: 学生信息表
return: 数据表内容
"""
data = await TableTool.fetch_table_data(table_name)
return data
if __name__ == "__main__":
# 以标准 I/O 方式运行 MCP 服务器
mcp.run(transport='stdio')
@@ -1,30 +0,0 @@
import json
from fastapi.encoders import jsonable_encoder
from sqlalchemy import select
from config.database import Base
from config.get_db import get_db
import logging
from module_admin.entity.do.car_driver_do import CarDriver
from module_admin.entity.do.student_info_do import StudentInfo
class TableTool:
logger = logging.getLogger(__name__)
# 因为mcp服务是在另外进程里面,需要导入模型,否则Base.registry.mappers是空的
support_modules = [CarDriver, StudentInfo]
@classmethod
async def fetch_table_data(cls, table_name: str) -> str:
async for query_db in get_db():
for mapper in Base.registry.mappers:
table_cls = mapper.class_
if hasattr(table_cls, '__tablename__') and table_cls.__tablename__ == table_name:
result = await query_db.execute(select(table_cls))
data = result.scalars().all()
json_str = json.dumps(jsonable_encoder(data), ensure_ascii=False)
return json_str
raise ValueError(f"No model found for table name: {table_name}to check if you have imported it")
@@ -1,72 +0,0 @@
import json
from typing import Any
import httpx
class WeatherTool:
# OpenWeather API 配置
OPENWEATHER_API_BASE = "https://api.openweathermap.org/data/2.5/weather"
API_KEY = "146d600baa0f4f7a7687bdb573fb9138" # 请替换为你自己的 OpenWeather API Key
USER_AGENT = "weather-app/1.0"
@classmethod
async def fetch_weather(cls, city: str) -> dict[str, Any] | None:
"""
从 OpenWeather API 获取天气信息。
:param city: 城市名称(需使用英文,如 Beijing)
:return: 天气数据字典;若出错返回包含 error 信息的字典
"""
params = {
"q": city,
"appid": cls.API_KEY,
"units": "metric",
"lang": "zh_cn"
}
headers = {"User-Agent": cls.USER_AGENT}
async with httpx.AsyncClient() as client:
try:
response = await client.get(cls.OPENWEATHER_API_BASE, params=params, headers=headers, timeout=30.0)
response.raise_for_status()
return response.json() # 返回字典类型
except httpx.HTTPStatusError as e:
return {"error": f"HTTP 错误: {e.response.status_code}"}
except Exception as e:
return {"error": f"请求失败: {str(e)}"}
@classmethod
def format_weather(cls, data: dict[str, Any] | str) -> str:
"""
将天气数据格式化为易读文本。
:param data: 天气数据(可以是字典或 JSON 字符串)
:return: 格式化后的天气信息字符串
"""
# 如果传入的是字符串,则先转换为字典
if isinstance(data, str):
try:
data = json.loads(data)
except Exception as e:
return f"无法解析天气数据: {e}"
# 如果数据中包含错误信息,直接返回错误提示
if "error" in data:
return f"⚠️ {data['error']}"
# 提取数据时做容错处理
city = data.get("name", "未知")
country = data.get("sys", {}).get("country", "未知")
temp = data.get("main", {}).get("temp", "N/A")
humidity = data.get("main", {}).get("humidity", "N/A")
wind_speed = data.get("wind", {}).get("speed", "N/A")
# weather 可能为空列表,因此用 [0] 前先提供默认字典
weather_list = data.get("weather", [{}])
description = weather_list[0].get("description", "未知")
return (
f"🌍 {city}, {country}\n"
f"🌡 温度: {temp}°C\n"
f"💧 湿度: {humidity}%\n"
f"🌬 风速: {wind_speed} m/s\n"
f"🌤 天气: {description}\n"
)
@@ -19,6 +19,8 @@ class ResourceType(Enum):
class ResourceItemSchema(BaseModel):
"""资源项目模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
name: str = Field(..., description="文件名")
path: str = Field(..., description="文件路径")
relative_path: str = Field(..., description="相对路径")
@@ -37,6 +39,8 @@ class ResourceItemSchema(BaseModel):
class ResourceDirectorySchema(BaseModel):
"""资源目录模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
path: str = Field(..., description="目录路径")
name: str = Field(..., description="目录名称")
items: List[ResourceItemSchema] = Field(default_factory=list, description="目录项")
@@ -47,6 +51,8 @@ class ResourceDirectorySchema(BaseModel):
class ResourceStatsSchema(BaseModel):
"""资源统计模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
mount_point: str = Field(..., description="挂载点")
total_files: int = Field(0, description="文件总数")
total_dirs: int = Field(0, description="目录总数")
@@ -60,6 +66,8 @@ class ResourceStatsSchema(BaseModel):
class ResourceSearchSchema(BaseModel):
"""资源搜索模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
keyword: Optional[str] = Field(None, description="关键词")
file_type: Optional[str] = Field(None, description="文件类型")
resource_type: Optional[ResourceType] = Field(None, description="资源类型")
@@ -74,6 +82,8 @@ class ResourceSearchSchema(BaseModel):
class ResourceUploadSchema(BaseModel):
"""资源上传响应模型"""
model_config = ConfigDict(from_attributes=True, use_enum_values=True)
filename: str = Field(..., description="文件名")
file_path: str = Field(..., description="文件路径")
file_url: str = Field(..., description="访问URL")
@@ -10,6 +10,8 @@ from pathlib import Path
from fastapi import UploadFile
from PIL import Image
# 尝试导入 magic 库,如果失败则标记为不可用
try:
import magic
MAGIC_AVAILABLE = True
@@ -18,6 +20,10 @@ except ImportError:
from app.core.exceptions import CustomException
from app.core.logger import logger
# 如果 magic 不可用,记录日志
if not MAGIC_AVAILABLE:
logger.info("没有找到 python-magic 库,将使用基于扩展名的文件类型检测")
from app.utils.excel_util import ExcelUtil
from app.config.setting import settings
from ...module_system.auth.schema import AuthSchema
@@ -38,32 +44,48 @@ from .schema import (
class ResourceService:
"""
资源管理模块服务层 - 直接操作文件系统
资源管理模块服务层 - 管理系统静态文件目录
"""
# 默认挂载点配置
DEFAULT_MOUNT_POINT = getattr(settings, 'RESOURCE_MOUNT_POINT', '/Users/tao/workspace/fastapi_vue3_admin/backend/static/upload')
ALLOWED_MOUNT_POINTS = getattr(settings, 'ALLOWED_MOUNT_POINTS', ['/Users/tao/workspace/fastapi_vue3_admin/backend/static', '/Users/tao/workspace/fastapi_vue3_admin/backend/static/upload'])
# 配置常量
MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100MB
MAX_SEARCH_RESULTS = 1000 # 最大搜索结果数
MAX_PATH_DEPTH = 20 # 最大路径深度
@classmethod
def _get_safe_path(cls, path: str) -> str:
def _get_resource_root(cls) -> str:
"""获取资源管理根目录"""
if not settings.STATIC_ENABLE:
raise CustomException(msg='静态文件服务未启用')
return str(settings.STATIC_ROOT)
@classmethod
def _get_safe_path(cls, path: str = None) -> str:
"""获取安全的文件路径"""
if not path:
return cls.DEFAULT_MOUNT_POINT
# 规范化路径
safe_path = os.path.normpath(os.path.abspath(path))
resource_root = cls._get_resource_root()
# 检查路径是否在允许的挂载点内
allowed = False
for mount_point in cls.ALLOWED_MOUNT_POINTS:
mount_abs = os.path.normpath(os.path.abspath(mount_point))
if safe_path.startswith(mount_abs):
allowed = True
break
if not allowed:
if not path:
return resource_root
# 清理路径,移除危险字符
path = path.strip().replace('..', '').replace('//', '/')
# 规范化路径
if os.path.isabs(path):
safe_path = os.path.normpath(path)
else:
safe_path = os.path.normpath(os.path.join(resource_root, path))
# 检查路径是否在允许的范围内
resource_root_abs = os.path.normpath(os.path.abspath(resource_root))
safe_path_abs = os.path.normpath(os.path.abspath(safe_path))
if not safe_path_abs.startswith(resource_root_abs):
raise CustomException(msg=f'访问路径不在允许范围内: {path}')
# 防止路径遍历攻击
if '..' in safe_path or safe_path.count('/') > cls.MAX_PATH_DEPTH: # 限制最大目录深度
raise CustomException(msg=f'不安全的路径格式: {path}')
return safe_path
@@ -86,16 +108,45 @@ class ResourceService:
stat = os.stat(safe_path)
path_obj = Path(safe_path)
resource_root = cls._get_resource_root()
# 获取文件扩展名和类型
file_extension = path_obj.suffix.lower() if path_obj.suffix else None
file_type = cls._get_mime_type_from_extension(file_extension) if file_extension else None
# 优先使用 magic 库检测 MIME 类型
file_type = None
if MAGIC_AVAILABLE and os.path.isfile(safe_path):
try:
file_type = magic.from_file(safe_path, mime=True)
except Exception as e:
logger.debug(f"magic 库检测文件类型失败: {e}")
# 如果 magic 检测失败或不可用,使用扩展名检测
if not file_type and file_extension:
file_type = cls._get_mime_type_from_extension(file_extension)
# 如果仍然没有类型,使用默认值
if not file_type:
file_type = 'application/octet-stream' if os.path.isfile(safe_path) else None
resource_type = cls._determine_resource_type(file_type, file_extension)
# 计算相对路径
try:
relative_path = os.path.relpath(safe_path, resource_root)
except ValueError:
relative_path = os.path.basename(safe_path)
# 计算深度
try:
depth = len(Path(safe_path).relative_to(resource_root).parts)
except ValueError:
depth = 0
return {
'name': path_obj.name,
'path': safe_path,
'relative_path': os.path.relpath(safe_path, cls.DEFAULT_MOUNT_POINT),
'relative_path': relative_path,
'is_file': os.path.isfile(safe_path),
'is_dir': os.path.isdir(safe_path),
'size': stat.st_size if os.path.isfile(safe_path) else None,
@@ -106,7 +157,7 @@ class ResourceService:
'modified_time': datetime.fromtimestamp(stat.st_mtime),
'accessed_time': datetime.fromtimestamp(stat.st_atime),
'parent_path': str(path_obj.parent),
'depth': len(path_obj.relative_to(cls.DEFAULT_MOUNT_POINT).parts) if cls.DEFAULT_MOUNT_POINT in safe_path else 0
'depth': depth
}
except Exception as e:
logger.error(f'获取文件信息失败: {str(e)}')
@@ -122,8 +173,11 @@ class ResourceService:
) -> Dict:
"""获取目录列表"""
try:
target_path = path or cls.DEFAULT_MOUNT_POINT
safe_path = cls._get_safe_path(target_path)
# 如果没有指定路径,使用静态文件根目录
if path is None:
safe_path = cls._get_resource_root()
else:
safe_path = cls._get_safe_path(path)
if not os.path.exists(safe_path):
raise CustomException(msg='目录不存在')
@@ -171,7 +225,7 @@ class ResourceService:
total_files=total_files,
total_dirs=total_dirs,
total_size=total_size
).model_dump()
).model_dump(mode='json')
except CustomException:
raise
@@ -214,27 +268,52 @@ class ResourceService:
) -> List[Dict]:
"""搜索资源"""
try:
mount_point = cls.DEFAULT_MOUNT_POINT
# 使用静态文件根目录作为搜索起点
search_root = cls._get_resource_root()
results = []
for root, dirs, files in os.walk(mount_point):
for root, dirs, files in os.walk(search_root):
# 控制搜索深度
depth = len(Path(root).relative_to(mount_point).parts)
try:
depth = len(Path(root).relative_to(search_root).parts)
except ValueError:
depth = 0
if depth > search.max_depth:
dirs.clear() # 阻止进一步深入
continue
# 过滤隐藏文件夹
# 过滤隐藏文件夹(性能优化)
if not search.include_hidden:
dirs[:] = [d for d in dirs if not d.startswith('.')]
files = [f for f in files if not f.startswith('.')]
# 优化:先过滤文件名,再进行详细检查
if search.keyword:
files = [f for f in files if search.keyword.lower() in f.lower()]
# 搜索文件
for file in files:
file_path = os.path.join(root, file)
# 优化:先进行快速检查
if search.extensions:
file_ext = os.path.splitext(file)[1].lower()
if file_ext not in search.extensions:
continue
file_info = cls._get_file_info(file_path)
if cls._match_search_criteria(file_info, search):
results.append(file_info)
# 限制结果数量防止内存溢出
if len(results) >= cls.MAX_SEARCH_RESULTS:
logger.warning(f"搜索结果过多,已截断到前{cls.MAX_SEARCH_RESULTS}")
break
if len(results) >= cls.MAX_SEARCH_RESULTS:
break
# 排序结果
return cls._sort_results(results, search)
@@ -314,10 +393,21 @@ class ResourceService:
if not file or not file.filename:
raise CustomException(msg="请选择要上传的文件")
# 文件名安全检查
if '..' in file.filename or '/' in file.filename or '\\' in file.filename:
raise CustomException(msg="文件名包含不安全字符")
try:
# 确定上传目录
upload_dir = target_path or cls.DEFAULT_MOUNT_POINT
safe_dir = cls._get_safe_path(upload_dir)
# 检查文件大小
content = await file.read()
if len(content) > cls.MAX_UPLOAD_SIZE:
raise CustomException(msg=f"文件太大,最大支持{cls.MAX_UPLOAD_SIZE // (1024*1024)}MB")
# 确定上传目录,如果没有指定目标路径,使用静态文件根目录
if target_path is None:
safe_dir = cls._get_resource_root()
else:
safe_dir = cls._get_safe_path(target_path)
# 创建目录(如果不存在)
os.makedirs(safe_dir, exist_ok=True)
@@ -337,24 +427,34 @@ class ResourceService:
counter += 1
filename = os.path.basename(file_path)
# 保存文件
content = await file.read()
# 保存文件(使用已读取的内容)
with open(file_path, 'wb') as f:
f.write(content)
# 获取文件信息
file_info = cls._get_file_info(file_path)
# 生成相对于资源根目录的URL路径
resource_root = cls._get_resource_root()
try:
relative_path = os.path.relpath(file_path, resource_root)
# 确保路径使用正斜杠(URL格式)
file_url_path = relative_path.replace(os.sep, '/')
file_url = f"/resource/download?path={file_url_path}"
except ValueError:
# 如果无法计算相对路径,使用文件名
file_url = f"/resource/download?path={filename}"
logger.info(f"文件上传成功: {filename}")
return ResourceUploadSchema(
filename=filename,
file_path=file_path,
file_url=f"/resource/download?path={file_path}",
file_url=file_url,
file_size=file_info.get('size', 0),
resource_type=file_info.get('resource_type', ResourceType.OTHER),
upload_time=datetime.now()
).model_dump()
).model_dump(mode='json')
except Exception as e:
logger.error(f"文件上传失败: {str(e)}")
@@ -531,10 +631,11 @@ class ResourceService:
async def get_stats_service(cls, auth: AuthSchema) -> Dict:
"""获取资源统计信息"""
try:
mount_point = cls.DEFAULT_MOUNT_POINT
# 使用静态文件根目录
stats_root = cls._get_resource_root()
# 获取磁盘空间信息
statvfs = os.statvfs(mount_point)
statvfs = os.statvfs(stats_root)
total_space = statvfs.f_frsize * statvfs.f_blocks
free_space = statvfs.f_frsize * statvfs.f_bavail
used_space = total_space - free_space
@@ -546,7 +647,7 @@ class ResourceService:
type_stats = {}
extension_stats = {}
for root, dirs, files in os.walk(mount_point):
for root, dirs, files in os.walk(stats_root):
total_dirs += len(dirs)
for file in files:
@@ -572,7 +673,7 @@ class ResourceService:
continue
return ResourceStatsSchema(
mount_point=mount_point,
mount_point=stats_root,
total_files=total_files,
total_dirs=total_dirs,
total_size=total_size,
@@ -581,7 +682,7 @@ class ResourceService:
total_space=total_space,
type_stats=type_stats,
extension_stats=extension_stats
).model_dump()
).model_dump(mode='json')
except Exception as e:
logger.error(f"获取统计信息失败: {str(e)}")
@@ -658,36 +759,39 @@ class ResourceService:
if not file_extension:
return 'application/octet-stream'
# 扩展更全面的MIME类型映射
mime_types = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv',
'.flv': 'video/x-flv',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.aac': 'audio/aac',
'.ogg': 'audio/ogg',
'.pdf': 'application/pdf',
'.doc': 'application/msword',
# 图片类型
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.tiff': 'image/tiff',
# 视频类型
'.mp4': 'video/mp4', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv', '.flv': 'video/x-flv', '.webm': 'video/webm',
'.mkv': 'video/x-matroska', '.m4v': 'video/x-m4v',
# 音频类型
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.aac': 'audio/aac',
'.ogg': 'audio/ogg', '.flac': 'audio/flac', '.m4a': 'audio/mp4',
# 文档类型
'.pdf': 'application/pdf', '.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.csv': 'text/csv',
'.zip': 'application/zip',
'.rar': 'application/x-rar-compressed',
'.7z': 'application/x-7z-compressed',
'.tar': 'application/x-tar',
'.gz': 'application/gzip'
'.txt': 'text/plain', '.csv': 'text/csv', '.rtf': 'application/rtf',
# 代码文件
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
'.json': 'application/json', '.xml': 'application/xml',
'.py': 'text/x-python', '.java': 'text/x-java-source',
# 压缩文件
'.zip': 'application/zip', '.rar': 'application/x-rar-compressed',
'.7z': 'application/x-7z-compressed', '.tar': 'application/x-tar',
'.gz': 'application/gzip', '.bz2': 'application/x-bzip2'
}
return mime_types.get(file_extension.lower(), 'application/octet-stream')