mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 20:55:14 +00:00
feat: 新增MCP服务器和客户端功能,支持AI工具调用和天气查询
refactor: 重构基础模型和响应类,增加success字段 fix: 修复定时任务日志记录和异常处理问题 style: 调整中间件日志记录逻辑,优化参数处理 docs: 更新数据库配置和依赖项说明 perf: 优化文件上传服务,支持OSS存储 test: 添加定时任务日志模型和参数验证 chore: 更新依赖项,添加openai库支持
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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}',
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
文件响应
|
文件响应
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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:
|
||||||
"""返回异常消息"""
|
"""返回异常消息"""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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'))
|
||||||
@@ -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')
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -31,3 +31,5 @@ motor==3.6.0 # mongodb 驱动
|
|||||||
asyncpg==0.30.0 # postgresql 异步操作数据库基于 psycopg2:asyncpg 是 psycopg2 的异步版本,psycopg2 是一个 pure-Python PostgreSQL 数据库适配器。性能:asyncpg 通常在性能上优于 psycopg2,特别是在高并发和大数据量的场景下。
|
asyncpg==0.30.0 # postgresql 异步操作数据库基于 psycopg2:asyncpg 是 psycopg2 的异步版本,psycopg2 是一个 pure-Python PostgreSQL 数据库适配器。性能:asyncpg 通常在性能上优于 psycopg2,特别是在高并发和大数据量的场景下。
|
||||||
PyMySQL==1.1.2 # mysql 异步操作数据库基于 pymysql:aiomysql 是 pymysql 的异步版本,pymysql 是一个纯 Python 实现的 MySQL 客户端。成熟度:aiomysql 相对较为成熟,社区支持较好,文档也比较完善。
|
PyMySQL==1.1.2 # mysql 异步操作数据库基于 pymysql:aiomysql 是 pymysql 的异步版本,pymysql 是一个纯 Python 实现的 MySQL 客户端。成熟度:aiomysql 相对较为成熟,社区支持较好,文档也比较完善。
|
||||||
cryptography==45.0.2 # mysql8 密码加密
|
cryptography==45.0.2 # mysql8 密码加密
|
||||||
|
|
||||||
|
openai==1.55.2 # ai 大模型
|
||||||
|
|||||||
Reference in New Issue
Block a user