style: 统一代码格式和字符串引号使用

refactor: 优化代码结构和可读性

feat: 添加http_limit模块实现请求限制功能

fix: 修复异步任务中使用time.sleep的问题

chore: 更新依赖项并添加pytest测试框架

docs: 更新项目描述信息

perf: 优化Redis序列化方式使用JSON替代pickle

test: 添加测试相关配置和依赖
This commit is contained in:
zhangtao
2026-01-17 20:07:25 +08:00
parent 32b8f18b17
commit 1d54dca76c
139 changed files with 4920 additions and 2826 deletions
@@ -11,7 +11,12 @@ from app.core.dependencies import AuthPermission
from app.core.logger import log
from app.core.router_class import OperationLogRoute
from .schema import ChatQuerySchema, McpCreateSchema, McpQueryParam, McpUpdateSchema
from .schema import (
ChatQuerySchema,
McpCreateSchema,
McpQueryParam,
McpUpdateSchema,
)
from .service import McpService
AIRouter = APIRouter(route_class=OperationLogRoute, prefix="/ai", tags=["MCP智能助手"])
@@ -20,7 +25,7 @@ AIRouter = APIRouter(route_class=OperationLogRoute, prefix="/ai", tags=["MCP智
@AIRouter.post("/chat", summary="智能对话", description="与MCP智能助手进行对话")
async def chat_controller(
query: ChatQuerySchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:chat"]))]
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:chat"]))],
) -> StreamingResponse:
"""
智能对话接口
@@ -39,7 +44,7 @@ async def chat_controller(
async for chunk in McpService.chat_query(query=query):
# 确保返回的是字节串
if chunk:
yield chunk.encode('utf-8') if isinstance(chunk, str) else chunk
yield (chunk.encode("utf-8") if isinstance(chunk, str) else chunk)
except Exception as e:
log.error(f"流式响应出错: {e!s}")
yield f"抱歉,处理您的请求时出现了错误: {e!s}".encode()
@@ -47,10 +52,14 @@ async def chat_controller(
return StreamResponse(generate_response(), media_type="text/plain; charset=utf-8")
@AIRouter.get("/detail/{id}", summary="获取 MCP 服务器详情", description="获取 MCP 服务器详情")
@AIRouter.get(
"/detail/{id}",
summary="获取 MCP 服务器详情",
description="获取 MCP 服务器详情",
)
async def detail_controller(
id: Annotated[int, Path(description="MCP ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:query"]))]
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:query"]))],
) -> JSONResponse:
"""
获取 MCP 服务器详情接口
@@ -70,7 +79,7 @@ async def detail_controller(
async def list_controller(
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[McpQueryParam, Depends()],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:query"]))]
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:query"]))],
) -> JSONResponse:
"""
查询 MCP 服务器列表接口
@@ -83,8 +92,14 @@ async def list_controller(
返回:
- JSONResponse: 包含 MCP 服务器列表的 JSON 响应
"""
result_dict_list = await McpService.list_service(auth=auth, search=search, order_by=page.order_by)
result_dict = await PaginationService.paginate(data_list=result_dict_list, page_no=page.page_no, page_size=page.page_size)
result_dict_list = await McpService.list_service(
auth=auth, search=search, order_by=page.order_by
)
result_dict = await PaginationService.paginate(
data_list=result_dict_list,
page_no=page.page_no,
page_size=page.page_size,
)
log.info("查询 MCP 服务器列表成功")
return SuccessResponse(data=result_dict, msg="查询 MCP 服务器列表成功")
@@ -92,7 +107,7 @@ async def list_controller(
@AIRouter.post("/create", summary="创建 MCP 服务器", description="创建 MCP 服务器")
async def create_controller(
data: McpCreateSchema,
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:create"]))]
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:create"]))],
) -> JSONResponse:
"""
创建 MCP 服务器接口
@@ -113,7 +128,7 @@ async def create_controller(
async def update_controller(
data: McpUpdateSchema,
id: Annotated[int, Path(description="MCP ID")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:update"]))]
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:update"]))],
) -> JSONResponse:
"""
修改 MCP 服务器接口
@@ -134,7 +149,7 @@ async def update_controller(
@AIRouter.delete("/delete", summary="删除 MCP 服务器", description="删除 MCP 服务器")
async def delete_controller(
ids: Annotated[list[int], Body(description="ID列表")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:delete"]))]
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_application:ai:delete"]))],
) -> JSONResponse:
"""
删除 MCP 服务器接口
@@ -21,7 +21,9 @@ class McpCRUD(CRUDBase[McpModel, McpCreateSchema, McpUpdateSchema]):
self.auth = auth
super().__init__(model=McpModel, auth=auth)
async def get_by_id_crud(self, id: int, preload: list[str | Any] | None = None) -> McpModel | None:
async def get_by_id_crud(
self, id: int, preload: list[str | Any] | None = None
) -> McpModel | None:
"""
获取MCP服务器详情
@@ -34,7 +36,9 @@ class McpCRUD(CRUDBase[McpModel, McpCreateSchema, McpUpdateSchema]):
"""
return await self.get(id=id, preload=preload)
async def get_by_name_crud(self, name: str, preload: list[str | Any] | None = None) -> McpModel | None:
async def get_by_name_crud(
self, name: str, preload: list[str | Any] | None = None
) -> McpModel | None:
"""
通过名称获取MCP服务器
@@ -47,7 +51,12 @@ class McpCRUD(CRUDBase[McpModel, McpCreateSchema, McpUpdateSchema]):
"""
return await self.get(name=name, preload=preload)
async def get_list_crud(self, search: dict | None = None, order_by: list[dict[str, str]] | None = None, preload: list[str | Any] | None = None) -> Sequence[McpModel]:
async def get_list_crud(
self,
search: dict | None = None,
order_by: list[dict[str, str]] | None = None,
preload: list[str | Any] | None = None,
) -> Sequence[McpModel]:
"""
列表查询MCP服务器
@@ -59,7 +68,11 @@ class McpCRUD(CRUDBase[McpModel, McpCreateSchema, McpUpdateSchema]):
返回:
- Sequence[McpModel]: MCP服务器模型实例序列
"""
return await self.list(search=search or {}, order_by=order_by or [{'id': 'asc'}], preload=preload)
return await self.list(
search=search or {},
order_by=order_by or [{"id": "asc"}],
preload=preload,
)
async def create_crud(self, data: McpCreateSchema) -> McpModel | None:
"""
@@ -11,13 +11,14 @@ class McpModel(ModelMixin, UserMixin):
- 0: stdio (标准输入输出)
- 1: sse (Server-Sent Events)
"""
__tablename__: str = 'app_ai_mcp'
__table_args__: dict[str, str] = ({'comment': 'MCP 服务器表'})
__tablename__: str = "app_ai_mcp"
__table_args__: dict[str, str] = {"comment": "MCP 服务器表"}
__loader_options__: list[str] = ["created_by", "updated_by"]
name: Mapped[str] = mapped_column(String(50), comment='MCP 名称')
type: Mapped[int] = mapped_column(Integer, default=0, comment='MCP 类型(0:stdio 1:sse)')
url: Mapped[str | None] = mapped_column(String(255), default=None, comment='远程 SSE 地址')
command: Mapped[str | None] = mapped_column(String(255), default=None, comment='MCP 命令')
args: Mapped[str | None] = mapped_column(String(255), default=None, comment='MCP 命令参数')
env: Mapped[dict[str, str] | None] = mapped_column(JSON(), default=None, comment='MCP 环境变量')
name: Mapped[str] = mapped_column(String(50), comment="MCP 名称")
type: Mapped[int] = mapped_column(Integer, default=0, comment="MCP 类型(0:stdio 1:sse)")
url: Mapped[str | None] = mapped_column(String(255), default=None, comment="远程 SSE 地址")
command: Mapped[str | None] = mapped_column(String(255), default=None, comment="MCP 命令")
args: Mapped[str | None] = mapped_column(String(255), default=None, comment="MCP 命令参数")
env: Mapped[dict[str, str] | None] = mapped_column(JSON(), default=None, comment="MCP 环境变量")
@@ -8,18 +8,20 @@ from app.core.validator import DateTimeStr
class ChatQuerySchema(BaseModel):
"""聊天查询模型"""
message: str = Field(..., min_length=1, max_length=4000, description="聊天消息")
class McpCreateSchema(BaseModel):
"""创建 MCP 服务器参数"""
name: str = Field(..., max_length=64, description='MCP 名称')
type: McpType = Field(McpType.stdio, description='MCP 类型')
description: str | None = Field(None, max_length=255, description='MCP 描述')
url: HttpUrl | None = Field(None, description='远程 SSE 地址')
command: str | None = Field(None, max_length=255, description='MCP 命令')
args: str | None = Field(None, max_length=255, description='MCP 命令参数,多个参数用英文逗号隔开')
env: dict[str, str] | None = Field(None, description='MCP 环境变量')
name: str = Field(..., max_length=64, description="MCP 名称")
type: McpType = Field(McpType.stdio, description="MCP 类型")
description: str | None = Field(None, max_length=255, description="MCP 描述")
url: HttpUrl | None = Field(None, description="远程 SSE 地址")
command: str | None = Field(None, max_length=255, description="MCP 命令")
args: str | None = Field(None, max_length=255, description="MCP 命令参数")
env: dict[str, str] | None = Field(None, description="MCP 环境变量")
class McpUpdateSchema(McpCreateSchema):
@@ -28,6 +30,7 @@ class McpUpdateSchema(McpCreateSchema):
class McpOutSchema(McpCreateSchema, BaseSchema, UserBySchema):
"""MCP 服务器详情"""
model_config = ConfigDict(from_attributes=True)
@@ -38,8 +41,16 @@ class McpQueryParam:
self,
name: str | None = Query(None, description="MCP 名称"),
type: McpType | None = Query(None, description="MCP 类型"),
created_time: list[DateTimeStr] | None = Query(None, description="创建时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
updated_time: list[DateTimeStr] | None = Query(None, description="更新时间范围", examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"]),
created_time: list[DateTimeStr] | None = Query(
None,
description="创建时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
updated_time: list[DateTimeStr] | None = Query(
None,
description="更新时间范围",
examples=["2025-01-01 00:00:00", "2025-12-31 23:59:59"],
),
created_id: int | None = Query(None, description="创建人"),
updated_id: int | None = Query(None, description="更新人"),
) -> None:
@@ -60,9 +71,10 @@ class McpQueryParam:
class McpChatParam(BaseSchema):
"""MCP 聊天参数"""
pk: list[int] = Field(..., description='MCP ID 列表')
provider: McpLLMProvider = Field(McpLLMProvider.openai, description='LLM 供应商')
model: str = Field(..., description='LLM 名称')
key: str = Field(..., description='LLM API Key')
base_url: str | None = Field(None, description='自定义 LLM API 地址,必须兼容 openai 供应商')
prompt: str = Field(..., description='用户提示词')
pk: list[int] = Field(..., description="MCP ID 列表")
provider: McpLLMProvider = Field(McpLLMProvider.openai, description="LLM 供应商")
model: str = Field(..., description="LLM 名称")
key: str = Field(..., description="LLM API Key")
base_url: str | None = Field(None, description="自定义 LLM API 地址,必须兼容 openai 供应商")
prompt: str = Field(..., description="用户提示词")
@@ -6,7 +6,13 @@ from app.core.exceptions import CustomException
from app.core.logger import log
from .crud import McpCRUD
from .schema import ChatQuerySchema, McpCreateSchema, McpOutSchema, McpQueryParam, McpUpdateSchema
from .schema import (
ChatQuerySchema,
McpCreateSchema,
McpOutSchema,
McpQueryParam,
McpUpdateSchema,
)
from .tools.ai_util import AIClient
@@ -27,11 +33,16 @@ class McpService:
"""
obj = await McpCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg='MCP 服务器不存在')
raise CustomException(msg="MCP 服务器不存在")
return McpOutSchema.model_validate(obj).model_dump()
@classmethod
async def list_service(cls, auth: AuthSchema, search: McpQueryParam | None = None, order_by: list[dict[str, str]] | None = None) -> list[dict[str, Any]]:
async def list_service(
cls,
auth: AuthSchema,
search: McpQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[dict[str, Any]]:
"""
列表查询MCP服务器
@@ -61,12 +72,14 @@ class McpService:
"""
obj = await McpCRUD(auth).get_by_name_crud(name=data.name)
if obj:
raise CustomException(msg='创建失败,MCP 服务器已存在')
raise CustomException(msg="创建失败,MCP 服务器已存在")
obj = await McpCRUD(auth).create_crud(data=data)
return McpOutSchema.model_validate(obj).model_dump()
@classmethod
async def update_service(cls, auth: AuthSchema, id: int, data: McpUpdateSchema) -> dict[str, Any]:
async def update_service(
cls, auth: AuthSchema, id: int, data: McpUpdateSchema
) -> dict[str, Any]:
"""
更新MCP服务器
@@ -80,10 +93,10 @@ class McpService:
"""
obj = await McpCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg='更新失败,该数据不存在')
raise CustomException(msg="更新失败,该数据不存在")
exist_obj = await McpCRUD(auth).get_by_name_crud(name=data.name)
if exist_obj and exist_obj.id != id:
raise CustomException(msg='更新失败,MCP 服务器名称重复')
raise CustomException(msg="更新失败,MCP 服务器名称重复")
obj = await McpCRUD(auth).update_crud(id=id, data=data)
return McpOutSchema.model_validate(obj).model_dump()
@@ -100,11 +113,11 @@ class McpService:
- None
"""
if len(ids) < 1:
raise CustomException(msg='删除失败,删除对象不能为空')
raise CustomException(msg="删除失败,删除对象不能为空")
for id in ids:
obj = await McpCRUD(auth).get_by_id_crud(id=id)
if not obj:
raise CustomException(msg='删除失败,该数据不存在')
raise CustomException(msg="删除失败,该数据不存在")
await McpCRUD(auth).delete_crud(ids=ids)
@classmethod
@@ -124,9 +137,8 @@ class McpService:
# 处理消息
async for response in mcp_client.process(query.message):
yield response
finally:
# 确保关闭客户端连接,即使在事件循环关闭时也能安全处理
try:
await mcp_client.close()
except Exception as e:
log.debug(f"关闭AIClient时发生异常(预期行为,服务可能正在关闭): {e!s}")
except Exception as e:
log.debug(f"关闭AIClient时发生异常(预期行为,服务可能正在关闭): {e}")
raise CustomException(
msg=f"关闭AIClient时发生异常(预期行为,服务可能正在关闭), 异常信息: {e}"
)
@@ -17,7 +17,12 @@ from langchain.agents.structured_output import (
ToolStrategy,
)
from langchain.chat_models import init_chat_model
from langchain.messages import AIMessage, HumanMessage, RemoveMessage, SystemMessage
from langchain.messages import (
AIMessage,
HumanMessage,
RemoveMessage,
SystemMessage,
)
from langchain.tools import ToolRuntime, tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph.message import REMOVE_ALL_MESSAGES
@@ -46,6 +51,7 @@ def get_weather_for_location(city: str) -> str:
@dataclass
class Context:
"""Custom runtime context schema."""
user_id: str
@@ -57,17 +63,13 @@ def get_user_location(runtime: ToolRuntime[Context]) -> str:
# =================定义模型=================
model = init_chat_model(
"claude-sonnet-4-5-20250929",
temperature=0.5,
timeout=10,
max_tokens=1000
)
model = init_chat_model("claude-sonnet-4-5-20250929", temperature=0.5, timeout=10, max_tokens=1000)
# =================定义响应模型=================
class ResponseFormat(BaseModel):
"""Response schema for the agent."""
rating: int | None = Field(description="Rating from 1-5", ge=1, le=5)
comment: str = Field(description="Review comment")
punny_response: str
@@ -98,28 +100,21 @@ def trim_messages(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
recent_messages = messages[-3:] if len(messages) % 2 == 0 else messages[-4:]
new_messages = [first_msg, *recent_messages]
return {
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
*new_messages
]
}
return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages]}
@after_model
def validate_response(state: AgentState, runtime: Runtime) -> dict | None:
"""Remove messages containing sensitive words."""
STOP_WORDS = ["password", "secret"]
last_message = state["messages"][-1]
if any(word in last_message.content for word in STOP_WORDS):
if any(word in last_message.content for word in ["password", "secret"]):
return {"messages": [RemoveMessage(id=last_message.id or "")]}
return None
@wrap_model_call
def inject_file_context(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse]
request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse]
) -> ModelResponse:
"""Inject context about files user has uploaded this session."""
# Read from State: get uploaded files metadata
@@ -127,7 +122,9 @@ def inject_file_context(
if uploaded_files:
# Build context about available files
file_descriptions = [f"- {file['name']} ({file['type']}): {file['summary']}" for file in uploaded_files]
file_descriptions = [
f"- {file['name']} ({file['type']}): {file['summary']}" for file in uploaded_files
]
file_context = f"""Files you have access to in this conversation:
{chr(10).join(file_descriptions)}
@@ -155,10 +152,18 @@ agent = create_agent(
model=model,
system_prompt=SYSTEM_PROMPT,
tools=[get_user_location, get_weather_for_location],
middleware=[dynamic_system_prompt, trim_messages, validate_response, inject_file_context],
middleware=[
dynamic_system_prompt,
trim_messages,
validate_response,
inject_file_context,
],
context_schema=Context,
response_format=ToolStrategy(schema=ResponseFormat, handle_errors=(ValueError, TypeError, custom_error_handler)),
checkpointer=checkpointer
response_format=ToolStrategy(
schema=ResponseFormat,
handle_errors=(ValueError, TypeError, custom_error_handler),
),
checkpointer=checkpointer,
)
# =================定义线程=================
@@ -167,18 +172,14 @@ config = {"configurable": {"thread_id": "1"}}
messages = [
SystemMessage("You are a poetry expert"),
HumanMessage("Write a haiku about spring"),
AIMessage("Cherry blossoms bloom...")
AIMessage("Cherry blossoms bloom..."),
]
# =================运行智能体=================
response = agent.invoke(
input=messages,
config=config,
context=Context(user_id="1")
)
response = agent.invoke(input=messages, config=config, context=Context(user_id="1"))
# =================解析响应=================
print(response['structured_response'])
print(response["structured_response"])
# ResponseFormat(
# punny_response="Florida is still having a 'sun-derful' day! The sunshine is playing 'ray-dio' hits all day long! I'd say it's the perfect weather for some 'solar-bration'! If you were hoping for rain, I'm afraid that idea is all 'washed up' - the forecast remains 'clear-ly' brilliant!",
# weather_conditions="It's always sunny in Florida!"
@@ -5,6 +5,7 @@ from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from app.config.setting import settings
from app.core.exceptions import CustomException
from app.core.logger import log
@@ -16,14 +17,14 @@ class AIClient:
def __init__(self) -> None:
# 使用LangChain的ChatOpenAI类
self.model = ChatOpenAI(
api_key=settings.OPENAI_API_KEY,
api_key=lambda: settings.OPENAI_API_KEY,
model=settings.OPENAI_MODEL,
base_url=settings.OPENAI_BASE_URL,
temperature=0.7,
streaming=True
streaming=True,
)
async def process(self, query: str) -> AsyncGenerator[str, Any]:
async def process(self, query: str) -> AsyncGenerator[str, Any]:
"""
处理查询并返回流式响应
@@ -33,13 +34,15 @@ class AIClient:
返回:
- AsyncGenerator[str, Any]: 流式响应内容
"""
system_prompt = """你是一个有用的AI助手,可以帮助用户回答问题和提供帮助。请用中文回答用户的问题。"""
system_prompt = (
"""你是一个有用的AI助手,可以帮助用户回答问题和提供帮助。请用中文回答用户的问题。"""
)
try:
# 使用LangChain的异步流式生成
messages = [
SystemMessage(content=system_prompt),
HumanMessage(content=query)
HumanMessage(content=query),
]
# 使用LangChain的流式响应
@@ -66,24 +69,33 @@ class AIClient:
error_code = err.get("code")
message = err.get("message")
except Exception:
# 忽略解析失败
pass
raise CustomException(f"解析 OpenAI 错误失败: {e!s}")
text = str(e)
msg = message or text
# 特定错误映射
# 欠费/账户状态异常
if (error_code == "Arrearage") or (error_type == "Arrearage") or ("in good standing" in (msg or "")):
if (
(error_code == "Arrearage")
or (error_type == "Arrearage")
or ("in good standing" in (msg or ""))
):
return "账户欠费或结算异常,访问被拒绝。请检查账号状态或更换有效的 API Key。"
# 鉴权失败
if status_code == 401 or "invalid api key" in msg.lower():
return "鉴权失败,API Key 无效或已过期。请检查系统配置中的 API Key。"
# 权限不足或被拒绝
if status_code == 403 or error_type in {"PermissionDenied", "permission_denied"}:
if status_code == 403 or error_type in {
"PermissionDenied",
"permission_denied",
}:
return "访问被拒绝,权限不足或账号受限。请检查账户权限设置。"
# 配额不足或限流
if status_code == 429 or error_type in {"insufficient_quota", "rate_limit_exceeded"}:
if status_code == 429 or error_type in {
"insufficient_quota",
"rate_limit_exceeded",
}:
return "请求过于频繁或配额已用尽。请稍后重试或提升账户配额。"
# 客户端错误
if status_code == 400:
@@ -6,7 +6,11 @@ from app.core.router_class import OperationLogRoute
from .schema import ChatQuerySchema
from .service import McpService
WS_AI = APIRouter(route_class=OperationLogRoute, prefix="/application/ai", tags=["MCP智能助手WebSocket"])
WS_AI = APIRouter(
route_class=OperationLogRoute,
prefix="/application/ai",
tags=["MCP智能助手WebSocket"],
)
@WS_AI.websocket("/ws", name="WebSocket聊天")