初始提交

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