fix: 修复Docker部署健康检查路径和API Key配置 [AC-AISVC-01]
- 修正docker-compose.yaml中健康检查路径从/health改为/ai/health - 在middleware中添加/ai/health到API Key和租户检查的跳过列表 - 添加前端.env.example配置文件说明API Key配置方法 - 更新README添加API Key配置步骤说明
This commit is contained in:
parent
c7a71d6e03
commit
1000158550
30
README.md
30
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. 访问服务
|
||||
|
||||
| 服务 | 地址 | 说明 |
|
||||
|------|------|------|
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue