按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
"""日志配置。
|
||
|
||
规范要求(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
|
||
from app.core.monitoring import REQUEST_ID
|
||
|
||
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(兼容带空格/横线分隔的写法,如 138 0013 8000)
|
||
(re.compile(r"\b(1[3-9]\d)[-\s]?(\d{4})[-\s]?(\d{4})\b"), r"\1****\3"),
|
||
# 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:
|
||
if not hasattr(record, "request_id"):
|
||
record.request_id = REQUEST_ID.get()
|
||
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 | request_id=%(request_id)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)
|