diff --git a/config/fastapi_config.py b/config/fastapi_config.py index c798613..ac85a61 100644 --- a/config/fastapi_config.py +++ b/config/fastapi_config.py @@ -15,6 +15,8 @@ from config import settings __all__ = ["app"] +from exceptions.global_exc import configure_exception + from middleware import add_middleware @@ -52,14 +54,14 @@ def custom_openapi(): routes=app.routes, ) - # # 添加全局安全方案 - # openapi_schema["components"]["securitySchemes"] = { - # "global_auth": { - # "type": "apiKey", - # "in": "header", - # "name": "Authorization" # 这里可以改为任何需要的请求头名称 - # } - # } + # 添加全局安全方案 + openapi_schema["components"]["securitySchemes"] = { + "global_auth": { + "type": "apiKey", + "in": "header", + "name": "Authorization" # 这里可以改为任何需要的请求头名称 + } + } # 应用全局安全要求 openapi_schema["security"] = [ @@ -73,7 +75,7 @@ def custom_openapi(): app.openapi = custom_openapi # 全局异常处理 -# configure_exception(app) +configure_exception(app) # 中间件 add_middleware(app=app) white_list = ["/docs", "/openapi.json", "/redoc"] diff --git a/exceptions/__init__.py b/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/exceptions/base.py b/exceptions/base.py new file mode 100644 index 0000000..8066dc9 --- /dev/null +++ b/exceptions/base.py @@ -0,0 +1,82 @@ +"""全局异常处理 +""" +import logging + +__all__ = ['AppException'] + +from enum import IntEnum, Enum + + +logger = logging.getLogger(__name__) +class CustomEnum(Enum): + @classmethod + def valid(cls, value): + try: + cls(value) + return True + except BaseException: + return False + + @classmethod + def values(cls): + return [member.value for member in cls.__members__.values()] + + @classmethod + def names(cls): + return [member.name for member in cls.__members__.values()] + + +class RetCode(IntEnum, CustomEnum): + """ + SUCCESS = 0 # 成功 + NOT_EFFECTIVE = 10 # 未生效 + EXCEPTION_ERROR = 100 # 异常错误 + ARGUMENT_ERROR = 101 # 参数错误 + DATA_ERROR = 102 # 数据错误 + OPERATING_ERROR = 103 # 操作错误 + CONNECTION_ERROR = 105 # 连接错误 + RUNNING = 106 # 运行中 + PERMISSION_ERROR = 108 # 权限错误 + AUTHENTICATION_ERROR = 109 # 认证错误 + UNAUTHORIZED = 401 # 未授权 + SERVER_ERROR = 500 # 服务器错误 + FORBIDDEN = 403 # 禁止访问 + NOT_FOUND = 404 # 未找到 + """ + SUCCESS = 0 # 成功 + NOT_EFFECTIVE = 10 # 未生效 + EXCEPTION_ERROR = 100 # 异常错误 + ARGUMENT_ERROR = 101 # 参数错误 + DATA_ERROR = 102 # 数据错误 + OPERATING_ERROR = 103 # 操作错误 + CONNECTION_ERROR = 105 # 连接错误 + RUNNING = 106 # 运行中 + PERMISSION_ERROR = 108 # 权限错误 + AUTHENTICATION_ERROR = 109 # 认证错误 + UNAUTHORIZED = 401 # 未授权 + SERVER_ERROR = 500 # 服务器错误 + FORBIDDEN = 403 # 禁止访问 + NOT_FOUND = 404 # 未找到 + +class AppException(Exception): + """应用异常基类 + """ + + def __init__(self, msg, *args, code: int = None, echo_exc: bool = False, **kwargs): + super().__init__() + self._code = RetCode.SERVER_ERROR.value if code is None else code + self._message = msg + self.echo_exc = echo_exc + self.args = args or [] + self.kwargs = kwargs or {} + + @property + def code(self) -> int: + return self._code + + @property + def msg(self) -> str: + return self._message + + def __str__(self): + return '{}: {}'.format(self.code, self.msg) diff --git a/exceptions/global_exc.py b/exceptions/global_exc.py new file mode 100644 index 0000000..8929541 --- /dev/null +++ b/exceptions/global_exc.py @@ -0,0 +1,38 @@ +import logging + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from utils.api_utils import server_error_response +from .base import AppException + +logger = logging.getLogger(__name__) + + +def configure_exception(app: FastAPI): + """配置全局异常处理 + """ + + @app.exception_handler(AppException) + async def app_exception_handler(request: Request, exc: AppException): + """处理自定义异常 + code: . + """ + if exc.echo_exc: + logging.error('app_exception_handler: url=[%s]', request.url.path) + logging.error(exc, exc_info=True) + return JSONResponse( + status_code=200, + content={'code': exc.code, 'message': exc.msg, 'data': False}) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + return JSONResponse( + status_code=422, + content={"detail": exc.errors(), "body": exc.body} + ) + + @app.exception_handler(Exception) + async def global_exception_handler(request: Request, exc: Exception): + return server_error_response(exc) diff --git a/pyproject.toml b/pyproject.toml index faf08c1..9ccaa62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "aiomysql>=0.2.0", "beartype>=0.21.0", "fastapi-pagination>=0.14.1", + "psutil>=7.1.0", ] [[tool.uv.index]] url = "https://mirrors.aliyun.com/pypi/simple" diff --git a/router/__init__.py b/router/__init__.py index e69de29..2523ffa 100644 --- a/router/__init__.py +++ b/router/__init__.py @@ -0,0 +1,83 @@ +import logging +from typing import Union, Type + +from pydantic import BaseModel + +from exceptions.base import AppException +from utils import get_uuid + + +class BaseController: + + def __init__(self, service): + self.service = service + + async def base_page(self, req: Union[dict, BaseModel], dto_class: Type[BaseModel] = None): + if not isinstance(req, dict): + req = req.model_dump() + result = await self.service.get_by_page(req) + datas = result.data + if datas and dto_class: + result.data = self.service.entity_conversion_dto(datas, dto_class) + return result + + async def base_list(self, req: Union[dict, BaseModel], dto_class: Type[BaseModel] = None): + if not isinstance(req, dict): + req = req.model_dump() + datas = await self.service.get_list(req) + if datas and dto_class: + datas = self.service.entity_conversion_dto(datas, dto_class) + return datas + + async def get_all(self, dto_class: Type[BaseModel] = None): + result = await self.service.get_all() + if dto_class: + result = self.service.entity_conversion_dto(result, dto_class) + return result + + async def get_by_id(self, id: str, dto_class: Type[BaseModel] = None): + data = await self.service.get_by_id(id) + if not data: + raise AppException(f"不存在 id 为{id}的数据") + result = data.to_dict() + if dto_class: + result = self.service.entity_conversion_dto(result, dto_class) + return result + + async def add(self, req: Union[dict, BaseModel]): + if not isinstance(req, dict): + req = req.model_dump() + req["id"] = get_uuid() + try: + return await self.service.save(**req) + except Exception as e: + logging.exception(e) + raise AppException(f"添加失败, error: {str(e)}") + + async def delete(self, id: str, db_query_data=None): + if db_query_data is None: + db_query_data = await self.service.get_by_id(id) + if not db_query_data: + raise AppException(f"数据不存在") + self.service.check_base_permission(db_query_data) + try: + return await self.service.delete_by_id(id) + except Exception as e: + logging.exception(e) + raise AppException(f"删除失败") + + async def update(self, request: BaseModel, db_query_data=None): + params = request.model_dump() + req = {k: v for k, v in params.items() if v is not None} + data_id = req.get("id") + if db_query_data is None: + db_query_data = await self.service.get_by_id(data_id) + if not db_query_data: + raise AppException(f"数据不存在") + self.service.check_base_permission(db_query_data) + + try: + return await self.service.update_by_id(data_id, req) + except Exception as e: + logging.exception(e) + raise AppException(f"更新失败, error: {str(e)}") diff --git a/router/monitor.py b/router/monitor.py new file mode 100644 index 0000000..0956c28 --- /dev/null +++ b/router/monitor.py @@ -0,0 +1,21 @@ +import logging + +from fastapi import APIRouter, Depends + +from server_info import ServerInfo + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix='/monitor', tags=["缓存监控服务"]) + + +@router.get('/server',summary='服务监控') +def monitor_server(): + """服务器信息监控""" + return { + 'cpu': ServerInfo.get_cpu_info(), + 'mem': ServerInfo.get_mem_info(), + 'sys': ServerInfo.get_sys_info(), + 'disk': ServerInfo.get_disk_info(), + 'py': ServerInfo.get_py_info(), + } diff --git a/server_info.py b/server_info.py new file mode 100644 index 0000000..9825eac --- /dev/null +++ b/server_info.py @@ -0,0 +1,122 @@ +import os +import platform +import sys +from datetime import datetime, timedelta +from typing import List, Any + +import psutil + +from config import get_settings +from utils.ip_utils import IpUtil + + +def get_attr(obj: Any, attr: str, default: Any = None) -> Any: + return getattr(obj, attr, default) + + +class ServerInfo: + """服务器相关信息""" + datetime_fmt = get_settings().datetime_fmt + + @staticmethod + def get_size(data, suffix='B') -> str: + """ + 按照正确的格式缩放字节 + eg: + 1253656 => '1.20MB' + 1253656678 => '1.17GB' + """ + factor = 1024 + for unit in ['', 'K', 'M', 'G', 'T', 'P']: + if data < factor: + return f'{data:.2f}{unit}{suffix}' + data /= factor + + @staticmethod + def fmt_timedelta(td: timedelta) -> str: + """格式化显示timedelta + eg: + timedelta => xx天xx小时xx分钟 + """ + rem = td.seconds + days, rem = rem // 86400, rem % 86400 + hours, rem = rem // 3600, rem % 3600 + minutes = rem // 60 + res = f'{minutes}分钟' + if hours > 0: + res = f'{hours}小时{res}' + if days > 0: + res = f'{days}天{res}' + return res + + @staticmethod + def get_cpu_info() -> dict: + """获取CPU信息""" + res = {'cpu_num': psutil.cpu_count(logical=True)} + cpu_times = psutil.cpu_times() + total = cpu_times.user + cpu_times.nice + cpu_times.system + cpu_times.idle \ + + get_attr(cpu_times, 'iowait', 0.0) + get_attr(cpu_times, 'irq', 0.0) \ + + get_attr(cpu_times, 'softirq', 0.0) + get_attr(cpu_times, 'steal', 0.0) + res['total'] = round(total, 2) + res['sys'] = round(cpu_times.system / total, 2) + res['used'] = round(cpu_times.user / total, 2) + res['wait'] = round(get_attr(cpu_times, 'iowait', 0.0) / total, 2) + res['free'] = round(cpu_times.idle / total, 2) + return res + + @staticmethod + def get_mem_info() -> dict: + """获取内存信息""" + number = 1024 ** 3 + return { + 'total': round(psutil.virtual_memory().total / number, 2), + 'used': round(psutil.virtual_memory().used / number, 2), + 'free': round(psutil.virtual_memory().available / number, 2), + 'usage': round(psutil.virtual_memory().percent, 2)} + + @staticmethod + def get_sys_info() -> dict: + """获取服务器信息""" + return { + 'computerName': IpUtil.get_host_name(), + 'computerIp': IpUtil.get_host_ip(), + 'userDir': os.path.dirname(os.path.abspath(os.path.join(__file__, '../..'))), + 'osName': platform.system(), + 'osArch': platform.machine()} + + @staticmethod + def get_disk_info() -> List[dict]: + """获取磁盘信息""" + disk_info = [] + for disk in psutil.disk_partitions(): + usage = psutil.disk_usage(disk.mountpoint) + disk_info.append({ + 'dirName': disk.mountpoint, + 'sysTypeName': disk.fstype, + 'typeName': disk.device, + 'total': ServerInfo.get_size(usage.total), + 'free': ServerInfo.get_size(usage.free), + 'used': ServerInfo.get_size(usage.used), + 'usage': round(usage.percent, 2), + }) + return disk_info + + @staticmethod + def get_py_info(): + """获取Python环境及服务信息""" + number = 1024 ** 2 + cur_proc = psutil.Process(os.getpid()) + mem_info = cur_proc.memory_info() + start_dt = datetime.fromtimestamp(cur_proc.create_time()) + return { + 'name': 'Python', + 'version': platform.python_version(), + 'home': sys.executable, + 'inputArgs': '[{}]'.format(', '.join(sys.argv[1:])), + 'total': round(mem_info.vms / number, 2), + 'max': round(mem_info.vms / number, 2), + 'free': round((mem_info.vms - mem_info.rss) / number, 2), + 'usage': round(mem_info.rss / number, 2), + 'runTime': ServerInfo.fmt_timedelta(datetime.now() - start_dt), + 'startTime': start_dt.strftime(ServerInfo.datetime_fmt), + } diff --git a/service/base_service.py b/service/base_service.py index a764299..82b5416 100644 --- a/service/base_service.py +++ b/service/base_service.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.global_context import current_session from entity.dto.base import BasePageQueryReq, BasePageResp, BaseQueryReq -from utils import get_uuid, current_timestamp +from utils import get_uuid """ session.execute: 执行任意数据库操作语句,返回结果需要额外处理获取 数据形式:Row对象(类似元组) @@ -18,6 +18,8 @@ session.scalars: 只适合单模型查询(不适合指定列或连表查询) session.scalar: 直接明确获取一条数据,可以直接返回,无需额外处理 """ + + class BaseService: model = None # 子类必须指定模型 @@ -29,8 +31,9 @@ class BaseService: raise RuntimeError("No database session in context. " "Make sure to use this service within a request context.") return session + @classmethod - def get_query_stmt(cls, query_params, sessions=None,*,fields:list=None): + def get_query_stmt(cls, query_params, sessions=None, *, fields: list = None): if sessions is None: if fields: sessions = cls.model.select(*fields) @@ -41,6 +44,7 @@ class BaseService: field = getattr(cls.model, key) sessions = sessions.where(field == value) return sessions + @classmethod def entity_conversion_dto(cls, entity_data: Union[list, BaseModel], dto: Type[BaseModel]) -> Union[ BaseModel, List[BaseModel]]: @@ -54,6 +58,11 @@ class BaseService: dto_list.append(dto(**temp)) return dto_list + @classmethod + def check_base_permission(cls, daba: Any): + # todo + pass + @classmethod async def get_by_page(cls, query_params: Union[dict, BasePageQueryReq]): if not isinstance(query_params, dict): @@ -202,15 +211,14 @@ class BaseService: return result.rowcount @classmethod - async def get_data_count(cls, query_params: dict = None)->int: + async def get_data_count(cls, query_params: dict = None) -> int: if not query_params: raise Exception("参数为空") - stmt = cls.get_query_stmt(query_params,fields=[func.count(cls.model.id)]) + stmt = cls.get_query_stmt(query_params, fields=[func.count(cls.model.id)]) # stmt = cls.get_query_stmt(query_params) session = cls.get_db() return await session.scalar(stmt) - @classmethod async def is_exist(cls, query_params: dict = None): return await cls.get_data_count(query_params) > 0 diff --git a/utils/ip_utils.py b/utils/ip_utils.py new file mode 100644 index 0000000..8d161d9 --- /dev/null +++ b/utils/ip_utils.py @@ -0,0 +1,26 @@ +import logging +import socket + +logger = logging.getLogger(__name__) + + +class IpUtil: + """IP工具类""" + + @staticmethod + def get_host_name() -> str: + """获取本地IP地址""" + return socket.gethostname() + + @staticmethod + def get_host_ip() -> str: + """获取本地主机名""" + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(('10.0.0.0', 0)) + ip = s.getsockname()[0] + except Exception as _: + ip = '127.0.0.1' + finally: + s.close() + return ip diff --git a/uv.lock b/uv.lock index 7bc9a7c..0a73735 100644 --- a/uv.lock +++ b/uv.lock @@ -736,6 +736,22 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759" }, ] +[[package]] +name = "psutil" +version = "7.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07" }, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -1027,6 +1043,7 @@ dependencies = [ { name = "langchainhub" }, { name = "mcp" }, { name = "openai" }, + { name = "psutil" }, { name = "ruamel-yaml" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlmodel" }, @@ -1047,6 +1064,7 @@ requires-dist = [ { name = "langchainhub", specifier = ">=0.1.21" }, { name = "mcp", specifier = ">=1.14.0" }, { name = "openai", specifier = ">=1.107.3" }, + { name = "psutil", specifier = ">=7.1.0" }, { name = "ruamel-yaml", specifier = ">=0.18.6,<0.19.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.43" }, { name = "sqlmodel", specifier = ">=0.0.25" },