"""日志配置。 规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天+大小切分、 保留 30 天、禁止输出密码/手机号/身份证/Token。 Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8, 否则中文日志会变成乱码。 """ import logging import re import sys from logging.handlers import TimedRotatingFileHandler from app.core.config import BACKEND_DIR LOG_DIR = BACKEND_DIR / "logs" # 文本脱敏规则。日志是最容易泄漏敏感信息的地方,且多半来自异常堆栈里的 # 请求体字符串,无法靠"调用方注意"来保证,所以在这里统一兜底。 _SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = ( # 含 password / passwd / pwd / secret / token 的键名,或 app_key / access_key 之类的短名 ( re.compile( r'((?:\w*(?:password|passwd|pwd|secret|token)\w*|app_?key|access_?key)["\']?\s*[:=]\s*["\']?)([^"\'\s,}&]+)', re.IGNORECASE, ), r"\1***", ), # Authorization: Bearer xxx (re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"), # 手机号:138****8888 (re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"), # 18 位身份证:保留前 6 位与后 4 位 (re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"), ) def scrub(text: str) -> str: """对文本按规则脱敏。导出供单测直接验证。""" for pattern, replacement in _SENSITIVE_RULES: text = pattern.sub(replacement, text) return text class SensitiveFormatter(logging.Formatter): """脱敏格式化器。 脱敏必须在父类完成 %-格式化**之后**进行,而不是在 filter 阶段改写 ``record.msg``。 后者会把 ``"登录失败 password=%s"`` 里的占位符一并抹成 ``password=***``, 随后 logging 内部执行 ``msg % args`` 时因参数无处安放抛 TypeError, 导致整条日志丢失 —— 这比不脱敏更危险。 """ def format(self, record: logging.LogRecord) -> str: return scrub(super().format(record)) def setup_logging(level: str = "INFO", log_to_file: bool = True) -> None: """初始化根日志配置。 重复调用是安全的:先清空已有 handler,避免 uvicorn --reload 或测试中重复挂载导致日志重复输出。 :param level: 日志级别 :param log_to_file: 是否写文件;测试环境可关闭以避免产生日志文件 """ root = logging.getLogger() root.setLevel(level) for handler in list(root.handlers): root.removeHandler(handler) formatter = SensitiveFormatter( fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) # 控制台:Windows 终端默认 cp936,中文日志可能报编码错误,显式重配为 utf-8 if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] console = logging.StreamHandler(sys.stdout) console.setFormatter(formatter) root.addHandler(console) if log_to_file: LOG_DIR.mkdir(parents=True, exist_ok=True) file_handler = TimedRotatingFileHandler( filename=LOG_DIR / "app.log", when="midnight", backupCount=30, # 规范要求保留 30 天 encoding="utf-8", ) file_handler.setFormatter(formatter) root.addHandler(file_handler)