DESKTOP-53URE31\USER vor 4 Monaten
Commit
c6e8f20489
49 geänderte Dateien mit 2913 neuen und 0 gelöschten Zeilen
  1. 20 0
      .gitignore
  2. 2 0
      app/__init__.py
  3. 0 0
      app/gpt/__init__.py
  4. 7 0
      app/gpt/api/__init__.py
  5. 24 0
      app/gpt/api/route.py
  6. 10 0
      app/router.py
  7. 2 0
      common/__init__.py
  8. 42 0
      common/dataclasses.py
  9. 139 0
      common/enums.py
  10. 2 0
      common/exception/__init__.py
  11. 87 0
      common/exception/errors.py
  12. 259 0
      common/exception/exception_handler.py
  13. 103 0
      common/log.py
  14. 65 0
      common/model.py
  15. 85 0
      common/pagination.py
  16. 2 0
      common/response/__init__.py
  17. 160 0
      common/response/response_code.py
  18. 110 0
      common/response/response_schema.py
  19. 152 0
      common/schema.py
  20. 2 0
      common/security/__init__.py
  21. 26 0
      common/security/permission.py
  22. 2 0
      core/__init__.py
  23. 153 0
      core/conf.py
  24. 24 0
      core/path_conf.py
  25. 163 0
      core/registrar.py
  26. 2 0
      database/__init__.py
  27. 63 0
      database/db_mysql.py
  28. 64 0
      database/db_redis.py
  29. 36 0
      main.py
  30. 2 0
      middleware/__init__.py
  31. 21 0
      middleware/access_middleware.py
  32. 186 0
      middleware/opera_log_middleware.py
  33. 0 0
      model/__init__.py
  34. BIN
      model/records.py
  35. BIN
      requirements.txt
  36. 2 0
      utils/__init__.py
  37. 81 0
      utils/build_tree.py
  38. 20 0
      utils/demo_site.py
  39. 110 0
      utils/encrypt.py
  40. 102 0
      utils/gen_template.py
  41. 36 0
      utils/health_check.py
  42. 16 0
      utils/openapi.py
  43. 43 0
      utils/re_verify.py
  44. 33 0
      utils/redis_info.py
  45. 111 0
      utils/request_parse.py
  46. 65 0
      utils/serializers.py
  47. 142 0
      utils/server_info.py
  48. 42 0
      utils/timezone.py
  49. 95 0
      utils/type_conversion.py

+ 20 - 0
.gitignore

@@ -0,0 +1,20 @@
+.env
+.idea
+__pycache__
+build
+dist
+*.pyc
+*.log
+*.pyo
+.pytest_cache
+.vscode
+.venv
+.coverage
+.coverage.*
+.tox
+.nox
+.pytest_cache
+.ipynb_checkpoints
+.pytest_cache
+.pytest_cache
+.pytest_

+ 2 - 0
app/__init__.py

@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-

+ 0 - 0
app/gpt/__init__.py


+ 7 - 0
app/gpt/api/__init__.py

@@ -0,0 +1,7 @@
+from fastapi import APIRouter
+
+from app.gpt.api.route import router
+
+v1 = APIRouter()
+
+v1.include_router(router)

+ 24 - 0
app/gpt/api/route.py

@@ -0,0 +1,24 @@
+import json
+from sre_constants import error
+
+from fastapi import APIRouter
+from pydantic import BaseModel
+
+from common.log import log
+
+router = APIRouter()
+
+class Result(BaseModel):
+    error: int = 0
+    msg: str = ""
+
+class Response(BaseModel):
+    result: Result = Result(error=0, msg="")
+
+
+
+
+@router.post("/gpt/intent",summary="用户意图判断")
+async def user_intent(body:dict):
+    log.info(json.dumps(body,indent=4))
+    return Response()

+ 10 - 0
app/router.py

@@ -0,0 +1,10 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from fastapi import APIRouter
+
+from app.gpt.api import v1 as gpt_v1
+from core.conf import settings
+
+route = APIRouter(prefix=settings.API_V1_STR)
+
+route.include_router(gpt_v1)

+ 2 - 0
common/__init__.py

@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-

+ 42 - 0
common/dataclasses.py

@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import dataclasses
+
+from datetime import datetime
+
+from fastapi import Response
+
+from common.enums import StatusType
+
+
+@dataclasses.dataclass
+class IpInfo:
+    ip: str
+    country: str | None
+    region: str | None
+    city: str | None
+
+
+@dataclasses.dataclass
+class UserAgentInfo:
+    user_agent: str
+    os: str | None
+    browser: str | None
+    device: str | None
+
+
+@dataclasses.dataclass
+class RequestCallNextReturn:
+    code: str
+    msg: str
+    status: StatusType
+    err: Exception | None
+    response: Response
+
+
+@dataclasses.dataclass
+class NewTokenReturn:
+    new_access_token: str
+    new_refresh_token: str
+    new_access_token_expire_time: datetime
+    new_refresh_token_expire_time: datetime

+ 139 - 0
common/enums.py

@@ -0,0 +1,139 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from enum import Enum
+from enum import IntEnum as SourceIntEnum
+from typing import Type
+
+
+class _EnumBase:
+    @classmethod
+    def get_member_keys(cls: Type[Enum]) -> list[str]:
+        return [name for name in cls.__members__.keys()]
+
+    @classmethod
+    def get_member_values(cls: Type[Enum]) -> list:
+        return [item.value for item in cls.__members__.values()]
+
+
+class IntEnum(_EnumBase, SourceIntEnum):
+    """整型枚举"""
+
+    pass
+
+
+class StrEnum(_EnumBase, str, Enum):
+    """字符串枚举"""
+
+    pass
+
+
+class MenuType(IntEnum):
+    """菜单类型"""
+
+    directory = 0
+    menu = 1
+    button = 2
+
+
+class RoleDataScopeType(IntEnum):
+    """数据范围"""
+
+    all = 1
+    custom = 2
+
+
+class MethodType(StrEnum):
+    """请求方法"""
+
+    GET = 'GET'
+    POST = 'POST'
+    PUT = 'PUT'
+    DELETE = 'DELETE'
+    PATCH = 'PATCH'
+    OPTIONS = 'OPTIONS'
+
+
+class LoginLogStatusType(IntEnum):
+    """登陆日志状态"""
+
+    fail = 0
+    success = 1
+
+
+class BuildTreeType(StrEnum):
+    """构建树形结构类型"""
+
+    traversal = 'traversal'
+    recursive = 'recursive'
+
+
+class OperaLogCipherType(IntEnum):
+    """操作日志加密类型"""
+
+    aes = 0
+    md5 = 1
+    itsdangerous = 2
+    plan = 3
+
+
+class StatusType(IntEnum):
+    """状态类型"""
+
+    disable = 0
+    enable = 1
+
+
+class UserSocialType(StrEnum):
+    """用户社交类型"""
+
+    github = 'GitHub'
+    linuxdo = 'LinuxDo'
+
+
+class GenModelColumnType(StrEnum):
+    """代码生成模型列类型"""
+
+    BIGINT = 'BIGINT'
+    BINARY = 'BINARY'
+    BIT = 'BIT'
+    BLOB = 'BLOB'
+    BOOL = 'BOOL'
+    BOOLEAN = 'BOOLEAN'
+    CHAR = 'CHAR'
+    DATE = 'DATE'
+    DATETIME = 'DATETIME'
+    DECIMAL = 'DECIMAL'
+    DOUBLE = 'DOUBLE'
+    DOUBLE_PRECISION = 'DOUBLE PRECISION'
+    ENUM = 'ENUM'
+    FLOAT = 'FLOAT'
+    GEOMETRY = 'GEOMETRY'
+    GEOMETRYCOLLECTION = 'GEOMETRYCOLLECTION'
+    INT = 'INT'
+    INTEGER = 'INTEGER'
+    JSON = 'JSON'
+    LINESTRING = 'LINESTRING'
+    LONGBLOB = 'LONGBLOB'
+    LONGTEXT = 'LONGTEXT'
+    MEDIUMBLOB = 'MEDIUMBLOB'
+    MEDIUMINT = 'MEDIUMINT'
+    MEDIUMTEXT = 'MEDIUMTEXT'
+    MULTILINESTRING = 'MULTILINESTRING'
+    MULTIPOINT = 'MULTIPOINT'
+    MULTIPOLYGON = 'MULTIPOLYGON'
+    NUMERIC = 'NUMERIC'
+    POINT = 'POINT'
+    POLYGON = 'POLYGON'
+    REAL = 'REAL'
+    SERIAL = 'SERIAL'
+    SET = 'SET'
+    SMALLINT = 'SMALLINT'
+    TEXT = 'TEXT'
+    TIME = 'TIME'
+    TIMESTAMP = 'TIMESTAMP'
+    TINYBLOB = 'TINYBLOB'
+    TINYINT = 'TINYINT'
+    TINYTEXT = 'TINYTEXT'
+    VARBINARY = 'VARBINARY'
+    VARCHAR = 'VARCHAR'
+    YEAR = 'YEAR'

+ 2 - 0
common/exception/__init__.py

@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-

+ 87 - 0
common/exception/errors.py

@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+全局业务异常类
+
+业务代码执行异常时,可以使用 raise xxxError 触发内部错误,它尽可能实现带有后台任务的异常,但它不适用于**自定义响应状态码**
+如果要求使用**自定义响应状态码**,则可以通过 return await response_base.fail(res=CustomResponseCode.xxx) 直接返回
+"""  # noqa: E501
+
+from typing import Any
+
+from fastapi import HTTPException
+from starlette.background import BackgroundTask
+
+from common.response.response_code import CustomErrorCode, StandardResponseCode
+
+
+class BaseExceptionMixin(Exception):
+    code: int
+
+    def __init__(self, *, msg: str = None, data: Any = None, background: BackgroundTask | None = None):
+        self.msg = msg
+        self.data = data
+        # The original background task: https://www.starlette.io/background/
+        self.background = background
+
+
+class HTTPError(HTTPException):
+    def __init__(self, *, code: int, msg: Any = None, headers: dict[str, Any] | None = None):
+        super().__init__(status_code=code, detail=msg, headers=headers)
+
+
+class CustomError(BaseExceptionMixin):
+    def __init__(self, *, error: CustomErrorCode, data: Any = None, background: BackgroundTask | None = None):
+        self.code = error.code
+        super().__init__(msg=error.msg, data=data, background=background)
+
+
+class RequestError(BaseExceptionMixin):
+    code = StandardResponseCode.HTTP_400
+
+    def __init__(self, *, msg: str = 'Bad Request', data: Any = None, background: BackgroundTask | None = None):
+        super().__init__(msg=msg, data=data, background=background)
+
+
+class ForbiddenError(BaseExceptionMixin):
+    code = StandardResponseCode.HTTP_403
+
+    def __init__(self, *, msg: str = 'Forbidden', data: Any = None, background: BackgroundTask | None = None):
+        super().__init__(msg=msg, data=data, background=background)
+
+
+class NotFoundError(BaseExceptionMixin):
+    code = StandardResponseCode.HTTP_404
+
+    def __init__(self, *, msg: str = 'Not Found', data: Any = None, background: BackgroundTask | None = None):
+        super().__init__(msg=msg, data=data, background=background)
+
+
+class ServerError(BaseExceptionMixin):
+    code = StandardResponseCode.HTTP_500
+
+    def __init__(
+        self, *, msg: str = 'Internal Server Error', data: Any = None, background: BackgroundTask | None = None
+    ):
+        super().__init__(msg=msg, data=data, background=background)
+
+
+class GatewayError(BaseExceptionMixin):
+    code = StandardResponseCode.HTTP_502
+
+    def __init__(self, *, msg: str = 'Bad Gateway', data: Any = None, background: BackgroundTask | None = None):
+        super().__init__(msg=msg, data=data, background=background)
+
+
+class AuthorizationError(BaseExceptionMixin):
+    code = StandardResponseCode.HTTP_401
+
+    def __init__(self, *, msg: str = 'Permission Denied', data: Any = None, background: BackgroundTask | None = None):
+        super().__init__(msg=msg, data=data, background=background)
+
+
+class TokenError(HTTPError):
+    code = StandardResponseCode.HTTP_401
+
+    def __init__(self, *, msg: str = 'Not Authenticated', headers: dict[str, Any] | None = None):
+        super().__init__(code=self.code, msg=msg, headers=headers or {'WWW-Authenticate': 'Bearer'})

+ 259 - 0
common/exception/exception_handler.py

@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from fastapi import FastAPI, Request
+from fastapi.exceptions import RequestValidationError
+from pydantic import ValidationError
+from pydantic.errors import PydanticUserError
+from starlette.exceptions import HTTPException
+from starlette.middleware.cors import CORSMiddleware
+from uvicorn.protocols.http.h11_impl import STATUS_PHRASES
+
+from common.exception.errors import BaseExceptionMixin
+from common.log import log
+from common.response.response_code import CustomResponseCode, StandardResponseCode
+from common.response.response_schema import response_base
+from common.schema import (
+    CUSTOM_USAGE_ERROR_MESSAGES,
+    CUSTOM_VALIDATION_ERROR_MESSAGES,
+)
+from core.conf import settings
+from utils.serializers import MsgSpecJSONResponse
+
+
+def _get_exception_code(status_code: int):
+    """
+    获取返回状态码, OpenAPI, Uvicorn... 可用状态码基于 RFC 定义, 详细代码见下方链接
+
+    `python 状态码标准支持 <https://github.com/python/cpython/blob/6e3cc72afeaee2532b4327776501eb8234ac787b/Lib/http
+    /__init__.py#L7>`__
+
+    `IANA 状态码注册表 <https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml>`__
+
+    :param status_code:
+    :return:
+    """
+    try:
+        STATUS_PHRASES[status_code]
+    except Exception:
+        code = StandardResponseCode.HTTP_400
+    else:
+        code = status_code
+    return code
+
+
+async def _validation_exception_handler(request: Request, e: RequestValidationError | ValidationError):
+    """
+    数据验证异常处理
+
+    :param e:
+    :return:
+    """
+    errors = []
+    for error in e.errors():
+        custom_message = CUSTOM_VALIDATION_ERROR_MESSAGES.get(error['type'])
+        if custom_message:
+            ctx = error.get('ctx')
+            if not ctx:
+                error['msg'] = custom_message
+            else:
+                error['msg'] = custom_message.format(**ctx)
+                ctx_error = ctx.get('error')
+                if ctx_error:
+                    error['ctx']['error'] = (
+                        ctx_error.__str__().replace("'", '"') if isinstance(ctx_error, Exception) else None
+                    )
+        errors.append(error)
+    error = errors[0]
+    if error.get('type') == 'json_invalid':
+        message = 'json解析失败'
+    else:
+        error_input = error.get('input')
+        field = str(error.get('loc')[-1])
+        error_msg = error.get('msg')
+        message = f'{error_msg}{field},输入:{error_input}' if settings.ENVIRONMENT == 'dev' else error_msg
+    msg = f'请求参数非法: {message}'
+    data = {'errors': errors} if settings.ENVIRONMENT == 'dev' else None
+    content = {
+        'code': StandardResponseCode.HTTP_422,
+        'msg': msg,
+        'data': data,
+    }
+    request.state.__request_validation_exception__ = content  # 用于在中间件中获取异常信息
+    return MsgSpecJSONResponse(status_code=422, content=content)
+
+
+def register_exception(app: FastAPI):
+    @app.exception_handler(HTTPException)
+    async def http_exception_handler(request: Request, exc: HTTPException):
+        """
+        全局HTTP异常处理
+
+        :param request:
+        :param exc:
+        :return:
+        """
+        if settings.ENVIRONMENT == 'dev':
+            content = {
+                'code': exc.status_code,
+                'msg': exc.detail,
+                'data': None,
+            }
+        else:
+            res = response_base.fail(res=CustomResponseCode.HTTP_400)
+            content = res.model_dump()
+        request.state.__request_http_exception__ = content  # 用于在中间件中获取异常信息
+        return MsgSpecJSONResponse(
+            status_code=_get_exception_code(exc.status_code),
+            content=content,
+            headers=exc.headers,
+        )
+
+    @app.exception_handler(RequestValidationError)
+    async def fastapi_validation_exception_handler(request: Request, exc: RequestValidationError):
+        """
+        fastapi 数据验证异常处理
+
+        :param request:
+        :param exc:
+        :return:
+        """
+        return await _validation_exception_handler(request, exc)
+
+    @app.exception_handler(ValidationError)
+    async def pydantic_validation_exception_handler(request: Request, exc: ValidationError):
+        """
+        pydantic 数据验证异常处理
+
+        :param request:
+        :param exc:
+        :return:
+        """
+        return await _validation_exception_handler(request, exc)
+
+    @app.exception_handler(PydanticUserError)
+    async def pydantic_user_error_handler(request: Request, exc: PydanticUserError):
+        """
+        Pydantic 用户异常处理
+
+        :param request:
+        :param exc:
+        :return:
+        """
+        return MsgSpecJSONResponse(
+            status_code=StandardResponseCode.HTTP_500,
+            content={
+                'code': StandardResponseCode.HTTP_500,
+                'msg': CUSTOM_USAGE_ERROR_MESSAGES.get(exc.code),
+                'data': None,
+            },
+        )
+
+    @app.exception_handler(AssertionError)
+    async def assertion_error_handler(request: Request, exc: AssertionError):
+        """
+        断言错误处理
+
+        :param request:
+        :param exc:
+        :return:
+        """
+        if settings.ENVIRONMENT == 'dev':
+            content = {
+                'code': StandardResponseCode.HTTP_500,
+                'msg': str(''.join(exc.args) if exc.args else exc.__doc__),
+                'data': None,
+            }
+        else:
+            res = response_base.fail(res=CustomResponseCode.HTTP_500)
+            content = res.model_dump()
+        return MsgSpecJSONResponse(
+            status_code=StandardResponseCode.HTTP_500,
+            content=content,
+        )
+
+    @app.exception_handler(Exception)
+    async def all_exception_handler(request: Request, exc: Exception):
+        """
+        全局异常处理
+
+        :param request:
+        :param exc:
+        :return:
+        """
+        if isinstance(exc, BaseExceptionMixin):
+            return MsgSpecJSONResponse(
+                status_code=_get_exception_code(exc.code),
+                content={
+                    'code': exc.code,
+                    'msg': str(exc.msg),
+                    'data': exc.data if exc.data else None,
+                },
+                background=exc.background,
+            )
+        else:
+            import traceback
+
+            log.error(f'未知异常: {exc}')
+            log.error(traceback.format_exc())
+            if settings.ENVIRONMENT == 'dev':
+                content = {
+                    'code': StandardResponseCode.HTTP_500,
+                    'msg': str(exc),
+                    'data': None,
+                }
+            else:
+                res = response_base.fail(res=CustomResponseCode.HTTP_500)
+                content = res.model_dump()
+            return MsgSpecJSONResponse(status_code=StandardResponseCode.HTTP_500, content=content)
+
+    if settings.MIDDLEWARE_CORS:
+
+        @app.exception_handler(StandardResponseCode.HTTP_500)
+        async def cors_status_code_500_exception_handler(request, exc):
+            """
+            跨域 500 异常处理
+
+            `Related issue <https://github.com/encode/starlette/issues/1175>`_
+
+            :param request:
+            :param exc:
+            :return:
+            """
+            if isinstance(exc, BaseExceptionMixin):
+                content = {
+                    'code': exc.code,
+                    'msg': exc.msg,
+                    'data': exc.data,
+                }
+            else:
+                if settings.ENVIRONMENT == 'dev':
+                    content = {
+                        'code': StandardResponseCode.HTTP_500,
+                        'msg': str(exc),
+                        'data': None,
+                    }
+                else:
+                    res = response_base.fail(res=CustomResponseCode.HTTP_500)
+                    content = res.model_dump()
+            response = MsgSpecJSONResponse(
+                status_code=exc.code if isinstance(exc, BaseExceptionMixin) else StandardResponseCode.HTTP_500,
+                content=content,
+                background=exc.background if isinstance(exc, BaseExceptionMixin) else None,
+            )
+            origin = request.headers.get('origin')
+            if origin:
+                cors = CORSMiddleware(
+                    app=app,
+                    allow_origins=['*'],
+                    allow_credentials=True,
+                    allow_methods=['*'],
+                    allow_headers=['*'],
+                )
+                response.headers.update(cors.simple_headers)
+                has_cookie = 'cookie' in request.headers
+                if cors.allow_all_origins and has_cookie:
+                    response.headers['Access-Control-Allow-Origin'] = origin
+                elif not cors.allow_all_origins and cors.is_allowed_origin(origin=origin):
+                    response.headers['Access-Control-Allow-Origin'] = origin
+                    response.headers.add_vary_header('Origin')
+            return response

+ 103 - 0
common/log.py

@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import inspect
+import logging
+import os
+
+from sys import stderr, stdout
+
+from loguru import logger
+
+from core import path_conf
+from core.conf import settings
+
+
+class InterceptHandler(logging.Handler):
+    """
+    Default handler from examples in loguru documentation.
+    See https://loguru.readthedocs.io/en/stable/overview.html#entirely-compatible-with-standard-logging
+    """
+
+    def emit(self, record: logging.LogRecord):
+        # Get corresponding Loguru level if it exists
+        try:
+            level = logger.level(record.levelname).name
+        except ValueError:
+            level = record.levelno
+
+        # Find caller from where originated the logged message.
+        frame, depth = inspect.currentframe(), 0
+        while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
+            frame = frame.f_back
+            depth += 1
+
+        logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
+
+
+def setup_logging():
+    """
+    From https://pawamoy.github.io/posts/unify-logging-for-a-gunicorn-uvicorn-app/
+    https://github.com/pawamoy/pawamoy.github.io/issues/17
+    """
+    # Intercept everything at the root logger
+    logging.root.handlers = [InterceptHandler()]
+    logging.root.setLevel(settings.LOG_LEVEL)
+
+    # Remove all log handlers and propagate to root logger
+    for name in logging.root.manager.loggerDict.keys():
+        logging.getLogger(name).handlers = []
+        if 'uvicorn.access' in name or 'watchfiles.main' in name:
+            logging.getLogger(name).propagate = False
+        else:
+            logging.getLogger(name).propagate = True
+
+        logging.debug(f'{logging.getLogger(name)}, {logging.getLogger(name).propagate}')
+
+    # Remove every other logger's handlers
+    logger.remove()
+
+    # Configure logger before starts logging
+    logger.configure(handlers=[{'sink': stdout, 'level': settings.LOG_LEVEL, 'format': settings.LOG_FORMAT}])
+    logger.configure(handlers=[{'sink': stderr, 'level': settings.LOG_LEVEL, 'format': settings.LOG_FORMAT}])
+
+
+def set_customize_logfile():
+    log_path = path_conf.LOG_DIR
+    if not os.path.exists(log_path):
+        os.mkdir(log_path)
+
+    # log files
+    log_stdout_file = os.path.join(log_path, settings.LOG_STDOUT_FILENAME)
+    log_stderr_file = os.path.join(log_path, settings.LOG_STDERR_FILENAME)
+
+    # loguru logger: https://loguru.readthedocs.io/en/stable/api/logger.html#loguru._logger.Logger.add
+    log_config = {
+        'rotation': '10 MB',
+        'retention': '15 days',
+        'compression': 'tar.gz',
+        'enqueue': True,
+        'format': settings.LOG_FORMAT,
+    }
+
+    # stdout
+    logger.add(
+        log_stdout_file,
+        level='INFO',
+        filter=lambda record: record['level'].name == 'INFO' or record['level'].no <= 25,
+        **log_config,
+        backtrace=False,
+        diagnose=False,
+    )
+
+    # stderr
+    logger.add(
+        log_stderr_file,
+        level='ERROR',
+        filter=lambda record: record['level'].name == 'ERROR' or record['level'].no >= 30,
+        **log_config,
+        backtrace=True,
+        diagnose=True,
+    )
+
+
+log = logger

+ 65 - 0
common/model.py

@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from datetime import datetime
+from typing import Annotated
+
+from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, declared_attr, mapped_column
+
+from utils.timezone import timezone
+
+# 通用 Mapped 类型主键, 需手动添加,参考以下使用方式
+# MappedBase -> id: Mapped[id_key]
+# DataClassBase && Base -> id: Mapped[id_key] = mapped_column(init=False)
+id_key = Annotated[
+    int, mapped_column(primary_key=True, index=True, autoincrement=True, sort_order=-999, comment='主键id')
+]
+
+
+# Mixin: 一种面向对象编程概念, 使结构变得更加清晰, `Wiki <https://en.wikipedia.org/wiki/Mixin/>`__
+class UserMixin(MappedAsDataclass):
+    """用户 Mixin 数据类"""
+
+    create_user: Mapped[int] = mapped_column(sort_order=998, comment='创建者')
+    update_user: Mapped[int | None] = mapped_column(init=False, default=None, sort_order=998, comment='修改者')
+
+
+class DateTimeMixin(MappedAsDataclass):
+    """日期时间 Mixin 数据类"""
+
+    created_time: Mapped[datetime] = mapped_column(
+        init=False, default_factory=timezone.now, sort_order=999, comment='创建时间'
+    )
+    updated_time: Mapped[datetime | None] = mapped_column(
+        init=False, onupdate=timezone.now, sort_order=999, comment='更新时间'
+    )
+
+
+class MappedBase(DeclarativeBase):
+    """
+    声明性基类, 原始 DeclarativeBase 类, 作为所有基类或数据模型类的父类而存在
+
+    `DeclarativeBase <https://docs.sqlalchemy.org/en/20/orm/declarative_config.html>`__
+    `mapped_column() <https://docs.sqlalchemy.org/en/20/orm/mapping_api.html#sqlalchemy.orm.mapped_column>`__
+    """
+
+    @declared_attr.directive
+    def __tablename__(cls) -> str:
+        return cls.__name__.lower()
+
+
+class DataClassBase(MappedAsDataclass, MappedBase):
+    """
+    声明性数据类基类, 它将带有数据类集成, 允许使用更高级配置, 但你必须注意它的一些特性, 尤其是和 DeclarativeBase 一起使用时
+
+    `MappedAsDataclass <https://docs.sqlalchemy.org/en/20/orm/dataclasses.html#orm-declarative-native-dataclasses>`__
+    """  # noqa: E501
+
+    __abstract__ = True
+
+
+class Base(DataClassBase, DateTimeMixin):
+    """
+    声明性 Mixin 数据类基类, 带有数据类集成, 并包含 MiXin 数据类基础表结构, 你可以简单的理解它为含有基础表结构的数据类基类
+    """  # noqa: E501
+
+    __abstract__ = True

+ 85 - 0
common/pagination.py

@@ -0,0 +1,85 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import math
+
+from typing import TYPE_CHECKING, Dict, Generic, Sequence, TypeVar
+
+from fastapi import Depends, Query
+from fastapi_pagination import pagination_ctx
+from fastapi_pagination.bases import AbstractPage, AbstractParams, RawParams
+from fastapi_pagination.ext.sqlalchemy import paginate
+from fastapi_pagination.links.bases import create_links
+from pydantic import BaseModel
+
+if TYPE_CHECKING:
+    from sqlalchemy import Select
+    from sqlalchemy.ext.asyncio import AsyncSession
+
+T = TypeVar('T')
+DataT = TypeVar('DataT')
+SchemaT = TypeVar('SchemaT')
+
+
+class _Params(BaseModel, AbstractParams):
+    page: int = Query(1, ge=1, description='Page number')
+    size: int = Query(20, gt=0, le=100, description='Page size')  # 默认 20 条记录
+
+    def to_raw_params(self) -> RawParams:
+        return RawParams(
+            limit=self.size,
+            offset=self.size * (self.page - 1),
+        )
+
+
+class _Page(AbstractPage[T], Generic[T]):
+    items: Sequence[T]  # 数据
+    total: int  # 总数据数
+    page: int  # 第n页
+    size: int  # 每页数量
+    total_pages: int  # 总页数
+    links: Dict[str, str | None]  # 跳转链接
+
+    __params_type__ = _Params  # 使用自定义的Params
+
+    @classmethod
+    def create(
+        cls,
+        items: Sequence[T],
+        total: int,
+        params: _Params,
+    ) -> _Page[T]:
+        page = params.page
+        size = params.size
+        total_pages = math.ceil(total / params.size)
+        links = create_links(**{
+            'first': {'page': 1, 'size': f'{size}'},
+            'last': {'page': f'{math.ceil(total / params.size)}', 'size': f'{size}'} if total > 0 else None,
+            'next': {'page': f'{page + 1}', 'size': f'{size}'} if (page + 1) <= total_pages else None,
+            'prev': {'page': f'{page - 1}', 'size': f'{size}'} if (page - 1) >= 1 else None,
+        }).model_dump()
+
+        return cls(items=items, total=total, page=params.page, size=params.size, total_pages=total_pages, links=links)
+
+
+class _PageData(BaseModel, Generic[DataT]):
+    page_data: DataT | None = None
+
+
+async def paging_data(db: AsyncSession, select: Select, page_data_schema: SchemaT) -> dict:
+    """
+    基于 SQLAlchemy 创建分页数据
+
+    :param db:
+    :param select:
+    :param page_data_schema:
+    :return:
+    """
+    _paginate = await paginate(db, select)
+    page_data = _PageData[_Page[page_data_schema]](page_data=_paginate).model_dump()['page_data']
+    return page_data
+
+
+# 分页依赖注入
+DependsPagination = Depends(pagination_ctx(_Page))

+ 2 - 0
common/response/__init__.py

@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-

+ 160 - 0
common/response/response_code.py

@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import dataclasses
+
+from enum import Enum
+
+
+class CustomCodeBase(Enum):
+    """自定义状态码基类"""
+
+    @property
+    def code(self):
+        """
+        获取状态码
+        """
+        return self.value[0]
+
+    @property
+    def msg(self):
+        """
+        获取状态码信息
+        """
+        return self.value[1]
+
+
+class CustomResponseCode(CustomCodeBase):
+    """自定义响应状态码"""
+
+    HTTP_200 = (200, '请求成功')
+    HTTP_201 = (201, '新建请求成功')
+    HTTP_202 = (202, '请求已接受,但处理尚未完成')
+    HTTP_204 = (204, '请求成功,但没有返回内容')
+    HTTP_400 = (400, '请求错误')
+    HTTP_401 = (401, '未经授权')
+    HTTP_403 = (403, '禁止访问')
+    HTTP_404 = (404, '请求的资源不存在')
+    HTTP_410 = (410, '请求的资源已永久删除')
+    HTTP_422 = (422, '请求参数非法')
+    HTTP_425 = (425, '无法执行请求,由于服务器无法满足要求')
+    HTTP_429 = (429, '请求过多,服务器限制')
+    HTTP_500 = (500, '服务器内部错误')
+    HTTP_502 = (502, '网关错误')
+    HTTP_503 = (503, '服务器暂时无法处理请求')
+    HTTP_504 = (504, '网关超时')
+
+
+class CustomErrorCode(CustomCodeBase):
+    """自定义错误状态码"""
+
+    CAPTCHA_ERROR = (40001, '验证码错误')
+
+
+@dataclasses.dataclass
+class CustomResponse:
+    """
+    提供开放式响应状态码,而不是枚举,如果你想自定义响应信息,这可能很有用
+    """
+
+    code: int
+    msg: str
+
+
+class StandardResponseCode:
+    """标准响应状态码"""
+
+    """
+    HTTP codes
+    See HTTP Status Code Registry:
+    https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
+
+    And RFC 2324 - https://tools.ietf.org/html/rfc2324
+    """
+    HTTP_100 = 100  # CONTINUE: 继续
+    HTTP_101 = 101  # SWITCHING_PROTOCOLS: 协议切换
+    HTTP_102 = 102  # PROCESSING: 处理中
+    HTTP_103 = 103  # EARLY_HINTS: 提示信息
+    HTTP_200 = 200  # OK: 请求成功
+    HTTP_201 = 201  # CREATED: 已创建
+    HTTP_202 = 202  # ACCEPTED: 已接受
+    HTTP_203 = 203  # NON_AUTHORITATIVE_INFORMATION: 非权威信息
+    HTTP_204 = 204  # NO_CONTENT: 无内容
+    HTTP_205 = 205  # RESET_CONTENT: 重置内容
+    HTTP_206 = 206  # PARTIAL_CONTENT: 部分内容
+    HTTP_207 = 207  # MULTI_STATUS: 多状态
+    HTTP_208 = 208  # ALREADY_REPORTED: 已报告
+    HTTP_226 = 226  # IM_USED: 使用了
+    HTTP_300 = 300  # MULTIPLE_CHOICES: 多种选择
+    HTTP_301 = 301  # MOVED_PERMANENTLY: 永久移动
+    HTTP_302 = 302  # FOUND: 临时移动
+    HTTP_303 = 303  # SEE_OTHER: 查看其他位置
+    HTTP_304 = 304  # NOT_MODIFIED: 未修改
+    HTTP_305 = 305  # USE_PROXY: 使用代理
+    HTTP_307 = 307  # TEMPORARY_REDIRECT: 临时重定向
+    HTTP_308 = 308  # PERMANENT_REDIRECT: 永久重定向
+    HTTP_400 = 400  # BAD_REQUEST: 请求错误
+    HTTP_401 = 401  # UNAUTHORIZED: 未授权
+    HTTP_402 = 402  # PAYMENT_REQUIRED: 需要付款
+    HTTP_403 = 403  # FORBIDDEN: 禁止访问
+    HTTP_404 = 404  # NOT_FOUND: 未找到
+    HTTP_405 = 405  # METHOD_NOT_ALLOWED: 方法不允许
+    HTTP_406 = 406  # NOT_ACCEPTABLE: 不可接受
+    HTTP_407 = 407  # PROXY_AUTHENTICATION_REQUIRED: 需要代理身份验证
+    HTTP_408 = 408  # REQUEST_TIMEOUT: 请求超时
+    HTTP_409 = 409  # CONFLICT: 冲突
+    HTTP_410 = 410  # GONE: 已删除
+    HTTP_411 = 411  # LENGTH_REQUIRED: 需要内容长度
+    HTTP_412 = 412  # PRECONDITION_FAILED: 先决条件失败
+    HTTP_413 = 413  # REQUEST_ENTITY_TOO_LARGE: 请求实体过大
+    HTTP_414 = 414  # REQUEST_URI_TOO_LONG: 请求 URI 过长
+    HTTP_415 = 415  # UNSUPPORTED_MEDIA_TYPE: 不支持的媒体类型
+    HTTP_416 = 416  # REQUESTED_RANGE_NOT_SATISFIABLE: 请求范围不符合要求
+    HTTP_417 = 417  # EXPECTATION_FAILED: 期望失败
+    HTTP_418 = 418  # UNUSED: 闲置
+    HTTP_421 = 421  # MISDIRECTED_REQUEST: 被错导的请求
+    HTTP_422 = 422  # UNPROCESSABLE_CONTENT: 无法处理的实体
+    HTTP_423 = 423  # LOCKED: 已锁定
+    HTTP_424 = 424  # FAILED_DEPENDENCY: 依赖失败
+    HTTP_425 = 425  # TOO_EARLY: 太早
+    HTTP_426 = 426  # UPGRADE_REQUIRED: 需要升级
+    HTTP_427 = 427  # UNASSIGNED: 未分配
+    HTTP_428 = 428  # PRECONDITION_REQUIRED: 需要先决条件
+    HTTP_429 = 429  # TOO_MANY_REQUESTS: 请求过多
+    HTTP_430 = 430  # Unassigned: 未分配
+    HTTP_431 = 431  # REQUEST_HEADER_FIELDS_TOO_LARGE: 请求头字段太大
+    HTTP_451 = 451  # UNAVAILABLE_FOR_LEGAL_REASONS: 由于法律原因不可用
+    HTTP_500 = 500  # INTERNAL_SERVER_ERROR: 服务器内部错误
+    HTTP_501 = 501  # NOT_IMPLEMENTED: 未实现
+    HTTP_502 = 502  # BAD_GATEWAY: 错误的网关
+    HTTP_503 = 503  # SERVICE_UNAVAILABLE: 服务不可用
+    HTTP_504 = 504  # GATEWAY_TIMEOUT: 网关超时
+    HTTP_505 = 505  # HTTP_VERSION_NOT_SUPPORTED: HTTP 版本不支持
+    HTTP_506 = 506  # VARIANT_ALSO_NEGOTIATES: 变体也会协商
+    HTTP_507 = 507  # INSUFFICIENT_STORAGE: 存储空间不足
+    HTTP_508 = 508  # LOOP_DETECTED: 检测到循环
+    HTTP_509 = 509  # UNASSIGNED: 未分配
+    HTTP_510 = 510  # NOT_EXTENDED: 未扩展
+    HTTP_511 = 511  # NETWORK_AUTHENTICATION_REQUIRED: 需要网络身份验证
+
+    """
+    WebSocket codes
+    https://www.iana.org/assignments/websocket/websocket.xml#close-code-number
+    https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
+    """
+    WS_1000 = 1000  # NORMAL_CLOSURE: 正常闭合
+    WS_1001 = 1001  # GOING_AWAY: 正在离开
+    WS_1002 = 1002  # PROTOCOL_ERROR: 协议错误
+    WS_1003 = 1003  # UNSUPPORTED_DATA: 不支持的数据类型
+    WS_1005 = 1005  # NO_STATUS_RCVD: 没有接收到状态
+    WS_1006 = 1006  # ABNORMAL_CLOSURE: 异常关闭
+    WS_1007 = 1007  # INVALID_FRAME_PAYLOAD_DATA: 无效的帧负载数据
+    WS_1008 = 1008  # POLICY_VIOLATION: 策略违规
+    WS_1009 = 1009  # MESSAGE_TOO_BIG: 消息太大
+    WS_1010 = 1010  # MANDATORY_EXT: 必需的扩展
+    WS_1011 = 1011  # INTERNAL_ERROR: 内部错误
+    WS_1012 = 1012  # SERVICE_RESTART: 服务重启
+    WS_1013 = 1013  # TRY_AGAIN_LATER: 请稍后重试
+    WS_1014 = 1014  # BAD_GATEWAY: 错误的网关
+    WS_1015 = 1015  # TLS_HANDSHAKE: TLS握手错误
+    WS_3000 = 3000  # UNAUTHORIZED: 未经授权
+    WS_3003 = 3003  # FORBIDDEN: 禁止访问

+ 110 - 0
common/response/response_schema.py

@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from datetime import datetime
+from typing import Any
+
+from fastapi import Response
+from pydantic import BaseModel, ConfigDict
+
+from common.response.response_code import CustomResponse, CustomResponseCode
+from core.conf import settings
+from utils.serializers import MsgSpecJSONResponse
+
+_ExcludeData = set[int | str] | dict[int | str, Any]
+
+__all__ = ['ResponseModel', 'response_base']
+
+
+class ResponseModel(BaseModel):
+    """
+    统一返回模型
+
+    E.g. ::
+
+        @router.get('/test', response_model=ResponseModel)
+        def test():
+            return ResponseModel(data={'test': 'test'})
+
+
+        @router.get('/test')
+        def test() -> ResponseModel:
+            return ResponseModel(data={'test': 'test'})
+
+
+        @router.get('/test')
+        def test() -> ResponseModel:
+            res = CustomResponseCode.HTTP_200
+            return ResponseModel(code=res.code, msg=res.msg, data={'test': 'test'})
+    """
+
+    # TODO: json_encoders 配置失效: https://github.com/tiangolo/fastapi/discussions/10252
+    model_config = ConfigDict(json_encoders={datetime: lambda x: x.strftime(settings.DATETIME_FORMAT)})
+
+    code: int = CustomResponseCode.HTTP_200.code
+    msg: str = CustomResponseCode.HTTP_200.msg
+    data: Any | None = None
+
+
+class ResponseBase:
+    """
+    统一返回方法
+
+    .. tip::
+
+        此类中的方法将返回 ResponseModel 模型,作为一种编码风格而存在;
+
+    E.g. ::
+
+        @router.get('/test')
+        def test() -> ResponseModel:
+            return await response_base.success(data={'test': 'test'})
+    """
+
+    @staticmethod
+    def __response(*, res: CustomResponseCode | CustomResponse = None, data: Any | None = None) -> ResponseModel:
+        """
+        请求成功返回通用方法
+
+        :param res: 返回信息
+        :param data: 返回数据
+        :return:
+        """
+        return ResponseModel(code=res.code, msg=res.msg, data=data)
+
+    def success(
+        self,
+        *,
+        res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200,
+        data: Any | None = None,
+    ) -> ResponseModel:
+        return self.__response(res=res, data=data)
+
+    def fail(
+        self,
+        *,
+        res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_400,
+        data: Any = None,
+    ) -> ResponseModel:
+        return self.__response(res=res, data=data)
+
+    @staticmethod
+    def fast_success(
+        *,
+        res: CustomResponseCode | CustomResponse = CustomResponseCode.HTTP_200,
+        data: Any | None = None,
+    ) -> Response:
+        """
+        此方法是为了提高接口响应速度而创建的,如果返回数据无需进行 pydantic 解析和验证,则推荐使用,相反,请不要使用!
+
+        .. warning::
+
+            使用此返回方法时,不要指定接口参数 response_model,也不要在接口函数后添加箭头返回类型
+
+        :param res:
+        :param data:
+        :return:
+        """
+        return MsgSpecJSONResponse({'code': res.code, 'msg': res.msg, 'data': data})
+
+
+response_base = ResponseBase()

+ 152 - 0
common/schema.py

@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from pydantic import BaseModel, ConfigDict, EmailStr, validate_email
+from pydantic_extra_types.phone_numbers import PhoneNumber
+
+# 自定义验证错误信息不包含验证预期内容(也就是输入内容),受支持的预期内容字段参考以下链接
+# https://github.com/pydantic/pydantic-core/blob/a5cb7382643415b716b1a7a5392914e50f726528/tests/test_errors.py#L266
+# 替换预期内容字段方式,参考以下链接
+# https://github.com/pydantic/pydantic/blob/caa78016433ec9b16a973f92f187a7b6bfde6cb5/docs/errors/errors.md?plain=1#L232
+CUSTOM_VALIDATION_ERROR_MESSAGES = {
+    'arguments_type': '参数类型输入错误',
+    'assertion_error': '断言执行错误',
+    'bool_parsing': '布尔值输入解析错误',
+    'bool_type': '布尔值类型输入错误',
+    'bytes_too_long': '字节长度输入过长',
+    'bytes_too_short': '字节长度输入过短',
+    'bytes_type': '字节类型输入错误',
+    'callable_type': '可调用对象类型输入错误',
+    'dataclass_exact_type': '数据类实例类型输入错误',
+    'dataclass_type': '数据类类型输入错误',
+    'date_from_datetime_inexact': '日期分量输入非零',
+    'date_from_datetime_parsing': '日期输入解析错误',
+    'date_future': '日期输入非将来时',
+    'date_parsing': '日期输入验证错误',
+    'date_past': '日期输入非过去时',
+    'date_type': '日期类型输入错误',
+    'datetime_future': '日期时间输入非将来时间',
+    'datetime_object_invalid': '日期时间输入对象无效',
+    'datetime_parsing': '日期时间输入解析错误',
+    'datetime_past': '日期时间输入非过去时间',
+    'datetime_type': '日期时间类型输入错误',
+    'decimal_max_digits': '小数位数输入过多',
+    'decimal_max_places': '小数位数输入错误',
+    'decimal_parsing': '小数输入解析错误',
+    'decimal_type': '小数类型输入错误',
+    'decimal_whole_digits': '小数位数输入错误',
+    'dict_type': '字典类型输入错误',
+    'enum': '枚举成员输入错误,允许 {expected}',
+    'extra_forbidden': '禁止额外字段输入',
+    'finite_number': '有限值输入错误',
+    'float_parsing': '浮点数输入解析错误',
+    'float_type': '浮点数类型输入错误',
+    'frozen_field': '冻结字段输入错误',
+    'frozen_instance': '冻结实例禁止修改',
+    'frozen_set_type': '冻结类型禁止输入',
+    'get_attribute_error': '获取属性错误',
+    'greater_than': '输入值过大',
+    'greater_than_equal': '输入值过大或相等',
+    'int_from_float': '整数类型输入错误',
+    'int_parsing': '整数输入解析错误',
+    'int_parsing_size': '整数输入解析长度错误',
+    'int_type': '整数类型输入错误',
+    'invalid_key': '输入无效键值',
+    'is_instance_of': '类型实例输入错误',
+    'is_subclass_of': '类型子类输入错误',
+    'iterable_type': '可迭代类型输入错误',
+    'iteration_error': '迭代值输入错误',
+    'json_invalid': 'JSON 字符串输入错误',
+    'json_type': 'JSON 类型输入错误',
+    'less_than': '输入值过小',
+    'less_than_equal': '输入值过小或相等',
+    'list_type': '列表类型输入错误',
+    'literal_error': '字面值输入错误',
+    'mapping_type': '映射类型输入错误',
+    'missing': '缺少必填字段',
+    'missing_argument': '缺少参数',
+    'missing_keyword_only_argument': '缺少关键字参数',
+    'missing_positional_only_argument': '缺少位置参数',
+    'model_attributes_type': '模型属性类型输入错误',
+    'model_type': '模型实例输入错误',
+    'multiple_argument_values': '参数值输入过多',
+    'multiple_of': '输入值非倍数',
+    'no_such_attribute': '分配无效属性值',
+    'none_required': '输入值必须为 None',
+    'recursion_loop': '输入循环赋值',
+    'set_type': '集合类型输入错误',
+    'string_pattern_mismatch': '字符串约束模式输入不匹配',
+    'string_sub_type': '字符串子类型(非严格实例)输入错误',
+    'string_too_long': '字符串输入过长',
+    'string_too_short': '字符串输入过短',
+    'string_type': '字符串类型输入错误',
+    'string_unicode': '字符串输入非 Unicode',
+    'time_delta_parsing': '时间差输入解析错误',
+    'time_delta_type': '时间差类型输入错误',
+    'time_parsing': '时间输入解析错误',
+    'time_type': '时间类型输入错误',
+    'timezone_aware': '缺少时区输入信息',
+    'timezone_naive': '禁止时区输入信息',
+    'too_long': '输入过长',
+    'too_short': '输入过短',
+    'tuple_type': '元组类型输入错误',
+    'unexpected_keyword_argument': '输入意外关键字参数',
+    'unexpected_positional_argument': '输入意外位置参数',
+    'union_tag_invalid': '联合类型字面值输入错误',
+    'union_tag_not_found': '联合类型参数输入未找到',
+    'url_parsing': 'URL 输入解析错误',
+    'url_scheme': 'URL 输入方案错误',
+    'url_syntax_violation': 'URL 输入语法错误',
+    'url_too_long': 'URL 输入过长',
+    'url_type': 'URL 类型输入错误',
+    'uuid_parsing': 'UUID 输入解析错误',
+    'uuid_type': 'UUID 类型输入错误',
+    'uuid_version': 'UUID 版本类型输入错误',
+    'value_error': '值输入错误',
+}
+
+CUSTOM_USAGE_ERROR_MESSAGES = {
+    'class-not-fully-defined': '类属性类型未完全定义',
+    'custom-json-schema': '__modify_schema__ 方法在V2中已被弃用',
+    'decorator-missing-field': '定义了无效字段验证器',
+    'discriminator-no-field': '鉴别器字段未全部定义',
+    'discriminator-alias-type': '鉴别器字段使用非字符串类型定义',
+    'discriminator-needs-literal': '鉴别器字段需要使用字面值定义',
+    'discriminator-alias': '鉴别器字段别名定义不一致',
+    'discriminator-validator': '鉴别器字段禁止定义字段验证器',
+    'model-field-overridden': '无类型定义字段禁止重写',
+    'model-field-missing-annotation': '缺少字段类型定义',
+    'config-both': '重复定义配置项',
+    'removed-kwargs': '调用已移除的关键字配置参数',
+    'invalid-for-json-schema': '存在无效的 JSON 类型',
+    'base-model-instantiated': '禁止实例化基础模型',
+    'undefined-annotation': '缺少类型定义',
+    'schema-for-unknown-type': '未知类型定义',
+    'create-model-field-definitions': '字段定义错误',
+    'create-model-config-base': '配置项定义错误',
+    'validator-no-fields': '字段验证器未指定字段',
+    'validator-invalid-fields': '字段验证器字段定义错误',
+    'validator-instance-method': '字段验证器必须为类方法',
+    'model-serializer-instance-method': '序列化器必须为实例方法',
+    'validator-v1-signature': 'V1字段验证器错误已被弃用',
+    'validator-signature': '字段验证器签名错误',
+    'field-serializer-signature': '字段序列化器签名无法识别',
+    'model-serializer-signature': '模型序列化器签名无法识别',
+    'multiple-field-serializers': '字段序列化器重复定义',
+    'invalid_annotated_type': '无效的类型定义',
+    'type-adapter-config-unused': '类型适配器配置项定义错误',
+    'root-model-extra': '根模型禁止定义额外字段',
+}
+
+
+class CustomPhoneNumber(PhoneNumber):
+    default_region_code = 'CN'
+
+
+class CustomEmailStr(EmailStr):
+    @classmethod
+    def _validate(cls, __input_value: str) -> str:
+        return None if __input_value == '' else validate_email(__input_value)[1]
+
+
+class SchemaBase(BaseModel):
+    model_config = ConfigDict(use_enum_values=True)

+ 2 - 0
common/security/__init__.py

@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-

+ 26 - 0
common/security/permission.py

@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from fastapi import Request
+
+from common.exception.errors import ServerError
+from core.conf import settings
+
+
+class RequestPermission:
+    """
+    请求权限,仅用于角色菜单RBAC
+
+    Tip:
+        使用此请求权限时,需要将 `Depends(RequestPermission('xxx'))` 在 `DependsRBAC` 之前设置,
+        因为 fastapi 当前版本的接口依赖注入按正序执行,意味着 RBAC 标识会在验证前被设置
+    """
+
+    def __init__(self, value: str):
+        self.value = value
+
+    async def __call__(self, request: Request):
+        if settings.PERMISSION_MODE == 'role-menu':
+            if not isinstance(self.value, str):
+                raise ServerError
+            # 附加权限标识
+            request.state.permission = self.value

+ 2 - 0
core/__init__.py

@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-

+ 153 - 0
core/conf.py

@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from functools import lru_cache
+from typing import Literal
+
+from pydantic import model_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from core.path_conf import BasePath
+
+
+class Settings(BaseSettings):
+    """Global Settings"""
+
+    model_config = SettingsConfigDict(env_file=f'{BasePath}/.env', env_file_encoding='utf-8', extra='ignore')
+
+    # Env Config
+    ENVIRONMENT: Literal['dev', 'pro']
+
+    # Env MySQL
+    MYSQL_HOST: str
+    MYSQL_PORT: int
+    MYSQL_USER: str
+    MYSQL_PASSWORD: str
+
+    # Env Redis
+    REDIS_HOST: str
+    REDIS_PORT: int
+    REDIS_PASSWORD: str
+    REDIS_DATABASE: int
+
+    # Env Token
+    TOKEN_SECRET_KEY: str  # 密钥 secrets.token_urlsafe(32)
+
+    # Env Opera Log
+    OPERA_LOG_ENCRYPT_SECRET_KEY: str  # 密钥 os.urandom(32), 需使用 bytes.hex() 方法转换为 str
+
+    # FastAPI
+    API_V1_STR: str = '/api/v1'
+    TITLE: str = '3ex AI 接口'
+    VERSION: str = '0.0.1'
+    DESCRIPTION: str = '3ex AI API'
+    DOCS_URL: str | None = f'{API_V1_STR}/docs'
+    REDOCS_URL: str | None = f'{API_V1_STR}/redocs'
+    OPENAPI_URL: str | None = f'{API_V1_STR}/openapi'
+
+    @model_validator(mode='before')
+    @classmethod
+    def validate_openapi_url(cls, values):
+        if values['ENVIRONMENT'] == 'pro':
+            values['OPENAPI_URL'] = None
+        return values
+
+    # Demo mode
+    # Only GET, OPTIONS requests are allowed
+    DEMO_MODE: bool = False
+    DEMO_MODE_EXCLUDE: set[tuple[str, str]] = {
+        ('POST', f'{API_V1_STR}/auth/login'),
+        ('POST', f'{API_V1_STR}/auth/logout'),
+        ('GET', f'{API_V1_STR}/auth/captcha'),
+    }
+
+    # Static Server
+    STATIC_FILES: bool = False
+
+    # Location Parse
+    LOCATION_PARSE: Literal['online', 'offline', 'false'] = 'offline'
+
+    # Limiter
+    LIMITER_REDIS_PREFIX: str = 'fba:limiter'
+
+    # DateTime
+    DATETIME_TIMEZONE: str = 'Asia/Shanghai'
+    DATETIME_FORMAT: str = '%Y-%m-%d %H:%M:%S'
+
+    # MySQL
+    MYSQL_ECHO: bool = False
+    MYSQL_DATABASE: str = 'fba'
+    MYSQL_CHARSET: str = 'utf8mb4'
+
+    # Redis
+    REDIS_TIMEOUT: int = 5
+
+    # Token
+    TOKEN_ALGORITHM: str = 'HS256'  # 算法
+    TOKEN_EXPIRE_SECONDS: int = 60 * 60 * 24 * 1  # 过期时间,单位:秒
+    TOKEN_REFRESH_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7  # 刷新过期时间,单位:秒
+    TOKEN_REDIS_PREFIX: str = 'fba:token'
+    TOKEN_REFRESH_REDIS_PREFIX: str = 'fba:token:refresh'
+    TOKEN_EXCLUDE: list[str] = [  # JWT / RBAC 白名单
+        f'{API_V1_STR}/auth/login',
+    ]
+
+    # Sys User
+    USER_REDIS_PREFIX: str = 'fba:user'
+    USER_REDIS_EXPIRE_SECONDS: int = 60 * 60 * 24 * 7
+
+    # Log
+    LOG_LEVEL: str = 'INFO'
+    LOG_FORMAT: str = '<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</> | <lvl>{level: <8}</> | <lvl>{message}</>'
+    LOG_STDOUT_FILENAME: str = 'fba_access.log'
+    LOG_STDERR_FILENAME: str = 'fba_error.log'
+
+    # Middleware
+    MIDDLEWARE_CORS: bool = True
+    MIDDLEWARE_ACCESS: bool = True
+
+    # RBAC Permission
+    PERMISSION_MODE: Literal['casbin', 'role-menu'] = 'casbin'
+    PERMISSION_REDIS_PREFIX: str = 'fba:permission'
+
+    # Casbin Auth
+    CASBIN_EXCLUDE: set[tuple[str, str]] = {
+        ('POST', f'{API_V1_STR}/auth/logout'),
+        ('POST', f'{API_V1_STR}/auth/token/new'),
+    }
+
+    # Role Menu Auth
+    ROLE_MENU_EXCLUDE: list[str] = [
+        'sys:monitor:redis',
+        'sys:monitor:server',
+    ]
+
+    # Opera log
+    OPERA_LOG_EXCLUDE: list[str] = [
+        '/favicon.ico',
+        DOCS_URL,
+        REDOCS_URL,
+        OPENAPI_URL,
+        f'{API_V1_STR}/auth/login/swagger',
+        f'{API_V1_STR}/auth/github/callback',
+    ]
+    OPERA_LOG_ENCRYPT: int = 1  # 0: AES (性能损耗); 1: md5; 2: ItsDangerous; 3: 不加密, others: 替换为 ******
+    OPERA_LOG_ENCRYPT_INCLUDE: list[str] = [
+        'password',
+        'old_password',
+        'new_password',
+        'confirm_password',
+    ]
+
+    # Ip location
+    IP_LOCATION_REDIS_PREFIX: str = 'fba:ip:location'
+    IP_LOCATION_EXPIRE_SECONDS: int = 60 * 60 * 24 * 1  # 过期时间,单位:秒
+
+
+@lru_cache
+def get_settings() -> Settings:
+    """获取全局配置"""
+    return Settings()
+
+
+# 创建配置实例
+settings = get_settings()

+ 24 - 0
core/path_conf.py

@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import os
+
+from pathlib import Path
+
+# 获取项目根目录
+# 或使用绝对路径,指到backend目录为止,例如windows:BasePath = D:\git_project\fastapi_mysql\backend
+BasePath = Path(__file__).resolve().parent.parent
+
+# alembic 迁移文件存放路径
+ALEMBIC_Versions_DIR = os.path.join(BasePath, 'alembic', 'versions')
+
+# 日志文件路径
+LOG_DIR = os.path.join(BasePath, 'log')
+
+# 离线 IP 数据库路径
+IP2REGION_XDB = os.path.join(BasePath, 'static', 'ip2region.xdb')
+
+# 挂载静态目录
+STATIC_DIR = os.path.join(BasePath, 'static')
+
+# jinja2 模版文件路径
+JINJA2_TEMPLATE_DIR = os.path.join(BasePath, 'templates')

+ 163 - 0
core/registrar.py

@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from contextlib import asynccontextmanager
+
+from fastapi import Depends, FastAPI
+from fastapi_limiter import FastAPILimiter
+from fastapi_pagination import add_pagination
+from starlette.middleware.authentication import AuthenticationMiddleware
+
+from app.router import route
+from common.exception.exception_handler import register_exception
+from common.log import set_customize_logfile, setup_logging
+from core.conf import settings
+from core.path_conf import STATIC_DIR
+from database.db_mysql import create_table
+from database.db_redis import redis_client
+
+from middleware.opera_log_middleware import OperaLogMiddleware
+from utils.demo_site import demo_site
+from utils.health_check import ensure_unique_route_names, http_limit_callback
+from utils.openapi import simplify_operation_ids
+from utils.serializers import MsgSpecJSONResponse
+
+
+@asynccontextmanager
+async def register_init(app: FastAPI):
+    """
+    启动初始化
+
+    :return:
+    """
+    # 创建数据库表
+    # await create_table()
+    # 连接 redis
+    await redis_client.open()
+    # 初始化 limiter
+    await FastAPILimiter.init(redis_client, prefix=settings.LIMITER_REDIS_PREFIX, http_callback=http_limit_callback)
+
+    yield
+
+    # 关闭 redis 连接
+    await redis_client.close()
+    # 关闭 limiter
+    await FastAPILimiter.close()
+
+
+def register_app():
+    # FastAPI
+    app = FastAPI(
+        title=settings.TITLE,
+        version=settings.VERSION,
+        description=settings.DESCRIPTION,
+        docs_url=settings.DOCS_URL,
+        redoc_url=settings.REDOCS_URL,
+        openapi_url=settings.OPENAPI_URL,
+        default_response_class=MsgSpecJSONResponse,
+        lifespan=register_init,
+    )
+
+    # 日志
+    register_logger()
+
+    # 静态文件
+    register_static_file(app)
+
+    # 中间件
+    register_middleware(app)
+
+    # 路由
+    register_router(app)
+
+    # 分页
+    register_page(app)
+
+    # 全局异常处理
+    register_exception(app)
+
+    return app
+
+
+def register_logger() -> None:
+    """
+    系统日志
+
+    :return:
+    """
+    setup_logging()
+    set_customize_logfile()
+
+
+def register_static_file(app: FastAPI):
+    """
+    静态文件交互开发模式, 生产使用 nginx 静态资源服务
+
+    :param app:
+    :return:
+    """
+    if settings.STATIC_FILES:
+        import os
+
+        from fastapi.staticfiles import StaticFiles
+
+        if not os.path.exists(STATIC_DIR):
+            os.mkdir(STATIC_DIR)
+        app.mount('/static', StaticFiles(directory=STATIC_DIR), name='static')
+
+
+def register_middleware(app: FastAPI):
+    """
+    中间件,执行顺序从下往上
+
+    :param app:
+    :return:
+    """
+    # Opera log
+    # app.add_middleware(OperaLogMiddleware)
+    # JWT auth, required
+    # app.add_middleware(
+    #     AuthenticationMiddleware, backend=JwtAuthMiddleware(), on_error=JwtAuthMiddleware.auth_exception_handler
+    # )
+    # Access log
+    if settings.MIDDLEWARE_ACCESS:
+        from middleware.access_middleware import AccessMiddleware
+
+        app.add_middleware(AccessMiddleware)
+    # CORS: Always at the end
+    if settings.MIDDLEWARE_CORS:
+        from fastapi.middleware.cors import CORSMiddleware
+
+        app.add_middleware(
+            CORSMiddleware,
+            allow_origins=['*'],
+            allow_credentials=True,
+            allow_methods=['*'],
+            allow_headers=['*'],
+        )
+
+
+def register_router(app: FastAPI):
+    """
+    路由
+
+    :param app: FastAPI
+    :return:
+    """
+    dependencies = [Depends(demo_site)] if settings.DEMO_MODE else None
+
+    # API
+    app.include_router(route, dependencies=dependencies)
+
+    # Extra
+    ensure_unique_route_names(app)
+    simplify_operation_ids(app)
+
+
+def register_page(app: FastAPI):
+    """
+    分页查询
+
+    :param app:
+    :return:
+    """
+    add_pagination(app)

+ 2 - 0
database/__init__.py

@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-

+ 63 - 0
database/db_mysql.py

@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import sys
+
+from typing import Annotated
+from urllib.parse import urlparse, quote
+from uuid import uuid4
+
+from fastapi import Depends
+from sqlalchemy import URL
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from common.log import log
+from common.model import MappedBase
+from core.conf import settings
+
+
+def create_engine_and_session(url: str | URL):
+    try:
+        # 数据库引擎
+        engine = create_async_engine(url, echo=settings.MYSQL_ECHO, future=True, pool_pre_ping=True)
+        # log.success('数据库连接成功')
+    except Exception as e:
+        log.error('❌ 数据库链接失败 {}', e)
+        sys.exit()
+    else:
+        db_session = async_sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
+        return engine, db_session
+
+
+SQLALCHEMY_DATABASE_URL = (
+    f'mysql+asyncmy://{settings.MYSQL_USER}:{quote(settings.MYSQL_PASSWORD)}@{settings.MYSQL_HOST}:'
+    f'{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}?charset={settings.MYSQL_CHARSET}&ssl=true'
+)
+
+async_engine, async_db_session = create_engine_and_session(SQLALCHEMY_DATABASE_URL)
+
+
+async def get_db() -> AsyncSession:
+    """session 生成器"""
+    session = async_db_session()
+    try:
+        yield session
+    except Exception as se:
+        await session.rollback()
+        raise se
+    finally:
+        await session.close()
+
+
+# Session Annotated
+CurrentSession = Annotated[AsyncSession, Depends(get_db)]
+
+
+async def create_table():
+    """创建数据库表"""
+    async with async_engine.begin() as coon:
+        await coon.run_sync(MappedBase.metadata.create_all)
+
+
+def uuid4_str() -> str:
+    """数据库引擎 UUID 类型兼容性解决方案"""
+    return str(uuid4())

+ 64 - 0
database/db_redis.py

@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import sys
+
+from redis.asyncio.client import Redis
+from redis.exceptions import AuthenticationError, TimeoutError
+
+from common.log import log
+from core.conf import settings
+
+
+class RedisCli(Redis):
+    def __init__(self):
+        super(RedisCli, self).__init__(
+            host=settings.REDIS_HOST,
+            port=settings.REDIS_PORT,
+            password=settings.REDIS_PASSWORD,
+            db=settings.REDIS_DATABASE,
+            socket_timeout=settings.REDIS_TIMEOUT,
+            decode_responses=True,  # 转码 utf-8
+        )
+
+    async def open(self):
+        """
+        触发初始化连接
+
+        :return:
+        """
+        try:
+            await self.ping()
+        except TimeoutError:
+            log.error('❌ 数据库 redis 连接超时')
+            sys.exit()
+        except AuthenticationError:
+            log.error('❌ 数据库 redis 连接认证失败')
+            sys.exit()
+        except Exception as e:
+            log.error('❌ 数据库 redis 连接异常 {}', e)
+            sys.exit()
+
+    async def delete_prefix(self, prefix: str, exclude: str | list = None):
+        """
+        删除指定前缀的所有key
+
+        :param prefix:
+        :param exclude:
+        :return:
+        """
+        keys = []
+        async for key in self.scan_iter(match=f'{prefix}*'):
+            if isinstance(exclude, str):
+                if key != exclude:
+                    keys.append(key)
+            elif isinstance(exclude, list):
+                if key not in exclude:
+                    keys.append(key)
+            else:
+                keys.append(key)
+        if keys:
+            await self.delete(*keys)
+
+
+# 创建 redis 客户端实例
+redis_client = RedisCli()

+ 36 - 0
main.py

@@ -0,0 +1,36 @@
+from contextlib import asynccontextmanager
+from pathlib import Path
+
+from fastapi import FastAPI
+import uvicorn
+
+# app = FastAPI()
+
+
+# @app.get("/")
+# async def root():
+#     return {"message": "Hello World"}
+#
+#
+# @app.get("/hello/{name}")
+# async def say_hello(name: str):
+#     return {"message": f"Hello {name}"}
+
+
+from core.registrar import register_app
+
+
+app = register_app()
+
+
+if __name__ == '__main__':
+    # 如果你喜欢在 IDE 中进行 DEBUG,main 启动方法会很有帮助
+    # 如果你喜欢通过 print 方式进行调试,建议使用 fastapi cli 方式启动服务
+    try:
+        config = uvicorn.Config(app=f'{Path(__file__).stem}:app', reload=False)
+        server = uvicorn.Server(config)
+        server.run()
+
+        # uvicorn.run(app, host='127.0.0.1', port=8000)
+    except Exception as e:
+        raise e

+ 2 - 0
middleware/__init__.py

@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-

+ 21 - 0
middleware/access_middleware.py

@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from fastapi import Request, Response
+from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
+
+from common.log import log
+from utils.timezone import timezone
+
+
+class AccessMiddleware(BaseHTTPMiddleware):
+    """请求日志中间件"""
+
+    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
+        start_time = timezone.now()
+        response = await call_next(request)
+        end_time = timezone.now()
+        log.info(
+            f'{request.client.host: <15} | {request.method: <8} | {response.status_code: <6} | '
+            f'{request.url.path} | {round((end_time - start_time).total_seconds(), 3) * 1000.0}ms'
+        )
+        return response

+ 186 - 0
middleware/opera_log_middleware.py

@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from asyncio import create_task
+
+from asgiref.sync import sync_to_async
+from fastapi import Response
+from starlette.datastructures import UploadFile
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request
+
+
+from common.dataclasses import RequestCallNextReturn
+from common.enums import OperaLogCipherType, StatusType
+from common.log import log
+from core.conf import settings
+from utils.encrypt import AESCipher, ItsDCipher, Md5Cipher
+from utils.request_parse import parse_ip_info, parse_user_agent_info
+from utils.timezone import timezone
+
+
+class OperaLogMiddleware(BaseHTTPMiddleware):
+    """操作日志中间件"""
+
+    async def dispatch(self, request: Request, call_next) -> Response:
+        # 排除记录白名单
+        path = request.url.path
+        if path in settings.OPERA_LOG_EXCLUDE or not path.startswith(f'{settings.API_V1_STR}'):
+            return await call_next(request)
+
+        # 请求解析
+        ip_info = await parse_ip_info(request)
+        ua_info = await parse_user_agent_info(request)
+        try:
+            # 此信息依赖于 jwt 中间件
+            username = request.user.username
+        except AttributeError:
+            username = None
+        method = request.method
+        args = await self.get_request_args(request)
+        args = await self.desensitization(args)
+
+        # 设置附加请求信息
+        request.state.ip = ip_info.ip
+        request.state.country = ip_info.country
+        request.state.region = ip_info.region
+        request.state.city = ip_info.city
+        request.state.user_agent = ua_info.user_agent
+        request.state.os = ua_info.os
+        request.state.browser = ua_info.browser
+        request.state.device = ua_info.device
+
+        # 执行请求
+        start_time = timezone.now()
+        res = await self.execute_request(request, call_next)
+        end_time = timezone.now()
+        cost_time = (end_time - start_time).total_seconds() * 1000.0
+
+        # 此信息只能在请求后获取
+        _route = request.scope.get('route')
+        summary = getattr(_route, 'summary', None) or ''
+
+        # 日志创建
+        # opera_log_in = CreateOperaLogParam(
+        #     username=username,
+        #     method=method,
+        #     title=summary,
+        #     path=path,
+        #     ip=request.state.ip,
+        #     country=request.state.country,
+        #     region=request.state.region,
+        #     city=request.state.city,
+        #     user_agent=request.state.user_agent,
+        #     os=request.state.os,
+        #     browser=request.state.browser,
+        #     device=request.state.device,
+        #     args=args,
+        #     status=res.status,
+        #     code=res.code,
+        #     msg=res.msg,
+        #     cost_time=cost_time,
+        #     opera_time=start_time,
+        # )
+        # create_task(OperaLogService.create(obj_in=opera_log_in))  # noqa: ignore
+
+        # 错误抛出
+        err = res.err
+        if err:
+            raise err from None
+
+        return res.response
+
+    async def execute_request(self, request: Request, call_next) -> RequestCallNextReturn:
+        """执行请求"""
+        code = 200
+        msg = 'Success'
+        status = StatusType.enable
+        err = None
+        response = None
+        try:
+            response = await call_next(request)
+        except Exception as e:
+            log.exception(e)
+            code, msg = await self.request_exception_handler(request, code, msg)
+            # code 处理包含 SQLAlchemy 和 Pydantic
+            code = getattr(e, 'code', None) or code
+            msg = getattr(e, 'msg', None) or msg
+            status = StatusType.disable
+            err = e
+
+        return RequestCallNextReturn(code=str(code), msg=msg, status=status, err=err, response=response)
+
+    @staticmethod
+    @sync_to_async
+    def request_exception_handler(request: Request, code: int, msg: str) -> tuple[str, str]:
+        """请求异常处理器"""
+        try:
+            http_exception = request.state.__request_http_exception__
+        except AttributeError:
+            pass
+        else:
+            code = http_exception.get('code', 500)
+            msg = http_exception.get('msg', 'Internal Server Error')
+        try:
+            validation_exception = request.state.__request_validation_exception__
+        except AttributeError:
+            pass
+        else:
+            code = validation_exception.get('code', 400)
+            msg = validation_exception.get('msg', 'Bad Request')
+        return code, msg
+
+    @staticmethod
+    async def get_request_args(request: Request) -> dict:
+        """获取请求参数"""
+        args = dict(request.query_params)
+        args.update(request.path_params)
+        # Tip: .body() 必须在 .form() 之前获取
+        # https://github.com/encode/starlette/discussions/1933
+        body_data = await request.body()
+        form_data = await request.form()
+        if len(form_data) > 0:
+            args.update({k: v.filename if isinstance(v, UploadFile) else v for k, v in form_data.items()})
+        else:
+            if body_data:
+                json_data = await request.json()
+                if not isinstance(json_data, dict):
+                    json_data = {
+                        f'{type(json_data)}_to_dict_data': json_data.decode('utf-8')
+                        if isinstance(json_data, bytes)
+                        else json_data
+                    }
+                args.update(json_data)
+        return args
+
+    @staticmethod
+    @sync_to_async
+    def desensitization(args: dict) -> dict | None:
+        """
+        脱敏处理
+
+        :param args:
+        :return:
+        """
+        if not args:
+            args = None
+        else:
+            match settings.OPERA_LOG_ENCRYPT:
+                case OperaLogCipherType.aes:
+                    for key in args.keys():
+                        if key in settings.OPERA_LOG_ENCRYPT_INCLUDE:
+                            args[key] = (AESCipher(settings.OPERA_LOG_ENCRYPT_SECRET_KEY).encrypt(args[key])).hex()
+                case OperaLogCipherType.md5:
+                    for key in args.keys():
+                        if key in settings.OPERA_LOG_ENCRYPT_INCLUDE:
+                            args[key] = Md5Cipher.encrypt(args[key])
+                case OperaLogCipherType.itsdangerous:
+                    for key in args.keys():
+                        if key in settings.OPERA_LOG_ENCRYPT_INCLUDE:
+                            args[key] = ItsDCipher(settings.OPERA_LOG_ENCRYPT_SECRET_KEY).encrypt(args[key])
+                case OperaLogCipherType.plan:
+                    pass
+                case _:
+                    for key in args.keys():
+                        if key in settings.OPERA_LOG_ENCRYPT_INCLUDE:
+                            args[key] = '******'
+        return args

+ 0 - 0
model/__init__.py


BIN
model/records.py


BIN
requirements.txt


+ 2 - 0
utils/__init__.py

@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-

+ 81 - 0
utils/build_tree.py

@@ -0,0 +1,81 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from typing import Any, Sequence
+
+from common.enums import BuildTreeType
+from utils.serializers import RowData, select_list_serialize
+
+
+def get_tree_nodes(row: Sequence[RowData]) -> list[dict[str, Any]]:
+    """获取所有树形结构节点"""
+    tree_nodes = select_list_serialize(row)
+    tree_nodes.sort(key=lambda x: x['sort'])
+    return tree_nodes
+
+
+def traversal_to_tree(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
+    """
+    通过遍历算法构造树形结构
+
+    :param nodes:
+    :return:
+    """
+    tree = []
+    node_dict = {node['id']: node for node in nodes}
+
+    for node in nodes:
+        parent_id = node['parent_id']
+        if parent_id is None:
+            tree.append(node)
+        else:
+            parent_node = node_dict.get(parent_id)
+            if parent_node is not None:
+                if 'children' not in parent_node:
+                    parent_node['children'] = []
+                if node not in parent_node['children']:
+                    parent_node['children'].append(node)
+            else:
+                if node not in tree:
+                    tree.append(node)
+
+    return tree
+
+
+def recursive_to_tree(nodes: list[dict[str, Any]], *, parent_id: int | None = None) -> list[dict[str, Any]]:
+    """
+    通过递归算法构造树形结构(性能影响较大)
+
+    :param nodes:
+    :param parent_id:
+    :return:
+    """
+    tree = []
+    for node in nodes:
+        if node['parent_id'] == parent_id:
+            child_node = recursive_to_tree(nodes, parent_id=node['id'])
+            if child_node:
+                node['children'] = child_node
+            tree.append(node)
+    return tree
+
+
+def get_tree_data(
+    row: Sequence[RowData], build_type: BuildTreeType = BuildTreeType.traversal, *, parent_id: int | None = None
+) -> list[dict[str, Any]]:
+    """
+    获取树形结构数据
+
+    :param row:
+    :param build_type:
+    :param parent_id:
+    :return:
+    """
+    nodes = get_tree_nodes(row)
+    match build_type:
+        case BuildTreeType.traversal:
+            tree = traversal_to_tree(nodes)
+        case BuildTreeType.recursive:
+            tree = recursive_to_tree(nodes, parent_id=parent_id)
+        case _:
+            raise ValueError(f'无效的算法类型:{build_type}')
+    return tree

+ 20 - 0
utils/demo_site.py

@@ -0,0 +1,20 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from fastapi import Request
+
+from common.exception import errors
+from core.conf import settings
+
+
+async def demo_site(request: Request):
+    """演示站点"""
+
+    method = request.method
+    path = request.url.path
+    if (
+        settings.DEMO_MODE
+        and method != 'GET'
+        and method != 'OPTIONS'
+        and (method, path) not in settings.DEMO_MODE_EXCLUDE
+    ):
+        raise errors.ForbiddenError(msg='演示环境下禁止执行此操作')

+ 110 - 0
utils/encrypt.py

@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import os
+
+from typing import Any
+
+from cryptography.hazmat.backends.openssl import backend
+from cryptography.hazmat.primitives import padding
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+from itsdangerous import URLSafeSerializer
+
+from common.log import log
+
+
+class AESCipher:
+    def __init__(self, key: bytes | str):
+        """
+        :param key: 密钥,16/24/32 bytes 或 16 进制字符串
+        """
+        self.key = key if isinstance(key, bytes) else bytes.fromhex(key)
+
+    def encrypt(self, plaintext: bytes | str) -> bytes:
+        """
+        AES 加密
+
+        :param plaintext: 加密前的明文
+        :return:
+        """
+        if not isinstance(plaintext, bytes):
+            plaintext = str(plaintext).encode('utf-8')
+        iv = os.urandom(16)
+        cipher = Cipher(algorithms.AES(self.key), modes.CBC(iv), backend=backend)
+        encryptor = cipher.encryptor()
+        padder = padding.PKCS7(cipher.algorithm.block_size).padder()  # type: ignore
+        padded_plaintext = padder.update(plaintext) + padder.finalize()
+        ciphertext = encryptor.update(padded_plaintext) + encryptor.finalize()
+        return iv + ciphertext
+
+    def decrypt(self, ciphertext: bytes | str) -> str:
+        """
+        AES 解密
+
+        :param ciphertext: 解密前的密文, bytes 或 16 进制字符串
+        :return:
+        """
+        ciphertext = ciphertext if isinstance(ciphertext, bytes) else bytes.fromhex(ciphertext)
+        iv = ciphertext[:16]
+        ciphertext = ciphertext[16:]
+        cipher = Cipher(algorithms.AES(self.key), modes.CBC(iv), backend=backend)
+        decryptor = cipher.decryptor()
+        unpadder = padding.PKCS7(cipher.algorithm.block_size).unpadder()  # type: ignore
+        padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()
+        plaintext = unpadder.update(padded_plaintext) + unpadder.finalize()
+        return plaintext.decode('utf-8')
+
+
+class Md5Cipher:
+    @staticmethod
+    def encrypt(plaintext: bytes | str) -> str:
+        """
+        MD5 加密
+
+        :param plaintext: 加密前的明文
+        :return:
+        """
+        import hashlib
+
+        md5 = hashlib.md5()
+        if not isinstance(plaintext, bytes):
+            plaintext = str(plaintext).encode('utf-8')
+        md5.update(plaintext)
+        return md5.hexdigest()
+
+
+class ItsDCipher:
+    def __init__(self, key: bytes | str):
+        """
+        :param key: 密钥,16/24/32 bytes 或 16 进制字符串
+        """
+        self.key = key if isinstance(key, bytes) else bytes.fromhex(key)
+
+    def encrypt(self, plaintext: Any) -> str:
+        """
+        ItsDangerous 加密 (可能失败,如果 plaintext 无法序列化,则会加密为 MD5)
+
+        :param plaintext: 加密前的明文
+        :return:
+        """
+        serializer = URLSafeSerializer(self.key)
+        try:
+            ciphertext = serializer.dumps(plaintext)
+        except Exception as e:
+            log.error(f'ItsDangerous encrypt failed: {e}')
+            ciphertext = Md5Cipher.encrypt(plaintext)
+        return ciphertext
+
+    def decrypt(self, ciphertext: str) -> Any:
+        """
+        ItsDangerous 解密 (可能失败,如果 ciphertext 无法反序列化,则解密失败, 返回原始密文)
+
+        :param ciphertext: 解密前的密文
+        :return:
+        """
+        serializer = URLSafeSerializer(self.key)
+        try:
+            plaintext = serializer.loads(ciphertext)
+        except Exception as e:
+            log.error(f'ItsDangerous decrypt failed: {e}')
+            plaintext = ciphertext
+        return plaintext

+ 102 - 0
utils/gen_template.py

@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from jinja2 import Environment, FileSystemLoader, Template, select_autoescape
+from pydantic.alias_generators import to_pascal, to_snake
+
+from app.generator.conf import generator_settings
+from app.generator.model import GenBusiness, GenModel
+from core.path_conf import JINJA2_TEMPLATE_DIR
+
+
+class GenTemplate:
+    def __init__(self):
+        self.env = Environment(
+            loader=FileSystemLoader(JINJA2_TEMPLATE_DIR),
+            autoescape=select_autoescape(enabled_extensions=['jinja']),
+            trim_blocks=True,
+            lstrip_blocks=True,
+            keep_trailing_newline=True,
+            enable_async=True,
+        )
+        self.init_content = '#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n'
+
+    def get_template(self, jinja_file: str) -> Template:
+        """
+        获取模版文件
+
+        :param jinja_file:
+        :return:
+        """
+
+        return self.env.get_template(jinja_file)
+
+    @staticmethod
+    def get_template_paths() -> list[str]:
+        """
+        获取模版文件路径
+
+        :return:
+        """
+        return [
+            f'{generator_settings.TEMPLATE_BACKEND_DIR_NAME}/api.jinja',
+            f'{generator_settings.TEMPLATE_BACKEND_DIR_NAME}/crud.jinja',
+            f'{generator_settings.TEMPLATE_BACKEND_DIR_NAME}/model.jinja',
+            f'{generator_settings.TEMPLATE_BACKEND_DIR_NAME}/schema.jinja',
+            f'{generator_settings.TEMPLATE_BACKEND_DIR_NAME}/service.jinja',
+        ]
+
+    @staticmethod
+    def get_code_gen_paths(business: GenBusiness) -> list[str]:
+        """
+        获取代码生成路径列表
+
+        :param business:
+        :return:
+        """
+        app_name = business.app_name
+        module_name = business.table_name_en
+        target_files = [
+            f'{generator_settings.TEMPLATE_BACKEND_DIR_NAME}/{app_name}/api/{business.api_version}/{module_name}.py',
+            f'{generator_settings.TEMPLATE_BACKEND_DIR_NAME}/{app_name}/crud/crud_{module_name}.py',
+            f'{generator_settings.TEMPLATE_BACKEND_DIR_NAME}/{app_name}/model/{module_name}.py',
+            f'{generator_settings.TEMPLATE_BACKEND_DIR_NAME}/{app_name}/schema/{module_name}.py',
+            f'{generator_settings.TEMPLATE_BACKEND_DIR_NAME}/{app_name}/service/{module_name}_service.py',
+        ]
+        return target_files
+
+    def get_code_gen_path(self, tpl_path: str, business: GenBusiness) -> str:
+        """
+        获取代码生成路径
+
+        :param tpl_path:
+        :param business:
+        :return:
+        """
+        target_files = self.get_code_gen_paths(business)
+        code_gen_path_mapping = dict(zip(self.get_template_paths(), target_files))
+        return code_gen_path_mapping[tpl_path]
+
+    @staticmethod
+    def get_vars(business: GenBusiness, models: list[GenModel]) -> dict:
+        """
+        获取模版变量
+
+        :param business:
+        :param models:
+        :return:
+        """
+        return {
+            'app_name': business.app_name,
+            'table_name_en': to_snake(business.table_name_en),
+            'table_name_class': to_pascal(business.table_name_en),
+            'table_name_zh': business.table_name_zh,
+            'table_simple_name_zh': business.table_simple_name_zh,
+            'table_comment': business.table_comment,
+            'schema_name': to_pascal(business.schema_name),
+            'have_datetime_column': business.default_datetime_column,
+            'permission_sign': str(business.table_name_en.replace('_', ':')),
+            'models': models,
+        }
+
+
+gen_template = GenTemplate()

+ 36 - 0
utils/health_check.py

@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from math import ceil
+
+from fastapi import FastAPI, Request, Response
+from fastapi.routing import APIRoute
+
+from common.exception import errors
+
+
+def ensure_unique_route_names(app: FastAPI) -> None:
+    """
+    检查路由名称是否唯一
+
+    :param app:
+    :return:
+    """
+    temp_routes = set()
+    for route in app.routes:
+        if isinstance(route, APIRoute):
+            if route.name in temp_routes:
+                raise ValueError(f'Non-unique route name: {route.name}')
+            temp_routes.add(route.name)
+
+
+async def http_limit_callback(request: Request, response: Response, expire: int):
+    """
+    请求限制时的默认回调函数
+
+    :param request:
+    :param response:
+    :param expire: 剩余毫秒
+    :return:
+    """
+    expires = ceil(expire / 1000)
+    raise errors.HTTPError(code=429, msg='请求过于频繁,请稍后重试', headers={'Retry-After': str(expires)})

+ 16 - 0
utils/openapi.py

@@ -0,0 +1,16 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from fastapi import FastAPI
+from fastapi.routing import APIRoute
+
+
+def simplify_operation_ids(app: FastAPI) -> None:
+    """
+    简化操作 ID,以便生成的客户端具有更简单的 api 函数名称
+
+    :param app:
+    :return:
+    """
+    for route in app.routes:
+        if isinstance(route, APIRoute):
+            route.operation_id = route.name

+ 43 - 0
utils/re_verify.py

@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import re
+
+
+def search_string(pattern, text) -> bool:
+    """
+    全字段正则匹配
+
+    :param pattern:
+    :param text:
+    :return:
+    """
+    result = re.search(pattern, text)
+    if result:
+        return True
+    else:
+        return False
+
+
+def match_string(pattern, text) -> bool:
+    """
+    从字段开头正则匹配
+
+    :param pattern:
+    :param text:
+    :return:
+    """
+    result = re.match(pattern, text)
+    if result:
+        return True
+    else:
+        return False
+
+
+def is_phone(text: str) -> bool:
+    """
+    检查手机号码
+
+    :param text:
+    :return:
+    """
+    return match_string(r'^1[3-9]\d{9}$', text)

+ 33 - 0
utils/redis_info.py

@@ -0,0 +1,33 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from database.db_redis import redis_client
+from utils.server_info import server_info
+
+
+class RedisInfo:
+    @staticmethod
+    async def get_info():
+        info = await redis_client.info()
+        fmt_info = {}
+        for key, value in info.items():
+            if isinstance(value, dict):
+                value = ','.join({f'{k}={v}' for k, v in value.items()})
+            else:
+                value = str(value)
+            fmt_info[key] = value
+        db_size = await redis_client.dbsize()
+        fmt_info.update({'keys_num': db_size})
+        fmt_uptime = server_info.fmt_seconds(fmt_info.get('uptime_in_seconds', 0))
+        fmt_info.update({'uptime_in_seconds': fmt_uptime})
+        return fmt_info
+
+    @staticmethod
+    async def get_stats():
+        stats_list = []
+        command_stats = await redis_client.info('commandstats')
+        for k, v in command_stats.items():
+            stats_list.append({'name': k.split('_')[-1], 'value': str(v.get('calls', ''))})
+        return stats_list
+
+
+redis_info = RedisInfo()

+ 111 - 0
utils/request_parse.py

@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import httpx
+
+from asgiref.sync import sync_to_async
+from fastapi import Request
+from user_agents import parse
+from XdbSearchIP.xdbSearcher import XdbSearcher
+
+from common.dataclasses import IpInfo, UserAgentInfo
+from common.log import log
+from core.conf import settings
+from core.path_conf import IP2REGION_XDB
+from database.db_redis import redis_client
+
+
+@sync_to_async
+def get_request_ip(request: Request) -> str:
+    """获取请求的 ip 地址"""
+    real = request.headers.get('X-Real-IP')
+    if real:
+        ip = real
+    else:
+        forwarded = request.headers.get('X-Forwarded-For')
+        if forwarded:
+            ip = forwarded.split(',')[0]
+        else:
+            ip = request.client.host
+    # 忽略 pytest
+    if ip == 'testclient':
+        ip = '127.0.0.1'
+    return ip
+
+
+async def get_location_online(ip: str, user_agent: str) -> dict | None:
+    """
+    在线获取 ip 地址属地,无法保证可用性,准确率较高
+
+    :param ip:
+    :param user_agent:
+    :return:
+    """
+    async with httpx.AsyncClient(timeout=3) as client:
+        ip_api_url = f'http://ip-api.com/json/{ip}?lang=zh-CN'
+        headers = {'User-Agent': user_agent}
+        try:
+            response = await client.get(ip_api_url, headers=headers)
+            if response.status_code == 200:
+                return response.json()
+        except Exception as e:
+            log.error(f'在线获取 ip 地址属地失败,错误信息:{e}')
+            return None
+
+
+@sync_to_async
+def get_location_offline(ip: str) -> dict | None:
+    """
+    离线获取 ip 地址属地,无法保证准确率,100%可用
+
+    :param ip:
+    :return:
+    """
+    try:
+        cb = XdbSearcher.loadContentFromFile(dbfile=IP2REGION_XDB)
+        searcher = XdbSearcher(contentBuff=cb)
+        data = searcher.search(ip)
+        searcher.close()
+        data = data.split('|')
+        return {
+            'country': data[0] if data[0] != '0' else None,
+            'regionName': data[2] if data[2] != '0' else None,
+            'city': data[3] if data[3] != '0' else None,
+        }
+    except Exception as e:
+        log.error(f'离线获取 ip 地址属地失败,错误信息:{e}')
+        return None
+
+
+async def parse_ip_info(request: Request) -> IpInfo:
+    country, region, city = None, None, None
+    ip = await get_request_ip(request)
+    location = await redis_client.get(f'{settings.IP_LOCATION_REDIS_PREFIX}:{ip}')
+    if location:
+        country, region, city = location.split(' ')
+        return IpInfo(ip=ip, country=country, region=region, city=city)
+    if settings.LOCATION_PARSE == 'online':
+        location_info = await get_location_online(ip, request.headers.get('User-Agent'))
+    elif settings.LOCATION_PARSE == 'offline':
+        location_info = await get_location_offline(ip)
+    else:
+        location_info = None
+    if location_info:
+        country = location_info.get('country')
+        region = location_info.get('regionName')
+        city = location_info.get('city')
+        await redis_client.set(
+            f'{settings.IP_LOCATION_REDIS_PREFIX}:{ip}',
+            f'{country} {region} {city}',
+            ex=settings.IP_LOCATION_EXPIRE_SECONDS,
+        )
+    return IpInfo(ip=ip, country=country, region=region, city=city)
+
+
+@sync_to_async
+def parse_user_agent_info(request: Request) -> UserAgentInfo:
+    user_agent = request.headers.get('User-Agent')
+    _user_agent = parse(user_agent)
+    os = _user_agent.get_os()
+    browser = _user_agent.get_browser()
+    device = _user_agent.get_device()
+    return UserAgentInfo(user_agent=user_agent, device=device, os=os, browser=browser)

+ 65 - 0
utils/serializers.py

@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from decimal import Decimal
+from typing import Any, Sequence, TypeVar
+
+import msgspec
+
+from sqlalchemy import Row, RowMapping
+from starlette.responses import JSONResponse
+
+RowData = Row | RowMapping | Any
+
+R = TypeVar('R', bound=RowData)
+
+
+def select_columns_serialize(row: R) -> dict:
+    """
+    Serialize SQLAlchemy select table columns, does not contain relational columns
+
+    :param row:
+    :return:
+    """
+    obj_dict = {}
+    for column in row.__table__.columns.keys():
+        val = getattr(row, column)
+        if isinstance(val, Decimal):
+            if val % 1 == 0:
+                val = int(val)
+            val = float(val)
+        obj_dict[column] = val
+    return obj_dict
+
+
+def select_list_serialize(row: Sequence[R]) -> list:
+    """
+    Serialize SQLAlchemy select list
+
+    :param row:
+    :return:
+    """
+    ret_list = [select_columns_serialize(_) for _ in row]
+    return ret_list
+
+
+def select_as_dict(row: R) -> dict:
+    """
+    Converting SQLAlchemy select to dict, which can contain relational data,
+    depends on the properties of the select object itself
+
+    :param row:
+    :return:
+    """
+    obj_dict = row.__dict__
+    if '_sa_instance_state' in obj_dict:
+        del obj_dict['_sa_instance_state']
+        return obj_dict
+
+
+class MsgSpecJSONResponse(JSONResponse):
+    """
+    JSON response using the high-performance msgspec library to serialize data to JSON.
+    """
+
+    def render(self, content: Any) -> bytes:
+        return msgspec.json.encode(content)

+ 142 - 0
utils/server_info.py

@@ -0,0 +1,142 @@
+import os
+import platform
+import socket
+import sys
+
+from datetime import datetime, timedelta
+from datetime import timezone as tz
+from typing import List
+
+import psutil
+
+from utils.timezone import timezone
+
+
+class ServerInfo:
+    @staticmethod
+    def format_bytes(size) -> str:
+        """格式化字节"""
+        factor = 1024
+        for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']:
+            if abs(size) < factor:
+                return f'{size:.2f} {unit}B'
+            size /= factor
+        return f'{size:.2f} YB'
+
+    @staticmethod
+    def fmt_seconds(seconds: int) -> str:
+        days, rem = divmod(int(seconds), 86400)
+        hours, rem = divmod(rem, 3600)
+        minutes, seconds = divmod(rem, 60)
+        parts = []
+        if days:
+            parts.append('{} 天'.format(days))
+        if hours:
+            parts.append('{} 小时'.format(hours))
+        if minutes:
+            parts.append('{} 分钟'.format(minutes))
+        if seconds:
+            parts.append('{} 秒'.format(seconds))
+        if len(parts) == 0:
+            return '0 秒'
+        else:
+            return ' '.join(parts)
+
+    @staticmethod
+    def fmt_timedelta(td: timedelta) -> str:
+        """格式化时间差"""
+        total_seconds = round(td.total_seconds())
+        return ServerInfo.fmt_seconds(total_seconds)
+
+    @staticmethod
+    def get_cpu_info() -> dict:
+        """获取 CPU 信息"""
+        cpu_info = {'usage': round(psutil.cpu_percent(interval=1, percpu=False), 2)}  # %
+
+        # 检查是否是 Apple M系列芯片
+        if platform.system() == 'Darwin' and 'arm' in platform.machine().lower():
+            cpu_info['max_freq'] = 0
+            cpu_info['min_freq'] = 0
+            cpu_info['current_freq'] = 0
+        else:
+            try:
+                # CPU 频率信息,最大、最小和当前频率
+                cpu_freq = psutil.cpu_freq()
+                cpu_info['max_freq'] = round(cpu_freq.max, 2)  # MHz
+                cpu_info['min_freq'] = round(cpu_freq.min, 2)  # MHz
+                cpu_info['current_freq'] = round(cpu_freq.current, 2)  # MHz
+            except FileNotFoundError:
+                # 处理无法获取频率的情况
+                cpu_info['max_freq'] = 0
+                cpu_info['min_freq'] = 0
+                cpu_info['current_freq'] = 0
+            except AttributeError:
+                # 处理属性不存在的情况(更安全的做法)
+                cpu_info['max_freq'] = 0
+                cpu_info['min_freq'] = 0
+                cpu_info['current_freq'] = 0
+
+        # CPU 逻辑核心数,物理核心数
+        cpu_info['logical_num'] = psutil.cpu_count(logical=True)
+        cpu_info['physical_num'] = psutil.cpu_count(logical=False)
+        return cpu_info
+
+    @staticmethod
+    def get_mem_info() -> dict:
+        """获取内存信息"""
+        mem = psutil.virtual_memory()
+        return {
+            'total': round(mem.total / 1024 / 1024 / 1024, 2),  # GB
+            'used': round(mem.used / 1024 / 1024 / 1024, 2),  # GB
+            'free': round(mem.available / 1024 / 1024 / 1024, 2),  # GB
+            'usage': round(mem.percent, 2),  # %
+        }
+
+    @staticmethod
+    def get_sys_info() -> dict:
+        """获取服务器信息"""
+        try:
+            with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sk:
+                sk.connect(('8.8.8.8', 80))
+                ip = sk.getsockname()[0]
+        except socket.gaierror:
+            ip = '127.0.0.1'
+        return {'name': socket.gethostname(), 'ip': ip, 'os': platform.system(), 'arch': 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({
+                'dir': disk.mountpoint,
+                'type': disk.fstype,
+                'device': disk.device,
+                'total': ServerInfo.format_bytes(usage.total),
+                'free': ServerInfo.format_bytes(usage.free),
+                'used': ServerInfo.format_bytes(usage.used),
+                'usage': f'{round(usage.percent, 2)} %',
+            })
+        return disk_info
+
+    @staticmethod
+    def get_service_info():
+        """获取服务信息"""
+        process = psutil.Process(os.getpid())
+        mem_info = process.memory_info()
+        start_time = timezone.f_datetime(datetime.utcfromtimestamp(process.create_time()).replace(tzinfo=tz.utc))
+        return {
+            'name': 'Python3',
+            'version': platform.python_version(),
+            'home': sys.executable,
+            'cpu_usage': f'{round(process.cpu_percent(interval=1), 2)} %',
+            'mem_vms': ServerInfo.format_bytes(mem_info.vms),  # 虚拟内存, 即当前进程申请的虚拟内存
+            'mem_rss': ServerInfo.format_bytes(mem_info.rss),  # 常驻内存, 即当前进程实际使用的物理内存
+            'mem_free': ServerInfo.format_bytes(mem_info.vms - mem_info.rss),  # 空闲内存
+            'startup': start_time,
+            'elapsed': f'{ServerInfo.fmt_timedelta(timezone.now() - start_time)}',
+        }
+
+
+server_info = ServerInfo()

+ 42 - 0
utils/timezone.py

@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+import zoneinfo
+
+from datetime import datetime
+
+from core.conf import settings
+
+
+class TimeZone:
+    def __init__(self, tz: str = settings.DATETIME_TIMEZONE):
+        self.tz_info = zoneinfo.ZoneInfo(tz)
+
+    def now(self) -> datetime:
+        """
+        获取时区时间
+
+        :return:
+        """
+        return datetime.now(self.tz_info)
+
+    def f_datetime(self, dt: datetime) -> datetime:
+        """
+        datetime 时间转时区时间
+
+        :param dt:
+        :return:
+        """
+        return dt.astimezone(self.tz_info)
+
+    def f_str(self, date_str: str, format_str: str = settings.DATETIME_FORMAT) -> datetime:
+        """
+        时间字符串转时区时间
+
+        :param date_str:
+        :param format_str:
+        :return:
+        """
+        return datetime.strptime(date_str, format_str).replace(tzinfo=self.tz_info)
+
+
+timezone = TimeZone()

+ 95 - 0
utils/type_conversion.py

@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from common.enums import GenModelColumnType
+
+
+def sql_type_to_sqlalchemy(typing: str) -> str:
+    """
+    Converts a sql type to a SQLAlchemy type.
+
+    :param typing:
+    :return:
+    """
+    type_mapping = {
+        GenModelColumnType.BIGINT: 'BIGINT',
+        GenModelColumnType.BINARY: 'BINARY',
+        GenModelColumnType.BIT: 'BIT',
+        GenModelColumnType.BLOB: 'BLOG',
+        GenModelColumnType.BOOL: 'BOOLEAN',
+        GenModelColumnType.BOOLEAN: 'BOOLEAN',
+        GenModelColumnType.CHAR: 'CHAR',
+        GenModelColumnType.DATE: 'DATE',
+        GenModelColumnType.DATETIME: 'DATETIME',
+        GenModelColumnType.DECIMAL: 'DECIMAL',
+        GenModelColumnType.DOUBLE: 'DOUBLE',
+        GenModelColumnType.ENUM: 'ENUM',
+        GenModelColumnType.FLOAT: 'FLOAT',
+        GenModelColumnType.INT: 'INT',
+        GenModelColumnType.INTEGER: 'INTEGER',
+        GenModelColumnType.JSON: 'JSON',
+        GenModelColumnType.LONGBLOB: 'LONGBLOB',
+        GenModelColumnType.LONGTEXT: 'LONGTEXT',
+        GenModelColumnType.MEDIUMBLOB: 'MEDIUMBLOB',
+        GenModelColumnType.MEDIUMINT: 'MEDIUMINT',
+        GenModelColumnType.MEDIUMTEXT: 'MEDIUMTEXT',
+        GenModelColumnType.NUMERIC: 'NUMERIC',
+        GenModelColumnType.SET: 'SET',
+        GenModelColumnType.SMALLINT: 'SMALLINT',
+        GenModelColumnType.REAL: 'REAL',
+        GenModelColumnType.TEXT: 'TEXT',
+        GenModelColumnType.TIME: 'TIME',
+        GenModelColumnType.TIMESTAMP: 'TIMESTAMP',
+        GenModelColumnType.TINYBLOB: 'TINYBLOB',
+        GenModelColumnType.TINYINT: 'TINYINT',
+        GenModelColumnType.TINYTEXT: 'TINYTEXT',
+        GenModelColumnType.VARBINARY: 'VARBINARY',
+        GenModelColumnType.VARCHAR: 'VARCHAR',
+        GenModelColumnType.YEAR: 'YEAR',
+    }
+    return type_mapping.get(typing, 'VARCHAR')
+
+
+def sql_type_to_pydantic(typing: str) -> str:
+    """
+    Converts a sql type to a pydantic type.
+
+    :param typing:
+    :return:
+    """
+    type_mapping = {
+        GenModelColumnType.BIGINT: 'int',
+        GenModelColumnType.BINARY: 'bytes',
+        GenModelColumnType.BIT: 'bool',
+        GenModelColumnType.BLOB: 'bytes',
+        GenModelColumnType.BOOL: 'bool',
+        GenModelColumnType.BOOLEAN: 'bool',
+        GenModelColumnType.CHAR: 'str',
+        GenModelColumnType.DATE: 'date',
+        GenModelColumnType.DATETIME: 'datetime',
+        GenModelColumnType.DECIMAL: 'Decimal',
+        GenModelColumnType.DOUBLE: 'float',
+        GenModelColumnType.ENUM: 'Enum',
+        GenModelColumnType.FLOAT: 'float',
+        GenModelColumnType.INT: 'int',
+        GenModelColumnType.INTEGER: 'int',
+        GenModelColumnType.JSON: 'dict',
+        GenModelColumnType.LONGBLOB: 'bytes',
+        GenModelColumnType.LONGTEXT: 'str',
+        GenModelColumnType.MEDIUMBLOB: 'bytes',
+        GenModelColumnType.MEDIUMINT: 'int',
+        GenModelColumnType.MEDIUMTEXT: 'str',
+        GenModelColumnType.NUMERIC: 'NUMERIC',
+        GenModelColumnType.SET: 'List[str]',
+        GenModelColumnType.SMALLINT: 'int',
+        GenModelColumnType.REAL: 'float',
+        GenModelColumnType.TEXT: 'str',
+        GenModelColumnType.TIME: 'time',
+        GenModelColumnType.TIMESTAMP: 'datetime',
+        GenModelColumnType.TINYBLOB: 'bytes',
+        GenModelColumnType.TINYINT: 'int',
+        GenModelColumnType.TINYTEXT: 'str',
+        GenModelColumnType.VARBINARY: 'bytes',
+        GenModelColumnType.VARCHAR: 'str',
+        GenModelColumnType.YEAR: 'int',
+    }
+    return type_mapping.get(typing, 'str')