初始提交
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
from importlib import import_module
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from config.database import async_engine
|
||||
from config.env import DataBaseConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agno.db.base import AsyncBaseDb
|
||||
from agno.models.base import Model
|
||||
|
||||
# 提供商名称 -> (模块路径, 类名) 的映射,延迟导入避免启动时加载所有AI SDK
|
||||
_PROVIDER_REGISTRY: dict[str, tuple[str, str]] = {
|
||||
'AIMLAPI': ('agno.models.aimlapi', 'AIMLAPI'),
|
||||
'Anthropic': ('agno.models.anthropic', 'Claude'),
|
||||
'Cerebras': ('agno.models.cerebras', 'Cerebras'),
|
||||
'CerebrasOpenAI': ('agno.models.cerebras', 'CerebrasOpenAI'),
|
||||
'Cohere': ('agno.models.cohere', 'Cohere'),
|
||||
'CometAPI': ('agno.models.cometapi', 'CometAPI'),
|
||||
'DashScope': ('agno.models.dashscope', 'DashScope'),
|
||||
'DeepInfra': ('agno.models.deepinfra', 'DeepInfra'),
|
||||
'DeepSeek': ('agno.models.deepseek', 'DeepSeek'),
|
||||
'Fireworks': ('agno.models.fireworks', 'Fireworks'),
|
||||
'Google': ('agno.models.google', 'Gemini'),
|
||||
'Groq': ('agno.models.groq', 'Groq'),
|
||||
'HuggingFace': ('agno.models.huggingface', 'HuggingFace'),
|
||||
'LangDB': ('agno.models.langdb', 'LangDB'),
|
||||
'LiteLLM': ('agno.models.litellm', 'LiteLLM'),
|
||||
'LiteLLMOpenAI': ('agno.models.litellm', 'LiteLLMOpenAI'),
|
||||
'LlamaCpp': ('agno.models.llama_cpp', 'LlamaCpp'),
|
||||
'LMStudio': ('agno.models.lmstudio', 'LMStudio'),
|
||||
'Meta': ('agno.models.meta', 'Llama'),
|
||||
'Mistral': ('agno.models.mistral', 'MistralChat'),
|
||||
'N1N': ('agno.models.n1n', 'N1N'),
|
||||
'Nebius': ('agno.models.nebius', 'Nebius'),
|
||||
'Nexus': ('agno.models.nexus', 'Nexus'),
|
||||
'Nvidia': ('agno.models.nvidia', 'Nvidia'),
|
||||
'Ollama': ('agno.models.ollama', 'Ollama'),
|
||||
'OpenAI': ('agno.models.openai', 'OpenAIChat'),
|
||||
'OpenAIResponses': ('agno.models.openai.responses', 'OpenAIResponses'),
|
||||
'OpenRouter': ('agno.models.openrouter', 'OpenRouter'),
|
||||
'Perplexity': ('agno.models.perplexity', 'Perplexity'),
|
||||
'Portkey': ('agno.models.portkey', 'Portkey'),
|
||||
'Requesty': ('agno.models.requesty', 'Requesty'),
|
||||
'Sambanova': ('agno.models.sambanova', 'Sambanova'),
|
||||
'SiliconFlow': ('agno.models.siliconflow', 'Siliconflow'),
|
||||
'Together': ('agno.models.together', 'Together'),
|
||||
'Vercel': ('agno.models.vercel', 'V0'),
|
||||
'VLLM': ('agno.models.vllm', 'VLLM'),
|
||||
'xAI': ('agno.models.xai', 'xAI'),
|
||||
}
|
||||
|
||||
# 存储引擎名称 -> (模块路径, 类名) 的映射
|
||||
_STORAGE_ENGINE_REGISTRY: dict[str, tuple[str, str]] = {
|
||||
'mysql': ('agno.db.mysql', 'AsyncMySQLDb'),
|
||||
'postgresql': ('agno.db.postgres', 'AsyncPostgresDb'),
|
||||
}
|
||||
|
||||
# 已加载的提供商类缓存,避免重复import_module
|
||||
_provider_class_cache: dict[str, 'type[Model]'] = {}
|
||||
_storage_class_cache: dict[str, 'type[AsyncBaseDb]'] = {}
|
||||
|
||||
|
||||
class AiUtil:
|
||||
"""
|
||||
AI工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def _resolve_provider_class(cls, provider: str) -> 'type[Model] | None':
|
||||
"""
|
||||
按需加载并缓存提供商模型类
|
||||
|
||||
:param provider: 提供商名称
|
||||
:return: 模型类,未找到返回None
|
||||
"""
|
||||
if provider in _provider_class_cache:
|
||||
return _provider_class_cache[provider]
|
||||
entry = _PROVIDER_REGISTRY.get(provider)
|
||||
if entry is None:
|
||||
return None
|
||||
module_path, class_name = entry
|
||||
provider_cls = getattr(import_module(module_path), class_name)
|
||||
_provider_class_cache[provider] = provider_cls
|
||||
return provider_cls
|
||||
|
||||
@classmethod
|
||||
def _resolve_storage_class(cls, db_type: str) -> 'type[AsyncBaseDb]':
|
||||
"""
|
||||
按需加载并缓存存储引擎类
|
||||
|
||||
:param db_type: 数据库类型
|
||||
:return: 存储引擎类
|
||||
"""
|
||||
if db_type in _storage_class_cache:
|
||||
return _storage_class_cache[db_type]
|
||||
entry = _STORAGE_ENGINE_REGISTRY.get(db_type)
|
||||
if entry is None:
|
||||
# 默认使用MySQL
|
||||
entry = _STORAGE_ENGINE_REGISTRY['mysql']
|
||||
module_path, class_name = entry
|
||||
storage_cls = getattr(import_module(module_path), class_name)
|
||||
_storage_class_cache[db_type] = storage_cls
|
||||
return storage_cls
|
||||
|
||||
@classmethod
|
||||
def get_storage_engine(cls) -> 'AsyncBaseDb':
|
||||
"""
|
||||
获取存储引擎实例
|
||||
|
||||
:return: 存储引擎实例
|
||||
"""
|
||||
storage_engine_class = cls._resolve_storage_class(DataBaseConfig.db_type)
|
||||
|
||||
return storage_engine_class(
|
||||
db_engine=async_engine,
|
||||
db_schema=DataBaseConfig.db_database if DataBaseConfig.db_type == 'mysql' else 'public',
|
||||
session_table='ai_sessions',
|
||||
memory_table='ai_memories',
|
||||
metrics_table='ai_metrics',
|
||||
eval_table='ai_eval_runs',
|
||||
knowledge_table='ai_knowledge',
|
||||
culture_table='ai_culture',
|
||||
traces_table='ai_traces',
|
||||
spans_table='ai_spans',
|
||||
versions_table='ai_schema_versions',
|
||||
create_schema=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_model_from_factory(
|
||||
cls,
|
||||
provider: str,
|
||||
model_code: str,
|
||||
model_name: str | None = None,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
**kwargs,
|
||||
) -> 'Model':
|
||||
"""
|
||||
从工厂获取模型实例
|
||||
|
||||
:param provider: 提供商
|
||||
:param model_code: 模型编码
|
||||
:param model_name: 模型名称
|
||||
:param api_key: API密钥
|
||||
:param base_url: 基础URL
|
||||
:param temperature: 温度
|
||||
:param max_tokens: 最大令牌数
|
||||
:return: 模型实例
|
||||
"""
|
||||
params = {
|
||||
'id': model_code,
|
||||
'name': model_name,
|
||||
'base_url': base_url,
|
||||
'api_key': api_key,
|
||||
'temperature': temperature,
|
||||
'max_tokens': max_tokens,
|
||||
**kwargs,
|
||||
}
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
if provider == 'Ollama':
|
||||
params['host'] = base_url
|
||||
if provider == 'DashScope' and not base_url:
|
||||
params['base_url'] = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
model_class = cls._resolve_provider_class(provider)
|
||||
if model_class is None:
|
||||
# 未知提供商,回退到OpenAI
|
||||
model_class = cls._resolve_provider_class('OpenAI')
|
||||
|
||||
return model_class(**params)
|
||||
@@ -0,0 +1,102 @@
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import TypeVar
|
||||
|
||||
from fastapi import Request
|
||||
from redis import asyncio as aioredis
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
from common.enums import HttpMethod
|
||||
from utils.log_util import logger
|
||||
|
||||
P = ParamSpec('P')
|
||||
R = TypeVar('R')
|
||||
|
||||
|
||||
class ApiAnnotationUtil:
|
||||
"""
|
||||
接口装饰器通用工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_request(cls, func: Callable[P, Awaitable[R]], *args: P.args, **kwargs: P.kwargs) -> Request | None:
|
||||
"""
|
||||
从被装饰函数的入参中提取Request对象
|
||||
|
||||
:param func: 被装饰的异步接口函数
|
||||
:param args: 位置参数
|
||||
:param kwargs: 关键字参数
|
||||
:return: Request对象,未找到时返回None
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
bound_arguments = signature.bind_partial(*args, **kwargs)
|
||||
for argument in bound_arguments.arguments.values():
|
||||
if isinstance(argument, Request):
|
||||
return argument
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_redis_client(cls, request: Request, skip_message: str) -> aioredis.Redis | None:
|
||||
"""
|
||||
从应用状态中获取Redis连接
|
||||
|
||||
:param request: 当前请求对象
|
||||
:param skip_message: 未初始化Redis连接时的日志信息
|
||||
:return: Redis连接对象,未初始化时返回None
|
||||
"""
|
||||
redis = getattr(request.app.state, 'redis', None)
|
||||
if redis is None:
|
||||
logger.warning(skip_message)
|
||||
|
||||
return redis
|
||||
|
||||
@classmethod
|
||||
def resolve_request_redis(
|
||||
cls,
|
||||
func: Callable[P, Awaitable[R]],
|
||||
skip_message: str,
|
||||
*args: P.args,
|
||||
**kwargs: P.kwargs,
|
||||
) -> tuple[Request | None, aioredis.Redis | None]:
|
||||
"""
|
||||
从被装饰函数入参中同时解析Request与Redis连接
|
||||
|
||||
:param func: 被装饰的异步接口函数
|
||||
:param skip_message: 未初始化Redis连接时的日志信息
|
||||
:param args: 位置参数
|
||||
:param kwargs: 关键字参数
|
||||
:return: Request对象与Redis连接对象组成的元组
|
||||
"""
|
||||
request = cls.get_request(func, *args, **kwargs)
|
||||
if request is None:
|
||||
return None, None
|
||||
|
||||
return request, cls.get_redis_client(request, skip_message)
|
||||
|
||||
@classmethod
|
||||
def normalize_http_methods(
|
||||
cls,
|
||||
methods: Sequence[HttpMethod] | None,
|
||||
default_methods: Sequence[HttpMethod] | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
"""
|
||||
标准化HTTP请求方法配置
|
||||
|
||||
:param methods: 显式配置的HTTP请求方法
|
||||
:param default_methods: methods为空时使用的默认HTTP请求方法
|
||||
:return: 去重且标准化后的HTTP请求方法元组
|
||||
"""
|
||||
target_methods = methods if methods is not None else default_methods
|
||||
if not target_methods:
|
||||
return ()
|
||||
|
||||
normalized_methods: list[str] = []
|
||||
for method in target_methods:
|
||||
if not isinstance(method, HttpMethod):
|
||||
raise TypeError('methods参数仅支持HttpMethod枚举')
|
||||
normalized_method = method.value
|
||||
if normalized_method not in normalized_methods:
|
||||
normalized_methods.append(normalized_method)
|
||||
|
||||
return tuple(normalized_methods)
|
||||
@@ -0,0 +1,25 @@
|
||||
from collections.abc import Mapping
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
class ApiResponseHeaderUtil:
|
||||
"""
|
||||
接口响应头通用工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def merge_headers(cls, request: Request, headers: Mapping[str, str] | None) -> None:
|
||||
"""
|
||||
将响应头暂存到请求上下文中,供出站中间件统一追加
|
||||
|
||||
:param request: 当前请求对象
|
||||
:param headers: 需要追加的响应头
|
||||
:return: None
|
||||
"""
|
||||
if not headers:
|
||||
return
|
||||
|
||||
api_response_headers = dict(getattr(request.state, 'api_response_headers', {}))
|
||||
api_response_headers.update(headers)
|
||||
request.state.api_response_headers = api_response_headers
|
||||
@@ -0,0 +1,60 @@
|
||||
from fastapi import Request
|
||||
|
||||
from config.env import AppConfig
|
||||
|
||||
|
||||
class ClientIPUtil:
|
||||
"""
|
||||
客户端IP提取工具
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_client_ip(cls, request: Request) -> str:
|
||||
"""
|
||||
获取客户端真实IP
|
||||
|
||||
仅当请求来源命中可信代理列表,且可信代理跳数大于0时,才会解析
|
||||
X-Forwarded-For / X-Real-IP 请求头;否则回退到直接连接来源地址。
|
||||
|
||||
:param request: 当前请求对象
|
||||
:return: 客户端IP
|
||||
"""
|
||||
remote_addr = request.client.host if request.client else 'unknown'
|
||||
if AppConfig.app_trusted_proxy_hops <= 0:
|
||||
return remote_addr
|
||||
if not cls._should_trust_proxy_headers(remote_addr):
|
||||
return remote_addr
|
||||
|
||||
forwarded_for = request.headers.get('X-Forwarded-For', '')
|
||||
if forwarded_for:
|
||||
forwarded_chain = [item.strip() for item in forwarded_for.split(',') if item.strip()]
|
||||
if forwarded_chain:
|
||||
if len(forwarded_chain) > AppConfig.app_trusted_proxy_hops:
|
||||
return forwarded_chain[-(AppConfig.app_trusted_proxy_hops + 1)]
|
||||
return forwarded_chain[0]
|
||||
|
||||
real_ip = request.headers.get('X-Real-IP', '').strip()
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
return remote_addr
|
||||
|
||||
@classmethod
|
||||
def _should_trust_proxy_headers(cls, remote_addr: str) -> bool:
|
||||
"""
|
||||
判断当前连接来源是否属于可信代理
|
||||
|
||||
:param remote_addr: 与应用直接建立连接的来源IP
|
||||
:return: 是否信任代理头
|
||||
"""
|
||||
trusted_proxy_ips = cls._get_trusted_proxy_ips()
|
||||
return '*' in trusted_proxy_ips or remote_addr in trusted_proxy_ips
|
||||
|
||||
@classmethod
|
||||
def _get_trusted_proxy_ips(cls) -> set[str]:
|
||||
"""
|
||||
获取可信代理IP集合
|
||||
|
||||
:return: 可信代理IP集合
|
||||
"""
|
||||
return {item.strip() for item in AppConfig.app_trusted_proxy_ips.split(',') if item.strip()}
|
||||
@@ -0,0 +1,403 @@
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Generator, Sequence
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
import pandas as pd
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
from sqlalchemy.engine.row import Row
|
||||
from sqlalchemy.orm.collections import InstrumentedList
|
||||
from sqlalchemy.sql.expression import TextClause, null
|
||||
|
||||
from config.database import Base
|
||||
from config.env import CachePathConfig
|
||||
from utils.log_util import logger
|
||||
|
||||
|
||||
def worship() -> None:
|
||||
logger.info(r"""
|
||||
////////////////////////////////////////////////////////////////////
|
||||
// _ooOoo_ //
|
||||
// o8888888o //
|
||||
// 88" . "88 //
|
||||
// (| ^_^ |) //
|
||||
// O\ = /O //
|
||||
// ____/`---'\____ //
|
||||
// .' \\| |// `. //
|
||||
// / \\||| : |||// \ //
|
||||
// / _||||| -:- |||||- \ //
|
||||
// | | \\\ - /// | | //
|
||||
// | \_| ''\---/'' | | //
|
||||
// \ .-\__ `-` ___/-. / //
|
||||
// ___`. .' /--.--\ `. . ___ //
|
||||
// ."" '< `.___\_<|>_/___.' >'"". //
|
||||
// | | : `- \`.;`\ _ /`;.`/ - ` : | | //
|
||||
// \ \ `-. \_ __\ /__ _/ .-` / / //
|
||||
// ========`-.____`-.___\_____/___.-`____.-'======== //
|
||||
// `=---=' //
|
||||
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ //
|
||||
// 佛祖保佑 永不宕机 永无BUG //
|
||||
////////////////////////////////////////////////////////////////////
|
||||
""")
|
||||
|
||||
|
||||
class SqlalchemyUtil:
|
||||
"""
|
||||
sqlalchemy工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def base_to_dict(
|
||||
cls, obj: Base | dict, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case'
|
||||
) -> dict:
|
||||
"""
|
||||
将sqlalchemy模型对象转换为字典
|
||||
|
||||
:param obj: sqlalchemy模型对象或普通字典
|
||||
:param transform_case: 转换得到的结果形式,可选的有'no_case'(不转换)、'snake_to_camel'(下划线转小驼峰)、'camel_to_snake'(小驼峰转下划线),默认为'no_case'
|
||||
:return: 字典结果
|
||||
"""
|
||||
if isinstance(obj, Base):
|
||||
base_dict = obj.__dict__.copy()
|
||||
base_dict.pop('_sa_instance_state', None)
|
||||
for name, value in base_dict.items():
|
||||
if isinstance(value, InstrumentedList):
|
||||
base_dict[name] = cls.serialize_result(value, 'snake_to_camel')
|
||||
elif isinstance(obj, dict):
|
||||
base_dict = obj.copy()
|
||||
if transform_case == 'snake_to_camel':
|
||||
return {CamelCaseUtil.snake_to_camel(k): v for k, v in base_dict.items()}
|
||||
if transform_case == 'camel_to_snake':
|
||||
return {SnakeCaseUtil.camel_to_snake(k): v for k, v in base_dict.items()}
|
||||
|
||||
return base_dict
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def serialize_result(
|
||||
cls, result: Base, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case'
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def serialize_result(
|
||||
cls, result: dict, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case'
|
||||
) -> dict[Any, Any]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def serialize_result(
|
||||
cls, result: Row, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case'
|
||||
) -> dict[str, Any] | list[dict[Any, Any]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def serialize_result(
|
||||
cls, result: Sequence[Base], transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case'
|
||||
) -> list[dict[str, Any]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def serialize_result(
|
||||
cls, result: Sequence[dict], transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case'
|
||||
) -> list[dict[Any, Any]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def serialize_result(
|
||||
cls, result: Sequence[Row], transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case'
|
||||
) -> list[dict[str, Any] | list[dict[Any, Any]]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def serialize_result(
|
||||
cls, result: Sequence[Any], transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case'
|
||||
) -> list[Any]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def serialize_result(
|
||||
cls, result: Any, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case'
|
||||
) -> Any: ...
|
||||
|
||||
@classmethod
|
||||
def serialize_result(
|
||||
cls, result: Any, transform_case: Literal['no_case', 'snake_to_camel', 'camel_to_snake'] = 'no_case'
|
||||
) -> Any:
|
||||
"""
|
||||
将sqlalchemy查询结果序列化
|
||||
|
||||
:param result: sqlalchemy查询结果
|
||||
:param transform_case: 转换得到的结果形式,可选的有'no_case'(不转换)、'snake_to_camel'(下划线转小驼峰)、'camel_to_snake'(小驼峰转下划线),默认为'no_case'
|
||||
:return: 序列化结果
|
||||
"""
|
||||
if isinstance(result, (Base, dict)):
|
||||
return cls.base_to_dict(result, transform_case)
|
||||
if isinstance(result, list):
|
||||
return [cls.serialize_result(row, transform_case) for row in result]
|
||||
if isinstance(result, Row):
|
||||
if all(isinstance(row, Base) for row in result):
|
||||
return [cls.base_to_dict(row, transform_case) for row in result]
|
||||
if any(isinstance(row, Base) for row in result):
|
||||
return [cls.serialize_result(row, transform_case) for row in result]
|
||||
result_dict = result._asdict()
|
||||
if transform_case == 'snake_to_camel':
|
||||
return {CamelCaseUtil.snake_to_camel(k): v for k, v in result_dict.items()}
|
||||
if transform_case == 'camel_to_snake':
|
||||
return {SnakeCaseUtil.camel_to_snake(k): v for k, v in result_dict.items()}
|
||||
return result_dict
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def get_server_default_null(cls, dialect_name: str, need_explicit_null: bool = True) -> TextClause | None:
|
||||
"""
|
||||
根据数据库方言动态返回值为null的server_default
|
||||
|
||||
:param dialect_name: 数据库方言名称
|
||||
:param need_explicit_null: 是否需要显式DEFAULT NULL
|
||||
:return: 不同数据库方言对应的null_server_default
|
||||
"""
|
||||
if need_explicit_null and dialect_name == 'postgresql':
|
||||
return null()
|
||||
return None
|
||||
|
||||
|
||||
class CamelCaseUtil:
|
||||
"""
|
||||
下划线形式(snake_case)转小驼峰形式(camelCase)工具方法
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def snake_to_camel(cls, snake_str: str) -> str:
|
||||
"""
|
||||
下划线形式字符串(snake_case)转换为小驼峰形式字符串(camelCase)
|
||||
|
||||
:param snake_str: 下划线形式字符串
|
||||
:return: 小驼峰形式字符串
|
||||
"""
|
||||
# 分割字符串
|
||||
words = snake_str.split('_')
|
||||
# 小驼峰命名,第一个词首字母小写,其余词首字母大写
|
||||
return words[0] + ''.join(word.capitalize() for word in words[1:])
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Base) -> dict[str, Any]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: dict) -> dict[Any, Any]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Row) -> dict[str, Any] | list[dict[Any, Any]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Sequence[Base]) -> list[dict[str, Any]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Sequence[dict]) -> list[dict[Any, Any]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Sequence[Row]) -> list[dict[str, Any] | list[dict[Any, Any]]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Sequence[Any]) -> list[Any]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Any) -> Any: ...
|
||||
|
||||
@classmethod
|
||||
def transform_result(cls, result: Any) -> Any:
|
||||
"""
|
||||
针对不同类型将下划线形式(snake_case)批量转换为小驼峰形式(camelCase)方法
|
||||
|
||||
:param result: 输入数据
|
||||
:return: 小驼峰形式结果
|
||||
"""
|
||||
return SqlalchemyUtil.serialize_result(result=result, transform_case='snake_to_camel')
|
||||
|
||||
|
||||
class SnakeCaseUtil:
|
||||
"""
|
||||
小驼峰形式(camelCase)转下划线形式(snake_case)工具方法
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def camel_to_snake(cls, camel_str: str) -> str:
|
||||
"""
|
||||
小驼峰形式字符串(camelCase)转换为下划线形式字符串(snake_case)
|
||||
|
||||
:param camel_str: 小驼峰形式字符串
|
||||
:return: 下划线形式字符串
|
||||
"""
|
||||
# 在大写字母前添加一个下划线,然后将整个字符串转为小写
|
||||
words = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', camel_str)
|
||||
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', words).lower()
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Base) -> dict[str, Any]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: dict) -> dict[Any, Any]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Row) -> dict[str, Any] | list[dict[Any, Any]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Sequence[Base]) -> list[dict[str, Any]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Sequence[dict]) -> list[dict[Any, Any]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Sequence[Row]) -> list[dict[str, Any] | list[dict[Any, Any]]]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Sequence[Any]) -> list[Any]: ...
|
||||
|
||||
@classmethod
|
||||
@overload
|
||||
def transform_result(cls, result: Any) -> Any: ...
|
||||
|
||||
@classmethod
|
||||
def transform_result(cls, result: Any) -> Any:
|
||||
"""
|
||||
针对不同类型将小驼峰形式(camelCase)批量转换为下划线形式(snake_case)方法
|
||||
|
||||
:param result: 输入数据
|
||||
:return: 下划线形式结果
|
||||
"""
|
||||
return SqlalchemyUtil.serialize_result(result=result, transform_case='camel_to_snake')
|
||||
|
||||
|
||||
def bytes2human(n: int, format_str: str = '%(value).1f%(symbol)s') -> str:
|
||||
"""Used by various scripts. See:
|
||||
http://goo.gl/zeJZl
|
||||
|
||||
>>> bytes2human(10000)
|
||||
'9.8K'
|
||||
>>> bytes2human(100001221)
|
||||
'95.4M'
|
||||
"""
|
||||
symbols = ('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB')
|
||||
prefix = {}
|
||||
for i, s in enumerate(symbols[1:]):
|
||||
prefix[s] = 1 << (i + 1) * 10
|
||||
for symbol in reversed(symbols[1:]):
|
||||
if n >= prefix[symbol]:
|
||||
value = float(n) / prefix[symbol]
|
||||
return format_str % locals()
|
||||
return format_str % {'symbol': symbols[0], 'value': n}
|
||||
|
||||
|
||||
def bytes2file_response(bytes_info: bytes) -> Generator[bytes]:
|
||||
yield bytes_info
|
||||
|
||||
|
||||
def export_list2excel(list_data: list) -> bytes:
|
||||
"""
|
||||
工具方法:将需要导出的list数据转化为对应excel的二进制数据
|
||||
|
||||
:param list_data: 数据列表
|
||||
:return: 字典信息对应excel的二进制数据
|
||||
"""
|
||||
df = pd.DataFrame(list_data)
|
||||
binary_data = io.BytesIO()
|
||||
df.to_excel(binary_data, index=False, engine='openpyxl')
|
||||
binary_data = binary_data.getvalue()
|
||||
|
||||
return binary_data
|
||||
|
||||
|
||||
def get_excel_template(header_list: list, selector_header_list: list, option_list: list[dict]) -> bytes:
|
||||
"""
|
||||
工具方法:将需要导出的list数据转化为对应excel的二进制数据
|
||||
|
||||
:param header_list: 表头数据列表
|
||||
:param selector_header_list: 需要设置为选择器格式的表头数据列表
|
||||
:param option_list: 选择器格式的表头预设的选项列表
|
||||
:return: 模板excel的二进制数据
|
||||
"""
|
||||
# 创建Excel工作簿
|
||||
wb = Workbook()
|
||||
# 选择默认的活动工作表
|
||||
ws = wb.active
|
||||
|
||||
# 设置表头文字
|
||||
headers = header_list
|
||||
|
||||
# 设置表头背景样式为灰色,前景色为白色
|
||||
header_fill = PatternFill(start_color='ababab', end_color='ababab', fill_type='solid')
|
||||
|
||||
# 将表头写入第一行
|
||||
for col_num, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_num)
|
||||
cell.value = header
|
||||
cell.fill = header_fill
|
||||
# 设置列宽度为16
|
||||
ws.column_dimensions[chr(64 + col_num)].width = 12
|
||||
# 设置水平居中对齐
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
|
||||
# 设置选择器的预设选项
|
||||
options = option_list
|
||||
|
||||
# 获取selector_header的字母索引
|
||||
for selector_header in selector_header_list:
|
||||
column_selector_header_index = headers.index(selector_header) + 1
|
||||
|
||||
# 创建数据有效性规则
|
||||
header_option = []
|
||||
for option in options:
|
||||
if option.get(selector_header):
|
||||
header_option = option.get(selector_header)
|
||||
dv = DataValidation(type='list', formula1=f'"{",".join(header_option)}"')
|
||||
# 设置数据有效性规则的起始单元格和结束单元格
|
||||
dv.add(
|
||||
f'{get_column_letter(column_selector_header_index)}2:{get_column_letter(column_selector_header_index)}1048576'
|
||||
)
|
||||
# 添加数据有效性规则到工作表
|
||||
ws.add_data_validation(dv)
|
||||
|
||||
# 保存Excel文件为字节类型的数据
|
||||
file = io.BytesIO()
|
||||
wb.save(file)
|
||||
file.seek(0)
|
||||
|
||||
# 读取字节数据
|
||||
excel_data = file.getvalue()
|
||||
|
||||
return excel_data
|
||||
|
||||
|
||||
def get_filepath_from_url(url: str) -> str:
|
||||
"""
|
||||
工具方法:根据请求参数获取文件路径
|
||||
|
||||
:param url: 请求参数中的url参数
|
||||
:return: 文件路径
|
||||
"""
|
||||
file_info = url.split('?')[1].split('&')
|
||||
task_id = file_info[0].split('=')[1]
|
||||
file_name = file_info[1].split('=')[1]
|
||||
task_path = file_info[2].split('=')[1]
|
||||
filepath = os.path.join(CachePathConfig.PATH, task_path, task_id, file_name)
|
||||
|
||||
return filepath
|
||||
@@ -0,0 +1,166 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class CronUtil:
|
||||
"""
|
||||
Cron表达式工具类
|
||||
"""
|
||||
|
||||
ONE_YEAR_LENGTH = 4
|
||||
MAX_YEAR = 2099
|
||||
CRON_EXPRESSION_LENGTH_MIN = 6
|
||||
CRON_EXPRESSION_LENGTH_MAX = 7
|
||||
|
||||
@classmethod
|
||||
def __valid_range(cls, search_str: str, start_range: int, end_range: int) -> bool:
|
||||
match = re.match(r'^(\d+)-(\d+)$', search_str)
|
||||
if match:
|
||||
start, end = int(match.group(1)), int(match.group(2))
|
||||
return start_range <= start < end <= end_range
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def __valid_sum(
|
||||
cls, search_str: str, start_range_a: int, start_range_b: int, end_range_a: int, end_range_b: int, sum_range: int
|
||||
) -> bool:
|
||||
match = re.match(r'^(\d+)/(\d+)$', search_str)
|
||||
if match:
|
||||
start, end = int(match.group(1)), int(match.group(2))
|
||||
return (
|
||||
start_range_a <= start <= start_range_b
|
||||
and end_range_a <= end <= end_range_b
|
||||
and start + end <= sum_range
|
||||
)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def validate_second_or_minute(cls, second_or_minute: str) -> bool:
|
||||
"""
|
||||
校验秒或分钟值是否正确
|
||||
|
||||
:param second_or_minute: 秒或分钟值
|
||||
:return: 校验结果
|
||||
"""
|
||||
return bool(
|
||||
second_or_minute == '*'
|
||||
or ('-' in second_or_minute and cls.__valid_range(second_or_minute, 0, 59))
|
||||
or ('/' in second_or_minute and cls.__valid_sum(second_or_minute, 0, 58, 1, 59, 59))
|
||||
or re.match(r'^(?:[0-5]?\d|59)(?:,[0-5]?\d|59)*$', second_or_minute)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_hour(cls, hour: str) -> bool:
|
||||
"""
|
||||
校验小时值是否正确
|
||||
|
||||
:param hour: 小时值
|
||||
:return: 校验结果
|
||||
"""
|
||||
return bool(
|
||||
hour == '*'
|
||||
or ('-' in hour and cls.__valid_range(hour, 0, 23))
|
||||
or ('/' in hour and cls.__valid_sum(hour, 0, 22, 1, 23, 23))
|
||||
or re.match(r'^(?:0|[1-9]|1\d|2[0-3])(?:,(?:0|[1-9]|1\d|2[0-3]))*$', hour)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_day(cls, day: str) -> bool:
|
||||
"""
|
||||
校验日值是否正确
|
||||
|
||||
:param day: 日值
|
||||
:return: 校验结果
|
||||
"""
|
||||
return bool(
|
||||
day in ['*', '?', 'L']
|
||||
or ('-' in day and cls.__valid_range(day, 1, 31))
|
||||
or ('/' in day and cls.__valid_sum(day, 1, 30, 1, 30, 31))
|
||||
or ('W' in day and re.match(r'^(?:[1-9]|1\d|2\d|3[01])W$', day))
|
||||
or re.match(r'^(?:0|[1-9]|1\d|2[0-9]|3[0-1])(?:,(?:0|[1-9]|1\d|2[0-9]|3[0-1]))*$', day)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_month(cls, month: str) -> bool:
|
||||
"""
|
||||
校验月值是否正确
|
||||
|
||||
:param month: 月值
|
||||
:return: 校验结果
|
||||
"""
|
||||
return bool(
|
||||
month == '*'
|
||||
or ('-' in month and cls.__valid_range(month, 1, 12))
|
||||
or ('/' in month and cls.__valid_sum(month, 1, 11, 1, 11, 12))
|
||||
or re.match(r'^(?:0|[1-9]|1[0-2])(?:,(?:0|[1-9]|1[0-2]))*$', month)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_week(cls, week: str) -> bool:
|
||||
"""
|
||||
校验周值是否正确
|
||||
|
||||
:param week: 周值
|
||||
:return: 校验结果
|
||||
"""
|
||||
return bool(
|
||||
week in ['*', '?']
|
||||
or ('-' in week and cls.__valid_range(week, 1, 7))
|
||||
or ('#' in week and re.match(r'^[1-7]#[1-4]$', week))
|
||||
or ('L' in week and re.match(r'^[1-7]L$', week))
|
||||
or re.match(r'^[1-7](?:(,[1-7]))*$', week)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_year(cls, year: str) -> bool:
|
||||
"""
|
||||
校验年值是否正确
|
||||
|
||||
:param year: 年值
|
||||
:return: 校验结果
|
||||
"""
|
||||
current_year = int(datetime.now().year)
|
||||
future_years = [current_year + i for i in range(9)]
|
||||
return bool(
|
||||
year == '*'
|
||||
or ('-' in year and cls.__valid_range(year, current_year, 2099))
|
||||
or ('/' in year and cls.__valid_sum(year, current_year, 2098, 1, 2099 - current_year, 2099))
|
||||
or ('#' in year and re.match(r'^[1-7]#[1-4]$', year))
|
||||
or ('L' in year and re.match(r'^[1-7]L$', year))
|
||||
or (
|
||||
(len(year) == cls.ONE_YEAR_LENGTH or ',' in year)
|
||||
and all(
|
||||
int(item) in future_years and current_year <= int(item) <= cls.MAX_YEAR for item in year.split(',')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_cron_expression(cls, cron_expression: str) -> bool:
|
||||
"""
|
||||
校验Cron表达式是否正确
|
||||
|
||||
:param cron_expression: Cron表达式
|
||||
:return: 校验结果
|
||||
"""
|
||||
values = cron_expression.split()
|
||||
if len(values) != cls.CRON_EXPRESSION_LENGTH_MIN and len(values) != cls.CRON_EXPRESSION_LENGTH_MAX:
|
||||
return False
|
||||
second_validation = cls.validate_second_or_minute(values[0])
|
||||
minute_validation = cls.validate_second_or_minute(values[1])
|
||||
hour_validation = cls.validate_hour(values[2])
|
||||
day_validation = cls.validate_day(values[3])
|
||||
month_validation = cls.validate_month(values[4])
|
||||
week_validation = cls.validate_week(values[5])
|
||||
validation = (
|
||||
second_validation
|
||||
and minute_validation
|
||||
and hour_validation
|
||||
and day_validation
|
||||
and month_validation
|
||||
and week_validation
|
||||
)
|
||||
if len(values) == cls.CRON_EXPRESSION_LENGTH_MIN:
|
||||
return validation
|
||||
year_validation = cls.validate_year(values[6])
|
||||
return validation and year_validation
|
||||
@@ -0,0 +1,58 @@
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from config.env import JwtConfig
|
||||
from exceptions.exception import ServiceException
|
||||
|
||||
|
||||
class CryptoUtil:
|
||||
"""
|
||||
加解密工具类
|
||||
"""
|
||||
|
||||
_cipher_suite = None
|
||||
|
||||
@classmethod
|
||||
def _get_cipher_suite(cls) -> Fernet:
|
||||
if cls._cipher_suite is None:
|
||||
# 使用 JWT secret key 派生 Fernet key
|
||||
# SHA256 hash (32 bytes) -> Base64 encoded (44 bytes) -> Fernet key
|
||||
key = base64.urlsafe_b64encode(hashlib.sha256(JwtConfig.jwt_secret_key.encode()).digest())
|
||||
cls._cipher_suite = Fernet(key)
|
||||
return cls._cipher_suite
|
||||
|
||||
@classmethod
|
||||
def encrypt(cls, data: str) -> str:
|
||||
"""
|
||||
加密字符串
|
||||
|
||||
:param data: 明文
|
||||
:return: 密文
|
||||
"""
|
||||
if not data:
|
||||
return data
|
||||
try:
|
||||
cipher_suite = cls._get_cipher_suite()
|
||||
encrypted_bytes = cipher_suite.encrypt(data.encode('utf-8'))
|
||||
return encrypted_bytes.decode('utf-8')
|
||||
except Exception as e:
|
||||
raise ServiceException('加密失败') from e
|
||||
|
||||
@classmethod
|
||||
def decrypt(cls, token: str) -> str:
|
||||
"""
|
||||
解密字符串
|
||||
|
||||
:param token: 密文
|
||||
:return: 明文
|
||||
"""
|
||||
if not token:
|
||||
return token
|
||||
try:
|
||||
cipher_suite = cls._get_cipher_suite()
|
||||
decrypted_bytes = cipher_suite.decrypt(token.encode('utf-8'))
|
||||
return decrypted_bytes.decode('utf-8')
|
||||
except Exception as e:
|
||||
raise ServiceException('解密失败') from e
|
||||
@@ -0,0 +1,49 @@
|
||||
from fastapi import Request
|
||||
|
||||
from common.context import RequestContext
|
||||
from config.env import AppConfig
|
||||
from exceptions.exception import PermissionException
|
||||
|
||||
|
||||
class DependencyUtil:
|
||||
"""
|
||||
依赖项工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def check_exclude_routes(cls, request: Request, err_msg: str = '当前路由不在认证规则内,不可使用该依赖项') -> None:
|
||||
"""
|
||||
检查路径和方法是否匹配排除路由模式
|
||||
|
||||
:param request: 请求对象
|
||||
:param err_msg: 错误信息
|
||||
:return: None
|
||||
"""
|
||||
# 获取当前请求路径和方法
|
||||
path = request.url.path
|
||||
method = request.method.upper()
|
||||
|
||||
# 从配置中获取APP_ROOT_PATH
|
||||
app_root_path = AppConfig.app_root_path
|
||||
|
||||
# 去掉APP_ROOT_PATH前缀
|
||||
if app_root_path and path.startswith(app_root_path):
|
||||
path = path[len(app_root_path) :]
|
||||
|
||||
# 获取编译后的排除路由模式列表
|
||||
exclude_patterns = RequestContext.get_current_exclude_patterns()
|
||||
|
||||
# 检查当前路由是否在排除路由列表中
|
||||
if path and method and exclude_patterns:
|
||||
for item in exclude_patterns:
|
||||
pattern = item['pattern']
|
||||
exclude_methods = item['methods']
|
||||
ignore_paths = item['ignore_paths']
|
||||
|
||||
# 检查当前路径是否在忽略列表中
|
||||
if path in ignore_paths:
|
||||
continue
|
||||
|
||||
# 检查路径是否匹配,并且methods为空列表(匹配所有方法)或者当前方法在允许列表中
|
||||
if pattern.match(path) and (not exclude_methods or method in exclude_methods):
|
||||
raise PermissionException(data='', message=err_msg)
|
||||
@@ -0,0 +1,104 @@
|
||||
import io
|
||||
|
||||
import pandas as pd
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.worksheet.datavalidation import DataValidation
|
||||
|
||||
|
||||
class ExcelUtil:
|
||||
"""
|
||||
Excel操作类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def __mapping_list(cls, list_data: list, mapping_dict: dict) -> list[dict]:
|
||||
"""
|
||||
工具方法:将list数据中的字段名映射为对应的中文字段名
|
||||
|
||||
:param list_data: 数据列表
|
||||
:param mapping_dict: 映射字典
|
||||
:return: 映射后的数据列表
|
||||
"""
|
||||
mapping_data = [{mapping_dict.get(key): item.get(key) for key in mapping_dict} for item in list_data]
|
||||
|
||||
return mapping_data
|
||||
|
||||
@classmethod
|
||||
def export_list2excel(cls, list_data: list, mapping_dict: dict) -> bytes:
|
||||
"""
|
||||
工具方法:将需要导出的list数据转化为对应excel的二进制数据
|
||||
|
||||
:param list_data: 数据列表
|
||||
:param mapping_dict: 映射字典
|
||||
:return: list数据对应excel的二进制数据
|
||||
"""
|
||||
mapping_data = cls.__mapping_list(list_data, mapping_dict)
|
||||
df = pd.DataFrame(mapping_data)
|
||||
binary_data = io.BytesIO()
|
||||
df.to_excel(binary_data, index=False, engine='openpyxl')
|
||||
binary_data = binary_data.getvalue()
|
||||
|
||||
return binary_data
|
||||
|
||||
@classmethod
|
||||
def get_excel_template(cls, header_list: list, selector_header_list: list, option_list: list[dict]) -> bytes:
|
||||
"""
|
||||
工具方法:将需要导出的list数据转化为对应excel的二进制数据
|
||||
|
||||
:param header_list: 表头数据列表
|
||||
:param selector_header_list: 需要设置为选择器格式的表头数据列表
|
||||
:param option_list: 选择器格式的表头预设的选项列表
|
||||
:return: 模板excel的二进制数据
|
||||
"""
|
||||
# 创建Excel工作簿
|
||||
wb = Workbook()
|
||||
# 选择默认的活动工作表
|
||||
ws = wb.active
|
||||
|
||||
# 设置表头文字
|
||||
headers = header_list
|
||||
|
||||
# 设置表头背景样式为灰色,前景色为白色
|
||||
header_fill = PatternFill(start_color='ababab', end_color='ababab', fill_type='solid')
|
||||
|
||||
# 将表头写入第一行
|
||||
for col_num, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_num)
|
||||
cell.value = header
|
||||
cell.fill = header_fill
|
||||
# 设置列宽度为16
|
||||
ws.column_dimensions[chr(64 + col_num)].width = 12
|
||||
# 设置水平居中对齐
|
||||
cell.alignment = Alignment(horizontal='center')
|
||||
|
||||
# 设置选择器的预设选项
|
||||
options = option_list
|
||||
|
||||
# 获取selector_header的字母索引
|
||||
for selector_header in selector_header_list:
|
||||
column_selector_header_index = headers.index(selector_header) + 1
|
||||
|
||||
# 创建数据有效性规则
|
||||
header_option = []
|
||||
for option in options:
|
||||
if option.get(selector_header):
|
||||
header_option = option.get(selector_header)
|
||||
dv = DataValidation(type='list', formula1=f'"{",".join(header_option)}"')
|
||||
# 设置数据有效性规则的起始单元格和结束单元格
|
||||
dv.add(
|
||||
f'{get_column_letter(column_selector_header_index)}2:{get_column_letter(column_selector_header_index)}1048576'
|
||||
)
|
||||
# 添加数据有效性规则到工作表
|
||||
ws.add_data_validation(dv)
|
||||
|
||||
# 保存Excel文件为字节类型的数据
|
||||
file = io.BytesIO()
|
||||
wb.save(file)
|
||||
file.seek(0)
|
||||
|
||||
# 读取字节数据
|
||||
excel_data = file.getvalue()
|
||||
|
||||
return excel_data
|
||||
@@ -0,0 +1,226 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from common.constant import GenConstant
|
||||
from config.env import GenConfig
|
||||
from module_generator.entity.vo.gen_vo import GenTableColumnModel, GenTableModel
|
||||
from utils.string_util import StringUtil
|
||||
|
||||
|
||||
class GenUtils:
|
||||
"""代码生成器工具类"""
|
||||
|
||||
TEXTAREA_COLUMN_LENGTH = 500
|
||||
|
||||
@classmethod
|
||||
def init_table(cls, gen_table: GenTableModel, oper_name: str) -> None:
|
||||
"""
|
||||
初始化表信息
|
||||
|
||||
param gen_table: 业务表对象
|
||||
param oper_name: 操作人
|
||||
:return:
|
||||
"""
|
||||
gen_table.class_name = cls.convert_class_name(gen_table.table_name)
|
||||
gen_table.package_name = GenConfig.package_name
|
||||
gen_table.module_name = cls.get_module_name(GenConfig.package_name)
|
||||
gen_table.business_name = cls.get_business_name(gen_table.table_name)
|
||||
gen_table.function_name = cls.replace_text(gen_table.table_comment)
|
||||
gen_table.function_author = GenConfig.author
|
||||
gen_table.create_by = oper_name
|
||||
gen_table.create_time = datetime.now()
|
||||
gen_table.update_by = oper_name
|
||||
gen_table.update_time = datetime.now()
|
||||
|
||||
@classmethod
|
||||
def init_column_field(cls, column: GenTableColumnModel, table: GenTableModel) -> None:
|
||||
"""
|
||||
初始化列属性字段
|
||||
|
||||
param column: 业务表字段对象
|
||||
param table: 业务表对象
|
||||
:return:
|
||||
"""
|
||||
data_type = cls.get_db_type(column.column_type)
|
||||
column_name = column.column_name
|
||||
column.table_id = table.table_id
|
||||
column.create_by = table.create_by
|
||||
# 设置Python字段名
|
||||
column.python_field = cls.to_camel_case(column_name)
|
||||
# 设置默认类型
|
||||
column.python_type = StringUtil.get_mapping_value_by_key_ignore_case(
|
||||
GenConstant.DB_TO_PYTHON_TYPE_MAPPING, data_type
|
||||
)
|
||||
column.query_type = GenConstant.QUERY_EQ
|
||||
|
||||
if cls.arrays_contains(GenConstant.COLUMNTYPE_STR, data_type) or cls.arrays_contains(
|
||||
GenConstant.COLUMNTYPE_TEXT, data_type
|
||||
):
|
||||
# 字符串长度超过500设置为文本域
|
||||
column_length = cls.get_column_length(column.column_type)
|
||||
html_type = (
|
||||
GenConstant.HTML_TEXTAREA
|
||||
if column_length >= cls.TEXTAREA_COLUMN_LENGTH
|
||||
or cls.arrays_contains(GenConstant.COLUMNTYPE_TEXT, data_type)
|
||||
else GenConstant.HTML_INPUT
|
||||
)
|
||||
column.html_type = html_type
|
||||
elif cls.arrays_contains(GenConstant.COLUMNTYPE_TIME, data_type):
|
||||
column.html_type = GenConstant.HTML_DATETIME
|
||||
elif cls.arrays_contains(GenConstant.COLUMNTYPE_NUMBER, data_type):
|
||||
column.html_type = GenConstant.HTML_INPUT
|
||||
|
||||
# 插入字段(默认所有字段都需要插入)
|
||||
column.is_insert = GenConstant.REQUIRE
|
||||
|
||||
# 编辑字段
|
||||
if not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_EDIT, column_name) and not column.pk:
|
||||
column.is_edit = GenConstant.REQUIRE
|
||||
# 列表字段
|
||||
if not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_LIST, column_name) and not column.pk:
|
||||
column.is_list = GenConstant.REQUIRE
|
||||
# 查询字段
|
||||
if not cls.arrays_contains(GenConstant.COLUMNNAME_NOT_QUERY, column_name) and not column.pk:
|
||||
column.is_query = GenConstant.REQUIRE
|
||||
|
||||
# 查询字段类型
|
||||
if column_name.lower().endswith('name'):
|
||||
column.query_type = GenConstant.QUERY_LIKE
|
||||
# 状态字段设置单选框
|
||||
if column_name.lower().endswith('status'):
|
||||
column.html_type = GenConstant.HTML_RADIO
|
||||
# 类型&性别字段设置下拉框
|
||||
elif column_name.lower().endswith('type') or column_name.lower().endswith('sex'):
|
||||
column.html_type = GenConstant.HTML_SELECT
|
||||
# 图片字段设置图片上传控件
|
||||
elif column_name.lower().endswith('image'):
|
||||
column.html_type = GenConstant.HTML_IMAGE_UPLOAD
|
||||
# 文件字段设置文件上传控件
|
||||
elif column_name.lower().endswith('file'):
|
||||
column.html_type = GenConstant.HTML_FILE_UPLOAD
|
||||
# 内容字段设置富文本控件
|
||||
elif column_name.lower().endswith('content'):
|
||||
column.html_type = GenConstant.HTML_EDITOR
|
||||
|
||||
column.create_by = table.create_by
|
||||
column.create_time = datetime.now()
|
||||
column.update_by = table.update_by
|
||||
column.update_time = datetime.now()
|
||||
|
||||
@classmethod
|
||||
def arrays_contains(cls, arr: list[str], target_value: str) -> bool:
|
||||
"""
|
||||
校验数组是否包含指定值
|
||||
|
||||
param arr: 数组
|
||||
param target_value: 需要校验的值
|
||||
:return: 校验结果
|
||||
"""
|
||||
return target_value in arr
|
||||
|
||||
@classmethod
|
||||
def get_module_name(cls, package_name: str) -> str:
|
||||
"""
|
||||
获取模块名
|
||||
|
||||
param package_name: 包名
|
||||
:return: 模块名
|
||||
"""
|
||||
return package_name.rsplit('.', maxsplit=1)[-1]
|
||||
|
||||
@classmethod
|
||||
def get_business_name(cls, table_name: str) -> str:
|
||||
"""
|
||||
获取业务名
|
||||
|
||||
param table_name: 业务表名
|
||||
:return: 业务名
|
||||
"""
|
||||
return table_name.rsplit('_', maxsplit=1)[-1]
|
||||
|
||||
@classmethod
|
||||
def convert_class_name(cls, table_name: str) -> str:
|
||||
"""
|
||||
表名转换成Python类名
|
||||
|
||||
param table_name: 业务表名
|
||||
:return: Python类名
|
||||
"""
|
||||
auto_remove_pre = GenConfig.auto_remove_pre
|
||||
table_prefix = GenConfig.table_prefix
|
||||
if auto_remove_pre and table_prefix:
|
||||
search_list = table_prefix.split(',')
|
||||
table_name = cls.replace_first(table_name, search_list)
|
||||
return StringUtil.convert_to_camel_case(table_name)
|
||||
|
||||
@classmethod
|
||||
def replace_first(cls, replacement: str, search_list: list[str]) -> str:
|
||||
"""
|
||||
批量替换前缀
|
||||
|
||||
param replacement: 需要被替换的字符串
|
||||
param search_list: 可替换的字符串列表
|
||||
:return: 替换后的字符串
|
||||
"""
|
||||
for search_string in search_list:
|
||||
if replacement.startswith(search_string):
|
||||
return replacement.replace(search_string, '', 1)
|
||||
return replacement
|
||||
|
||||
@classmethod
|
||||
def replace_text(cls, text: str) -> str:
|
||||
"""
|
||||
关键字替换
|
||||
|
||||
param text: 需要被替换的字符串
|
||||
:return: 替换后的字符串
|
||||
"""
|
||||
return re.sub(r'(?:表|施尔丰)', '', text)
|
||||
|
||||
@classmethod
|
||||
def get_db_type(cls, column_type: str) -> str:
|
||||
"""
|
||||
获取数据库类型字段
|
||||
|
||||
param column_type: 字段类型
|
||||
:return: 数据库类型
|
||||
"""
|
||||
if '(' in column_type:
|
||||
return column_type.split('(', maxsplit=1)[0]
|
||||
return column_type
|
||||
|
||||
@classmethod
|
||||
def get_column_length(cls, column_type: str) -> int:
|
||||
"""
|
||||
获取字段长度
|
||||
|
||||
param column_type: 字段类型
|
||||
:return: 字段长度
|
||||
"""
|
||||
if '(' in column_type:
|
||||
length = len(column_type.split('(')[1].split(')', maxsplit=1)[0])
|
||||
return length
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def split_column_type(cls, column_type: str) -> list[str]:
|
||||
"""
|
||||
拆分列类型
|
||||
|
||||
param column_type: 字段类型
|
||||
:return: 拆分结果
|
||||
"""
|
||||
if '(' in column_type and ')' in column_type:
|
||||
return column_type.split('(')[1].split(')', maxsplit=1)[0].split(',')
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def to_camel_case(cls, text: str) -> str:
|
||||
"""
|
||||
将字符串转换为驼峰命名
|
||||
|
||||
param text: 需要转换的字符串
|
||||
:return: 驼峰命名
|
||||
"""
|
||||
parts = text.split('_')
|
||||
return parts[0] + ''.join(word.capitalize() for word in parts[1:])
|
||||
@@ -0,0 +1,123 @@
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class ImportUtil:
|
||||
@classmethod
|
||||
def find_project_root(cls) -> Path:
|
||||
"""
|
||||
查找项目根目录
|
||||
|
||||
:return: 项目根目录路径
|
||||
"""
|
||||
current_dir = Path(__file__).resolve().parent
|
||||
while current_dir != current_dir.parent:
|
||||
if any(current_dir.joinpath(file).exists() for file in ['setup.py', 'pyproject.toml', 'requirements.txt']):
|
||||
return current_dir
|
||||
current_dir = current_dir.parent
|
||||
return Path(__file__).resolve().parent
|
||||
|
||||
@classmethod
|
||||
def is_valid_model(cls, obj: Any, base_class: Base) -> bool:
|
||||
"""
|
||||
验证是否为有效的SQLAlchemy模型类
|
||||
|
||||
:param obj: 待验证的对象
|
||||
:param base_class: SQLAlchemy的Base类
|
||||
:return: 验证结果
|
||||
"""
|
||||
# 必须继承自Base且不是Base本身
|
||||
if not (inspect.isclass(obj) and issubclass(obj, base_class) and obj is not base_class):
|
||||
return False
|
||||
|
||||
# 必须有表名定义(排除抽象基类)
|
||||
if not hasattr(obj, '__tablename__') or obj.__tablename__ is None:
|
||||
return False
|
||||
|
||||
# 必须有至少一个列定义
|
||||
try:
|
||||
return len(sa_inspect(obj).columns) > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=256)
|
||||
def find_models(cls, base_class: Base) -> list[Base]:
|
||||
"""
|
||||
查找并过滤有效的模型类,避免重复和无效定义
|
||||
|
||||
:param base_class: SQLAlchemy的Base类,用于验证模型类
|
||||
:return: 有效模型类列表
|
||||
"""
|
||||
models = []
|
||||
# 按类对象去重
|
||||
seen_models = set()
|
||||
# 按表名去重(防止同表名冲突)
|
||||
seen_tables = set()
|
||||
project_root = cls.find_project_root()
|
||||
|
||||
sys.path.append(str(project_root))
|
||||
print(f'⏰️ 开始在项目根目录 {project_root} 中查找模型...')
|
||||
|
||||
# 排除目录扩展
|
||||
exclude_dirs = {
|
||||
'venv',
|
||||
'.env',
|
||||
'.git',
|
||||
'__pycache__',
|
||||
'migrations',
|
||||
'alembic',
|
||||
'tests',
|
||||
'test',
|
||||
'docs',
|
||||
'examples',
|
||||
'scripts',
|
||||
}
|
||||
|
||||
for root, dirs, files in os.walk(project_root):
|
||||
dirs[:] = [d for d in dirs if d not in exclude_dirs]
|
||||
|
||||
for file in files:
|
||||
if file.endswith('.py') and not file.startswith('__'):
|
||||
relative_path = Path(root).relative_to(project_root)
|
||||
module_parts = [*list(relative_path.parts), file[:-3]]
|
||||
module_name = '.'.join(module_parts)
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
|
||||
for _name, obj in inspect.getmembers(module, inspect.isclass):
|
||||
# 验证模型有效性
|
||||
if not cls.is_valid_model(obj, base_class):
|
||||
continue
|
||||
|
||||
# 检查类对象重复
|
||||
if obj in seen_models:
|
||||
continue
|
||||
|
||||
# 检查表名重复
|
||||
table_name = obj.__tablename__
|
||||
if table_name in seen_tables:
|
||||
continue
|
||||
|
||||
seen_models.add(obj)
|
||||
seen_tables.add(table_name)
|
||||
models.append(obj)
|
||||
print(f'✅️ 找到有效模型: {obj.__module__}.{obj.__name__} (表: {table_name})')
|
||||
|
||||
except ImportError as e:
|
||||
if 'cannot import name' not in str(e):
|
||||
print(f'❗️ 警告: 无法导入模块 {module_name}: {e}')
|
||||
except Exception as e:
|
||||
print(f'❌️ 处理模块 {module_name} 时出错: {e}')
|
||||
|
||||
return models
|
||||
@@ -0,0 +1,675 @@
|
||||
import ast
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger as _logger
|
||||
from loguru._logger import Logger
|
||||
|
||||
from config.env import AppConfig, LogConfig
|
||||
from middlewares.trace_middleware import TraceCtx
|
||||
from utils.server_util import WorkerIdUtil
|
||||
|
||||
|
||||
def _split_field_tokens(field_name: str) -> tuple[str, ...]:
|
||||
"""
|
||||
拆分字段名为文本匹配用的 token
|
||||
|
||||
:param field_name: 原始字段名
|
||||
:return: token 元组
|
||||
"""
|
||||
separated_name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', field_name)
|
||||
normalized_name = re.sub(r'[^A-Za-z0-9]+', ' ', separated_name)
|
||||
return tuple(token.lower() for token in normalized_name.split() if token)
|
||||
|
||||
|
||||
def _build_text_key_pattern(field_name: str) -> str:
|
||||
"""
|
||||
构建文本日志中的字段名匹配正则,自动兼容 snake_case / camelCase / kebab-case / dot.case
|
||||
|
||||
:param field_name: 原始字段名
|
||||
:return: 正则片段
|
||||
"""
|
||||
tokens = _split_field_tokens(field_name)
|
||||
if not tokens:
|
||||
return re.escape(field_name)
|
||||
return r'[^\S\r\n._-]*[._-]*[^\S\r\n._-]*'.join(re.escape(token) for token in tokens)
|
||||
|
||||
|
||||
def _build_text_assignment_patterns(key_pattern: str) -> list[re.Pattern[str]]:
|
||||
"""
|
||||
构建文本日志中的键值对匹配正则
|
||||
|
||||
:param key_pattern: 字段名正则片段
|
||||
:return: 正则列表
|
||||
"""
|
||||
if not key_pattern:
|
||||
return []
|
||||
return [
|
||||
re.compile(
|
||||
rf'(?P<prefix>(?P<key_quote>[\'"]?)(?P<key>(?:{key_pattern}))(?P=key_quote)\s*[:=]\s*)'
|
||||
r'(?P<quote>[\'"])(?P<value>.*?)(?P=quote)',
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(
|
||||
rf'(?P<prefix>(?P<key_quote>[\'"]?)(?P<key>(?:{key_pattern}))(?P=key_quote)\s*[:=]\s*)'
|
||||
r'(?P<quote>[\'"]?)(?P<value>[^\'"\s,;]+)(?P=quote)',
|
||||
re.IGNORECASE,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class LogSanitizer:
|
||||
"""
|
||||
日志脱敏工具
|
||||
"""
|
||||
|
||||
_MASK = LogConfig.log_mask_placeholder
|
||||
_PHONE_MIN_LENGTH = 7
|
||||
_PHONE_PREFIX_LENGTH = 3
|
||||
_PHONE_SUFFIX_LENGTH = 4
|
||||
_EMAIL_SHORT_LOCAL_LENGTH = 2
|
||||
_IPV4_PARTS = 4
|
||||
_IPV4_VISIBLE_PARTS = 3
|
||||
_IPV6_MASK_THRESHOLD = 2
|
||||
_IPV6_VISIBLE_PARTS = 2
|
||||
_SENSITIVE_FIELDS = {
|
||||
re.sub(r'[^a-z0-9]', '', item.lower())
|
||||
for item in (field.strip() for field in LogConfig.log_mask_fields.split(','))
|
||||
if item
|
||||
}
|
||||
_TEXT_SENSITIVE_FIELDS = tuple(field.strip() for field in LogConfig.log_mask_fields.split(',') if field.strip())
|
||||
_TEXT_SENSITIVE_KEY_PATTERN = '|'.join(
|
||||
sorted({_build_text_key_pattern(field_name) for field_name in _TEXT_SENSITIVE_FIELDS}, key=len, reverse=True)
|
||||
)
|
||||
_PARTIAL_MASK_FIELDS = {
|
||||
re.sub(r'[^a-z0-9]', '', item.lower())
|
||||
for item in (field.strip() for field in LogConfig.log_partial_mask_fields.split(','))
|
||||
if item
|
||||
}
|
||||
_TEXT_PARTIAL_FIELDS = tuple(
|
||||
field.strip() for field in LogConfig.log_partial_mask_fields.split(',') if field.strip()
|
||||
)
|
||||
_TEXT_PARTIAL_KEY_PATTERN = '|'.join(
|
||||
sorted({_build_text_key_pattern(field_name) for field_name in _TEXT_PARTIAL_FIELDS}, key=len, reverse=True)
|
||||
)
|
||||
_CONFIG_SECRET_PATTERNS = [
|
||||
re.compile(pattern.strip(), re.IGNORECASE)
|
||||
for pattern in LogConfig.log_config_secret_patterns.split(',')
|
||||
if pattern.strip()
|
||||
]
|
||||
_KV_PATTERNS = [
|
||||
re.compile(
|
||||
r'(?P<prefix>authorization\s*[:=]\s*)(?P<quote>[\'"]?)(?P<value>bearer\s+[^\s\'",;]+|[^\s\'",;]+)(?P=quote)',
|
||||
re.IGNORECASE,
|
||||
),
|
||||
*_build_text_assignment_patterns(_TEXT_SENSITIVE_KEY_PATTERN),
|
||||
re.compile(r'(?P<prefix>Bearer\s+)(?P<value>[A-Za-z0-9\-._~+/]+=*)', re.IGNORECASE),
|
||||
]
|
||||
_PARTIAL_KV_PATTERNS = _build_text_assignment_patterns(_TEXT_PARTIAL_KEY_PATTERN)
|
||||
_LOGIN_CODE_PATTERN = re.compile(r'^[A-Za-z0-9]{4,8}$')
|
||||
|
||||
@classmethod
|
||||
def sanitize_data(cls, data: Any, field_name: str | None = None) -> Any:
|
||||
"""
|
||||
对日志数据进行脱敏
|
||||
|
||||
:param data: 原始日志数据
|
||||
:param field_name: 当前字段名
|
||||
:return: 脱敏后的数据
|
||||
"""
|
||||
if not LogConfig.log_mask_enabled:
|
||||
return data
|
||||
if data is None:
|
||||
return None
|
||||
if hasattr(data, 'model_dump'):
|
||||
return cls.sanitize_data(data.model_dump(by_alias=True, exclude_none=True), field_name)
|
||||
if isinstance(data, Mapping):
|
||||
return cls._sanitize_mapping(data)
|
||||
if isinstance(data, str):
|
||||
return cls._sanitize_string(data, field_name)
|
||||
if isinstance(data, bytes):
|
||||
return f'<bytes:{len(data)}>'
|
||||
if isinstance(data, Sequence) and not isinstance(data, (str, bytes, bytearray)):
|
||||
return [cls.sanitize_data(item, field_name) for item in data]
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def sanitize_text(cls, text: str) -> str:
|
||||
"""
|
||||
对普通文本日志进行脱敏
|
||||
|
||||
:param text: 原始文本
|
||||
:return: 脱敏后的文本
|
||||
"""
|
||||
if not LogConfig.log_mask_enabled:
|
||||
return text
|
||||
if not isinstance(text, str):
|
||||
return text
|
||||
sanitized_text = cls._sanitize_string(text)
|
||||
return sanitized_text if isinstance(sanitized_text, str) else json.dumps(sanitized_text, ensure_ascii=False)
|
||||
|
||||
@classmethod
|
||||
def _sanitize_mapping(cls, data: Mapping[Any, Any]) -> dict[Any, Any]:
|
||||
"""
|
||||
对字典类型数据进行脱敏
|
||||
|
||||
:param data: 字典数据
|
||||
:return: 脱敏后的字典
|
||||
"""
|
||||
sanitized: dict[Any, Any] = {}
|
||||
normalized_map = {cls._normalize_key(str(key)): value for key, value in data.items()}
|
||||
|
||||
for key, value in data.items():
|
||||
key_str = str(key)
|
||||
normalized_key = cls._normalize_key(key_str)
|
||||
if cls._should_fully_mask_field(normalized_key, value, normalized_map):
|
||||
sanitized[key] = cls._MASK
|
||||
elif normalized_key in cls._PARTIAL_MASK_FIELDS and isinstance(value, str):
|
||||
sanitized[key] = cls._mask_partial_value(value, normalized_key)
|
||||
else:
|
||||
sanitized[key] = cls.sanitize_data(value, key_str)
|
||||
|
||||
config_key = normalized_map.get('configkey')
|
||||
if isinstance(config_key, str) and cls._is_secret_config_key(config_key):
|
||||
for key in data:
|
||||
if cls._normalize_key(str(key)) == 'configvalue':
|
||||
sanitized[key] = cls._MASK
|
||||
|
||||
return sanitized
|
||||
|
||||
@classmethod
|
||||
def _sanitize_string(cls, value: str, field_name: str | None = None) -> Any:
|
||||
"""
|
||||
对字符串数据进行脱敏
|
||||
|
||||
:param value: 字符串值
|
||||
:param field_name: 当前字段名
|
||||
:return: 脱敏后的字符串或结构化数据
|
||||
"""
|
||||
normalized_field = cls._normalize_key(field_name or '')
|
||||
if normalized_field in cls._SENSITIVE_FIELDS:
|
||||
return cls._MASK
|
||||
if normalized_field in cls._PARTIAL_MASK_FIELDS:
|
||||
return cls._mask_partial_value(value, normalized_field)
|
||||
|
||||
stripped_value = value.strip()
|
||||
if stripped_value and stripped_value[0] in '{[':
|
||||
try:
|
||||
parsed_value = json.loads(value)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
try:
|
||||
parsed_value = ast.literal_eval(value)
|
||||
except (SyntaxError, ValueError):
|
||||
pass
|
||||
else:
|
||||
return cls._dump_sanitized_structured_text(
|
||||
cls.sanitize_data(parsed_value, field_name),
|
||||
original_text=value,
|
||||
)
|
||||
else:
|
||||
return cls._dump_sanitized_structured_text(
|
||||
cls.sanitize_data(parsed_value, field_name),
|
||||
original_text=value,
|
||||
)
|
||||
|
||||
sanitized_text = value
|
||||
for pattern in cls._KV_PATTERNS:
|
||||
sanitized_text = pattern.sub(cls._replace_text_secret, sanitized_text)
|
||||
for pattern in cls._PARTIAL_KV_PATTERNS:
|
||||
sanitized_text = pattern.sub(cls._replace_text_partial_secret, sanitized_text)
|
||||
|
||||
if '验证码' in sanitized_text:
|
||||
sanitized_text = re.sub(r'(验证码(?:为|是)?\s*)([A-Za-z0-9]{4,8})', rf'\1{cls._MASK}', sanitized_text)
|
||||
|
||||
return sanitized_text
|
||||
|
||||
@classmethod
|
||||
def _replace_text_secret(cls, match: re.Match[str]) -> str:
|
||||
"""
|
||||
替换文本中的敏感值
|
||||
|
||||
:param match: 正则匹配对象
|
||||
:return: 脱敏后的文本
|
||||
"""
|
||||
prefix = match.group('prefix')
|
||||
quote = match.groupdict().get('quote', '')
|
||||
if quote is None:
|
||||
quote = ''
|
||||
return f'{prefix}{quote}{cls._MASK}{quote}'
|
||||
|
||||
@classmethod
|
||||
def _replace_text_partial_secret(cls, match: re.Match[str]) -> str:
|
||||
"""
|
||||
替换文本中的部分脱敏字段值
|
||||
|
||||
:param match: 正则匹配对象
|
||||
:return: 脱敏后的文本
|
||||
"""
|
||||
prefix = match.group('prefix')
|
||||
quote = match.groupdict().get('quote', '')
|
||||
if quote is None:
|
||||
quote = ''
|
||||
normalized_key = cls._normalize_key(match.groupdict().get('key', ''))
|
||||
masked_value = cls._mask_partial_value(match.group('value'), normalized_key)
|
||||
return f'{prefix}{quote}{masked_value}{quote}'
|
||||
|
||||
@staticmethod
|
||||
def _dump_sanitized_structured_text(sanitized_value: Any, original_text: str) -> Any:
|
||||
"""
|
||||
将脱敏后的结构化数据恢复为文本,并尽量保持原始换行风格
|
||||
|
||||
:param sanitized_value: 脱敏后的结构化数据
|
||||
:param original_text: 原始文本
|
||||
:return: 文本或原始值
|
||||
"""
|
||||
if isinstance(sanitized_value, (dict, list)):
|
||||
indent = 2 if '\n' in original_text or '\r' in original_text else None
|
||||
return json.dumps(sanitized_value, ensure_ascii=False, indent=indent)
|
||||
return sanitized_value
|
||||
|
||||
@classmethod
|
||||
def _should_fully_mask_field(
|
||||
cls, normalized_key: str, value: Any, full_mapping: Mapping[str, Any] | None = None
|
||||
) -> bool:
|
||||
"""
|
||||
判断字段是否需要全量脱敏
|
||||
|
||||
:param normalized_key: 标准化后的字段名
|
||||
:param value: 字段值
|
||||
:param full_mapping: 当前层级的完整字段映射
|
||||
:return: 是否需要全量脱敏
|
||||
"""
|
||||
if normalized_key in cls._SENSITIVE_FIELDS:
|
||||
return True
|
||||
if normalized_key in {'captchacode', 'smscode'}:
|
||||
return True
|
||||
if normalized_key == 'code' and isinstance(value, str):
|
||||
sibling_keys = set((full_mapping or {}).keys())
|
||||
if 'uuid' in sibling_keys and cls._LOGIN_CODE_PATTERN.fullmatch(value):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _is_secret_config_key(cls, config_key: str) -> bool:
|
||||
"""
|
||||
判断参数键是否属于敏感配置
|
||||
|
||||
:param config_key: 参数键
|
||||
:return: 是否敏感
|
||||
"""
|
||||
return any(pattern.search(config_key) for pattern in cls._CONFIG_SECRET_PATTERNS)
|
||||
|
||||
@classmethod
|
||||
def _mask_partial_value(cls, value: str, normalized_field: str) -> str:
|
||||
"""
|
||||
对部分字段进行部分脱敏
|
||||
|
||||
:param value: 原始值
|
||||
:param normalized_field: 标准化后的字段名
|
||||
:return: 脱敏后的值
|
||||
"""
|
||||
if normalized_field in {'phonenumber', 'phone', 'mobile'}:
|
||||
return cls._mask_phone(value)
|
||||
if normalized_field == 'email':
|
||||
return cls._mask_email(value)
|
||||
if normalized_field in {'ip', 'ipaddr', 'operip', 'loginip'}:
|
||||
return cls._mask_ip(value)
|
||||
return cls._MASK
|
||||
|
||||
@staticmethod
|
||||
def _normalize_key(field_name: str) -> str:
|
||||
"""
|
||||
标准化字段名
|
||||
|
||||
:param field_name: 原始字段名
|
||||
:return: 标准化字段名
|
||||
"""
|
||||
return re.sub(r'[^a-z0-9]', '', field_name.lower())
|
||||
|
||||
@classmethod
|
||||
def _mask_phone(cls, value: str) -> str:
|
||||
"""
|
||||
手机号脱敏
|
||||
|
||||
:param value: 原始手机号
|
||||
:return: 脱敏后的手机号
|
||||
"""
|
||||
digits = re.sub(r'\D', '', value)
|
||||
if len(digits) < cls._PHONE_MIN_LENGTH:
|
||||
return cls._MASK
|
||||
return f'{digits[: cls._PHONE_PREFIX_LENGTH]}****{digits[-cls._PHONE_SUFFIX_LENGTH :]}'
|
||||
|
||||
@classmethod
|
||||
def _mask_email(cls, value: str) -> str:
|
||||
"""
|
||||
邮箱脱敏
|
||||
|
||||
:param value: 原始邮箱
|
||||
:return: 脱敏后的邮箱
|
||||
"""
|
||||
if '@' not in value:
|
||||
return cls._MASK
|
||||
local_part, domain = value.split('@', 1)
|
||||
masked_local = (
|
||||
f'{local_part[:1]}***'
|
||||
if len(local_part) <= cls._EMAIL_SHORT_LOCAL_LENGTH
|
||||
else f'{local_part[:1]}***{local_part[-1:]}'
|
||||
)
|
||||
return f'{masked_local}@{domain}'
|
||||
|
||||
@classmethod
|
||||
def _mask_ip(cls, value: str) -> str:
|
||||
"""
|
||||
IP地址脱敏
|
||||
|
||||
:param value: 原始IP
|
||||
:return: 脱敏后的IP
|
||||
"""
|
||||
if '.' in value:
|
||||
parts = value.split('.')
|
||||
if len(parts) == cls._IPV4_PARTS:
|
||||
return '.'.join([*parts[: cls._IPV4_VISIBLE_PARTS], '*'])
|
||||
if ':' in value:
|
||||
parts = value.split(':')
|
||||
if len(parts) > cls._IPV6_MASK_THRESHOLD:
|
||||
return ':'.join([*parts[: cls._IPV6_VISIBLE_PARTS], '*', '*'])
|
||||
return cls._MASK
|
||||
|
||||
|
||||
class InterceptHandler(logging.Handler):
|
||||
target_logger = _logger
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
"""
|
||||
拦截标准 logging 记录并转发到 Loguru
|
||||
|
||||
:param record: 原生 logging 日志记录
|
||||
:return: None
|
||||
"""
|
||||
try:
|
||||
level = self.target_logger.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
frame, depth = logging.currentframe(), 2
|
||||
while frame and frame.f_code.co_filename == logging.__file__:
|
||||
frame = frame.f_back
|
||||
depth += 1
|
||||
self.target_logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
|
||||
|
||||
|
||||
class LoggerInitializer:
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
初始化日志基础配置与运行时标识
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.worker_id = WorkerIdUtil.get_worker_id(LogConfig.log_worker_id)
|
||||
self.instance_id = LogConfig.log_instance_id
|
||||
self.service_name = LogConfig.log_service_name or AppConfig.app_name
|
||||
self._log_file_enabled = LogConfig.log_file_enabled
|
||||
self._log_base_dir = LogConfig.log_file_base_dir
|
||||
self._ensure_log_directory_exists()
|
||||
|
||||
def _ensure_log_directory_exists(self) -> None:
|
||||
"""
|
||||
确保日志目录存在
|
||||
|
||||
:return: None
|
||||
"""
|
||||
if not self._log_file_enabled:
|
||||
return
|
||||
if self._log_base_dir and not os.path.exists(self._log_base_dir):
|
||||
os.makedirs(self._log_base_dir, exist_ok=True)
|
||||
|
||||
def _filter(self, record: dict) -> bool:
|
||||
"""
|
||||
注入 Trace 上下文并控制启动阶段日志输出
|
||||
|
||||
:param record: Loguru 日志记录字典
|
||||
:return: 是否允许输出日志
|
||||
"""
|
||||
record['extra']['trace_id'] = TraceCtx.get_trace_id()
|
||||
record['extra']['request_id'] = TraceCtx.get_request_id()
|
||||
record['extra']['span_id'] = TraceCtx.get_span_id()
|
||||
record['extra']['path'] = TraceCtx.get_request_path()
|
||||
record['extra']['method'] = TraceCtx.get_request_method()
|
||||
record['extra']['worker_id'] = self.worker_id
|
||||
record['extra']['instance_id'] = self.instance_id
|
||||
record['extra']['service'] = self.service_name
|
||||
startup_phase = record['extra'].get('startup_phase')
|
||||
startup_log_enabled = record['extra'].get('startup_log_enabled')
|
||||
record['extra'] = LogSanitizer.sanitize_data(record['extra'])
|
||||
if startup_phase:
|
||||
return bool(startup_log_enabled)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _get_exception_value_text(exception: Any) -> str | None:
|
||||
"""
|
||||
获取脱敏后的异常值文本
|
||||
|
||||
:param exception: Loguru 异常对象
|
||||
:return: 脱敏后的异常值文本
|
||||
"""
|
||||
if not exception or not exception.value:
|
||||
return None
|
||||
return LogSanitizer.sanitize_text(str(exception.value))
|
||||
|
||||
def _get_exception_traceback_text(self, exception: Any) -> str | None:
|
||||
"""
|
||||
获取脱敏后的异常堆栈文本
|
||||
|
||||
:param exception: Loguru 异常对象
|
||||
:return: 脱敏后的异常堆栈文本
|
||||
"""
|
||||
if not exception or not exception.traceback:
|
||||
return None
|
||||
if isinstance(exception.traceback, str):
|
||||
traceback_text = exception.traceback
|
||||
elif exception.type and exception.value:
|
||||
traceback_text = ''.join(traceback.format_exception(exception.type, exception.value, exception.traceback))
|
||||
else:
|
||||
traceback_text = str(exception.traceback)
|
||||
return LogSanitizer.sanitize_text(traceback_text.rstrip())
|
||||
|
||||
def _build_plain_exception_suffix(self, record: dict) -> str:
|
||||
"""
|
||||
构建普通文本日志使用的异常后缀
|
||||
|
||||
:param record: Loguru 日志记录字典
|
||||
:return: 异常后缀文本
|
||||
"""
|
||||
exception_text = self._get_exception_traceback_text(record.get('exception'))
|
||||
return f'\n{exception_text}' if exception_text else ''
|
||||
|
||||
def _build_json_payload(self, record: dict) -> dict:
|
||||
"""
|
||||
构建统一的 JSON 日志结构
|
||||
|
||||
:param record: Loguru 日志记录字典
|
||||
:return: JSON 日志结构
|
||||
"""
|
||||
exception = None
|
||||
if record['exception']:
|
||||
exception = {
|
||||
'type': record['exception'].type.__name__ if record['exception'].type else None,
|
||||
'value': self._get_exception_value_text(record['exception']),
|
||||
'traceback': self._get_exception_traceback_text(record['exception']),
|
||||
}
|
||||
return {
|
||||
'timestamp': record['time'].isoformat(),
|
||||
'level': record['level'].name,
|
||||
'message': record['message'],
|
||||
'logger': record['name'],
|
||||
'trace_id': record['extra'].get('trace_id'),
|
||||
'request_id': record['extra'].get('request_id'),
|
||||
'span_id': record['extra'].get('span_id'),
|
||||
'worker_id': record['extra'].get('worker_id'),
|
||||
'instance_id': record['extra'].get('instance_id'),
|
||||
'service': record['extra'].get('service'),
|
||||
'method': record['extra'].get('method'),
|
||||
'path': record['extra'].get('path'),
|
||||
'module': record['module'],
|
||||
'function': record['function'],
|
||||
'line': record['line'],
|
||||
'exception': exception,
|
||||
'extra': {key: value for key, value in record['extra'].items() if key != 'json_payload'},
|
||||
}
|
||||
|
||||
def _json_log_formatter(self, record: dict) -> str:
|
||||
"""
|
||||
将 Loguru 日志记录格式化为 JSON 文本
|
||||
|
||||
:param record: Loguru 日志记录字典
|
||||
:return: JSON 格式文本
|
||||
"""
|
||||
record['extra']['json_payload'] = json.dumps(self._build_json_payload(record), ensure_ascii=False, default=str)
|
||||
return '{extra[json_payload]}\n'
|
||||
|
||||
def _plain_log_formatter(self, record: dict) -> str:
|
||||
"""
|
||||
将 Loguru 日志记录格式化为普通文本,并追加脱敏后的异常堆栈
|
||||
|
||||
:param record: Loguru 日志记录字典
|
||||
:return: 普通文本格式模板
|
||||
"""
|
||||
record['extra']['sanitized_exception'] = self._build_plain_exception_suffix(record)
|
||||
return (
|
||||
'<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | '
|
||||
'<cyan>{extra[trace_id]}</cyan> | '
|
||||
'<magenta>{extra[span_id]}</magenta> | '
|
||||
'<yellow>{extra[request_id]}</yellow> | '
|
||||
'<blue>{extra[worker_id]}</blue> | '
|
||||
'<level>{level: <8}</level> | '
|
||||
'<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - '
|
||||
'<level>{message}</level>{extra[sanitized_exception]}\n'
|
||||
)
|
||||
|
||||
def _patch_record(self, record: dict) -> dict:
|
||||
"""
|
||||
在日志落地前统一脱敏
|
||||
|
||||
:param record: Loguru 日志记录字典
|
||||
:return: 脱敏后的日志记录字典
|
||||
"""
|
||||
record['message'] = LogSanitizer.sanitize_text(record['message'])
|
||||
return record
|
||||
|
||||
def _info_file_filter(self, record: dict) -> bool:
|
||||
"""
|
||||
仅输出 INFO 级别日志到 info 文件
|
||||
|
||||
:param record: Loguru 日志记录字典
|
||||
:return: 是否允许输出日志
|
||||
"""
|
||||
return self._filter(record) and record['level'].name == 'INFO'
|
||||
|
||||
def _error_file_filter(self, record: dict) -> bool:
|
||||
"""
|
||||
输出 WARNING 及以上日志到 error 文件
|
||||
|
||||
:param record: Loguru 日志记录字典
|
||||
:return: 是否允许输出日志
|
||||
"""
|
||||
return self._filter(record) and record['level'].no >= logging.WARNING
|
||||
|
||||
def _configure_logging(self) -> None:
|
||||
"""
|
||||
统一接管标准 logging 与第三方日志输出
|
||||
|
||||
:return: None
|
||||
"""
|
||||
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
|
||||
for logger_name in ('uvicorn', 'uvicorn.error', 'uvicorn.access', 'fastapi'):
|
||||
logging.getLogger(logger_name).handlers = [InterceptHandler()]
|
||||
logging.getLogger(logger_name).propagate = False
|
||||
for logger_name in ('LiteLLM', 'litellm'):
|
||||
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
||||
|
||||
def init_log(self) -> Logger:
|
||||
"""
|
||||
初始化 Loguru 输出与标准 logging 配置
|
||||
|
||||
:return: 已配置的 Loguru Logger 实例
|
||||
"""
|
||||
configured_logger = _logger.patch(self._patch_record)
|
||||
InterceptHandler.target_logger = configured_logger
|
||||
configured_logger.remove()
|
||||
info_log_path = os.path.join(self._log_base_dir, '{time:YYYY}', '{time:MM}', '{time:DD}', 'info.log')
|
||||
error_log_path = os.path.join(self._log_base_dir, '{time:YYYY}', '{time:MM}', '{time:DD}', 'error.log')
|
||||
if LogConfig.loguru_stdout:
|
||||
if LogConfig.loguru_json:
|
||||
configured_logger.add(
|
||||
sys.stdout,
|
||||
level=LogConfig.loguru_level,
|
||||
enqueue=True,
|
||||
filter=self._filter,
|
||||
format=self._json_log_formatter,
|
||||
)
|
||||
else:
|
||||
configured_logger.add(
|
||||
sys.stdout,
|
||||
level=LogConfig.loguru_level,
|
||||
enqueue=True,
|
||||
filter=self._filter,
|
||||
format=self._plain_log_formatter,
|
||||
)
|
||||
if self._log_file_enabled:
|
||||
if LogConfig.loguru_json:
|
||||
configured_logger.add(
|
||||
info_log_path,
|
||||
level='INFO',
|
||||
rotation=LogConfig.loguru_rotation,
|
||||
retention=LogConfig.loguru_retention,
|
||||
compression=LogConfig.loguru_compression,
|
||||
enqueue=True,
|
||||
filter=self._info_file_filter,
|
||||
serialize=False,
|
||||
format=self._json_log_formatter,
|
||||
)
|
||||
configured_logger.add(
|
||||
error_log_path,
|
||||
level='WARNING',
|
||||
rotation=LogConfig.loguru_rotation,
|
||||
retention=LogConfig.loguru_retention,
|
||||
compression=LogConfig.loguru_compression,
|
||||
enqueue=True,
|
||||
filter=self._error_file_filter,
|
||||
serialize=False,
|
||||
format=self._json_log_formatter,
|
||||
)
|
||||
else:
|
||||
configured_logger.add(
|
||||
info_log_path,
|
||||
level='INFO',
|
||||
rotation=LogConfig.loguru_rotation,
|
||||
retention=LogConfig.loguru_retention,
|
||||
compression=LogConfig.loguru_compression,
|
||||
enqueue=True,
|
||||
filter=self._info_file_filter,
|
||||
format=self._plain_log_formatter,
|
||||
)
|
||||
configured_logger.add(
|
||||
error_log_path,
|
||||
level='WARNING',
|
||||
rotation=LogConfig.loguru_rotation,
|
||||
retention=LogConfig.loguru_retention,
|
||||
compression=LogConfig.loguru_compression,
|
||||
enqueue=True,
|
||||
filter=self._error_file_filter,
|
||||
format=self._plain_log_formatter,
|
||||
)
|
||||
self._configure_logging()
|
||||
return configured_logger
|
||||
|
||||
|
||||
# 初始化日志处理器
|
||||
log_initializer = LoggerInitializer()
|
||||
logger = log_initializer.init_log()
|
||||
@@ -0,0 +1,5 @@
|
||||
from utils.log_util import logger
|
||||
|
||||
|
||||
def message_service(sms_code: str) -> None:
|
||||
logger.info(f'短信验证码为{sms_code}')
|
||||
@@ -0,0 +1,105 @@
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Row, Select, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import PageModel
|
||||
from utils.common_util import CamelCaseUtil
|
||||
|
||||
|
||||
class PageUtil:
|
||||
"""
|
||||
分页工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_page_obj(cls, data_list: list, page_num: int, page_size: int) -> PageModel:
|
||||
"""
|
||||
输入数据列表data_list和分页信息,返回分页数据列表结果
|
||||
|
||||
:param data_list: 原始数据列表
|
||||
:param page_num: 当前页码
|
||||
:param page_size: 当前页面数据量
|
||||
:return: 分页数据对象
|
||||
"""
|
||||
# 计算起始索引和结束索引
|
||||
start = (page_num - 1) * page_size
|
||||
end = page_num * page_size
|
||||
|
||||
# 根据计算得到的起始索引和结束索引对数据列表进行切片
|
||||
paginated_data = data_list[start:end]
|
||||
has_next = math.ceil(len(data_list) / page_size) > page_num
|
||||
|
||||
result = PageModel[Any](
|
||||
rows=paginated_data, pageNum=page_num, pageSize=page_size, total=len(data_list), hasNext=has_next
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
async def paginate(
|
||||
cls, db: AsyncSession, query: Select, page_num: int, page_size: int, is_page: bool = False
|
||||
) -> PageModel | list[dict[str, Any] | list[dict[Any, Any]]]:
|
||||
"""
|
||||
输入查询语句和分页信息,返回分页数据列表结果
|
||||
|
||||
:param db: orm对象
|
||||
:param query: sqlalchemy查询语句
|
||||
:param page_num: 当前页码
|
||||
:param page_size: 当前页面数据量
|
||||
:param is_page: 是否开启分页
|
||||
:return: 分页数据对象
|
||||
"""
|
||||
if is_page:
|
||||
total = (await db.execute(select(func.count('*')).select_from(query.subquery()))).scalar()
|
||||
query_result = await db.execute(query.offset((page_num - 1) * page_size).limit(page_size))
|
||||
paginated_data: list[Row] = []
|
||||
for row in query_result:
|
||||
if row and len(row) == 1:
|
||||
paginated_data.append(row[0])
|
||||
else:
|
||||
paginated_data.append(row)
|
||||
has_next = math.ceil(total / page_size) > page_num
|
||||
result = PageModel[Any](
|
||||
rows=CamelCaseUtil.transform_result(paginated_data),
|
||||
pageNum=page_num,
|
||||
pageSize=page_size,
|
||||
total=total,
|
||||
hasNext=has_next,
|
||||
)
|
||||
else:
|
||||
query_result = await db.execute(query)
|
||||
no_paginated_data: list[Row] = []
|
||||
for row in query_result:
|
||||
if row and len(row) == 1:
|
||||
no_paginated_data.append(row[0])
|
||||
else:
|
||||
no_paginated_data.append(row)
|
||||
result = CamelCaseUtil.transform_result(no_paginated_data)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_page_obj(data_list: list, page_num: int, page_size: int) -> PageModel:
|
||||
"""
|
||||
输入数据列表data_list和分页信息,返回分页数据列表结果
|
||||
|
||||
:param data_list: 原始数据列表
|
||||
:param page_num: 当前页码
|
||||
:param page_size: 当前页面数据量
|
||||
:return: 分页数据对象
|
||||
"""
|
||||
# 计算起始索引和结束索引
|
||||
start = (page_num - 1) * page_size
|
||||
end = page_num * page_size
|
||||
|
||||
# 根据计算得到的起始索引和结束索引对数据列表进行切片
|
||||
paginated_data = data_list[start:end]
|
||||
has_next = math.ceil(len(data_list) / page_size) > page_num
|
||||
|
||||
result = PageModel[Any](
|
||||
rows=paginated_data, pageNum=page_num, pageSize=page_size, total=len(data_list), hasNext=has_next
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,30 @@
|
||||
import bcrypt
|
||||
|
||||
|
||||
class PwdUtil:
|
||||
"""
|
||||
密码工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def verify_password(cls, plain_password: str, hashed_password: str) -> bool:
|
||||
"""
|
||||
工具方法:校验当前输入的密码与数据库存储的密码是否一致
|
||||
|
||||
:param plain_password: 当前输入的密码
|
||||
:param hashed_password: 数据库存储的密码
|
||||
:return: 校验结果
|
||||
"""
|
||||
return (
|
||||
bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) if hashed_password else None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_password_hash(cls, input_password: str) -> str:
|
||||
"""
|
||||
工具方法:对当前输入的密码进行加密
|
||||
|
||||
:param input_password: 输入的密码
|
||||
:return: 加密成功的密码
|
||||
"""
|
||||
return bcrypt.hashpw(input_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
@@ -0,0 +1,315 @@
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from common.constant import HttpStatusConstant
|
||||
|
||||
|
||||
class ResponseUtil:
|
||||
"""
|
||||
响应工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def success(
|
||||
cls,
|
||||
msg: str = '操作成功',
|
||||
data: Any | None = None,
|
||||
rows: Any | None = None,
|
||||
dict_content: dict | None = None,
|
||||
model_content: BaseModel | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
media_type: str | None = None,
|
||||
background: BackgroundTask | None = None,
|
||||
) -> Response:
|
||||
"""
|
||||
成功响应方法
|
||||
|
||||
:param msg: 可选,自定义成功响应信息
|
||||
:param data: 可选,成功响应结果中属性为data的值
|
||||
:param rows: 可选,成功响应结果中属性为rows的值
|
||||
:param dict_content: 可选,dict类型,成功响应结果中自定义属性的值
|
||||
:param model_content: 可选,BaseModel类型,成功响应结果中自定义属性的值
|
||||
:param headers: 可选,响应头信息
|
||||
:param media_type: 可选,响应结果媒体类型
|
||||
:param background: 可选,响应返回后执行的后台任务
|
||||
:return: 成功响应结果
|
||||
"""
|
||||
result = {'code': HttpStatusConstant.SUCCESS, 'msg': msg}
|
||||
|
||||
if data is not None:
|
||||
result['data'] = data
|
||||
if rows is not None:
|
||||
result['rows'] = rows
|
||||
if dict_content is not None:
|
||||
result.update(dict_content)
|
||||
if model_content is not None:
|
||||
result.update(model_content.model_dump(by_alias=True))
|
||||
|
||||
result.update({'success': True, 'time': datetime.now()})
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content=jsonable_encoder(result),
|
||||
headers=headers,
|
||||
media_type=media_type,
|
||||
background=background,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def failure(
|
||||
cls,
|
||||
msg: str = '操作失败',
|
||||
data: Any | None = None,
|
||||
rows: Any | None = None,
|
||||
dict_content: dict | None = None,
|
||||
model_content: BaseModel | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
media_type: str | None = None,
|
||||
background: BackgroundTask | None = None,
|
||||
) -> Response:
|
||||
"""
|
||||
失败响应方法
|
||||
|
||||
:param msg: 可选,自定义失败响应信息
|
||||
:param data: 可选,失败响应结果中属性为data的值
|
||||
:param rows: 可选,失败响应结果中属性为rows的值
|
||||
:param dict_content: 可选,dict类型,失败响应结果中自定义属性的值
|
||||
:param model_content: 可选,BaseModel类型,失败响应结果中自定义属性的值
|
||||
:param headers: 可选,响应头信息
|
||||
:param media_type: 可选,响应结果媒体类型
|
||||
:param background: 可选,响应返回后执行的后台任务
|
||||
:return: 失败响应结果
|
||||
"""
|
||||
result = {'code': HttpStatusConstant.WARN, 'msg': msg}
|
||||
|
||||
if data is not None:
|
||||
result['data'] = data
|
||||
if rows is not None:
|
||||
result['rows'] = rows
|
||||
if dict_content is not None:
|
||||
result.update(dict_content)
|
||||
if model_content is not None:
|
||||
result.update(model_content.model_dump(by_alias=True))
|
||||
|
||||
result.update({'success': False, 'time': datetime.now()})
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content=jsonable_encoder(result),
|
||||
headers=headers,
|
||||
media_type=media_type,
|
||||
background=background,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def unauthorized(
|
||||
cls,
|
||||
msg: str = '登录信息已过期,访问系统资源失败',
|
||||
data: Any | None = None,
|
||||
rows: Any | None = None,
|
||||
dict_content: dict | None = None,
|
||||
model_content: BaseModel | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
media_type: str | None = None,
|
||||
background: BackgroundTask | None = None,
|
||||
) -> Response:
|
||||
"""
|
||||
未认证响应方法
|
||||
|
||||
:param msg: 可选,自定义未认证响应信息
|
||||
:param data: 可选,未认证响应结果中属性为data的值
|
||||
:param rows: 可选,未认证响应结果中属性为rows的值
|
||||
:param dict_content: 可选,dict类型,未认证响应结果中自定义属性的值
|
||||
:param model_content: 可选,BaseModel类型,未认证响应结果中自定义属性的值
|
||||
:param headers: 可选,响应头信息
|
||||
:param media_type: 可选,响应结果媒体类型
|
||||
:param background: 可选,响应返回后执行的后台任务
|
||||
:return: 未认证响应结果
|
||||
"""
|
||||
result = {'code': HttpStatusConstant.UNAUTHORIZED, 'msg': msg}
|
||||
|
||||
if data is not None:
|
||||
result['data'] = data
|
||||
if rows is not None:
|
||||
result['rows'] = rows
|
||||
if dict_content is not None:
|
||||
result.update(dict_content)
|
||||
if model_content is not None:
|
||||
result.update(model_content.model_dump(by_alias=True))
|
||||
|
||||
result.update({'success': False, 'time': datetime.now()})
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content=jsonable_encoder(result),
|
||||
headers=headers,
|
||||
media_type=media_type,
|
||||
background=background,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def forbidden(
|
||||
cls,
|
||||
msg: str = '该用户无此接口权限',
|
||||
data: Any | None = None,
|
||||
rows: Any | None = None,
|
||||
dict_content: dict | None = None,
|
||||
model_content: BaseModel | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
media_type: str | None = None,
|
||||
background: BackgroundTask | None = None,
|
||||
) -> Response:
|
||||
"""
|
||||
未授权响应方法
|
||||
|
||||
:param msg: 可选,自定义未授权响应信息
|
||||
:param data: 可选,未授权响应结果中属性为data的值
|
||||
:param rows: 可选,未授权响应结果中属性为rows的值
|
||||
:param dict_content: 可选,dict类型,未授权响应结果中自定义属性的值
|
||||
:param model_content: 可选,BaseModel类型,未授权响应结果中自定义属性的值
|
||||
:param headers: 可选,响应头信息
|
||||
:param media_type: 可选,响应结果媒体类型
|
||||
:param background: 可选,响应返回后执行的后台任务
|
||||
:return: 未授权响应结果
|
||||
"""
|
||||
result = {'code': HttpStatusConstant.FORBIDDEN, 'msg': msg}
|
||||
|
||||
if data is not None:
|
||||
result['data'] = data
|
||||
if rows is not None:
|
||||
result['rows'] = rows
|
||||
if dict_content is not None:
|
||||
result.update(dict_content)
|
||||
if model_content is not None:
|
||||
result.update(model_content.model_dump(by_alias=True))
|
||||
|
||||
result.update({'success': False, 'time': datetime.now()})
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content=jsonable_encoder(result),
|
||||
headers=headers,
|
||||
media_type=media_type,
|
||||
background=background,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def error(
|
||||
cls,
|
||||
msg: str = '接口异常',
|
||||
data: Any | None = None,
|
||||
rows: Any | None = None,
|
||||
dict_content: dict | None = None,
|
||||
model_content: BaseModel | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
media_type: str | None = None,
|
||||
background: BackgroundTask | None = None,
|
||||
) -> Response:
|
||||
"""
|
||||
错误响应方法
|
||||
|
||||
:param msg: 可选,自定义错误响应信息
|
||||
:param data: 可选,错误响应结果中属性为data的值
|
||||
:param rows: 可选,错误响应结果中属性为rows的值
|
||||
:param dict_content: 可选,dict类型,错误响应结果中自定义属性的值
|
||||
:param model_content: 可选,BaseModel类型,错误响应结果中自定义属性的值
|
||||
:param headers: 可选,响应头信息
|
||||
:param media_type: 可选,响应结果媒体类型
|
||||
:param background: 可选,响应返回后执行的后台任务
|
||||
:return: 错误响应结果
|
||||
"""
|
||||
result = {'code': HttpStatusConstant.ERROR, 'msg': msg}
|
||||
|
||||
if data is not None:
|
||||
result['data'] = data
|
||||
if rows is not None:
|
||||
result['rows'] = rows
|
||||
if dict_content is not None:
|
||||
result.update(dict_content)
|
||||
if model_content is not None:
|
||||
result.update(model_content.model_dump(by_alias=True))
|
||||
|
||||
result.update({'success': False, 'time': datetime.now()})
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content=jsonable_encoder(result),
|
||||
headers=headers,
|
||||
media_type=media_type,
|
||||
background=background,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def too_many_requests(
|
||||
cls,
|
||||
msg: str = '请求过于频繁,请稍后再试',
|
||||
data: Any | None = None,
|
||||
rows: Any | None = None,
|
||||
dict_content: dict | None = None,
|
||||
model_content: BaseModel | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
media_type: str | None = None,
|
||||
background: BackgroundTask | None = None,
|
||||
) -> Response:
|
||||
"""
|
||||
接口限流响应方法
|
||||
|
||||
:param msg: 可选,自定义限流响应信息
|
||||
:param data: 可选,限流响应结果中属性为data的值
|
||||
:param rows: 可选,限流响应结果中属性为rows的值
|
||||
:param dict_content: 可选,dict类型,限流响应结果中自定义属性的值
|
||||
:param model_content: 可选,BaseModel类型,限流响应结果中自定义属性的值
|
||||
:param headers: 可选,响应头信息
|
||||
:param media_type: 可选,响应结果媒体类型
|
||||
:param background: 可选,响应返回后执行的后台任务
|
||||
:return: 限流响应结果
|
||||
"""
|
||||
result = {'code': HttpStatusConstant.TOO_MANY_REQUESTS, 'msg': msg}
|
||||
|
||||
if data is not None:
|
||||
result['data'] = data
|
||||
if rows is not None:
|
||||
result['rows'] = rows
|
||||
if dict_content is not None:
|
||||
result.update(dict_content)
|
||||
if model_content is not None:
|
||||
result.update(model_content.model_dump(by_alias=True))
|
||||
|
||||
result.update({'success': False, 'time': datetime.now()})
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
content=jsonable_encoder(result),
|
||||
headers=headers,
|
||||
media_type=media_type,
|
||||
background=background,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def streaming(
|
||||
cls,
|
||||
*,
|
||||
data: Any = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
media_type: str | None = None,
|
||||
background: BackgroundTask | None = None,
|
||||
) -> Response:
|
||||
"""
|
||||
流式响应方法
|
||||
|
||||
:param data: 流式传输的内容
|
||||
:param headers: 可选,响应头信息
|
||||
:param media_type: 可选,响应结果媒体类型
|
||||
:param background: 可选,响应返回后执行的后台任务
|
||||
:return: 流式响应结果
|
||||
"""
|
||||
return StreamingResponse(
|
||||
status_code=status.HTTP_200_OK, content=data, headers=headers, media_type=media_type, background=background
|
||||
)
|
||||
@@ -0,0 +1,496 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
|
||||
import psutil
|
||||
from fastapi import FastAPI, Request, applications
|
||||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from redis import asyncio as aioredis
|
||||
|
||||
from config.env import AppConfig
|
||||
|
||||
|
||||
class APIDocsUtil:
|
||||
"""
|
||||
API文档工具类
|
||||
"""
|
||||
|
||||
# API文档URLs
|
||||
_OPENAPI_URL = '/openapi.json'
|
||||
_PROXY_OPENAPI_URL = '/proxy-openapi.json'
|
||||
_DOCS_URL = '/docs'
|
||||
_PROXY_DOCS_URL = '/proxy-docs'
|
||||
_REDOC_URL = '/redoc'
|
||||
_PROXY_REDOC_URL = '/proxy-redoc'
|
||||
_OAUTH2_REDIRECT_URL = '/docs/oauth2-redirect'
|
||||
_PROXY_OAUTH2_REDIRECT_URL = '/proxy-docs/oauth2-redirect'
|
||||
|
||||
# 文档静态资源URLs
|
||||
DEFAULT_REDOC_JS_URL = 'https://registry.npmmirror.com/redoc/2/files/bundles/redoc.standalone.js'
|
||||
DEFAULT_REDOC_FAVICON_URL = 'https://fastapi.tiangolo.com/img/favicon.png'
|
||||
DEFAULT_SWAGGER_JS_URL = 'https://registry.npmmirror.com/swagger-ui-dist/5/files/swagger-ui-bundle.js'
|
||||
DEFAULT_SWAGGER_CSS_URL = 'https://registry.npmmirror.com/swagger-ui-dist/5/files/swagger-ui.css'
|
||||
DEFAULT_SWAGGER_FAVICON_URL = 'https://fastapi.tiangolo.com/img/favicon.png'
|
||||
|
||||
@classmethod
|
||||
def proxy_openapi_url(cls) -> str:
|
||||
"""
|
||||
代理OpenAPI文档URL
|
||||
"""
|
||||
return cls._PROXY_OPENAPI_URL if not AppConfig.app_disable_swagger and not AppConfig.app_disable_redoc else None
|
||||
|
||||
@classmethod
|
||||
def docs_url(cls) -> str:
|
||||
"""
|
||||
文档URL
|
||||
"""
|
||||
return cls._DOCS_URL
|
||||
|
||||
@classmethod
|
||||
def proxy_docs_url(cls) -> str:
|
||||
"""
|
||||
代理文档URL
|
||||
"""
|
||||
return cls._PROXY_DOCS_URL if not AppConfig.app_disable_swagger else None
|
||||
|
||||
@classmethod
|
||||
def redoc_url(cls) -> str:
|
||||
"""
|
||||
ReDoc文档URL
|
||||
"""
|
||||
return cls._REDOC_URL
|
||||
|
||||
@classmethod
|
||||
def proxy_redoc_url(cls) -> str:
|
||||
"""
|
||||
代理ReDoc文档URL
|
||||
"""
|
||||
return cls._PROXY_REDOC_URL if not AppConfig.app_disable_redoc else None
|
||||
|
||||
@classmethod
|
||||
def proxy_oauth2_redirect_url(cls) -> str:
|
||||
"""
|
||||
代理OAuth2重定向URL
|
||||
"""
|
||||
return cls._PROXY_OAUTH2_REDIRECT_URL if not AppConfig.app_disable_swagger else None
|
||||
|
||||
@classmethod
|
||||
def setup_docs_static_resources(
|
||||
cls,
|
||||
redoc_js_url: str = DEFAULT_REDOC_JS_URL,
|
||||
redoc_favicon_url: str = DEFAULT_REDOC_FAVICON_URL,
|
||||
swagger_js_url: str = DEFAULT_SWAGGER_JS_URL,
|
||||
swagger_css_url: str = DEFAULT_SWAGGER_CSS_URL,
|
||||
swagger_favicon_url: str = DEFAULT_SWAGGER_FAVICON_URL,
|
||||
) -> None:
|
||||
"""
|
||||
配置文档静态资源
|
||||
|
||||
:param redoc_js_url: 用于加载ReDoc JavaScript的URL
|
||||
:param redoc_favicon_url: ReDoc要使用的favicon的URL
|
||||
:param swagger_js_url: 用于加载Swagger UI JavaScript的URL
|
||||
:param swagger_css_url: 用于加载Swagger UI CSS的URL
|
||||
:param swagger_favicon_url: Swagger UI要使用的favicon的URL
|
||||
:return:
|
||||
"""
|
||||
|
||||
def redoc_monkey_patch(*args, **kwargs) -> HTMLResponse:
|
||||
return get_redoc_html(
|
||||
*args,
|
||||
**kwargs,
|
||||
redoc_js_url=redoc_js_url,
|
||||
redoc_favicon_url=redoc_favicon_url,
|
||||
)
|
||||
|
||||
def swagger_ui_monkey_patch(*args, **kwargs) -> HTMLResponse:
|
||||
return get_swagger_ui_html(
|
||||
*args,
|
||||
**kwargs,
|
||||
swagger_js_url=swagger_js_url,
|
||||
swagger_css_url=swagger_css_url,
|
||||
swagger_favicon_url=swagger_favicon_url,
|
||||
)
|
||||
|
||||
applications.get_redoc_html = redoc_monkey_patch
|
||||
applications.get_swagger_ui_html = swagger_ui_monkey_patch
|
||||
|
||||
@classmethod
|
||||
def custom_api_docs_router(
|
||||
cls,
|
||||
app: FastAPI,
|
||||
redoc_js_url: str = DEFAULT_REDOC_JS_URL,
|
||||
redoc_favicon_url: str = DEFAULT_REDOC_FAVICON_URL,
|
||||
swagger_js_url: str = DEFAULT_SWAGGER_JS_URL,
|
||||
swagger_css_url: str = DEFAULT_SWAGGER_CSS_URL,
|
||||
swagger_favicon_url: str = DEFAULT_SWAGGER_FAVICON_URL,
|
||||
) -> None:
|
||||
"""
|
||||
自定义API文档路由
|
||||
|
||||
:param app: FastAPI对象
|
||||
:param redoc_js_url: 用于加载ReDoc JavaScript的URL
|
||||
:param redoc_favicon_url: ReDoc要使用的favicon的URL
|
||||
:param swagger_js_url: 用于加载Swagger UI JavaScript的URL
|
||||
:param swagger_css_url: 用于加载Swagger UI CSS的URL
|
||||
:param swagger_favicon_url: Swagger UI要使用的favicon的URL
|
||||
:return:
|
||||
"""
|
||||
|
||||
async def custom_openapi(request: Request) -> JSONResponse:
|
||||
return await cls._custom_openapi(app)
|
||||
|
||||
async def custom_redoc(request: Request) -> HTMLResponse:
|
||||
return await cls._custom_redoc(app, redoc_js_url, redoc_favicon_url)
|
||||
|
||||
async def custom_swagger(request: Request) -> HTMLResponse:
|
||||
return await cls._custom_swagger(app, swagger_js_url, swagger_css_url, swagger_favicon_url)
|
||||
|
||||
async def custom_swagger_ui_redirect(request: Request) -> HTMLResponse:
|
||||
return await cls._custom_swagger_ui_redirect(app, swagger_favicon_url)
|
||||
|
||||
# 注册路由
|
||||
app.add_route(cls._OPENAPI_URL, custom_openapi, include_in_schema=False)
|
||||
cls._register_docs_routes(app, custom_swagger, custom_swagger_ui_redirect, custom_redoc)
|
||||
|
||||
@classmethod
|
||||
async def _custom_openapi(cls, app: FastAPI) -> JSONResponse:
|
||||
"""
|
||||
自定义 OpenAPI 路由处理函数
|
||||
|
||||
:param app: FastAPI对象
|
||||
:return: openapi的json响应
|
||||
"""
|
||||
openapi_schema = get_openapi(
|
||||
title=app.title,
|
||||
version=app.version,
|
||||
openapi_version=app.openapi_version,
|
||||
summary=app.summary,
|
||||
description=app.description,
|
||||
terms_of_service=app.terms_of_service,
|
||||
contact=app.contact,
|
||||
license_info=app.license_info,
|
||||
routes=app.routes,
|
||||
webhooks=app.webhooks.routes,
|
||||
tags=app.openapi_tags,
|
||||
separate_input_output_schemas=app.separate_input_output_schemas,
|
||||
external_docs=app.openapi_external_docs,
|
||||
)
|
||||
return JSONResponse(openapi_schema)
|
||||
|
||||
@classmethod
|
||||
async def _custom_redoc(
|
||||
cls,
|
||||
app: FastAPI,
|
||||
redoc_js_url: str,
|
||||
redoc_favicon_url: str,
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
自定义 ReDoc 路由处理函数
|
||||
|
||||
:param app: FastAPI对象
|
||||
:param redoc_js_url: 用于加载ReDoc JavaScript的URL
|
||||
:param redoc_favicon_url: ReDoc要使用的favicon的URL
|
||||
:return: ReDoc HTML响应
|
||||
"""
|
||||
if not AppConfig.app_disable_redoc:
|
||||
return get_redoc_html(
|
||||
openapi_url=cls._OPENAPI_URL,
|
||||
title=f'{app.title} - ReDoc',
|
||||
redoc_js_url=redoc_js_url,
|
||||
redoc_favicon_url=redoc_favicon_url,
|
||||
)
|
||||
return cls._get_disabled_html_content(
|
||||
f'{app.title} - ReDoc',
|
||||
'ReDoc',
|
||||
redoc_favicon_url,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _custom_swagger(
|
||||
cls,
|
||||
app: FastAPI,
|
||||
swagger_js_url: str,
|
||||
swagger_css_url: str,
|
||||
swagger_favicon_url: str,
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
自定义 Swagger UI 路由处理函数
|
||||
|
||||
:param app: FastAPI对象
|
||||
:param swagger_js_url: 用于加载Swagger UI JavaScript的URL
|
||||
:param swagger_css_url: 用于加载Swagger UI CSS的URL
|
||||
:param swagger_favicon_url: Swagger UI要使用的favicon的URL
|
||||
:return: Swagger UI HTML响应
|
||||
"""
|
||||
if not AppConfig.app_disable_swagger:
|
||||
return get_swagger_ui_html(
|
||||
openapi_url=cls._OPENAPI_URL,
|
||||
title=f'{app.title} - Swagger UI',
|
||||
swagger_js_url=swagger_js_url,
|
||||
swagger_css_url=swagger_css_url,
|
||||
swagger_favicon_url=swagger_favicon_url,
|
||||
oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
|
||||
init_oauth=app.swagger_ui_init_oauth,
|
||||
swagger_ui_parameters=app.swagger_ui_parameters,
|
||||
)
|
||||
return cls._get_disabled_html_content(
|
||||
f'{app.title} - Swagger UI',
|
||||
'Swagger UI',
|
||||
swagger_favicon_url,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _custom_swagger_ui_redirect(
|
||||
cls,
|
||||
app: FastAPI,
|
||||
swagger_favicon_url: str,
|
||||
) -> HTMLResponse:
|
||||
"""
|
||||
自定义 Swagger UI OAuth2 重定向路由处理函数
|
||||
|
||||
:param app: FastAPI对象
|
||||
:param swagger_favicon_url: Swagger UI要使用的favicon的URL
|
||||
:return: Swagger UI OAuth2重定向HTML响应
|
||||
"""
|
||||
if not AppConfig.app_disable_swagger:
|
||||
return get_swagger_ui_oauth2_redirect_html()
|
||||
return cls._get_disabled_html_content(
|
||||
f'{app.title} - Swagger UI OAuth2 Redirect',
|
||||
'Swagger UI OAuth2 Redirect',
|
||||
swagger_favicon_url,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_disabled_html_content(title: str, name: str, favicon_url: str) -> HTMLResponse:
|
||||
"""
|
||||
生成禁用文档的HTML内容
|
||||
|
||||
:param title: 页面标题
|
||||
:param name: 文档名称
|
||||
:param favicon_url: 图标地址
|
||||
:return: 禁用文档HTML响应
|
||||
"""
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<!-- needed for adaptive design -->
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="{favicon_url}">
|
||||
<!--
|
||||
{name} doesn't change outer page styles
|
||||
-->
|
||||
<style>
|
||||
body {{
|
||||
margin: 10px;
|
||||
padding: 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
{name} requires Javascript to function. Please enable it to browse the documentation.
|
||||
</noscript>
|
||||
<h1 style="color: #ff4d4f;">{name} has been disabled. Please enable it first.</h1>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(html)
|
||||
|
||||
@classmethod
|
||||
def _register_docs_routes(
|
||||
cls, app: FastAPI, swagger_handler: Callable, redirect_handler: Callable, redoc_handler: Callable
|
||||
) -> None:
|
||||
"""
|
||||
注册文档路由
|
||||
|
||||
:param app: FastAPI对象
|
||||
:param swagger_handler: Swagger UI 路由处理函数
|
||||
:param redirect_handler: Swagger UI OAuth2 重定向路由处理函数
|
||||
:param redoc_handler: ReDoc 路由处理函数
|
||||
:return:
|
||||
"""
|
||||
swagger_urls: list[str] = (
|
||||
[cls._DOCS_URL] if not AppConfig.app_disable_swagger else [cls._DOCS_URL, cls._PROXY_DOCS_URL]
|
||||
)
|
||||
swagger_redirect_urls: list[str] = (
|
||||
[cls._OAUTH2_REDIRECT_URL]
|
||||
if not AppConfig.app_disable_swagger
|
||||
else [cls._OAUTH2_REDIRECT_URL, cls._PROXY_OAUTH2_REDIRECT_URL]
|
||||
)
|
||||
redoc_urls: list[str] = (
|
||||
[cls._REDOC_URL] if not AppConfig.app_disable_redoc else [cls._REDOC_URL, cls._PROXY_REDOC_URL]
|
||||
)
|
||||
|
||||
for url in swagger_urls:
|
||||
app.add_route(url, swagger_handler, include_in_schema=False)
|
||||
for url in swagger_redirect_urls:
|
||||
app.add_route(url, redirect_handler, include_in_schema=False)
|
||||
for url in redoc_urls:
|
||||
app.add_route(url, redoc_handler, include_in_schema=False)
|
||||
|
||||
|
||||
class StartupUtil:
|
||||
"""
|
||||
启动门禁工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
async def acquire_startup_log_gate(
|
||||
cls, redis: aioredis.Redis, lock_key: str, worker_id: str, lock_expire_seconds: int
|
||||
) -> bool:
|
||||
"""
|
||||
获取启动日志门禁
|
||||
|
||||
:param redis: Redis连接对象
|
||||
:param lock_key: 分布式锁key
|
||||
:param worker_id: 当前worker标识
|
||||
:param lock_expire_seconds: 锁过期时间
|
||||
:return: 是否获得启动日志输出权
|
||||
"""
|
||||
acquired = await redis.set(lock_key, worker_id, nx=True, ex=lock_expire_seconds)
|
||||
if acquired:
|
||||
return True
|
||||
current_holder = await redis.get(lock_key)
|
||||
return current_holder == worker_id
|
||||
|
||||
@classmethod
|
||||
def start_lock_renewal(
|
||||
cls,
|
||||
redis: aioredis.Redis,
|
||||
lock_key: str,
|
||||
worker_id: str,
|
||||
lock_expire_seconds: int,
|
||||
interval_seconds: int,
|
||||
on_lock_lost: Callable[[], None] | None = None,
|
||||
) -> asyncio.Task:
|
||||
"""
|
||||
启动分布式锁续期任务
|
||||
|
||||
:param redis: Redis连接对象
|
||||
:param lock_key: 分布式锁key
|
||||
:param worker_id: 当前worker标识
|
||||
:param lock_expire_seconds: 锁过期时间
|
||||
:param interval_seconds: 续期间隔时间
|
||||
:param on_lock_lost: 失去锁时的回调
|
||||
:return: 异步任务对象
|
||||
"""
|
||||
|
||||
async def _loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
current_holder = await redis.get(lock_key)
|
||||
if current_holder == worker_id:
|
||||
await redis.expire(lock_key, lock_expire_seconds)
|
||||
await asyncio.sleep(interval_seconds)
|
||||
continue
|
||||
if on_lock_lost:
|
||||
on_lock_lost()
|
||||
break
|
||||
except Exception:
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
return asyncio.create_task(_loop())
|
||||
|
||||
|
||||
class WorkerIdUtil:
|
||||
"""
|
||||
Worker标识生成工具类
|
||||
"""
|
||||
|
||||
_worker_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def get_worker_id(cls, configured_worker_id: str | None) -> str:
|
||||
"""
|
||||
获取当前worker标识
|
||||
|
||||
:param configured_worker_id: 配置的worker标识
|
||||
:return: 当前worker标识
|
||||
"""
|
||||
if cls._worker_id:
|
||||
return cls._worker_id
|
||||
worker_id = configured_worker_id
|
||||
if not worker_id or worker_id.lower() == 'auto':
|
||||
worker_id = f'{os.getpid()}-{uuid.uuid4().hex[:6]}'
|
||||
cls._worker_id = worker_id
|
||||
return worker_id
|
||||
|
||||
|
||||
class IPUtil:
|
||||
"""
|
||||
IP工具类
|
||||
"""
|
||||
|
||||
_PREFERRED_DNS_HOSTS: tuple[str, str] = ('223.5.5.5', '8.8.8.8')
|
||||
_DNS_CONNECT_TIMEOUT = 1
|
||||
|
||||
@classmethod
|
||||
def get_local_ip(cls) -> str:
|
||||
"""
|
||||
获取本机Local IP
|
||||
"""
|
||||
try:
|
||||
for snics in psutil.net_if_addrs().values():
|
||||
for snic in snics:
|
||||
if snic.family == socket.AF_INET and snic.address.startswith('127.'):
|
||||
return snic.address
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return '127.0.0.1'
|
||||
|
||||
@classmethod
|
||||
def get_network_ips(cls) -> list[str]:
|
||||
"""
|
||||
获取本机Network IP列表
|
||||
"""
|
||||
network_ips = []
|
||||
try:
|
||||
# 获取网卡状态
|
||||
stats = psutil.net_if_stats()
|
||||
for name, snics in psutil.net_if_addrs().items():
|
||||
# 过滤掉状态为DOWN的网卡
|
||||
if name in stats and not stats[name].isup:
|
||||
continue
|
||||
|
||||
for snic in snics:
|
||||
if snic.family == socket.AF_INET:
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(snic.address)
|
||||
if ip_obj.is_loopback or ip_obj.is_link_local:
|
||||
continue
|
||||
network_ips.append(snic.address)
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 优先显示首选出站IP
|
||||
preferred_ip = None
|
||||
for dns_host in cls._PREFERRED_DNS_HOSTS:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.settimeout(cls._DNS_CONNECT_TIMEOUT)
|
||||
s.connect((dns_host, 80))
|
||||
preferred_ip = s.getsockname()[0]
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if preferred_ip:
|
||||
if preferred_ip in network_ips:
|
||||
network_ips.remove(preferred_ip)
|
||||
network_ips.insert(0, preferred_ip)
|
||||
|
||||
if not network_ips:
|
||||
network_ips = ['127.0.0.1']
|
||||
|
||||
return network_ips
|
||||
@@ -0,0 +1,165 @@
|
||||
from common.constant import CommonConstant
|
||||
|
||||
|
||||
class StringUtil:
|
||||
"""
|
||||
字符串工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def is_blank(cls, string: str | None) -> bool:
|
||||
"""
|
||||
校验字符串是否为''或全空格
|
||||
|
||||
:param string: 需要校验的字符串
|
||||
:return: 校验结果
|
||||
"""
|
||||
if string is None:
|
||||
return False
|
||||
str_len = len(string)
|
||||
if str_len == 0:
|
||||
return True
|
||||
return all(string[i] == ' ' for i in range(str_len))
|
||||
|
||||
@classmethod
|
||||
def is_empty(cls, string: str) -> bool:
|
||||
"""
|
||||
校验字符串是否为''或None
|
||||
|
||||
:param string: 需要校验的字符串
|
||||
:return: 校验结果
|
||||
"""
|
||||
return string is None or len(string) == 0
|
||||
|
||||
@classmethod
|
||||
def is_not_empty(cls, string: str) -> bool:
|
||||
"""
|
||||
校验字符串是否不是''和None
|
||||
|
||||
:param string: 需要校验的字符串
|
||||
:return: 校验结果
|
||||
"""
|
||||
return not cls.is_empty(string)
|
||||
|
||||
@classmethod
|
||||
def is_http(cls, link: str) -> bool:
|
||||
"""
|
||||
判断是否为http(s)://开头
|
||||
|
||||
:param link: 链接
|
||||
:return: 是否为http(s)://开头
|
||||
"""
|
||||
return link.startswith((CommonConstant.HTTP, CommonConstant.HTTPS))
|
||||
|
||||
@classmethod
|
||||
def contains_ignore_case(cls, search_str: str, compare_str: str) -> bool:
|
||||
"""
|
||||
查找指定字符串是否包含指定字符串同时忽略大小写
|
||||
|
||||
:param search_str: 查找的字符串
|
||||
:param compare_str: 比对的字符串
|
||||
:return: 查找结果
|
||||
"""
|
||||
if compare_str and search_str:
|
||||
return compare_str.lower() in search_str.lower()
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def contains_any_ignore_case(cls, search_str: str, compare_str_list: list[str]) -> bool:
|
||||
"""
|
||||
查找指定字符串是否包含指定字符串列表中的任意一个字符串同时忽略大小写
|
||||
|
||||
:param search_str: 查找的字符串
|
||||
:param compare_str_list: 比对的字符串列表
|
||||
:return: 查找结果
|
||||
"""
|
||||
if search_str and compare_str_list:
|
||||
return any(cls.contains_ignore_case(search_str, compare_str) for compare_str in compare_str_list)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def equals_ignore_case(cls, search_str: str, compare_str: str) -> bool:
|
||||
"""
|
||||
比较两个字符串是否相等同时忽略大小写
|
||||
|
||||
:param search_str: 查找的字符串
|
||||
:param compare_str: 比对的字符串
|
||||
:return: 比较结果
|
||||
"""
|
||||
if search_str and compare_str:
|
||||
return search_str.lower() == compare_str.lower()
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def equals_any_ignore_case(cls, search_str: str, compare_str_list: list[str]) -> bool:
|
||||
"""
|
||||
比较指定字符串是否与指定字符串列表中的任意一个字符串相等同时忽略大小写
|
||||
|
||||
:param search_str: 查找的字符串
|
||||
:param compare_str_list: 比对的字符串列表
|
||||
:return: 比较结果
|
||||
"""
|
||||
if search_str and compare_str_list:
|
||||
return any(cls.equals_ignore_case(search_str, compare_str) for compare_str in compare_str_list)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def startswith_case(cls, search_str: str, compare_str: str) -> bool:
|
||||
"""
|
||||
查找指定字符串是否以指定字符串开头
|
||||
|
||||
:param search_str: 查找的字符串
|
||||
:param compare_str: 比对的字符串
|
||||
:return: 查找结果
|
||||
"""
|
||||
if compare_str and search_str:
|
||||
return search_str.startswith(compare_str)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def startswith_any_case(cls, search_str: str, compare_str_list: list[str]) -> bool:
|
||||
"""
|
||||
查找指定字符串是否以指定字符串列表中的任意一个字符串开头
|
||||
|
||||
:param search_str: 查找的字符串
|
||||
:param compare_str_list: 比对的字符串列表
|
||||
:return: 查找结果
|
||||
"""
|
||||
if search_str and compare_str_list:
|
||||
return any(cls.startswith_case(search_str, compare_str) for compare_str in compare_str_list)
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def convert_to_camel_case(cls, name: str) -> str:
|
||||
"""
|
||||
将下划线大写方式命名的字符串转换为驼峰式。如果转换前的下划线大写方式命名的字符串为空,则返回空字符串
|
||||
|
||||
:param name: 转换前的下划线大写方式命名的字符串
|
||||
:return: 转换后的驼峰式命名的字符串
|
||||
"""
|
||||
if not name:
|
||||
return ''
|
||||
if '_' not in name:
|
||||
return name[0].upper() + name[1:]
|
||||
parts = name.split('_')
|
||||
result = []
|
||||
for part in parts:
|
||||
if not part:
|
||||
continue
|
||||
result.append(part[0].upper() + part[1:].lower())
|
||||
return ''.join(result)
|
||||
|
||||
@classmethod
|
||||
def get_mapping_value_by_key_ignore_case(cls, mapping: dict[str, str], key: str) -> str:
|
||||
"""
|
||||
根据忽略大小写的键获取字典中的对应的值
|
||||
|
||||
param mapping: 字典
|
||||
param key: 字典的键
|
||||
:return: 字典键对应的值
|
||||
"""
|
||||
for k, v in mapping.items():
|
||||
if key.lower() == k.lower():
|
||||
return v
|
||||
|
||||
return ''
|
||||
@@ -0,0 +1,473 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from common.constant import GenConstant
|
||||
from config.env import DataBaseConfig
|
||||
from exceptions.exception import ServiceWarning
|
||||
from module_generator.entity.vo.gen_vo import GenTableColumnModel, GenTableModel
|
||||
from utils.common_util import CamelCaseUtil, SnakeCaseUtil
|
||||
from utils.string_util import StringUtil
|
||||
|
||||
|
||||
class TemplateInitializer:
|
||||
"""
|
||||
模板引擎初始化类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def init_jinja2(cls) -> Environment:
|
||||
"""
|
||||
初始化 Jinja2 模板引擎
|
||||
|
||||
:return: Jinja2 环境对象
|
||||
"""
|
||||
try:
|
||||
template_dir = os.path.join(os.getcwd(), 'module_generator', 'templates')
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(template_dir),
|
||||
keep_trailing_newline=True,
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
env.filters.update(
|
||||
{
|
||||
'camel_to_snake': SnakeCaseUtil.camel_to_snake,
|
||||
'snake_to_camel': CamelCaseUtil.snake_to_camel,
|
||||
'get_sqlalchemy_type': TemplateUtils.get_sqlalchemy_type,
|
||||
}
|
||||
)
|
||||
return env
|
||||
except Exception as e:
|
||||
raise RuntimeError(f'初始化Jinja2模板引擎失败: {e}') from e
|
||||
|
||||
|
||||
class TemplateUtils:
|
||||
"""
|
||||
模板工具类
|
||||
"""
|
||||
|
||||
# 项目路径
|
||||
FRONTEND_PROJECT_PATH = 'frontend'
|
||||
BACKEND_PROJECT_PATH = 'backend'
|
||||
DEFAULT_PARENT_MENU_ID = '3'
|
||||
|
||||
@classmethod
|
||||
def prepare_context(cls, gen_table: GenTableModel) -> dict[str, Any]:
|
||||
"""
|
||||
准备模板变量
|
||||
|
||||
:param gen_table: 生成表的配置信息
|
||||
:return: 模板上下文字典
|
||||
"""
|
||||
if not gen_table.options:
|
||||
raise ServiceWarning(message='请先完善生成配置信息')
|
||||
class_name = gen_table.class_name
|
||||
module_name = gen_table.module_name
|
||||
business_name = gen_table.business_name
|
||||
package_name = gen_table.package_name
|
||||
tpl_category = gen_table.tpl_category
|
||||
function_name = gen_table.function_name
|
||||
|
||||
context = {
|
||||
'tplCategory': tpl_category,
|
||||
'tableName': gen_table.table_name,
|
||||
'tableComment': gen_table.table_comment,
|
||||
'functionName': function_name if StringUtil.is_not_empty(function_name) else '【请填写功能名称】',
|
||||
'ClassName': class_name,
|
||||
'className': class_name.lower(),
|
||||
'moduleName': module_name,
|
||||
'BusinessName': business_name.capitalize(),
|
||||
'businessName': business_name,
|
||||
'basePackage': cls.get_package_prefix(package_name),
|
||||
'packageName': package_name,
|
||||
'author': gen_table.function_author,
|
||||
'datetime': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'pkColumn': gen_table.pk_column,
|
||||
'doImportList': cls.get_do_import_list(gen_table),
|
||||
'voImportList': cls.get_vo_import_list(gen_table),
|
||||
'permissionPrefix': cls.get_permission_prefix(module_name, business_name),
|
||||
'columns': gen_table.columns,
|
||||
'table': gen_table,
|
||||
'dicts': cls.get_dicts(gen_table),
|
||||
'dbType': DataBaseConfig.db_type,
|
||||
'column_not_add_show': GenConstant.COLUMNNAME_NOT_ADD_SHOW,
|
||||
'column_not_edit_show': GenConstant.COLUMNNAME_NOT_EDIT_SHOW,
|
||||
}
|
||||
|
||||
# 设置菜单、树形结构、子表的上下文
|
||||
cls.set_menu_context(context, gen_table)
|
||||
if tpl_category == GenConstant.TPL_TREE:
|
||||
cls.set_tree_context(context, gen_table)
|
||||
if tpl_category == GenConstant.TPL_SUB:
|
||||
cls.set_sub_context(context, gen_table)
|
||||
|
||||
return context
|
||||
|
||||
@classmethod
|
||||
def set_menu_context(cls, context: dict, gen_table: GenTableModel) -> None:
|
||||
"""
|
||||
设置菜单上下文
|
||||
|
||||
:param context: 模板上下文字典
|
||||
:param gen_table: 生成表的配置信息
|
||||
:return: 新的模板上下文字典
|
||||
"""
|
||||
options = gen_table.options
|
||||
params_obj = json.loads(options)
|
||||
context['parentMenuId'] = cls.get_parent_menu_id(params_obj)
|
||||
|
||||
@classmethod
|
||||
def set_tree_context(cls, context: dict, gen_table: GenTableModel) -> None:
|
||||
"""
|
||||
设置树形结构上下文
|
||||
|
||||
:param context: 模板上下文字典
|
||||
:param gen_table: 生成表的配置信息
|
||||
:return: 新的模板上下文字典
|
||||
"""
|
||||
options = gen_table.options
|
||||
params_obj = json.loads(options)
|
||||
context['treeCode'] = cls.get_tree_code(params_obj)
|
||||
context['treeParentCode'] = cls.get_tree_parent_code(params_obj)
|
||||
context['treeName'] = cls.get_tree_name(params_obj)
|
||||
context['expandColumn'] = cls.get_expand_column(gen_table)
|
||||
|
||||
@classmethod
|
||||
def set_sub_context(cls, context: dict, gen_table: GenTableModel) -> None:
|
||||
"""
|
||||
设置子表上下文
|
||||
|
||||
:param context: 模板上下文字典
|
||||
:param gen_table: 生成表的配置信息
|
||||
:return: 新的模板上下文字典
|
||||
"""
|
||||
sub_table = gen_table.sub_table
|
||||
sub_table_name = gen_table.sub_table_name
|
||||
sub_table_fk_name = gen_table.sub_table_fk_name
|
||||
sub_class_name = sub_table.class_name
|
||||
sub_table_fk_class_name = StringUtil.convert_to_camel_case(sub_table_fk_name)
|
||||
context['subTable'] = sub_table
|
||||
context['subTableName'] = sub_table_name
|
||||
context['subTableFkName'] = sub_table_fk_name
|
||||
context['subTableFkClassName'] = sub_table_fk_class_name
|
||||
context['subTableFkclassName'] = sub_table_fk_class_name.lower()
|
||||
context['subClassName'] = sub_class_name
|
||||
context['subclassName'] = sub_class_name.lower()
|
||||
|
||||
@classmethod
|
||||
def get_template_list(cls, tpl_category: str, tpl_web_type: str) -> list[str]:
|
||||
"""
|
||||
获取模板列表
|
||||
|
||||
:param tpl_category: 生成模板类型
|
||||
:param tpl_web_type: 前端类型
|
||||
:return: 模板列表
|
||||
"""
|
||||
use_web_type = 'vue'
|
||||
if tpl_web_type == 'element-plus':
|
||||
use_web_type = 'vue/v3'
|
||||
templates = [
|
||||
'python/controller.py.jinja2',
|
||||
'python/dao.py.jinja2',
|
||||
'python/do.py.jinja2',
|
||||
'python/service.py.jinja2',
|
||||
'python/vo.py.jinja2',
|
||||
'sql/sql.jinja2',
|
||||
'js/api.js.jinja2',
|
||||
]
|
||||
if tpl_category == GenConstant.TPL_CRUD:
|
||||
templates.append(f'{use_web_type}/index.vue.jinja2')
|
||||
elif tpl_category == GenConstant.TPL_TREE:
|
||||
templates.append(f'{use_web_type}/index-tree.vue.jinja2')
|
||||
elif tpl_category == GenConstant.TPL_SUB:
|
||||
templates.append(f'{use_web_type}/index.vue.jinja2')
|
||||
# templates.append('python/sub-domain.python.jinja2')
|
||||
return templates
|
||||
|
||||
@classmethod
|
||||
def get_file_name(cls, template: list[str], gen_table: GenTableModel) -> str:
|
||||
"""
|
||||
根据模板生成文件名
|
||||
|
||||
:param template: 模板列表
|
||||
:param gen_table: 生成表的配置信息
|
||||
:return: 模板生成文件名
|
||||
"""
|
||||
package_name = gen_table.package_name
|
||||
module_name = gen_table.module_name
|
||||
business_name = gen_table.business_name
|
||||
|
||||
vue_path = cls.FRONTEND_PROJECT_PATH
|
||||
python_path = f'{cls.BACKEND_PROJECT_PATH}/{package_name.replace(".", "/")}'
|
||||
|
||||
if 'controller.py.jinja2' in template:
|
||||
return f'{python_path}/controller/{business_name}_controller.py'
|
||||
if 'dao.py.jinja2' in template:
|
||||
return f'{python_path}/dao/{business_name}_dao.py'
|
||||
if 'do.py.jinja2' in template:
|
||||
return f'{python_path}/entity/do/{business_name}_do.py'
|
||||
if 'service.py.jinja2' in template:
|
||||
return f'{python_path}/service/{business_name}_service.py'
|
||||
if 'vo.py.jinja2' in template:
|
||||
return f'{python_path}/entity/vo/{business_name}_vo.py'
|
||||
if 'sql.jinja2' in template:
|
||||
return f'{cls.BACKEND_PROJECT_PATH}/sql/{business_name}_menu.sql'
|
||||
if 'api.js.jinja2' in template:
|
||||
return f'{vue_path}/api/{module_name}/{business_name}.js'
|
||||
if 'index.vue.jinja2' in template or 'index-tree.vue.jinja2' in template:
|
||||
return f'{vue_path}/views/{module_name}/{business_name}/index.vue'
|
||||
return ''
|
||||
|
||||
@classmethod
|
||||
def get_package_prefix(cls, package_name: str) -> str:
|
||||
"""
|
||||
获取包前缀
|
||||
|
||||
:param package_name: 包名
|
||||
:return: 包前缀
|
||||
"""
|
||||
return package_name[: package_name.rfind('.')]
|
||||
|
||||
@classmethod
|
||||
def get_vo_import_list(cls, gen_table: GenTableModel) -> list[str]:
|
||||
"""
|
||||
获取vo模板导入包列表
|
||||
|
||||
:param gen_table: 生成表的配置信息
|
||||
:return: 导入包列表
|
||||
"""
|
||||
columns = gen_table.columns or []
|
||||
import_list = set()
|
||||
for column in columns:
|
||||
if column.python_type in GenConstant.TYPE_DATE:
|
||||
import_list.add(f'from datetime import {column.python_type}')
|
||||
elif column.python_type == GenConstant.TYPE_DECIMAL:
|
||||
import_list.add('from decimal import Decimal')
|
||||
if gen_table.sub:
|
||||
sub_columns = gen_table.sub_table.columns or []
|
||||
for sub_column in sub_columns:
|
||||
if sub_column.python_type in GenConstant.TYPE_DATE:
|
||||
import_list.add(f'from datetime import {sub_column.python_type}')
|
||||
elif sub_column.python_type == GenConstant.TYPE_DECIMAL:
|
||||
import_list.add('from decimal import Decimal')
|
||||
return cls.merge_same_imports(list(import_list), 'from datetime import')
|
||||
|
||||
@classmethod
|
||||
def get_do_import_list(cls, gen_table: GenTableModel) -> list[str]:
|
||||
"""
|
||||
获取do模板导入包列表
|
||||
|
||||
:param gen_table: 生成表的配置信息
|
||||
:return: 导入包列表
|
||||
"""
|
||||
columns = gen_table.columns or []
|
||||
import_list = set()
|
||||
import_list.add('from sqlalchemy import Column')
|
||||
for column in columns:
|
||||
data_type = cls.get_db_type(column.column_type)
|
||||
if data_type in GenConstant.COLUMNTYPE_GEOMETRY:
|
||||
import_list.add('from geoalchemy2 import Geometry')
|
||||
import_list.add(
|
||||
f'from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, data_type)}'
|
||||
)
|
||||
if gen_table.sub:
|
||||
import_list.add('from sqlalchemy import ForeignKey')
|
||||
sub_columns = gen_table.sub_table.columns or []
|
||||
for sub_column in sub_columns:
|
||||
data_type = cls.get_db_type(sub_column.column_type)
|
||||
import_list.add(
|
||||
f'from sqlalchemy import {StringUtil.get_mapping_value_by_key_ignore_case(GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, data_type)}'
|
||||
)
|
||||
return cls.merge_same_imports(list(import_list), 'from sqlalchemy import')
|
||||
|
||||
@classmethod
|
||||
def get_db_type(cls, column_type: str) -> str:
|
||||
"""
|
||||
获取数据库类型字段
|
||||
|
||||
param column_type: 字段类型
|
||||
:return: 数据库类型
|
||||
"""
|
||||
if '(' in column_type:
|
||||
return column_type.split('(', maxsplit=1)[0]
|
||||
return column_type
|
||||
|
||||
@classmethod
|
||||
def merge_same_imports(cls, imports: list[str], import_start: str) -> list[str]:
|
||||
"""
|
||||
合并相同的导入语句
|
||||
|
||||
:param imports: 导入语句列表
|
||||
:param import_start: 导入语句的起始字符串
|
||||
:return: 合并后的导入语句列表
|
||||
"""
|
||||
merged_imports = []
|
||||
_imports = []
|
||||
for import_stmt in imports:
|
||||
if import_stmt.startswith(import_start):
|
||||
imported_items = import_stmt.split('import')[1].strip()
|
||||
_imports.extend(imported_items.split(', '))
|
||||
else:
|
||||
merged_imports.append(import_stmt)
|
||||
|
||||
if _imports:
|
||||
merged_datetime_import = f'{import_start} {", ".join(_imports)}'
|
||||
merged_imports.append(merged_datetime_import)
|
||||
|
||||
return merged_imports
|
||||
|
||||
@classmethod
|
||||
def get_dicts(cls, gen_table: GenTableModel) -> str:
|
||||
"""
|
||||
获取字典列表
|
||||
|
||||
:param gen_table: 生成表的配置信息
|
||||
:return: 字典列表
|
||||
"""
|
||||
columns = gen_table.columns or []
|
||||
dicts = set()
|
||||
cls.add_dicts(dicts, columns)
|
||||
if gen_table.sub_table is not None:
|
||||
cls.add_dicts(dicts, gen_table.sub_table.columns)
|
||||
return ', '.join(dicts)
|
||||
|
||||
@classmethod
|
||||
def add_dicts(cls, dicts: set[str], columns: list[GenTableColumnModel]) -> None:
|
||||
"""
|
||||
添加字典列表
|
||||
|
||||
:param dicts: 字典列表
|
||||
:param columns: 字段列表
|
||||
:return: 新的字典列表
|
||||
"""
|
||||
for column in columns:
|
||||
if (
|
||||
not column.super_column
|
||||
and StringUtil.is_not_empty(column.dict_type)
|
||||
and StringUtil.equals_any_ignore_case(
|
||||
column.html_type, [GenConstant.HTML_SELECT, GenConstant.HTML_RADIO, GenConstant.HTML_CHECKBOX]
|
||||
)
|
||||
):
|
||||
dicts.add(f"'{column.dict_type}'")
|
||||
|
||||
@classmethod
|
||||
def get_permission_prefix(cls, module_name: str, business_name: str) -> str:
|
||||
"""
|
||||
获取权限前缀
|
||||
|
||||
:param module_name: 模块名
|
||||
:param business_name: 业务名
|
||||
:return: 权限前缀
|
||||
"""
|
||||
return f'{module_name}:{business_name}'
|
||||
|
||||
@classmethod
|
||||
def get_parent_menu_id(cls, params_obj: dict) -> str:
|
||||
"""
|
||||
获取上级菜单ID
|
||||
|
||||
:param params_obj: 菜单参数字典
|
||||
:return: 上级菜单ID
|
||||
"""
|
||||
if params_obj and params_obj.get(GenConstant.PARENT_MENU_ID):
|
||||
return params_obj.get(GenConstant.PARENT_MENU_ID)
|
||||
return cls.DEFAULT_PARENT_MENU_ID
|
||||
|
||||
@classmethod
|
||||
def get_tree_code(cls, params_obj: dict) -> str:
|
||||
"""
|
||||
获取树编码
|
||||
|
||||
:param params_obj: 菜单参数字典
|
||||
:return: 树编码
|
||||
"""
|
||||
if GenConstant.TREE_CODE in params_obj:
|
||||
return cls.to_camel_case(params_obj.get(GenConstant.TREE_CODE))
|
||||
return ''
|
||||
|
||||
@classmethod
|
||||
def get_tree_parent_code(cls, params_obj: dict) -> str:
|
||||
"""
|
||||
获取树父编码
|
||||
|
||||
:param params_obj: 菜单参数字典
|
||||
:return: 树父编码
|
||||
"""
|
||||
if GenConstant.TREE_PARENT_CODE in params_obj:
|
||||
return cls.to_camel_case(params_obj.get(GenConstant.TREE_PARENT_CODE))
|
||||
return ''
|
||||
|
||||
@classmethod
|
||||
def get_tree_name(cls, params_obj: dict) -> str:
|
||||
"""
|
||||
获取树名称
|
||||
|
||||
:param params_obj: 菜单参数字典
|
||||
:return: 树名称
|
||||
"""
|
||||
if GenConstant.TREE_NAME in params_obj:
|
||||
return cls.to_camel_case(params_obj.get(GenConstant.TREE_NAME))
|
||||
return ''
|
||||
|
||||
@classmethod
|
||||
def get_expand_column(cls, gen_table: GenTableModel) -> int:
|
||||
"""
|
||||
获取展开列
|
||||
|
||||
:param gen_table: 生成表的配置信息
|
||||
:return: 展开列
|
||||
"""
|
||||
options = gen_table.options
|
||||
params_obj = json.loads(options)
|
||||
tree_name = params_obj.get(GenConstant.TREE_NAME)
|
||||
num = 0
|
||||
for column in gen_table.columns or []:
|
||||
if column.list:
|
||||
num += 1
|
||||
if column.column_name == tree_name:
|
||||
break
|
||||
return num
|
||||
|
||||
@classmethod
|
||||
def to_camel_case(cls, text: str) -> str:
|
||||
"""
|
||||
将字符串转换为驼峰命名
|
||||
|
||||
:param text: 待转换的字符串
|
||||
:return: 转换后的驼峰命名字符串
|
||||
"""
|
||||
parts = text.split('_')
|
||||
return parts[0] + ''.join(word.capitalize() for word in parts[1:])
|
||||
|
||||
@classmethod
|
||||
def get_sqlalchemy_type(cls, column_type: str) -> str:
|
||||
"""
|
||||
获取SQLAlchemy类型
|
||||
|
||||
:param column_type: 列类型
|
||||
:return: SQLAlchemy类型
|
||||
"""
|
||||
if '(' in column_type:
|
||||
column_type_list = column_type.split('(')
|
||||
if column_type_list[0] in GenConstant.COLUMNTYPE_STR:
|
||||
sqlalchemy_type = (
|
||||
StringUtil.get_mapping_value_by_key_ignore_case(
|
||||
GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, column_type_list[0]
|
||||
)
|
||||
+ '('
|
||||
+ column_type_list[1]
|
||||
)
|
||||
else:
|
||||
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(
|
||||
GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, column_type_list[0]
|
||||
)
|
||||
else:
|
||||
sqlalchemy_type = StringUtil.get_mapping_value_by_key_ignore_case(
|
||||
GenConstant.DB_TO_SQLALCHEMY_TYPE_MAPPING, column_type
|
||||
)
|
||||
|
||||
return sqlalchemy_type
|
||||
@@ -0,0 +1,142 @@
|
||||
from copy import deepcopy
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from dateutil.parser import parse
|
||||
|
||||
|
||||
def object_format_datetime(obj: Any) -> Any:
|
||||
"""
|
||||
:param obj: 输入一个对象
|
||||
:return:对目标对象所有datetime类型的属性格式化
|
||||
"""
|
||||
for attr in dir(obj):
|
||||
value = getattr(obj, attr)
|
||||
if isinstance(value, datetime):
|
||||
setattr(obj, attr, value.strftime('%Y-%m-%d %H:%M:%S'))
|
||||
return obj
|
||||
|
||||
|
||||
def list_format_datetime(lst: list[Any]) -> list[Any]:
|
||||
"""
|
||||
:param lst: 输入一个嵌套对象的列表
|
||||
:return: 对目标列表中所有对象的datetime类型的属性格式化
|
||||
"""
|
||||
for obj in lst:
|
||||
for attr in dir(obj):
|
||||
value = getattr(obj, attr)
|
||||
if isinstance(value, datetime):
|
||||
setattr(obj, attr, value.strftime('%Y-%m-%d %H:%M:%S'))
|
||||
return lst
|
||||
|
||||
|
||||
def format_datetime_dict_list(dicts: list[dict]) -> list[dict]:
|
||||
"""
|
||||
递归遍历嵌套字典,并将 datetime 值转换为字符串格式
|
||||
|
||||
:param dicts: 输入一个嵌套字典的列表
|
||||
:return: 对目标列表中所有字典的datetime类型的属性格式化
|
||||
"""
|
||||
result = []
|
||||
|
||||
for item in dicts:
|
||||
new_item = {}
|
||||
for k, v in item.items():
|
||||
if isinstance(v, dict):
|
||||
# 递归遍历子字典
|
||||
new_item[k] = format_datetime_dict_list([v])[0]
|
||||
elif isinstance(v, datetime):
|
||||
# 如果值是 datetime 类型,则格式化为字符串
|
||||
new_item[k] = v.strftime('%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
# 否则保留原始值
|
||||
new_item[k] = v
|
||||
result.append(new_item)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class TimeFormatUtil:
|
||||
"""
|
||||
时间格式化工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def format_time(cls, time_info: str | datetime, fmt: str = '%Y-%m-%d %H:%M:%S') -> str:
|
||||
"""
|
||||
格式化时间字符串或datetime对象为指定格式
|
||||
|
||||
:param time_info: 时间字符串或datetime对象
|
||||
:param fmt: 格式化格式,默认为'%Y-%m-%d %H:%M:%S'
|
||||
:return: 格式化后的时间字符串
|
||||
"""
|
||||
if isinstance(time_info, datetime):
|
||||
format_date = time_info.strftime(fmt)
|
||||
else:
|
||||
try:
|
||||
date = parse(time_info)
|
||||
format_date = date.strftime(fmt)
|
||||
except Exception:
|
||||
format_date = time_info
|
||||
|
||||
return format_date
|
||||
|
||||
@classmethod
|
||||
def parse_date(cls, time_str: str) -> date | str:
|
||||
"""
|
||||
解析时间字符串提取日期部分
|
||||
|
||||
:param time_str: 时间字符串
|
||||
:return: 日期部分
|
||||
"""
|
||||
try:
|
||||
dt = parse(time_str)
|
||||
return dt.date()
|
||||
except Exception:
|
||||
return time_str
|
||||
|
||||
@classmethod
|
||||
def format_time_dict(cls, time_dict: dict, fmt: str = '%Y-%m-%d %H:%M:%S') -> dict:
|
||||
"""
|
||||
格式化时间字典
|
||||
|
||||
:param time_dict: 时间字典
|
||||
:param fmt: 格式化格式,默认为'%Y-%m-%d %H:%M:%S'
|
||||
:return: 格式化后的时间字典
|
||||
"""
|
||||
copy_time_dict = deepcopy(time_dict)
|
||||
for k, v in copy_time_dict.items():
|
||||
if isinstance(v, (str, datetime)):
|
||||
copy_time_dict[k] = cls.format_time(v, fmt)
|
||||
elif isinstance(v, dict):
|
||||
copy_time_dict[k] = cls.format_time_dict(v, fmt)
|
||||
elif isinstance(v, list):
|
||||
copy_time_dict[k] = cls.format_time_list(v, fmt)
|
||||
else:
|
||||
copy_time_dict[k] = v
|
||||
|
||||
return copy_time_dict
|
||||
|
||||
@classmethod
|
||||
def format_time_list(cls, time_list: list, fmt: str = '%Y-%m-%d %H:%M:%S') -> list:
|
||||
"""
|
||||
格式化时间列表
|
||||
|
||||
:param time_list: 时间列表
|
||||
:param fmt: 格式化格式,默认为'%Y-%m-%d %H:%M:%S'
|
||||
:return: 格式化后的时间列表
|
||||
"""
|
||||
format_time_list = []
|
||||
for item in time_list:
|
||||
if isinstance(item, (str, datetime)):
|
||||
format_item = cls.format_time(item, fmt)
|
||||
elif isinstance(item, dict):
|
||||
format_item = cls.format_time_dict(item, fmt)
|
||||
elif isinstance(item, list):
|
||||
format_item = cls.format_time_list(item, fmt)
|
||||
else:
|
||||
format_item = item
|
||||
|
||||
format_time_list.append(format_item)
|
||||
|
||||
return format_time_list
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
import os
|
||||
import random
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime
|
||||
|
||||
import aiofiles
|
||||
from fastapi import UploadFile
|
||||
|
||||
from config.env import UploadConfig
|
||||
|
||||
|
||||
class UploadUtil:
|
||||
"""
|
||||
上传工具类
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def generate_random_number(cls) -> str:
|
||||
"""
|
||||
生成3位数字构成的字符串
|
||||
|
||||
:return: 3位数字构成的字符串
|
||||
"""
|
||||
random_number = random.randint(1, 999)
|
||||
|
||||
return f'{random_number:03}'
|
||||
|
||||
@classmethod
|
||||
def check_file_exists(cls, filepath: str) -> bool:
|
||||
"""
|
||||
检查文件是否存在
|
||||
|
||||
:param filepath: 文件路径
|
||||
:return: 校验结果
|
||||
"""
|
||||
return os.path.exists(filepath)
|
||||
|
||||
@classmethod
|
||||
def check_file_extension(cls, file: UploadFile) -> bool:
|
||||
"""
|
||||
检查文件后缀是否合法
|
||||
|
||||
:param file: 文件对象
|
||||
:return: 校验结果
|
||||
"""
|
||||
file_extension = file.filename.rsplit('.', 1)[-1]
|
||||
|
||||
return file_extension in UploadConfig.DEFAULT_ALLOWED_EXTENSION
|
||||
|
||||
@classmethod
|
||||
def check_file_timestamp(cls, filename: str) -> bool:
|
||||
"""
|
||||
校验文件时间戳是否合法
|
||||
|
||||
:param filename: 文件名称
|
||||
:return: 校验结果
|
||||
"""
|
||||
timestamp = (
|
||||
filename.rsplit('.', 1)[0].rsplit('_', maxsplit=1)[-1].split(UploadConfig.UPLOAD_MACHINE, maxsplit=1)[0]
|
||||
)
|
||||
try:
|
||||
datetime.strptime(timestamp, '%Y%m%d%H%M%S')
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def check_file_machine(cls, filename: str) -> bool:
|
||||
"""
|
||||
校验文件机器码是否合法
|
||||
|
||||
:param filename: 文件名称
|
||||
:return: 校验结果
|
||||
"""
|
||||
return filename.rsplit('.', 1)[0][-4] == UploadConfig.UPLOAD_MACHINE
|
||||
|
||||
@classmethod
|
||||
def check_file_random_code(cls, filename: str) -> bool:
|
||||
"""
|
||||
校验文件随机码是否合法
|
||||
|
||||
:param filename: 文件名称
|
||||
:return: 校验结果
|
||||
"""
|
||||
valid_code_list = [f'{i:03}' for i in range(1, 999)]
|
||||
|
||||
return filename.rsplit('.', 1)[0][-3:] in valid_code_list
|
||||
|
||||
@classmethod
|
||||
async def generate_file(cls, filepath: str) -> AsyncGenerator[bytes, None]:
|
||||
"""
|
||||
根据文件生成二进制数据
|
||||
|
||||
:param filepath: 文件路径
|
||||
:yield: 二进制数据
|
||||
"""
|
||||
async with aiofiles.open(filepath, 'rb') as response_file:
|
||||
async for chunk in response_file:
|
||||
yield chunk
|
||||
|
||||
@classmethod
|
||||
def delete_file(cls, filepath: str) -> None:
|
||||
"""
|
||||
根据文件路径删除对应文件
|
||||
|
||||
:param filepath: 文件路径
|
||||
"""
|
||||
os.remove(filepath)
|
||||
Reference in New Issue
Block a user