feat(logging): improve local and docker log handling

This commit is contained in:
yml2213
2026-08-28 16:49:00 +08:00
parent 70621cff8f
commit 5ba70d5b4e
8 changed files with 348 additions and 47 deletions
+5
View File
@@ -20,6 +20,11 @@ APP_PORT=8000
# 应用日志等级:DEBUG、INFO、WARNING、ERROR、CRITICAL;生产环境建议保持 INFO。 # 应用日志等级:DEBUG、INFO、WARNING、ERROR、CRITICAL;生产环境建议保持 INFO。
LOG_LEVEL=INFO LOG_LEVEL=INFO
# 单日日志 app-YYYY-MM-DD.log 达到该大小时自动压缩归档;支持 K/M/G,例如 100M。
LOG_ROTATION_SIZE=50M
# 压缩归档保留天数;到期后写日志时自动清理。
LOG_RETENTION_DAYS=14
# 日志目录由启动脚本固定:./dev.sh 使用项目 logs/Docker 使用挂载的 /app/logs。
# CK 重登保护:单账号最多尝试次数与总耗时秒数,超时/失败均保留旧 Cookie。 # CK 重登保护:单账号最多尝试次数与总耗时秒数,超时/失败均保留旧 Cookie。
COOKIE_RELOGIN_MAX_RETRIES=5 COOKIE_RELOGIN_MAX_RETRIES=5
+3 -2
View File
@@ -65,13 +65,14 @@ YYB_WORKER_KEY=随机密钥
随后执行 `./deploy.sh`,它会构建并启动应用与 MySQL。Worker 不发布端口;扫码图片和支付二维码只经后台授权接口返回。服务器只需要此仓库,不需要部署 `js_reverse` 随后执行 `./deploy.sh`,它会构建并启动应用与 MySQL。Worker 不发布端口;扫码图片和支付二维码只经后台授权接口返回。服务器只需要此仓库,不需要部署 `js_reverse`
本机热更新调试使用 `./dev.sh`(或 `./deploy.sh dev`)。它会直接启动一个仅绑定 `127.0.0.1:8810` 的开发 Worker,后端自动使用该地址;Worker 动态数据写入 `data/yyb-worker-dev/`,按 `Ctrl+C` 会一并停止。 本机热更新调试使用 `./dev.sh`(或 `./deploy.sh dev`)。它会直接启动一个仅绑定 `127.0.0.1:8810` 的开发 Worker,后端自动使用该地址;Worker 动态数据写入 `data/yyb-worker-dev/`,按 `Ctrl+C` 会一并停止。应用文件日志按日期写入项目的 `logs/app-YYYY-MM-DD.log`,可另开终端执行 `./dev.sh logs` 跟踪当天文件。
**常用命令:** **常用命令:**
```bash ```bash
./deploy.sh # 构建并部署 ./deploy.sh # 构建并部署
./deploy.sh logs # 查看实时日志 ./deploy.sh logs # 查看当天持久化应用日志(logs/app-YYYY-MM-DD.log
./deploy.sh logs container # 查看 Docker 容器输出(含 Worker
./deploy.sh stop # 停止服务 ./deploy.sh stop # 停止服务
./deploy.sh restart # 重启服务 ./deploy.sh restart # 重启服务
./deploy.sh reset # 重置所有数据(需二次确认) ./deploy.sh reset # 重置所有数据(需二次确认)
+17 -4
View File
@@ -85,7 +85,8 @@ cmd_deploy() {
echo " 访问地址: http://localhost:${APP_PORT}" echo " 访问地址: http://localhost:${APP_PORT}"
echo " 默认账号见环境变量 ADMIN_USERNAME/ADMIN_PASSWORD(未设置则为 admin/admin123" echo " 默认账号见环境变量 ADMIN_USERNAME/ADMIN_PASSWORD(未设置则为 admin/admin123"
echo "" echo ""
echo " 查看日志: $COMPOSE logs -f" echo " 查看应用日志: ./deploy.sh logs"
echo " 查看容器输出: ./deploy.sh logs container"
echo " 停止服务: $COMPOSE down" echo " 停止服务: $COMPOSE down"
echo " 重启服务: $COMPOSE restart" echo " 重启服务: $COMPOSE restart"
echo " 重置数据: ./deploy.sh reset" echo " 重置数据: ./deploy.sh reset"
@@ -156,12 +157,24 @@ cmd_reset() {
} }
cmd_logs() { cmd_logs() {
local mode="${1:-app}"
if [ "$mode" = "app" ]; then
local app_log
app_log="$ROOT_DIR/logs/app-$(date '+%Y-%m-%d').log"
mkdir -p "$ROOT_DIR/logs"
echo "跟踪应用文件日志: ${app_log}Ctrl+C 退出)"
exec tail -n "${LOG_TAIL_LINES:-200}" -F "$app_log"
fi
if [ "$mode" != "container" ]; then
echo "用法: ./deploy.sh logs [app|container]"
exit 1
fi
COMPOSE=$(detect_compose) COMPOSE=$(detect_compose)
if [ -z "$COMPOSE" ]; then if [ -z "$COMPOSE" ]; then
echo "❌ 未检测到 docker compose" echo "❌ 未检测到 docker compose"
exit 1 exit 1
fi fi
$COMPOSE logs -f $COMPOSE logs --tail "${LOG_TAIL_LINES:-200}" -f
} }
cmd_migrate_mysql() { cmd_migrate_mysql() {
@@ -271,7 +284,7 @@ cmd_help() {
echo " reset 删除所有数据并重置(需确认)" echo " reset 删除所有数据并重置(需确认)"
echo " migrate-mysql 先启动 MySQL 并迁移 data/web.db" echo " migrate-mysql 先启动 MySQL 并迁移 data/web.db"
echo " migrate 仅执行数据库结构迁移" echo " migrate 仅执行数据库结构迁移"
echo " logs 查看实时日志" echo " logs [app|container] 查看应用文件日志(默认)或容器输出"
echo " stop 停止服务" echo " stop 停止服务"
echo " restart 重启服务" echo " restart 重启服务"
echo " dev 本地调试启动(后端 reload + 前端热更新)" echo " dev 本地调试启动(后端 reload + 前端热更新)"
@@ -289,7 +302,7 @@ case "${1:-}" in
reset) cmd_reset ;; reset) cmd_reset ;;
migrate-mysql) cmd_migrate_mysql ;; migrate-mysql) cmd_migrate_mysql ;;
migrate) cmd_migrate ;; migrate) cmd_migrate ;;
logs) cmd_logs ;; logs) shift; cmd_logs "${1:-app}" ;;
stop) cmd_stop ;; stop) cmd_stop ;;
restart) cmd_restart ;; restart) cmd_restart ;;
dev) cmd_dev ;; dev) cmd_dev ;;
+92 -8
View File
@@ -15,6 +15,11 @@ if [ -f "$ROOT_DIR/.env" ]; then
fi fi
BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}" BACKEND_HOST="${BACKEND_HOST:-0.0.0.0}"
# 8800 是项目默认开发端口并由脚本独占;其他显式端口发生冲突时只报错。
BACKEND_PORT_EXPLICIT=false
if [ -n "${BACKEND_PORT+x}" ]; then
BACKEND_PORT_EXPLICIT=true
fi
BACKEND_PORT="${BACKEND_PORT:-8800}" BACKEND_PORT="${BACKEND_PORT:-8800}"
FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}" FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"
FRONTEND_PORT="${FRONTEND_PORT:-5174}" FRONTEND_PORT="${FRONTEND_PORT:-5174}"
@@ -40,7 +45,14 @@ DEV_YYB_WORKER_URL="${DEV_YYB_WORKER_URL:-http://127.0.0.1:${YYB_WORKER_PORT}}"
YYB_WORKER_DATA_DIR="${YYB_WORKER_DATA_DIR:-$ROOT_DIR/data/yyb-worker-dev}" YYB_WORKER_DATA_DIR="${YYB_WORKER_DATA_DIR:-$ROOT_DIR/data/yyb-worker-dev}"
YYB_WORKER_NODE_MODULES="$ROOT_DIR/services/yyb-worker/node_modules" YYB_WORKER_NODE_MODULES="$ROOT_DIR/services/yyb-worker/node_modules"
LOG_DIR="$ROOT_DIR/logs" LOG_DIR="$ROOT_DIR/logs"
APP_LOG="$LOG_DIR/app.log" APP_LOG="$LOG_DIR/app-$(date '+%Y-%m-%d').log"
# 本地一键启动始终写入项目内的持久化日志目录,避免与旧的 /Users/.../app.log 混淆。
if [ "${1:-}" = "logs" ]; then
mkdir -p "$LOG_DIR"
echo "跟踪本地应用日志: ${APP_LOG}Ctrl+C 退出)"
exec tail -n "${LOG_TAIL_LINES:-200}" -F "$APP_LOG"
fi
BACKEND_PID="" BACKEND_PID=""
FRONTEND_PID="" FRONTEND_PID=""
@@ -92,13 +104,62 @@ detect_backend_proxy_host() {
echo "$BACKEND_HOST" echo "$BACKEND_HOST"
return return
fi fi
# 绑定 0.0.0.0 时,Vite 代理不要固定连 127.0.0.1;某些本地工具会抢占 loopback 端口 # Vite 是本机进程,回环代理最稳定;LAN 客户端仍可经 Vite 的 0.0.0.0 监听访问
local ip echo "127.0.0.1"
ip="$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)" }
if [ -z "$ip" ] && command -v hostname >/dev/null 2>&1; then
ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" port_in_use() {
command -v lsof >/dev/null 2>&1 && \
lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
}
show_port_owner() {
lsof -nP -iTCP:"$1" -sTCP:LISTEN 2>/dev/null | awk 'NR == 2 {print " 占用进程: " $1 " (pid " $2 ")"}'
}
ensure_backend_port_available() {
if ! port_in_use "$BACKEND_PORT"; then
return
fi fi
echo "${ip:-127.0.0.1}" # 本地开发默认端口由本项目独占;编辑器残留的端口转发会让健康检查命中错误进程。
if [ "$BACKEND_PORT" = "8800" ]; then
local pids pid
pids="$(lsof -tiTCP:"$BACKEND_PORT" -sTCP:LISTEN 2>/dev/null || true)"
echo "默认后端端口 $BACKEND_PORT 已被占用,正在停止占用进程..."
show_port_owner "$BACKEND_PORT"
for pid in $pids; do
kill -TERM "$pid" 2>/dev/null || true
done
for _ in $(seq 1 5); do
if ! port_in_use "$BACKEND_PORT"; then
echo "端口 $BACKEND_PORT 已释放。"
return
fi
sleep 1
done
for pid in $pids; do
kill -KILL "$pid" 2>/dev/null || true
done
sleep 1
if ! port_in_use "$BACKEND_PORT"; then
echo "端口 $BACKEND_PORT 已释放。"
return
fi
echo "端口 $BACKEND_PORT 仍被占用,未启动后端。"
show_port_owner "$BACKEND_PORT"
exit 1
fi
if [ "$BACKEND_PORT_EXPLICIT" = true ]; then
echo "后端端口 $BACKEND_PORT 已被占用,无法启动。"
show_port_owner "$BACKEND_PORT"
echo "请关闭占用程序,或在 .env 中设置一个空闲的 BACKEND_PORT 后重试。"
exit 1
fi
echo "后端端口 $BACKEND_PORT 已被占用,无法启动。"
show_port_owner "$BACKEND_PORT"
echo "请在 .env 中设置空闲的 BACKEND_PORT 后重试。"
exit 1
} }
if [ -n "${DATABASE_URL:-}" ] && [[ "$DATABASE_URL" != mysql+* ]]; then if [ -n "${DATABASE_URL:-}" ] && [[ "$DATABASE_URL" != mysql+* ]]; then
@@ -121,6 +182,8 @@ if [ -z "${YYB_WORKER_KEY:-}" ]; then
exit 1 exit 1
fi fi
ensure_backend_port_available
COMPOSE=$(detect_compose) COMPOSE=$(detect_compose)
if [ -z "$COMPOSE" ]; then if [ -z "$COMPOSE" ]; then
echo "缺少 docker compose" echo "缺少 docker compose"
@@ -217,6 +280,7 @@ echo " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
echo " YYB Worker: ${DEV_YYB_WORKER_URL}" echo " YYB Worker: ${DEV_YYB_WORKER_URL}"
echo " 数据库迁移: 已检查至最新版本" echo " 数据库迁移: 已检查至最新版本"
echo " 应用日志: ${APP_LOG}" echo " 应用日志: ${APP_LOG}"
echo " 跟踪日志: ./dev.sh logs"
echo " 前端与 Worker 输出: 当前终端" echo " 前端与 Worker 输出: 当前终端"
echo " 退出: Ctrl+C" echo " 退出: Ctrl+C"
echo "==============================" echo "=============================="
@@ -225,7 +289,7 @@ echo ""
( (
export DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD export DB_HOST DB_PORT DB_NAME DB_USER DB_PASSWORD
export DB_POOL_SIZE DB_MAX_OVERFLOW DB_POOL_TIMEOUT DB_POOL_RECYCLE export DB_POOL_SIZE DB_MAX_OVERFLOW DB_POOL_TIMEOUT DB_POOL_RECYCLE
export YYB_WORKER_KEY LOG_LEVEL export YYB_WORKER_KEY LOG_LEVEL LOG_DIR
export YYB_WORKER_URL="$DEV_YYB_WORKER_URL" export YYB_WORKER_URL="$DEV_YYB_WORKER_URL"
uv run uvicorn web.backend.main:app \ uv run uvicorn web.backend.main:app \
--host "$BACKEND_HOST" \ --host "$BACKEND_HOST" \
@@ -237,6 +301,26 @@ echo ""
) & ) &
BACKEND_PID=$! BACKEND_PID=$!
echo "等待本地后端就绪..."
for i in $(seq 1 20); do
if curl -fsS --max-time 2 "http://127.0.0.1:${BACKEND_PORT}/api/health" 2>/dev/null | grep -q '"status":"ok"'; then
echo "后端已就绪,日志持续写入: $APP_LOG"
break
fi
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
echo "后端启动失败,最近应用日志:"
tail -n 80 "$APP_LOG" 2>/dev/null || true
wait "$BACKEND_PID" || true
exit 1
fi
if [ "$i" = "20" ]; then
echo "后端启动超时,最近应用日志:"
tail -n 80 "$APP_LOG" 2>/dev/null || true
exit 1
fi
sleep 1
done
( (
cd web/frontend cd web/frontend
VITE_BACKEND_TARGET="$BACKEND_PROXY_TARGET" \ VITE_BACKEND_TARGET="$BACKEND_PROXY_TARGET" \
+4
View File
@@ -18,6 +18,10 @@ services:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
# 应用日志等级由 .env 控制;生产环境默认只记录 INFO 及以上。 # 应用日志等级由 .env 控制;生产环境默认只记录 INFO 及以上。
- LOG_LEVEL=${LOG_LEVEL:-INFO} - LOG_LEVEL=${LOG_LEVEL:-INFO}
# 统一写入宿主机挂载的 ./logs,避免容器重建后丢失或写入不可见路径。
- LOG_DIR=/app/logs
- LOG_ROTATION_SIZE=${LOG_ROTATION_SIZE:-50M}
- LOG_RETENTION_DAYS=${LOG_RETENTION_DAYS:-14}
# MySQL 仅在 Compose 内网开放,不映射宿主机端口。 # MySQL 仅在 Compose 内网开放,不映射宿主机端口。
- DB_HOST=mysql - DB_HOST=mysql
- DB_PORT=3306 - DB_PORT=3306
+69
View File
@@ -0,0 +1,69 @@
"""日志文件轮转与敏感信息脱敏测试。"""
import gzip
import logging
import tempfile
import unittest
from datetime import datetime, timedelta
from pathlib import Path
from utils.logger import _SensitiveDataFilter, _SizeAndDayRotatingFileHandler, _parse_size
class LoggerTests(unittest.TestCase):
def test_size_parser_accepts_human_readable_values(self):
self.assertEqual(_parse_size("2K"), 2 * 1024)
self.assertEqual(_parse_size("3MiB"), 3 * 1024 * 1024)
self.assertEqual(_parse_size("invalid", default=123), 123)
def test_file_log_redacts_credentials_and_compresses_rotation(self):
with tempfile.TemporaryDirectory() as tmpdir:
log_path = Path(tmpdir) / "app-2026-08-28.log"
handler = _SizeAndDayRotatingFileHandler(log_path, max_bytes=1, retention_days=14)
handler.setFormatter(logging.Formatter("%(message)s"))
handler.addFilter(_SensitiveDataFilter())
test_logger = logging.getLogger("tests.logger.redaction")
test_logger.handlers = [handler]
test_logger.setLevel(logging.INFO)
test_logger.propagate = False
try:
test_logger.info("Cookie: access_token=super-secret; uid=1")
test_logger.info("Authorization: Bearer top-secret-token")
finally:
handler.close()
test_logger.handlers = []
archives = list(Path(tmpdir).glob("app-2026-08-28.log.*.gz"))
self.assertEqual(len(archives), 1)
archived_content = gzip.open(archives[0], "rt", encoding="utf-8").read()
current_content = log_path.read_text(encoding="utf-8")
combined = archived_content + current_content
self.assertNotIn("super-secret", combined)
self.assertNotIn("top-secret-token", combined)
self.assertIn("Cookie: [REDACTED]", combined)
self.assertIn("Authorization: [REDACTED]", combined)
def test_daily_log_switches_to_a_new_dated_file(self):
with tempfile.TemporaryDirectory() as tmpdir:
today = datetime.now().date()
old_path = Path(tmpdir) / f"app-{(today - timedelta(days=1)).isoformat()}.log"
handler = _SizeAndDayRotatingFileHandler(old_path, max_bytes=1024, retention_days=14)
handler.setFormatter(logging.Formatter("%(message)s"))
handler._active_day = today - timedelta(days=1)
test_logger = logging.getLogger("tests.logger.daily")
test_logger.handlers = [handler]
test_logger.setLevel(logging.INFO)
test_logger.propagate = False
try:
test_logger.info("today")
finally:
handler.close()
test_logger.handlers = []
current_path = Path(tmpdir) / f"app-{today.isoformat()}.log"
self.assertTrue(current_path.exists())
self.assertEqual(current_path.read_text(encoding="utf-8").strip(), "today")
if __name__ == "__main__":
unittest.main()
+156 -31
View File
@@ -1,15 +1,23 @@
"""日志配置模块""" """应用日志配置:统一文件位置、脱敏与按日期/大小轮转。"""
import sys from __future__ import annotations
import gzip
import logging import logging
import os
import re import re
from logging.handlers import TimedRotatingFileHandler import shutil
import sys
from datetime import datetime, timedelta
from logging.handlers import BaseRotatingHandler
from pathlib import Path from pathlib import Path
from loguru import logger from loguru import logger
_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} _LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
_DEFAULT_RETENTION_DAYS = 14
_DEFAULT_ROTATION_SIZE = 50 * 1024 * 1024
def _normalize_log_level(level: str | None) -> str: def _normalize_log_level(level: str | None) -> str:
@@ -18,6 +26,59 @@ def _normalize_log_level(level: str | None) -> str:
return normalized if normalized in _LOG_LEVELS else "INFO" return normalized if normalized in _LOG_LEVELS else "INFO"
def _parse_positive_int(value: str | None, default: int) -> int:
try:
parsed = int(str(value or default).strip())
except (TypeError, ValueError):
return default
return parsed if parsed > 0 else default
def _parse_size(value: str | None, default: int = _DEFAULT_ROTATION_SIZE) -> int:
"""解析 50M、1GiB 等易读大小;非法值保持安全默认值。"""
matched = re.fullmatch(r"\s*(\d+)\s*([kmgt]?i?b?)?\s*", str(value or ""), re.I)
if not matched:
return default
amount = int(matched.group(1))
if amount <= 0:
return default
unit = (matched.group(2) or "b").lower().rstrip("b").rstrip("i")
power = {"": 0, "k": 1, "m": 2, "g": 3, "t": 4}.get(unit)
return amount * (1024**power) if power is not None else default
class _SensitiveDataFilter(logging.Filter):
"""在日志离开进程前移除 Cookie、令牌和常见密钥值。"""
_HEADER_RE = re.compile(
r"\b(cookie|set-cookie|authorization|proxy-authorization)\s*([:=])\s*[^\r\n]+",
re.IGNORECASE,
)
_KEY_VALUE_RE = re.compile(
r"(?P<prefix>(?:[?&,\s{\[]|\")"
r"(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|"
r"secret|api[_-]?key|app[_-]?key|cookie)"
r"(?:\"|\])?\s*[=:]\s*(?:\"|')?)"
r"(?P<value>[^&\s,}\]\\\"']+)",
re.IGNORECASE,
)
_BEARER_RE = re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE)
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")
@classmethod
def redact(cls, message: str) -> str:
message = cls._HEADER_RE.sub(lambda m: f"{m.group(1)}{m.group(2)} [REDACTED]", message)
message = cls._KEY_VALUE_RE.sub(lambda m: f"{m.group('prefix')}[REDACTED]", message)
message = cls._BEARER_RE.sub("Bearer [REDACTED]", message)
return cls._JWT_RE.sub("[REDACTED_JWT]", message)
def filter(self, record: logging.LogRecord) -> bool:
# 先展开 args,再清空 args,确保 Formatter 不会重新格式化出敏感值。
record.msg = self.redact(record.getMessage())
record.args = ()
return True
class _RelevantAccessFilter(logging.Filter): class _RelevantAccessFilter(logging.Filter):
"""仅保留异常 HTTP 请求,避免前端轮询淹没业务日志。""" """仅保留异常 HTTP 请求,避免前端轮询淹没业务日志。"""
@@ -30,67 +91,131 @@ class _RelevantAccessFilter(logging.Filter):
return bool(matched and int(matched.group(1)) >= 400) return bool(matched and int(matched.group(1)) >= 400)
def _configure_standard_logging(level: str, log_dir: Path) -> logging.Logger: class _SizeAndDayRotatingFileHandler(BaseRotatingHandler):
"""将业务日志和关键框架日志收敛到单一轮转文件""" """写入日期文件;跨日切换文件,按大小压缩归档"""
def __init__(self, filename: Path, max_bytes: int, retention_days: int) -> None:
super().__init__(str(filename), "a", encoding="utf-8", delay=True)
self.max_bytes = max_bytes
self.retention_days = retention_days
self._active_day = datetime.now().date()
path = Path(filename)
self._log_dir = path.parent
self._suffix = path.suffix
self._filename_prefix = re.sub(r"-\d{4}-\d{2}-\d{2}$", "", path.stem)
def _path_for_day(self, day) -> Path:
return self._log_dir / f"{self._filename_prefix}-{day.isoformat()}{self._suffix}"
def shouldRollover(self, record: logging.LogRecord) -> bool: # noqa: N802
if datetime.now().date() != self._active_day:
return True
if self.stream is None:
self.stream = self._open()
self.stream.seek(0, os.SEEK_END)
message = f"{self.format(record)}\n"
return self.stream.tell() + len(message.encode("utf-8")) >= self.max_bytes
def doRollover(self) -> None: # noqa: N802
if self.stream is not None:
self.stream.close()
self.stream = None
source = Path(self.baseFilename)
current_day = datetime.now().date()
if current_day != self._active_day:
# 每日文件本身已带日期,跨日时直接切换到新文件,无需再移动旧文件。
self.baseFilename = os.fspath(self._path_for_day(current_day).resolve())
self._active_day = current_day
self._delete_expired_archives()
return
if source.exists() and source.stat().st_size:
stamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.gz")
sequence = 1
while archive.exists():
archive = source.with_name(f"{source.name}.{stamp}.{os.getpid()}.{sequence}.gz")
sequence += 1
with source.open("rb") as raw, gzip.open(archive, "wb") as compressed:
shutil.copyfileobj(raw, compressed)
source.unlink()
self._delete_expired_archives()
def _delete_expired_archives(self) -> None:
cutoff = datetime.now() - timedelta(days=self.retention_days)
for archive in self._log_dir.glob(f"{self._filename_prefix}-*.log*.gz"):
try:
if datetime.fromtimestamp(archive.stat().st_mtime) < cutoff:
archive.unlink()
except OSError:
continue
def _close_handlers(target: logging.Logger) -> None:
for handler in target.handlers[:]:
target.removeHandler(handler)
handler.close()
def _configure_standard_logging(level: str, log_path: Path) -> logging.Logger:
"""将业务日志和关键框架日志收敛到单一安全轮转文件。"""
log_level = getattr(logging, level.upper(), logging.INFO) log_level = getattr(logging, level.upper(), logging.INFO)
formatter = logging.Formatter( framework_level = max(log_level, logging.INFO)
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" formatter = logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
) redaction_filter = _SensitiveDataFilter()
console_handler = logging.StreamHandler(sys.stdout) console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(log_level) console_handler.setLevel(log_level)
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
console_handler.addFilter(redaction_filter)
file_handler = TimedRotatingFileHandler( retention_days = _parse_positive_int(os.getenv("LOG_RETENTION_DAYS"), _DEFAULT_RETENTION_DAYS)
log_dir / "app.log", when="midnight", backupCount=7, encoding="utf-8" rotation_size = _parse_size(os.getenv("LOG_ROTATION_SIZE"))
) file_handler = _SizeAndDayRotatingFileHandler(log_path, rotation_size, retention_days)
file_handler.setLevel(log_level) file_handler.setLevel(log_level)
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
file_handler.addFilter(redaction_filter)
app_logger = logging.getLogger("app") app_logger = logging.getLogger("app")
app_logger.handlers.clear() _close_handlers(app_logger)
app_logger.handlers = [console_handler, file_handler] app_logger.handlers = [console_handler, file_handler]
app_logger.setLevel(log_level) app_logger.setLevel(log_level)
app_logger.propagate = False app_logger.propagate = False
for name in ("uvicorn", "uvicorn.error", "fastapi", "starlette"): for name in ("uvicorn", "uvicorn.error", "fastapi", "starlette"):
standard_logger = logging.getLogger(name) standard_logger = logging.getLogger(name)
_close_handlers(standard_logger)
standard_logger.handlers = [console_handler, file_handler] standard_logger.handlers = [console_handler, file_handler]
standard_logger.setLevel(log_level) # 应用 DEBUG 不应打开 Uvicorn 的 WebSocket 协议/请求头细节。
standard_logger.setLevel(framework_level)
standard_logger.propagate = False standard_logger.propagate = False
access_logger = logging.getLogger("uvicorn.access") access_logger = logging.getLogger("uvicorn.access")
_close_handlers(access_logger)
access_logger.handlers = [console_handler, file_handler] access_logger.handlers = [console_handler, file_handler]
access_logger.filters = [_RelevantAccessFilter()] access_logger.filters.clear()
access_logger.addFilter(_RelevantAccessFilter())
access_logger.setLevel(logging.INFO) access_logger.setLevel(logging.INFO)
access_logger.propagate = False access_logger.propagate = False
return app_logger return app_logger
def setup_logger(level: str = "INFO", log_dir: str = None, log_file: str = None) -> None: def setup_logger(level: str = "INFO", log_dir: str | None = None, log_file: str | None = None) -> None:
""" """配置控制台和文件日志。
配置日志
Args: ``log_dir`` 默认写入 ``app-YYYY-MM-DD.log````log_file`` 可指定完整文件名。环境变量
level: 日志级别 ``LOG_ROTATION_SIZE``(默认 50M)和 ``LOG_RETENTION_DAYS``(默认 14)控制归档。
log_dir: 日志目录路径(写入 app.log,按日轮转)
log_file: 日志文件路径(兼容旧接口,优先级低于 log_dir)
""" """
level = _normalize_log_level(level) level = _normalize_log_level(level)
# 移除默认 handler,由标准 logging 统一处理控制台和文件输出。
logger.remove() logger.remove()
# 确定日志文件路径 if log_file:
if log_dir: file_path = Path(log_file).expanduser()
dir_path = Path(log_dir) elif log_dir:
dir_path.mkdir(parents=True, exist_ok=True) file_path = Path(log_dir).expanduser() / f"app-{datetime.now():%Y-%m-%d}.log"
app_logger = _configure_standard_logging(level, dir_path)
elif log_file:
file_path = Path(log_file)
file_path.parent.mkdir(parents=True, exist_ok=True)
app_logger = _configure_standard_logging(level, file_path.parent)
else: else:
return return
file_path.parent.mkdir(parents=True, exist_ok=True)
app_logger = _configure_standard_logging(level, file_path)
def forward_to_standard(message) -> None: def forward_to_standard(message) -> None:
record = message.record record = message.record
+2 -2
View File
@@ -20,9 +20,9 @@ from utils import setup_logger
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# 初始化日志(控制台 + 按天命名文件) # 本地 dev.sh 和 Docker 均显式设置 LOG_DIR;直接运行时回退到项目 logs/。
_log_level = os.getenv("LOG_LEVEL", "INFO") _log_level = os.getenv("LOG_LEVEL", "INFO")
_log_dir = Path(__file__).resolve().parents[2] / "logs" _log_dir = Path(os.getenv("LOG_DIR", str(Path(__file__).resolve().parents[2] / "logs")))
setup_logger(level=_log_level, log_dir=str(_log_dir)) setup_logger(level=_log_level, log_dir=str(_log_dir))
init_db() init_db()