diff --git a/.env.example b/.env.example index 58cb61a..86d83dd 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 6c52051..6835565 100644 --- a/README.md +++ b/README.md @@ -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 # 重置所有数据(需二次确认) diff --git a/deploy.sh b/deploy.sh index eb174fa..1eae788 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 ;; diff --git a/dev.sh b/dev.sh index 138626e..8b47700 100755 --- a/dev.sh +++ b/dev.sh @@ -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" \ diff --git a/docker-compose.yml b/docker-compose.yml index 8a84135..eb8fb10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..8bcb598 --- /dev/null +++ b/tests/test_logger.py @@ -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() diff --git a/utils/logger.py b/utils/logger.py index 08ab500..9fc422b 100644 --- a/utils/logger.py +++ b/utils/logger.py @@ -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(?:[?&,\s{\[]|\")" + r"(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|" + r"secret|api[_-]?key|app[_-]?key|cookie)" + r"(?:\"|\])?\s*[=:]\s*(?:\"|')?)" + r"(?P[^&\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 diff --git a/web/backend/main.py b/web/backend/main.py index 5ae33c9..866ebe4 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -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()