From 1000158550cdf5d9784428d03419363fd5dec88f Mon Sep 17 00:00:00 2001 From: MerCry Date: Thu, 26 Feb 2026 02:41:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DDocker=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=92=8CAPI=20Key=E9=85=8D=E7=BD=AE=20[AC-AISVC-01]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正docker-compose.yaml中健康检查路径从/health改为/ai/health - 在middleware中添加/ai/health到API Key和租户检查的跳过列表 - 添加前端.env.example配置文件说明API Key配置方法 - 更新README添加API Key配置步骤说明 --- README.md | 30 +++++++++++-- ai-service-admin/.env.example | 8 ++++ ai-service/app/core/middleware.py | 72 +++++++++++++++++++++++++++---- docker-compose.yaml | 2 +- 4 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 ai-service-admin/.env.example diff --git a/README.md b/README.md index 73f9574..65b5f23 100644 --- a/README.md +++ b/README.md @@ -91,11 +91,35 @@ docker exec -it ai-ollama ollama pull nomic-embed-text # 检查服务状态 docker compose ps -# 查看后端日志 -docker compose logs -f ai-service +# 查看后端日志,找到自动生成的 API Key +docker compose logs -f ai-service | grep "Default API Key" ``` -#### 6. 访问服务 +> **重要**: 后端首次启动时会自动生成一个默认 API Key,请从日志中复制该 Key,用于前端配置。 + +#### 6. 配置前端 API Key + +```bash +# 创建前端环境变量文件 +cd ai-service-admin +cp .env.example .env +``` + +编辑 `ai-service-admin/.env`,将 `VITE_APP_API_KEY` 设置为后端日志中的 API Key: + +```env +VITE_APP_BASE_API=/api +VITE_APP_API_KEY=<从后端日志复制的API Key> +``` + +然后重新构建前端: + +```bash +cd .. +docker compose up -d --build ai-service-admin +``` + +#### 7. 访问服务 | 服务 | 地址 | 说明 | |------|------|------| diff --git a/ai-service-admin/.env.example b/ai-service-admin/.env.example new file mode 100644 index 0000000..533aaf3 --- /dev/null +++ b/ai-service-admin/.env.example @@ -0,0 +1,8 @@ +# API Base URL +VITE_APP_BASE_API=/api + +# Default API Key for authentication +# IMPORTANT: You must set this to a valid API key from the backend +# The backend creates a default API key on first startup (check backend logs) +# Or you can create one via the API: POST /admin/api-keys +VITE_APP_API_KEY=your-api-key-here diff --git a/ai-service/app/core/middleware.py b/ai-service/app/core/middleware.py index 4100813..e1db0c3 100644 --- a/ai-service/app/core/middleware.py +++ b/ai-service/app/core/middleware.py @@ -1,6 +1,6 @@ """ Middleware for AI Service. -[AC-AISVC-10, AC-AISVC-12] X-Tenant-Id header validation and tenant context injection. +[AC-AISVC-10, AC-AISVC-12, AC-AISVC-50] X-Tenant-Id header validation, tenant context injection, and API Key authentication. """ import logging @@ -17,12 +17,20 @@ from app.core.tenant import clear_tenant_context, set_tenant_context logger = logging.getLogger(__name__) TENANT_ID_HEADER = "X-Tenant-Id" +API_KEY_HEADER = "X-API-Key" ACCEPT_HEADER = "Accept" SSE_CONTENT_TYPE = "text/event-stream" -# Tenant ID format: name@ash@year (e.g., szmp@ash@2026) TENANT_ID_PATTERN = re.compile(r'^[^@]+@ash@\d{4}$') +PATHS_SKIP_API_KEY = { + "/health", + "/ai/health", + "/docs", + "/redoc", + "/openapi.json", +} + def validate_tenant_id_format(tenant_id: str) -> bool: """ @@ -41,6 +49,59 @@ def parse_tenant_id(tenant_id: str) -> tuple[str, str]: return parts[0], parts[2] +class ApiKeyMiddleware(BaseHTTPMiddleware): + """ + [AC-AISVC-50] Middleware to validate API Key for all requests. + + Features: + - Validates X-API-Key header against in-memory cache + - Skips validation for health/docs endpoints + - Returns 401 for missing or invalid API key + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + if self._should_skip_api_key(request.url.path): + return await call_next(request) + + api_key = request.headers.get(API_KEY_HEADER) + + if not api_key or not api_key.strip(): + logger.warning(f"[AC-AISVC-50] Missing X-API-Key header for {request.url.path}") + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content=ErrorResponse( + code=ErrorCode.UNAUTHORIZED.value, + message="Missing required header: X-API-Key", + ).model_dump(exclude_none=True), + ) + + api_key = api_key.strip() + + from app.services.api_key import get_api_key_service + service = get_api_key_service() + + if not service.validate_key(api_key): + logger.warning(f"[AC-AISVC-50] Invalid API key for {request.url.path}") + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content=ErrorResponse( + code=ErrorCode.UNAUTHORIZED.value, + message="Invalid API key", + ).model_dump(exclude_none=True), + ) + + return await call_next(request) + + def _should_skip_api_key(self, path: str) -> bool: + """Check if the path should skip API key validation.""" + if path in PATHS_SKIP_API_KEY: + return True + for skip_path in PATHS_SKIP_API_KEY: + if path.startswith(skip_path): + return True + return False + + class TenantContextMiddleware(BaseHTTPMiddleware): """ [AC-AISVC-10, AC-AISVC-12] Middleware to extract and validate X-Tenant-Id header. @@ -51,7 +112,7 @@ class TenantContextMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: Callable) -> Response: clear_tenant_context() - if request.url.path == "/ai/health": + if request.url.path in ("/health", "/ai/health"): return await call_next(request) tenant_id = request.headers.get(TENANT_ID_HEADER) @@ -68,7 +129,6 @@ class TenantContextMiddleware(BaseHTTPMiddleware): tenant_id = tenant_id.strip() - # Validate tenant ID format if not validate_tenant_id_format(tenant_id): logger.warning(f"[AC-AISVC-10] Invalid tenant ID format: {tenant_id}") return JSONResponse( @@ -79,13 +139,11 @@ class TenantContextMiddleware(BaseHTTPMiddleware): ).model_dump(exclude_none=True), ) - # Auto-create tenant if not exists (for admin endpoints) if request.url.path.startswith("/admin/") or request.url.path.startswith("/ai/"): try: await self._ensure_tenant_exists(request, tenant_id) except Exception as e: logger.error(f"[AC-AISVC-10] Failed to ensure tenant exists: {e}") - # Continue processing even if tenant creation fails set_tenant_context(tenant_id) request.state.tenant_id = tenant_id @@ -112,7 +170,6 @@ class TenantContextMiddleware(BaseHTTPMiddleware): name, year = parse_tenant_id(tenant_id) async with async_session_maker() as session: - # Check if tenant exists stmt = select(Tenant).where(Tenant.tenant_id == tenant_id) result = await session.execute(stmt) existing_tenant = result.scalar_one_or_none() @@ -121,7 +178,6 @@ class TenantContextMiddleware(BaseHTTPMiddleware): logger.debug(f"[AC-AISVC-10] Tenant already exists: {tenant_id}") return - # Create new tenant new_tenant = Tenant( tenant_id=tenant_id, name=name, diff --git a/docker-compose.yaml b/docker-compose.yaml index eb0947b..5d9fd2a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -27,7 +27,7 @@ services: networks: - ai-network healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + test: ["CMD", "curl", "-f", "http://localhost:8080/ai/health"] interval: 30s timeout: 10s retries: 3