feat: 新增MCP服务器和客户端功能,支持AI工具调用和天气查询

refactor: 重构基础模型和响应类,增加success字段

fix: 修复定时任务日志记录和异常处理问题

style: 调整中间件日志记录逻辑,优化参数处理

docs: 更新数据库配置和依赖项说明

perf: 优化文件上传服务,支持OSS存储

test: 添加定时任务日志模型和参数验证

chore: 更新依赖项,添加openai库支持
This commit is contained in:
zhangtao
2025-08-30 13:22:48 +08:00
parent 6876116772
commit ecec6ceadb
33 changed files with 1026 additions and 98 deletions
@@ -7,7 +7,7 @@ from app.core.dependencies import AuthPermission
from app.core.router_class import OperationLogRoute from app.core.router_class import OperationLogRoute
from app.core.logger import logger from app.core.logger import logger
from app.api.v1.services.common.file_service import FileService from app.api.v1.services.common.file_service import FileService
from app.common.response import SuccessResponse, CustomFileResponse from app.common.response import SuccessResponse, UploadFileResponse
router = APIRouter(route_class=OperationLogRoute) router = APIRouter(route_class=OperationLogRoute)
@@ -27,4 +27,4 @@ async def download_controller(
) -> FileResponse: ) -> FileResponse:
result = await FileService.download_service(file_path=file_path) result = await FileService.download_service(file_path=file_path)
logger.info(f"下载文件成功") logger.info(f"下载文件成功")
return CustomFileResponse(file_path=result.file_path, file_name=result.file_name) return UploadFileResponse(file_path=result.file_path, file_name=result.file_name)
+26 -2
View File
@@ -3,8 +3,8 @@
from typing import Dict, List, Optional, Sequence from typing import Dict, List, Optional, Sequence
from app.core.base_crud import CRUDBase from app.core.base_crud import CRUDBase
from app.api.v1.models.monitor.job_model import JobModel from app.api.v1.models.monitor.job_model import JobModel, JobLogModel
from app.api.v1.schemas.monitor.job_schema import JobCreateSchema,JobUpdateSchema from app.api.v1.schemas.monitor.job_schema import JobCreateSchema,JobUpdateSchema,JobLogCreateSchema,JobLogUpdateSchema
from app.api.v1.schemas.system.auth_schema import AuthSchema from app.api.v1.schemas.system.auth_schema import AuthSchema
@@ -44,3 +44,27 @@ class JobCRUD(CRUDBase[JobModel, JobCreateSchema, JobUpdateSchema]):
"""清除定时任务日志""" """清除定时任务日志"""
return await self.clear() return await self.clear()
class JobLogCRUD(CRUDBase[JobLogModel, JobLogCreateSchema, JobLogUpdateSchema]):
"""定时任务日志数据层"""
def __init__(self, auth: AuthSchema) -> None:
"""初始化定时任务日志CRUD"""
self.auth = auth
super().__init__(model=JobLogModel, auth=auth)
async def get_obj_log_by_id_crud(self, id: int) -> Optional[JobLogModel]:
"""获取定时任务日志详情"""
return await self.get(id=id)
async def get_obj_log_list_crud(self, search: Dict = None, order_by: List[Dict[str, str]] = None) -> Sequence[JobLogModel]:
"""获取定时任务日志列表"""
return await self.list(search=search, order_by=order_by)
async def create_obj_log_crud(self, data: JobLogCreateSchema) -> Optional[JobLogModel]:
"""创建定时任务日志"""
return await self.create(data=data)
async def delete_obj_log_crud(self, ids: List[int]) -> None:
"""删除定时任务日志"""
return await self.delete(ids=ids)
@@ -0,0 +1,18 @@
# -*- coding:utf-8 -*-
from sqlalchemy import Boolean, Column, ForeignKey, String, Integer, Text, DateTime
from sqlalchemy.orm import relationship
from app.core.base_model import BaseMixin
class FormData(BaseMixin):
__tablename__ = "gen_form_data"
form_data = Column(Text, nullable=False, comment='表单数据')
form_id = Column(Integer, ForeignKey(SysForm.id), nullable=False, comment='表单ID')
form_name = Column(String(255), nullable=False, comment='表单名称')
@@ -0,0 +1,22 @@
# -*- coding:utf-8 -*-
from sqlalchemy import Boolean, Column, ForeignKey, String, Integer, Text, DateTime
from app.core.base_model import BaseMixin
class FormModel(BaseMixin):
__tablename__ = "gen_form"
content = Column(Text, nullable=False, comment='表单代码')
form_conf = Column(Text, nullable=False, comment='表单配置')
form_data = Column(Text, nullable=False, comment='表单内容')
generate_conf = Column(Text, nullable=False, comment='生成配置')
name = Column(String(255), nullable=False, comment='表单名称')
drawing_list = Column(Text, nullable=False, comment='字段列表')
@@ -0,0 +1,15 @@
# -*- coding:utf-8 -*-
from sqlalchemy import Boolean, Column, ForeignKey, String, Integer, Text, DateTime
from app.core.base_model import BaseMixin
class PageModel(BaseMixin):
__tablename__ = "gen_page"
page_name = Column(String(length=255), comment='页面名称')
keywords = Column(String(length=500), comment='页面关键词')
title = Column(String(length=500), comment='页面title标题')
@@ -0,0 +1,44 @@
# -*- coding:utf-8 -*-
import datetime
from sqlalchemy import Boolean, Column, ForeignKey, String, Integer, Text, DateTime
from app.core.base_model import BaseMixin
class SysTable(BaseMixin):
__tablename__ = "gen_table"
id = Column(Integer, primary_key=True, autoincrement=True)
create_time = Column(DateTime, nullable=False, default=datetime.datetime.now, comment='创建时间')
update_time = Column(DateTime, nullable=False, default=datetime.datetime.now, onupdate=datetime.datetime.now, index=True, comment='更新时间')
del_flag = Column(String(1), nullable=False, default='0', server_default=text("'0'"), comment='删除标志(0代表存在 2代表删除)')
align = Column(String(255), nullable=False, default='left', comment='对其方式')
field_name = Column(String(255), nullable=False, comment='字段名')
fixed = Column(String(1), nullable=False, default='0', comment='固定表头')
label = Column(String(255), nullable=False, comment='字段标签')
label_tip = Column(String(255), comment='字段标签解释')
prop = Column(String(255), nullable=False, comment='驼峰属性')
show = Column(String(1), nullable=False, default='1', comment='可见')
sortable = Column(String(1), nullable=False, default='0', comment='可排序')
table_name = Column(String(255), nullable=False, comment='表名')
tooltip = Column(String(1), nullable=False, default='1', comment='超出隐藏')
update_by = Column(Integer, comment='更新者')
update_by_name = Column(String(255), comment='更新者')
width = Column(Integer, nullable=False, default=150, comment='宽度')
sequence = Column(Integer, nullable=False, default=0, comment='字段顺序')
@@ -50,3 +50,23 @@ class JobModel(ModelBase):
uselist=False uselist=False
) )
class JobLogModel(Base):
"""
定时任务调度日志表
"""
__tablename__ = 'monitor_job_log'
job_log_id = Column(Integer, primary_key=True, autoincrement=True, comment='任务日志ID')
job_name = Column(String(64), nullable=False, comment='任务名称')
job_group = Column(String(64), nullable=False, comment='任务组名')
job_executor = Column(String(64), nullable=False, comment='任务执行器')
invoke_target = Column(String(500), nullable=False, comment='调用目标字符串')
job_args = Column(String(255), nullable=True, default='', comment='位置参数')
job_kwargs = Column(String(255), nullable=True, default='', comment='关键字参数')
job_trigger = Column(String(255), nullable=True, default='', comment='任务触发器')
job_message = Column(String(500), nullable=True, default='', comment='日志信息')
status = Column(Boolean, default=False, nullable=True, comment='任务状态:正常,失败')
exception_info = Column(String(2000), nullable=True, default='', comment='异常信息')
create_time = Column(DateTime, nullable=True, default=datetime.now(), comment='创建时间')
@@ -18,7 +18,7 @@ class DeptModel(ModelBase):
id = Column(Integer, primary_key=True, autoincrement=True, comment='主键ID') id = Column(Integer, primary_key=True, autoincrement=True, comment='主键ID')
name = Column(String(40), nullable=False, comment="部门名称", unique=True) name = Column(String(40), nullable=False, comment="部门名称", unique=True)
order = Column(Integer, nullable=False, default=1, comment="显示排序") order = Column(Integer, nullable=False, default=1, comment="显示排序")
# leader_id = Column(Integer, nullable=True, default=None, comment='负责人ID') # 部门领导绑定用户,作为后面的流程审批人
# 层级关系 # 层级关系
parent_id = Column( parent_id = Column(
Integer, Integer,
@@ -117,3 +117,22 @@ class UserModel(ModelBase):
lazy="selectin", lazy="selectin",
uselist=False uselist=False
) )
class UserWechat(Base, BaseMixin):
"""
用户微信信息
"""
__tablename__ = 'system_users_wechat'
user_id = Column(Integer, nullable=False, comment='用户ID')
city = Column(String(100), nullable=True, comment='城市')
country = Column(String(100), nullable=True, comment='国家')
head_img_url = Column(String(255), nullable=True, comment='微信头像')
nickname = Column(String(255), nullable=True, comment='微信昵称')
openid = Column(String(255), unique=True, nullable=False, comment='openid')
union_id = Column(String(255), nullable=False, comment='union_id')
user_phone = Column(String(15), unique=True, nullable=False, comment='手机号')
province = Column(String(255), nullable=True, comment='省份')
sex = Column(Integer, nullable=True, comment='性别')
@@ -32,3 +32,23 @@ class JobQueryParams:
start_datetime = datetime.strptime(str(start_time), '%Y-%m-%d %H:%M:%S') start_datetime = datetime.strptime(str(start_time), '%Y-%m-%d %H:%M:%S')
end_datetime = datetime.strptime(str(end_time), '%Y-%m-%d %H:%M:%S') end_datetime = datetime.strptime(str(end_time), '%Y-%m-%d %H:%M:%S')
self.created_at = ("between", (start_datetime, end_datetime)) self.created_at = ("between", (start_datetime, end_datetime))
class JobLogQueryParams:
"""定时任务查询参数"""
def __init__(
self,
status: Optional[bool] = Query(None, description="状态: 正常,失败"),
start_time: Optional[DateTimeStr] = Query(None, description="开始时间", example="2023-01-01 00:00:00"),
end_time: Optional[DateTimeStr] = Query(None, description="结束时间", example="2023-12-31 23:59:59"),
) -> None:
super().__init__()
# 精确查询字段
self.status = status
# 时间范围查询
if start_time and end_time:
start_datetime = datetime.strptime(str(start_time), '%Y-%m-%d %H:%M:%S')
end_datetime = datetime.strptime(str(end_time), '%Y-%m-%d %H:%M:%S')
self.created_at = ("between", (start_datetime, end_datetime))
@@ -0,0 +1,31 @@
# -*- coding:utf-8 -*-
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
from typing import List, Literal, Optional, Union
from module_admin.annotation.pydantic_annotation import as_query
class SysFormDataModel(BaseModel):
"""
表对应pydantic模型
"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
create_by: Optional[int] = Field(default=None, description='创建者')
create_time: Optional[datetime] = Field(default=None, description='创建时间')
del_flag: Optional[str] = Field(default=None, description='删除标志')
dept_id: Optional[int] = Field(default=None, description='部门id')
form_data: Optional[str] = Field(default=None, description='表单数据')
form_id: Optional[int] = Field(default=None, description='表单ID')
form_name: Optional[str] = Field(default=None, description='表单名称')
id: Optional[int] = Field(default=None, description='id')
update_time: Optional[datetime] = Field(default=None, description='更新时间')
@as_query
class SysFormDataPageModel(SysFormDataModel):
"""
分页查询模型
"""
page_num: int = Field(default=1, description='当前页码')
page_size: int = Field(default=10, description='每页记录数')
@@ -0,0 +1,58 @@
# -*- coding:utf-8 -*-
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
from typing import List, Literal, Optional, Union
from module_admin.annotation.pydantic_annotation import as_query
class SysTableModel(BaseModel):
"""
表对应pydantic模型
"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
align: Optional[str] = Field(default=None, description='对其方式')
create_time: Optional[datetime] = Field(default=None, description='创建时间')
del_flag: Optional[str] = Field(default=None, description='删除标志')
field_name: Optional[str] = Field(default=None, description='字段名')
fixed: Optional[str] = Field(default=None, description='固定表头')
id: Optional[int] = Field(default=None, description='ID')
label: Optional[str] = Field(default=None, description='字段标签')
label_tip: Optional[str] = Field(default=None, description='字段标签解释')
prop: Optional[str] = Field(default=None, description='驼峰属性')
show: Optional[str] = Field(default=None, description='可见')
sortable: Optional[str] = Field(default=None, description='可排序')
table_name: Optional[str] = Field(default=None, description='表名')
tooltip: Optional[str] = Field(default=None, description='超出隐藏')
update_by: Optional[int] = Field(default=None, description='更新者')
update_by_name: Optional[str] = Field(default=None, description='更新者')
update_time: Optional[datetime] = Field(default=None, description='更新时间')
width: Optional[int] = Field(default=None, description='宽度')
sequence: Optional[int] = Field(default=None, description='字段顺序')
@as_query
class SysTablePageModel(SysTableModel):
"""
分页查询模型
"""
page_num: int = Field(default=1, description='当前页码')
page_size: int = Field(default=10, description='每页记录数')
@as_query
class DbTablePageModel(BaseModel):
"""
分页查询模型
"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
table_name: Optional[str] = Field(default=None, description='表名')
table_comment: Optional[str] = Field(default=None, description='表描述')
page_num: int = Field(default=1, description='当前页码')
page_size: int = Field(default=10, description='每页记录数')
class SysTableColumnIdsModel(BaseModel):
"""
列排序
"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
ids: Optional[List[int]] = Field()
@@ -0,0 +1,15 @@
# -*- coding:utf-8 -*-
from sqlalchemy import Boolean, Column, ForeignKey, String, Integer, Text, DateTime
from app.core.base_model import BaseMixin
class PageModel(BaseMixin):
__tablename__ = "gen_page"
page_name = Column(String(length=255), comment='页面名称')
keywords = Column(String(length=500), comment='页面关键词')
title = Column(String(length=500), comment='页面title标题')
@@ -0,0 +1,44 @@
# -*- coding:utf-8 -*-
import datetime
from sqlalchemy import Boolean, Column, ForeignKey, String, Integer, Text, DateTime
from app.core.base_model import BaseMixin
class SysTable(BaseMixin):
__tablename__ = "gen_table"
id = Column(Integer, primary_key=True, autoincrement=True)
create_time = Column(DateTime, nullable=False, default=datetime.datetime.now, comment='创建时间')
update_time = Column(DateTime, nullable=False, default=datetime.datetime.now, onupdate=datetime.datetime.now, index=True, comment='更新时间')
del_flag = Column(String(1), nullable=False, default='0', server_default=text("'0'"), comment='删除标志(0代表存在 2代表删除)')
align = Column(String(255), nullable=False, default='left', comment='对其方式')
field_name = Column(String(255), nullable=False, comment='字段名')
fixed = Column(String(1), nullable=False, default='0', comment='固定表头')
label = Column(String(255), nullable=False, comment='字段标签')
label_tip = Column(String(255), comment='字段标签解释')
prop = Column(String(255), nullable=False, comment='驼峰属性')
show = Column(String(1), nullable=False, default='1', comment='可见')
sortable = Column(String(1), nullable=False, default='0', comment='可排序')
table_name = Column(String(255), nullable=False, comment='表名')
tooltip = Column(String(1), nullable=False, default='1', comment='超出隐藏')
update_by = Column(Integer, comment='更新者')
update_by_name = Column(String(255), comment='更新者')
width = Column(Integer, nullable=False, default=150, comment='宽度')
sequence = Column(Integer, nullable=False, default=0, comment='字段顺序')
@@ -34,4 +34,34 @@ class JobUpdateSchema(JobCreateSchema):
class JobOutSchema(JobCreateSchema, BaseSchema): class JobOutSchema(JobCreateSchema, BaseSchema):
"""定时任务响应模型""" """定时任务响应模型"""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
...
class JobLogCreateSchema(BaseModel):
"""
定时任务调度日志表对应pydantic模型
"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
job_log_id: Optional[int] = Field(default=None, description='任务日志ID')
job_name: Optional[str] = Field(default=None, description='任务名称')
job_group: Optional[str] = Field(default=None, description='任务组名')
job_executor: Optional[str] = Field(default=None, description='任务执行器')
invoke_target: Optional[str] = Field(default=None, description='调用目标字符串')
job_args: Optional[str] = Field(default=None, description='位置参数')
job_kwargs: Optional[str] = Field(default=None, description='关键字参数')
job_trigger: Optional[str] = Field(default=None, description='任务触发器')
job_message: Optional[str] = Field(default=None, description='日志信息')
status: Optional[bool] = Field(default=None, description='任务状态:正常,失败')
exception_info: Optional[str] = Field(default=None, description='异常信息')
create_time: Optional[datetime] = Field(default=None, description='创建时间')
class JobLogUpdateSchema(JobLogCreateSchema):
"""定时任务调度日志表更新模型"""
...
id: int = Field(..., gt=0, description="ID")
class JobLogOutSchema(JobLogCreateSchema, BaseSchema):
"""定时任务调度日志表响应模型"""
model_config = ConfigDict(from_attributes=True)
...
@@ -0,0 +1,21 @@
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
from typing import Literal, Optional
class ImportFieldModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
base_column: Optional[str] = Field(description='数据库字段名')
excel_column: Optional[str] = Field(description='excel字段名', default=None)
default_value: Optional[str] = Field(description='默认值', default=None)
is_required: Optional[str] = Field(description='是否必传')
selected: Optional[bool] = Field(description='是否勾选')
class ImportModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True)
table_name: Optional[str] = Field(description='表名')
sheet_name: Optional[str] = Field(description='Sheet名')
filed_info: Optional[list[ImportFieldModel]] = Field(description='字段关联表')
file_name: Optional[str] = Field(description='文件名')
@@ -2,7 +2,7 @@
import io import io
from typing import Dict from typing import Dict
from fastapi import UploadFile from fastapi import UploadFile, BackgroundTasks
from app.api.v1.schemas.system.auth_schema import AuthSchema from app.api.v1.schemas.system.auth_schema import AuthSchema
from app.core.exceptions import CustomException from app.core.exceptions import CustomException
@@ -17,11 +17,16 @@ class FileService:
""" """
@classmethod @classmethod
async def upload_service(cls, base_url: str, file: UploadFile) -> Dict: async def upload_service(cls, base_url: str, file: UploadFile, upload_type: str = 'local') -> Dict:
""" 上传文件""" """ 上传文件"""
if not file: if not file:
raise CustomException(msg="请选择要上传的文件") raise CustomException(msg="请选择要上传的文件")
if upload_type == 'local':
filename, filepath, file_url = await UploadUtil.upload_file(file=file, base_url=base_url) filename, filepath, file_url = await UploadUtil.upload_file(file=file, base_url=base_url)
elif upload_type == 'oss':
filename, filepath, file_url = await UploadUtil.upload_file_oss(file=file, oss_folder=file.filename)
else:
raise CustomException(msg="上传类型错误")
return UploadResponseSchema( return UploadResponseSchema(
file_path=f'{filepath}', file_path=f'{filepath}',
+8 -3
View File
@@ -14,6 +14,7 @@ class ResponseSchema(BaseModel):
msg: str = Field(default=RET.OK.msg, description="响应消息") msg: str = Field(default=RET.OK.msg, description="响应消息")
data: Optional[Any] = Field(default=None, description="响应数据") data: Optional[Any] = Field(default=None, description="响应数据")
status_code: int = Field(default=status.HTTP_200_OK, description="HTTP状态码") status_code: int = Field(default=status.HTTP_200_OK, description="HTTP状态码")
success: bool = Field(default=True, description='操作是否成功')
class SuccessResponse(JSONResponse): class SuccessResponse(JSONResponse):
"""成功响应类""" """成功响应类"""
@@ -24,6 +25,7 @@ class SuccessResponse(JSONResponse):
msg: Optional[str] = RET.OK.msg, msg: Optional[str] = RET.OK.msg,
code: int = RET.OK.code, code: int = RET.OK.code,
status_code: int = status.HTTP_200_OK, status_code: int = status.HTTP_200_OK,
success: bool = True
) -> None: ) -> None:
""" """
初始化成功响应类 初始化成功响应类
@@ -37,7 +39,8 @@ class SuccessResponse(JSONResponse):
code=code, code=code,
msg=msg, msg=msg,
data=data, data=data,
status_code=status_code status_code=status_code,
success=success
).model_dump() ).model_dump()
super().__init__(content=content, status_code=status_code) super().__init__(content=content, status_code=status_code)
@@ -51,6 +54,7 @@ class ErrorResponse(JSONResponse):
msg: Optional[str] = RET.ERROR.msg, msg: Optional[str] = RET.ERROR.msg,
code: int = RET.ERROR.code, code: int = RET.ERROR.code,
status_code: int = status.HTTP_400_BAD_REQUEST, status_code: int = status.HTTP_400_BAD_REQUEST,
success: bool = False
) -> None: ) -> None:
""" """
初始化错误响应类 初始化错误响应类
@@ -64,7 +68,8 @@ class ErrorResponse(JSONResponse):
code=code, code=code,
msg=msg, msg=msg,
data=data, data=data,
status_code=status_code status_code=status_code,
success=success
).model_dump() ).model_dump()
super().__init__(content=content, status_code=status_code) super().__init__(content=content, status_code=status_code)
@@ -97,7 +102,7 @@ class StreamResponse(StreamingResponse):
) )
class CustomFileResponse(FileResponse): class UploadFileResponse(FileResponse):
""" """
文件响应 文件响应
""" """
+24 -11
View File
@@ -7,6 +7,7 @@ from typing import Any, ClassVar, Dict, List, Optional, Union, Literal
from pydantic import MongoDsn, PostgresDsn, RedisDsn, MySQLDsn from pydantic import MongoDsn, PostgresDsn, RedisDsn, MySQLDsn
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from uvicorn.config import LifespanType from uvicorn.config import LifespanType
from urllib.parse import quote_plus
from app.common.enums import EnvironmentEnum from app.common.enums import EnvironmentEnum
@@ -16,6 +17,7 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file='.env', env_file='.env',
env_file_encoding="utf-8", env_file_encoding="utf-8",
extra='ignore',
case_sensitive=True, case_sensitive=True,
) )
@@ -78,16 +80,16 @@ class Settings(BaseSettings):
# ================================================= # # ================================================= #
SECRET_KEY: str = "vgb0tnl9d58+6n-6h-ea&u^1#s0ccp!794=krylxcjq75vzps$" # JWT密钥 SECRET_KEY: str = "vgb0tnl9d58+6n-6h-ea&u^1#s0ccp!794=krylxcjq75vzps$" # JWT密钥
ALGORITHM: str = "HS256" # JWT算法 ALGORITHM: str = "HS256" # JWT算法
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # access_token过期时间(分钟) ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 60 * 24 * 1 # access_token过期时间(秒)1 天
REFRESH_TOKEN_EXPIRE_MINUTES: int = 10080 # refresh_token过期时间(分钟) REFRESH_TOKEN_EXPIRE_MINUTES: int = 60 * 60 * 24 * 7 # refresh_token过期时间(秒)7 天
TOKEN_TYPE: str = "bearer" # token类型 TOKEN_TYPE: str = "bearer" # token类型
# ================================================= # # ================================================= #
# ******************** 数据库配置 ******************* # # ******************** 数据库配置 ******************* #
# ================================================= # # ================================================= #
SQL_DB_ENABLE: bool = True # 是否启用数据库 SQL_DB_ENABLE: bool = True # 是否启用数据库
DATABASE_ECHO: bool = False # 是否显示SQL日志 DATABASE_ECHO: bool | Literal['debug'] = False # 是否显示SQL日志
ECHO_POOL: bool = False # 是否显示连接池日志 ECHO_POOL: bool | Literal['debug'] = False # 是否显示连接池日志
POOL_SIZE: int = 20 # 连接池大小 POOL_SIZE: int = 20 # 连接池大小
MAX_OVERFLOW: int = 10 # 最大溢出连接数 MAX_OVERFLOW: int = 10 # 最大溢出连接数
POOL_TIMEOUT: int = 30 # 连接超时时间(秒) POOL_TIMEOUT: int = 30 # 连接超时时间(秒)
@@ -99,7 +101,7 @@ class Settings(BaseSettings):
EXPIRE_ON_COMMIT: bool = False # 是否在提交时过期 EXPIRE_ON_COMMIT: bool = False # 是否在提交时过期
# SQLite数据库连接 # SQLite数据库连接
DB_DRIVER: Literal['sqlite','mysql', 'postgresql'] = 'sqlite' DB_DRIVER: Literal['sqlite','mysql', 'postgresql']
SQLITE_DB_NAME: str SQLITE_DB_NAME: str
# MySQL数据库连接 # MySQL数据库连接
@@ -140,7 +142,7 @@ class Settings(BaseSettings):
# ******************** 验证码配置 ******************* # # ******************** 验证码配置 ******************* #
# ================================================= # # ================================================= #
CAPTCHA_ENABLE: bool = True # 是否启用验证码 CAPTCHA_ENABLE: bool = True # 是否启用验证码
CAPTCHA_EXPIRE_SECONDS: int = 60 # 验证码过期时间(秒) CAPTCHA_EXPIRE_SECONDS: int = 60 * 1 # 验证码过期时间(秒) 1分钟
CAPTCHA_FONT_SIZE: int = 40 # 字体大小 CAPTCHA_FONT_SIZE: int = 40 # 字体大小
CAPTCHA_FONT_PATH: Path = 'static/assets/font/Arial.ttf' # 字体路径 CAPTCHA_FONT_PATH: Path = 'static/assets/font/Arial.ttf' # 字体路径
@@ -221,6 +223,16 @@ class Settings(BaseSettings):
] ]
MAX_FILE_SIZE: int = 10 * 1024 * 1024 # 最大文件大小(10MB) MAX_FILE_SIZE: int = 10 * 1024 * 1024 # 最大文件大小(10MB)
# ================================================= #
# ***************** 对象存储配置 ***************** #
# ================================================= #
ALI_OSS_KEY: str = 'xxxx'
ALI_OSS_SECRET: str = 'xxxx'
ALI_OSS_END_POINT: str = 'xxxx'
ALI_OSS_PRE: str = 'xxxx'
ALI_OSS_BUCKET: str = 'xxxx'
UPLOAD_METHOD: str = 'xxxx'
# ================================================= # # ================================================= #
# ***************** Swagger配置 ***************** # # ***************** Swagger配置 ***************** #
# ================================================= # # ================================================= #
@@ -267,13 +279,14 @@ class Settings(BaseSettings):
if settings.DB_DRIVER not in supported_db_drivers: if settings.DB_DRIVER not in supported_db_drivers:
raise ValueError(f"数据库驱动不支持: {settings.DB_DRIVER}, 请选择 {supported_db_drivers}") raise ValueError(f"数据库驱动不支持: {settings.DB_DRIVER}, 请选择 {supported_db_drivers}")
if settings.DB_DRIVER == "mysql": if settings.DB_DRIVER == "mysql":
MYSQL_URI: MySQLDsn = f"mysql+asyncmy://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DB_NAME}?charset=utf8mb4" MYSQL_URI: MySQLDsn = f"mysql+asyncmy://{settings.MYSQL_USER}:{quote_plus(settings.MYSQL_PASSWORD)}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DB_NAME}?charset=utf8mb4"
return MYSQL_URI return MYSQL_URI
elif settings.DB_DRIVER == "sqlite": elif settings.DB_DRIVER == "sqlite":
SQLITE_URI: str = f"sqlite+aiosqlite:///{settings.BASE_DIR.joinpath(settings.SQLITE_DB_NAME)}?characterEncoding=UTF-8" # SQLITE_URI: str = f"sqlite+aiosqlite:///{settings.BASE_DIR.joinpath(settings.SQLITE_DB_NAME)}?characterEncoding=UTF-8"
SQLITE_URI: str = f"sqlite+asyncpg:///{settings.BASE_DIR.joinpath(settings.SQLITE_DB_NAME)}?characterEncoding=UTF-8"
return SQLITE_URI return SQLITE_URI
elif settings.DB_DRIVER == "postgresql": elif settings.DB_DRIVER == "postgresql":
POSRGRES_URI: PostgresDsn = f"postgresql+asyncpg://{settings.POSTGRESQL_USER}:{settings.POSTGRESQL_PASSWORD}@{settings.POSTGRESQL_HOST}:{settings.POSTGRESQL_PORT}/{settings.POSTGRESQL_DB_NAME}" POSRGRES_URI: PostgresDsn = f"postgresql+asyncpg://{settings.POSTGRESQL_USER}:{quote_plus(settings.POSTGRESQL_PASSWORD)}@{settings.POSTGRESQL_HOST}:{settings.POSTGRESQL_PORT}/{settings.POSTGRESQL_DB_NAME}"
return POSRGRES_URI return POSRGRES_URI
else: else:
raise ValueError(f"数据库驱动不支持: {settings.DB_DRIVER}, 请选择 {supported_db_drivers}") raise ValueError(f"数据库驱动不支持: {settings.DB_DRIVER}, 请选择 {supported_db_drivers}")
@@ -286,10 +299,10 @@ class Settings(BaseSettings):
if settings.DB_DRIVER not in supported_db_drivers: if settings.DB_DRIVER not in supported_db_drivers:
raise ValueError(f"数据库驱动不支持: {settings.DB_DRIVER}, 请选择 {supported_db_drivers}") raise ValueError(f"数据库驱动不支持: {settings.DB_DRIVER}, 请选择 {supported_db_drivers}")
if settings.DB_DRIVER == "mysql": if settings.DB_DRIVER == "mysql":
MYSQL_URI: MySQLDsn = f"mysql+pymysql://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DB_NAME}?charset=utf8mb4" MYSQL_URI: MySQLDsn = f"mysql+pymysql://{settings.MYSQL_USER}:{quote_plus(settings.MYSQL_PASSWORD)}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DB_NAME}?charset=utf8mb4"
return MYSQL_URI return MYSQL_URI
elif settings.DB_DRIVER == "postgresql": elif settings.DB_DRIVER == "postgresql":
POSRGRES_URI: PostgresDsn = f"postgresql://{settings.POSTGRESQL_USER}:{settings.POSTGRESQL_PASSWORD}@{settings.POSTGRESQL_HOST}:{settings.POSTGRESQL_PORT}/{settings.POSTGRESQL_DB_NAME}" POSRGRES_URI: PostgresDsn = f"postgresql+psycopg2://{settings.POSTGRESQL_USER}:{quote_plus(settings.POSTGRESQL_PASSWORD)}@{settings.POSTGRESQL_HOST}:{settings.POSTGRESQL_PORT}/{settings.POSTGRESQL_DB_NAME}"
return POSRGRES_URI return POSRGRES_URI
elif settings.DB_DRIVER == "sqlite": elif settings.DB_DRIVER == "sqlite":
SQLITE_URI: str = f"sqlite:///{settings.BASE_DIR.joinpath(settings.SQLITE_DB_NAME)}?characterEncoding=UTF-8" SQLITE_URI: str = f"sqlite:///{settings.BASE_DIR.joinpath(settings.SQLITE_DB_NAME)}?characterEncoding=UTF-8"
+38 -36
View File
@@ -2,7 +2,7 @@
import json import json
import importlib import importlib
import datetime from datetime import datetime
import croniter import croniter
from typing import Union, List, Dict, Any, Optional, Callable, Coroutine from typing import Union, List, Dict, Any, Optional, Callable, Coroutine
from asyncio import iscoroutinefunction from asyncio import iscoroutinefunction
@@ -28,30 +28,25 @@ from app.config.setting import settings
from app.core.database import engine, session_connect, SessionLocal from app.core.database import engine, session_connect, SessionLocal
from app.core.exceptions import CustomException from app.core.exceptions import CustomException
from app.core.logger import logger from app.core.logger import logger
from app.api.v1.cruds.monitor.job_crud import JobCRUD from app.api.v1.cruds.monitor.job_crud import JobCRUD, JobLogCRUD
from app.api.v1.models.monitor.job_model import JobModel from app.api.v1.models.monitor.job_model import JobModel
from app.api.v1.schemas.monitor.job_schema import JobLogCreateSchema
# job 存储
# 处理Redis 5.0+ ACL格式的用户名:密码
redis_config = {
'host': settings.REDIS_HOST,
'port': settings.REDIS_PORT,
'db': settings.REDIS_DB_NAME,
}
if settings.REDIS_PASSWORD:
redis_config['password'] = settings.REDIS_PASSWORD
if settings.REDIS_USER:
redis_config['username'] = settings.REDIS_USER
job_stores = { job_stores = {
'default': MemoryJobStore(), 'default': MemoryJobStore(),
'sqlalchemy': SQLAlchemyJobStore(url=settings.DATABASES_URI, engine=engine), 'sqlalchemy': SQLAlchemyJobStore(url=settings.DATABASES_URI, engine=engine),
'redis': RedisJobStore(**redis_config), 'redis': RedisJobStore(**dict(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
username=settings.REDIS_USER,
password=settings.REDIS_PASSWORD,
db=settings.REDIS_DB_NAME,
)),
} }
# 配置执行器 # 配置执行器
executors = {'default': AsyncIOExecutor(), 'processpool': ProcessPoolExecutor(5)} executors = {'default': AsyncIOExecutor(), 'processpool': ProcessPoolExecutor(5)}
# 配置默认参数
job_defaults = { job_defaults = {
'coalesce': False, # 是否合并执行 'coalesce': False, # 是否合并执行
'max_instances': 1, # 最大实例数 'max_instances': 1, # 最大实例数
@@ -90,21 +85,32 @@ class SchedulerUtil:
# 获取任务组名 # 获取任务组名
job_group = query_job._jobstore_alias job_group = query_job._jobstore_alias
# # 获取任务执行器 # # 获取任务执行器
# job_executor = query_job_info.get('executor') job_executor = query_job_info.get('executor')
# # 获取调用目标字符串 # 获取调用目标字符串
# invoke_target = query_job_info.get('func') invoke_target = query_job_info.get('func')
# # 获取调用函数位置参数 # 获取调用函数位置参数
# job_args = ','.join(query_job_info.get('args')) job_args = ','.join(query_job_info.get('args'))
# # 获取调用函数关键字参数 # 获取调用函数关键字参数
# job_kwargs = json.dumps(query_job_info.get('kwargs')) job_kwargs = json.dumps(query_job_info.get('kwargs'))
# # 获取任务触发器 # 获取任务触发器
# job_trigger = str(query_job_info.get('trigger')) job_trigger = str(query_job_info.get('trigger'))
# 构造日志消息 # 构造日志消息
job_message = f"事件类型: {event_type}, 任务ID: {job_id}, 任务名称: {job_name}, 状态: {status}, 任务组: {job_group}, 错误详情: {exception_info}, 执行于{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" job_message = f"事件类型: {event_type}, 任务ID: {job_id}, 任务名称: {job_name}, 状态: {status}, 任务组: {job_group}, 错误详情: {exception_info}, 执行于{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
job_log = JobLogCreateSchema(
logger.error(job_message) job_name=job_name,
job_group=job_group,
job_executor=job_executor,
invoke_target=invoke_target,
job_args=job_args,
job_kwargs=job_kwargs,
job_trigger=job_trigger,
job_message=job_message,
status=status,
exception_info=exception_info,
create_time=datetime.now(),
)
session = SessionLocal() session = SessionLocal()
JobCRUD(AuthSchema(db=session)).set_obj_field_crud(ids=[job_id], status=status, message=job_message) JobLogCRUD(AuthSchema(db=session)).create_obj_log_crud(data=job_log)
session.close() session.close()
@classmethod @classmethod
@@ -114,17 +120,14 @@ class SchedulerUtil:
:return: :return:
""" """
scheduler.add_listener(cls.scheduler_event_listener, EVENT_ALL)
scheduler.start()
scheduler.start()
auth = AuthSchema(db=db) auth = AuthSchema(db=db)
job_list = await JobCRUD(auth).get_obj_list_crud() job_list = await JobCRUD(auth).get_obj_list_crud()
ids = []
for item in job_list: for item in job_list:
ids.append(item.id)
cls.remove_job(job_id=item.id) # 删除旧任务 cls.remove_job(job_id=item.id) # 删除旧任务
cls.add_job(item) cls.add_job(item)
await JobCRUD(auth).set_obj_field_crud(ids=ids, status=True) # 添加新任务 scheduler.add_listener(cls.scheduler_event_listener, EVENT_ALL)
@classmethod @classmethod
async def close_system_scheduler(cls): async def close_system_scheduler(cls):
@@ -165,7 +168,7 @@ class SchedulerUtil:
# 动态导入模块 # 动态导入模块
# 1. 解析调用目标 # 1. 解析调用目标
# app.module_task.scheduler_test.job # app.module_task.scheduler_test.job
module_path, func_name = job_info.func.rsplit('.', 1) module_path, func_name = str(job_info.func).rsplit('.', 1)
module_path = "app.module_task." + module_path module_path = "app.module_task." + module_path
module = importlib.import_module(module_path) module = importlib.import_module(module_path)
job_func = getattr(module, func_name) job_func = getattr(module, func_name)
@@ -224,7 +227,7 @@ class SchedulerUtil:
job = scheduler.add_job( job = scheduler.add_job(
func=job_func, # 直接使用函数对象 func=job_func, # 直接使用函数对象
trigger=trigger, trigger=trigger,
args=job_info.args.split(',') if job_info.args else None, args=str(job_info.args).split(',') if job_info.args else None,
kwargs=json.loads(job_info.kwargs) if job_info.kwargs else None, kwargs=json.loads(job_info.kwargs) if job_info.kwargs else None,
id=str(job_info.id), id=str(job_info.id),
name=job_info.name, name=job_info.name,
@@ -253,7 +256,6 @@ class SchedulerUtil:
if query_job: if query_job:
scheduler.remove_job(job_id=str(job_id)) scheduler.remove_job(job_id=str(job_id))
@classmethod @classmethod
def clear_jobs(cls): def clear_jobs(cls):
""" """
+50
View File
@@ -1,8 +1,58 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime
from typing import Annotated
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy import Boolean, Column, String, Integer, DateTime, ForeignKey, Text, BigInteger, DateTime, TypeDecorator
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column
class DateTimeMixin(MappedAsDataclass):
"""日期时间 Mixin 数据类"""
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, comment='创建时间')
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now, onupdate=datetime.now, comment='更新时间')
class BaseMixin(AsyncAttrs, DeclarativeBase, DateTimeMixin):
"""
SQLAlchemy 基础模型类
继承自 AsyncAttrs DeclarativeBase,提供异步操作支持
"""
__abstract__ = True # 声明为抽象基类,不会创建实际数据库表
id: Mapped[int] = mapped_column(Integer, index=True, unique=True, primary_key=True, autoincrement=True, comment='主键ID')
# id: Mapped[int] = mapped_column(BigInteger, index=True, unique=True, primary_key=True, autoincrement=True, comment='主键ID')
# 状态字段
status: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="是否启用(True:启用 False:禁用)")
# 审计字段
description: Mapped[str] = mapped_column(Text, nullable=True, comment="备注说明")
creator_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("system_users.id", ondelete="SET NULL", onupdate="CASCADE"),
nullable=True,
index=True,
comment="创建人ID"
)
creator: Mapped["UserModel"] = relationship(
"UserModel",
foreign_keys=creator_id,
lazy="joined",
post_update=True,
uselist=False
)
# creator = relationship(
# "UserModel",
# remote_side=[id],
# foreign_keys=[creator_id],
# lazy="selectin",
# uselist=False
# )
class ModelBase(AsyncAttrs, DeclarativeBase): class ModelBase(AsyncAttrs, DeclarativeBase):
""" """
+2
View File
@@ -22,6 +22,7 @@ class CustomException(Exception):
code: int = RET.EXCEPTION.code, code: int = RET.EXCEPTION.code,
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
data: Optional[Any] = None, data: Optional[Any] = None,
success: bool = False
) -> None: ) -> None:
""" """
初始化异常 初始化异常
@@ -35,6 +36,7 @@ class CustomException(Exception):
self.code = code self.code = code
self.msg = msg self.msg = msg
self.data = data self.data = data
self.success = success
def __str__(self) -> str: def __str__(self) -> str:
"""返回异常消息""" """返回异常消息"""
+23 -5
View File
@@ -5,6 +5,7 @@ from typing import Any, Callable, Coroutine
from fastapi import Request, Response from fastapi import Request, Response
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from user_agents import parse from user_agents import parse
import json
from app.api.v1.schemas.system.auth_schema import AuthSchema from app.api.v1.schemas.system.auth_schema import AuthSchema
from app.api.v1.schemas.system.operation_log_schema import OperationLogCreateSchema from app.api.v1.schemas.system.operation_log_schema import OperationLogCreateSchema
@@ -43,18 +44,25 @@ class OperationLogRoute(APIRoute):
payload = b"{}" payload = b"{}"
req_content_type = request.headers.get("Content-Type", "") req_content_type = request.headers.get("Content-Type", "")
if 'multipart/form-data' in req_content_type or 'application/x-www-form-urlencoded' in req_content_type: if req_content_type and (
payload = ', '.join([f'{k}: {v}' for k, v in (await request.form()).items()]) req_content_type.startswith('multipart/form-data') or req_content_type.startswith('application/x-www-form-urlencoded')
):
payload = await request.form()
oper_param = '\n'.join([f'{k}: {v}' for k, v in payload.items()])
else: else:
payload = await request.body() payload = await request.body()
path_params = request.path_params path_params = request.path_params
oper_param = {} oper_param = {}
if payload: if payload:
oper_param = payload.decode() oper_param.update(json.loads(payload.decode()))
if path_params: if path_params:
oper_param.update(path_params) oper_param.update(path_params)
# payload = json.dumps(oper_param, ensure_ascii=False) payload = json.dumps(oper_param, ensure_ascii=False)
payload = str(oper_param) # payload = str(oper_param)
# 日志表请求参数字段长度最大为2000,因此在此处判断长度
if len(oper_param) > 2000:
oper_param = '请求参数过长'
response_data = response.body if "application/json" in response.headers.get("Content-Type", "") else b"{}" response_data = response.body if "application/json" in response.headers.get("Content-Type", "") else b"{}"
process_time = f"{(time.time() - start_time):.4f}s" process_time = f"{(time.time() - start_time):.4f}s"
@@ -77,7 +85,17 @@ class OperationLogRoute(APIRoute):
login_location = await IpLocalUtil.get_ip_location(request_ip) login_location = await IpLocalUtil.get_ip_location(request_ip)
# 判断请求是否来自api文档
request_from_swagger = (
request.headers.get('referer').endswith('docs') if request.headers.get('referer') else False
)
request_from_redoc = (
request.headers.get('referer').endswith('redoc') if request.headers.get('referer') else False
)
if request_from_swagger or request_from_redoc:
pass
else:
async with session_connect() as session: async with session_connect() as session:
async with session.begin(): async with session.begin():
auth = AuthSchema(db=session) auth = AuthSchema(db=session)
+37
View File
@@ -0,0 +1,37 @@
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()
+215
View File
@@ -0,0 +1,215 @@
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'))
+39
View File
@@ -0,0 +1,39 @@
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')
+30
View File
@@ -0,0 +1,30 @@
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")
+72
View File
@@ -0,0 +1,72 @@
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"
)
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import time
from dataclasses import dataclass
from backend.common.exception import errors
@dataclass(frozen=True)
class SnowflakeConfig:
"""雪花算法配置类"""
# 位分配
WORKER_ID_BITS: int = 5
DATACENTER_ID_BITS: int = 5
SEQUENCE_BITS: int = 12
# 最大值
MAX_WORKER_ID: int = (1 << WORKER_ID_BITS) - 1 # 31
MAX_DATACENTER_ID: int = (1 << DATACENTER_ID_BITS) - 1 # 31
SEQUENCE_MASK: int = (1 << SEQUENCE_BITS) - 1 # 4095
# 位移偏移
WORKER_ID_SHIFT: int = SEQUENCE_BITS
DATACENTER_ID_SHIFT: int = SEQUENCE_BITS + WORKER_ID_BITS
TIMESTAMP_LEFT_SHIFT: int = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS
# 元年时间戳
EPOCH: int = 1262275200000
# 默认值
DEFAULT_DATACENTER_ID: int = 1
DEFAULT_WORKER_ID: int = 0
DEFAULT_SEQUENCE: int = 0
snowflake = Snowflake()
+21 -1
View File
@@ -9,6 +9,7 @@ from fastapi import UploadFile
from pathlib import Path from pathlib import Path
from urllib.parse import urljoin # 添加URL拼接工具导入 from urllib.parse import urljoin # 添加URL拼接工具导入
from sqlalchemy.orm.writeonly import strategies from sqlalchemy.orm.writeonly import strategies
import oss2
from app.config.setting import settings from app.config.setting import settings
from app.core.exceptions import CustomException from app.core.exceptions import CustomException
@@ -149,5 +150,24 @@ class UploadUtil:
:return: 文件下载信息 :return: 文件下载信息
""" """
# 解析文件路径 # 解析文件路径
filename = cls.generate_file_name(file_path) filename = cls.generate_file(file_path)
return filename return filename
@classmethod
async def upload_file_oss(cls, file: UploadFile, oss_folder):
end_point = settings.ALI_OSS_END_POINT
access_key_id = settings.ALI_OSS_KEY
access_key_secret = settings.ALI_OSS_SECRET
access_pre = settings.ALI_OSS_PRE
auth = oss2.Auth(access_key_id, access_key_secret)
bucket = oss2.Bucket(auth, end_point, settings.ALI_OSS_BUCKET)
pic_data = file.file.read()
# file_name = oss_folder + str(time.time()) + file.filename.rsplit(".", 1)[-1]
target_file_name = oss_folder + str(time.time()) + Path(file.filename).suffix
bucket.put_object(target_file_name, pic_data)
# 后期优化
file_url = f'{access_pre}/{target_file_name}'
filepath = file_url
# 返回相对路径
return file.filename, filepath, file_url
+2
View File
@@ -31,3 +31,5 @@ motor==3.6.0 # mongodb 驱动
asyncpg==0.30.0 # postgresql 异步操作数据库基于 psycopg2asyncpg 是 psycopg2 的异步版本,psycopg2 是一个 pure-Python PostgreSQL 数据库适配器。性能:asyncpg 通常在性能上优于 psycopg2,特别是在高并发和大数据量的场景下。 asyncpg==0.30.0 # postgresql 异步操作数据库基于 psycopg2asyncpg 是 psycopg2 的异步版本,psycopg2 是一个 pure-Python PostgreSQL 数据库适配器。性能:asyncpg 通常在性能上优于 psycopg2,特别是在高并发和大数据量的场景下。
PyMySQL==1.1.2 # mysql 异步操作数据库基于 pymysqlaiomysql 是 pymysql 的异步版本,pymysql 是一个纯 Python 实现的 MySQL 客户端。成熟度:aiomysql 相对较为成熟,社区支持较好,文档也比较完善。 PyMySQL==1.1.2 # mysql 异步操作数据库基于 pymysqlaiomysql 是 pymysql 的异步版本,pymysql 是一个纯 Python 实现的 MySQL 客户端。成熟度:aiomysql 相对较为成熟,社区支持较好,文档也比较完善。
cryptography==45.0.2 # mysql8 密码加密 cryptography==45.0.2 # mysql8 密码加密
openai==1.55.2 # ai 大模型