mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 12:52:26 +00:00
docs(backend): 重构后端README文档,完善项目文档与开发指南
- 全面更新README,添加项目特性、架构设计与技术栈介绍 - 详细补充项目结构说明及模块设计规范 - 增加快速开始步骤,包括环境配置、数据库初始化和服务启动 - 补充主要API模块路径和认证授权使用示例 - 添加开发指南、数据库迁移与测试方法 - 集成监控、日志级别说明及性能监控内容 - 完善Docker、传统部署及Nginx配置示例 - 添加贡献指南和代码规范说明 - 新增MCP模块概述及智能对话API接口文档 - 清理和移除module_ai中旧的mcp_server相关实现代码 - 在api/v1初始化文件中注册AI模块路由 - 修改resource模块,增强资源路径安全检查和文件类型检测逻辑 - 增强资源搜索和上传服务的健壮性及安全性
This commit is contained in:
@@ -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"
|
||||
)
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user