初始提交

This commit is contained in:
焦龙言
2026-07-24 09:43:10 +08:00
commit b5b4721456
793 changed files with 116305 additions and 0 deletions
+172
View File
@@ -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
+226
View File
@@ -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
+675
View File
@@ -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)