From d7d9514348d2527092b5a95acfbc008c75aa713c Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 10 Jul 2026 12:14:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E5=8C=96=E6=97=A5=E5=BF=97=E5=92=8C=E6=9C=8D=E5=8A=A1=E5=B0=B1?= =?UTF-8?q?=E7=BB=AA=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ai_knowledge_base_v2/apps/backend/Dockerfile | 2 +- .../apps/backend/app/api/health.py | 33 +++++++ .../apps/backend/app/core/config.py | 2 + .../apps/backend/app/core/observability.py | 99 +++++++++++++++++++ ai_knowledge_base_v2/apps/backend/app/main.py | 3 + .../tests/test_production_readiness.py | 32 ++++++ ai_knowledge_base_v2/docker-compose.prod.yml | 17 +++- 7 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/backend/app/core/observability.py diff --git a/ai_knowledge_base_v2/apps/backend/Dockerfile b/ai_knowledge_base_v2/apps/backend/Dockerfile index b16047b..5467679 100644 --- a/ai_knowledge_base_v2/apps/backend/Dockerfile +++ b/ai_knowledge_base_v2/apps/backend/Dockerfile @@ -16,4 +16,4 @@ COPY . . EXPOSE 8100 -CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8100 --workers ${BACKEND_WORKERS:-1}"] +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8100 --workers ${BACKEND_WORKERS:-1} --no-access-log"] diff --git a/ai_knowledge_base_v2/apps/backend/app/api/health.py b/ai_knowledge_base_v2/apps/backend/app/api/health.py index bb6e9a1..f575da8 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/health.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/health.py @@ -1,8 +1,13 @@ from __future__ import annotations from fastapi import APIRouter +from fastapi.responses import JSONResponse +from sqlalchemy import text +from app.core.config import get_settings +from app.core.database import SessionLocal from app.core.responses import api_success +from app.services.redis_client import get_sync_redis_client router = APIRouter() @@ -10,3 +15,31 @@ router = APIRouter() @router.get("/health") def health() -> dict: return api_success({"status": "ok"}) + + +@router.get("/ready") +def ready(): + checks = {"database": _database_ready(), "redis": _redis_ready()} + redis_required = get_settings().readiness_requires_redis + ready_state = checks["database"] and (checks["redis"] or not redis_required) + payload = api_success({"status": "ready" if ready_state else "not_ready", "checks": checks}) + return payload if ready_state else JSONResponse(status_code=503, content=payload) + + +def _database_ready() -> bool: + try: + with SessionLocal() as db: + db.execute(text("SELECT 1")) + return True + except Exception: + return False + + +def _redis_ready() -> bool: + client = get_sync_redis_client() + if client is None: + return False + try: + return bool(client.ping()) + except Exception: + return False diff --git a/ai_knowledge_base_v2/apps/backend/app/core/config.py b/ai_knowledge_base_v2/apps/backend/app/core/config.py index a3c9024..a70dffe 100644 --- a/ai_knowledge_base_v2/apps/backend/app/core/config.py +++ b/ai_knowledge_base_v2/apps/backend/app/core/config.py @@ -12,6 +12,7 @@ class Settings(BaseSettings): app_name: str = "AI Knowledge Base V2 Backend" app_env: str = "local" debug: bool = True + log_level: str = "INFO" api_prefix: str = "/api" cors_origins: list[str] = Field( default_factory=lambda: [ @@ -26,6 +27,7 @@ class Settings(BaseSettings): database_url: str = "mysql+pymysql://ai_kb:ai_kb@127.0.0.1:3306/ai_knowledge_base_v2?charset=utf8mb4" redis_url: str = "" + readiness_requires_redis: bool = True db_pool_size: int = 10 db_max_overflow: int = 20 db_pool_timeout: int = 30 diff --git a/ai_knowledge_base_v2/apps/backend/app/core/observability.py b/ai_knowledge_base_v2/apps/backend/app/core/observability.py new file mode 100644 index 0000000..f20d416 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/core/observability.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from contextvars import ContextVar +from datetime import UTC, datetime +import json +import logging +from logging.config import dictConfig +from time import perf_counter +from uuid import uuid4 + + +request_id_context: ContextVar[str] = ContextVar("request_id", default="-") + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "timestamp": datetime.now(UTC).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "request_id": getattr(record, "request_id", None) or request_id_context.get(), + } + for key in ("method", "path", "status_code", "duration_ms", "client_ip"): + value = getattr(record, key, None) + if value is not None: + payload[key] = value + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +def configure_logging(level: str = "INFO") -> None: + dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "formatters": {"json": {"()": JsonFormatter}}, + "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "json", "stream": "ext://sys.stdout"}}, + "loggers": { + "app": {"handlers": ["console"], "level": level, "propagate": False}, + "app.access": {"handlers": ["console"], "level": level, "propagate": False}, + }, + } + ) + + +class RequestObservabilityMiddleware: + def __init__(self, app) -> None: + self.app = app + self.logger = logging.getLogger("app.access") + + async def __call__(self, scope, receive, send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + headers = {key.lower(): value for key, value in scope.get("headers", [])} + incoming_id = headers.get(b"x-request-id", b"").decode("ascii", errors="ignore").strip() + request_id = incoming_id[:64] if incoming_id else uuid4().hex + token = request_id_context.set(request_id) + started_at = perf_counter() + status_code = 500 + response_finished = False + + async def send_with_request_id(message) -> None: + nonlocal status_code, response_finished + if message["type"] == "http.response.start": + status_code = int(message["status"]) + message.setdefault("headers", []).append((b"x-request-id", request_id.encode("ascii"))) + elif message["type"] == "http.response.body" and not message.get("more_body", False): + response_finished = True + await send(message) + + try: + await self.app(scope, receive, send_with_request_id) + except Exception: + self._log(scope, request_id, 500, started_at, logging.ERROR, "request_failed", exc_info=True) + raise + finally: + if response_finished: + self._log(scope, request_id, status_code, started_at, logging.INFO, "request_completed") + request_id_context.reset(token) + + def _log(self, scope, request_id: str, status_code: int, started_at: float, level: int, message: str, **kwargs) -> None: + client = scope.get("client") + self.logger.log( + level, + message, + extra={ + "request_id": request_id, + "method": scope.get("method"), + "path": scope.get("path"), + "status_code": status_code, + "duration_ms": round((perf_counter() - started_at) * 1000, 2), + "client_ip": client[0] if client else "unknown", + }, + **kwargs, + ) diff --git a/ai_knowledge_base_v2/apps/backend/app/main.py b/ai_knowledge_base_v2/apps/backend/app/main.py index f015ff9..1246e56 100644 --- a/ai_knowledge_base_v2/apps/backend/app/main.py +++ b/ai_knowledge_base_v2/apps/backend/app/main.py @@ -9,6 +9,7 @@ from app.api.router import api_router from app.core.config import get_settings from app.core.database import create_tables from app.core.exception_handlers import register_exception_handlers +from app.core.observability import RequestObservabilityMiddleware, configure_logging from app.services.secret_service import SecretService @@ -22,9 +23,11 @@ async def lifespan(app: FastAPI): settings = get_settings() +configure_logging(settings.log_level) app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan) register_exception_handlers(app) +app.add_middleware(RequestObservabilityMiddleware) app.add_middleware( CORSMiddleware, diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py index 6645674..7b7768d 100644 --- a/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py +++ b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py @@ -5,7 +5,9 @@ import asyncio import json import pytest +import httpx from fastapi import HTTPException +from fastapi import FastAPI from sqlalchemy import create_engine from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool @@ -19,6 +21,7 @@ from app.services.chat_queue_service import ChatQueueConfig, QueueRequest from app.services.chat_service import ChatService from app.services.secret_service import ENCRYPTED_PREFIX, MASKED_SECRET, SecretService from app.services.security_state_service import SecurityStateService +from app.core.observability import RequestObservabilityMiddleware @pytest.fixture(autouse=True) @@ -113,3 +116,32 @@ def test_sensitive_system_config_never_returns_plaintext(): config = SystemConfig(config_key="feishu_app_secret", config_value="plain-secret") result = _config_dict(config) assert result["configValue"] == MASKED_SECRET + + +def test_request_id_is_returned_without_breaking_response(): + app = FastAPI() + app.add_middleware(RequestObservabilityMiddleware) + + @app.get("/test") + def endpoint(): + return {"ok": True} + + async def make_request(): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get("/test", headers={"X-Request-ID": "trace-123"}) + + response = asyncio.run(make_request()) + assert response.status_code == 200 + assert response.headers["X-Request-ID"] == "trace-123" + assert response.json() == {"ok": True} + + +def test_readiness_reports_dependency_state(monkeypatch): + from app.api import health + + monkeypatch.setattr(health, "_database_ready", lambda: True) + monkeypatch.setattr(health, "_redis_ready", lambda: True) + result = health.ready() + assert result["data"]["status"] == "ready" + assert result["data"]["checks"] == {"database": True, "redis": True} diff --git a/ai_knowledge_base_v2/docker-compose.prod.yml b/ai_knowledge_base_v2/docker-compose.prod.yml index 59a7a9c..b8cb5bb 100644 --- a/ai_knowledge_base_v2/docker-compose.prod.yml +++ b/ai_knowledge_base_v2/docker-compose.prod.yml @@ -40,6 +40,8 @@ services: DEBUG: "false" DATABASE_URL: ${DATABASE_URL:?必须配置经过 URL 编码的 DATABASE_URL} REDIS_URL: ${REDIS_URL:?必须配置经过 URL 编码的 REDIS_URL} + READINESS_REQUIRES_REDIS: "true" + LOG_LEVEL: INFO BACKEND_WORKERS: ${BACKEND_WORKERS:-4} DB_POOL_SIZE: ${DB_POOL_SIZE:-20} DB_MAX_OVERFLOW: ${DB_MAX_OVERFLOW:-20} @@ -58,6 +60,12 @@ services: condition: service_healthy redis: condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8100/api/ready', timeout=3)"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 30s user-client: build: @@ -79,9 +87,12 @@ services: volumes: - ./deploy/gateway.prod.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - - backend - - user-client - - admin-web + backend: + condition: service_healthy + user-client: + condition: service_started + admin-web: + condition: service_started volumes: ai_kb_v2_mysql_data: