feat(logging): improve local and docker log handling
This commit is contained in:
@@ -20,6 +20,11 @@ APP_PORT=8000
|
||||
|
||||
# 应用日志等级:DEBUG、INFO、WARNING、ERROR、CRITICAL;生产环境建议保持 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。
|
||||
COOKIE_RELOGIN_MAX_RETRIES=5
|
||||
|
||||
@@ -65,13 +65,14 @@ YYB_WORKER_KEY=随机密钥
|
||||
|
||||
随后执行 `./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
|
||||
./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 restart # 重启服务
|
||||
./deploy.sh reset # 重置所有数据(需二次确认)
|
||||
|
||||
@@ -85,7 +85,8 @@ cmd_deploy() {
|
||||
echo " 访问地址: http://localhost:${APP_PORT}"
|
||||
echo " 默认账号见环境变量 ADMIN_USERNAME/ADMIN_PASSWORD(未设置则为 admin/admin123)"
|
||||
echo ""
|
||||
echo " 查看日志: $COMPOSE logs -f"
|
||||
echo " 查看应用日志: ./deploy.sh logs"
|
||||
echo " 查看容器输出: ./deploy.sh logs container"
|
||||
echo " 停止服务: $COMPOSE down"
|
||||
echo " 重启服务: $COMPOSE restart"
|
||||
echo " 重置数据: ./deploy.sh reset"
|
||||
@@ -156,12 +157,24 @@ cmd_reset() {
|
||||
}
|
||||
|
||||
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)
|
||||
if [ -z "$COMPOSE" ]; then
|
||||
echo "❌ 未检测到 docker compose"
|
||||
exit 1
|
||||
fi
|
||||
$COMPOSE logs -f
|
||||
$COMPOSE logs --tail "${LOG_TAIL_LINES:-200}" -f
|
||||
}
|
||||
|
||||
cmd_migrate_mysql() {
|
||||
@@ -271,7 +284,7 @@ cmd_help() {
|
||||
echo " reset 删除所有数据并重置(需确认)"
|
||||
echo " migrate-mysql 先启动 MySQL 并迁移 data/web.db"
|
||||
echo " migrate 仅执行数据库结构迁移"
|
||||
echo " logs 查看实时日志"
|
||||
echo " logs [app|container] 查看应用文件日志(默认)或容器输出"
|
||||
echo " stop 停止服务"
|
||||
echo " restart 重启服务"
|
||||
echo " dev 本地调试启动(后端 reload + 前端热更新)"
|
||||
@@ -289,7 +302,7 @@ case "${1:-}" in
|
||||
reset) cmd_reset ;;
|
||||
migrate-mysql) cmd_migrate_mysql ;;
|
||||
migrate) cmd_migrate ;;
|
||||
logs) cmd_logs ;;
|
||||
logs) shift; cmd_logs "${1:-app}" ;;
|
||||
stop) cmd_stop ;;
|
||||
restart) cmd_restart ;;
|
||||
dev) cmd_dev ;;
|
||||
|
||||
@@ -15,6 +15,11 @@ if [ -f "$ROOT_DIR/.env" ]; then
|
||||
fi
|
||||
|
||||
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}"
|
||||
FRONTEND_HOST="${FRONTEND_HOST:-0.0.0.0}"
|
||||
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_NODE_MODULES="$ROOT_DIR/services/yyb-worker/node_modules"
|
||||
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=""
|
||||
FRONTEND_PID=""
|
||||
@@ -92,13 +104,62 @@ detect_backend_proxy_host() {
|
||||
echo "$BACKEND_HOST"
|
||||
return
|
||||
fi
|
||||
# 绑定 0.0.0.0 时,Vite 代理不要固定连 127.0.0.1;某些本地工具会抢占 loopback 端口。
|
||||
local ip
|
||||
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)"
|
||||
# Vite 是本机进程,回环代理最稳定;LAN 客户端仍可经 Vite 的 0.0.0.0 监听访问。
|
||||
echo "127.0.0.1"
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
@@ -121,6 +182,8 @@ if [ -z "${YYB_WORKER_KEY:-}" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_backend_port_available
|
||||
|
||||
COMPOSE=$(detect_compose)
|
||||
if [ -z "$COMPOSE" ]; then
|
||||
echo "缺少 docker compose"
|
||||
@@ -217,6 +280,7 @@ echo " MySQL: ${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||
echo " YYB Worker: ${DEV_YYB_WORKER_URL}"
|
||||
echo " 数据库迁移: 已检查至最新版本"
|
||||
echo " 应用日志: ${APP_LOG}"
|
||||
echo " 跟踪日志: ./dev.sh logs"
|
||||
echo " 前端与 Worker 输出: 当前终端"
|
||||
echo " 退出: Ctrl+C"
|
||||
echo "=============================="
|
||||
@@ -225,7 +289,7 @@ echo ""
|
||||
(
|
||||
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 YYB_WORKER_KEY LOG_LEVEL
|
||||
export YYB_WORKER_KEY LOG_LEVEL LOG_DIR
|
||||
export YYB_WORKER_URL="$DEV_YYB_WORKER_URL"
|
||||
uv run uvicorn web.backend.main:app \
|
||||
--host "$BACKEND_HOST" \
|
||||
@@ -237,6 +301,26 @@ echo ""
|
||||
) &
|
||||
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
|
||||
VITE_BACKEND_TARGET="$BACKEND_PROXY_TARGET" \
|
||||
|
||||
@@ -18,6 +18,10 @@ services:
|
||||
- TZ=Asia/Shanghai
|
||||
# 应用日志等级由 .env 控制;生产环境默认只记录 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 内网开放,不映射宿主机端口。
|
||||
- DB_HOST=mysql
|
||||
- DB_PORT=3306
|
||||
|
||||
@@ -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
@@ -1,15 +1,23 @@
|
||||
"""日志配置模块"""
|
||||
"""应用日志配置:统一文件位置、脱敏与按日期/大小轮转。"""
|
||||
|
||||
import sys
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
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 loguru import logger
|
||||
|
||||
|
||||
_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:
|
||||
@@ -18,6 +26,59 @@ def _normalize_log_level(level: str | None) -> str:
|
||||
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):
|
||||
"""仅保留异常 HTTP 请求,避免前端轮询淹没业务日志。"""
|
||||
|
||||
@@ -30,67 +91,131 @@ class _RelevantAccessFilter(logging.Filter):
|
||||
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)
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
||||
)
|
||||
framework_level = max(log_level, logging.INFO)
|
||||
formatter = logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
|
||||
redaction_filter = _SensitiveDataFilter()
|
||||
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(log_level)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.addFilter(redaction_filter)
|
||||
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
log_dir / "app.log", when="midnight", backupCount=7, encoding="utf-8"
|
||||
)
|
||||
retention_days = _parse_positive_int(os.getenv("LOG_RETENTION_DAYS"), _DEFAULT_RETENTION_DAYS)
|
||||
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.setFormatter(formatter)
|
||||
file_handler.addFilter(redaction_filter)
|
||||
|
||||
app_logger = logging.getLogger("app")
|
||||
app_logger.handlers.clear()
|
||||
_close_handlers(app_logger)
|
||||
app_logger.handlers = [console_handler, file_handler]
|
||||
app_logger.setLevel(log_level)
|
||||
app_logger.propagate = False
|
||||
|
||||
for name in ("uvicorn", "uvicorn.error", "fastapi", "starlette"):
|
||||
standard_logger = logging.getLogger(name)
|
||||
_close_handlers(standard_logger)
|
||||
standard_logger.handlers = [console_handler, file_handler]
|
||||
standard_logger.setLevel(log_level)
|
||||
# 应用 DEBUG 不应打开 Uvicorn 的 WebSocket 协议/请求头细节。
|
||||
standard_logger.setLevel(framework_level)
|
||||
standard_logger.propagate = False
|
||||
|
||||
access_logger = logging.getLogger("uvicorn.access")
|
||||
_close_handlers(access_logger)
|
||||
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.propagate = False
|
||||
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:
|
||||
level: 日志级别
|
||||
log_dir: 日志目录路径(写入 app.log,按日轮转)
|
||||
log_file: 日志文件路径(兼容旧接口,优先级低于 log_dir)
|
||||
``log_dir`` 默认写入 ``app-YYYY-MM-DD.log``;``log_file`` 可指定完整文件名。环境变量
|
||||
``LOG_ROTATION_SIZE``(默认 50M)和 ``LOG_RETENTION_DAYS``(默认 14)控制归档。
|
||||
"""
|
||||
level = _normalize_log_level(level)
|
||||
# 移除默认 handler,由标准 logging 统一处理控制台和文件输出。
|
||||
logger.remove()
|
||||
|
||||
# 确定日志文件路径
|
||||
if log_dir:
|
||||
dir_path = Path(log_dir)
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
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)
|
||||
if log_file:
|
||||
file_path = Path(log_file).expanduser()
|
||||
elif log_dir:
|
||||
file_path = Path(log_dir).expanduser() / f"app-{datetime.now():%Y-%m-%d}.log"
|
||||
else:
|
||||
return
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
app_logger = _configure_standard_logging(level, file_path)
|
||||
|
||||
def forward_to_standard(message) -> None:
|
||||
record = message.record
|
||||
|
||||
+2
-2
@@ -20,9 +20,9 @@ from utils import setup_logger
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 初始化日志(控制台 + 按天命名文件)
|
||||
# 本地 dev.sh 和 Docker 均显式设置 LOG_DIR;直接运行时回退到项目 logs/。
|
||||
_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))
|
||||
|
||||
init_db()
|
||||
|
||||
Reference in New Issue
Block a user