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 ps
|
||||||
|
|
||||||
# 查看后端日志
|
# 查看后端日志,找到自动生成的 API Key
|
||||||
docker compose logs -f ai-service
|
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.
|
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
|
import logging
|
||||||
|
|
@ -17,12 +17,20 @@ from app.core.tenant import clear_tenant_context, set_tenant_context
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
TENANT_ID_HEADER = "X-Tenant-Id"
|
TENANT_ID_HEADER = "X-Tenant-Id"
|
||||||
|
API_KEY_HEADER = "X-API-Key"
|
||||||
ACCEPT_HEADER = "Accept"
|
ACCEPT_HEADER = "Accept"
|
||||||
SSE_CONTENT_TYPE = "text/event-stream"
|
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}$')
|
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:
|
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]
|
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):
|
class TenantContextMiddleware(BaseHTTPMiddleware):
|
||||||
"""
|
"""
|
||||||
[AC-AISVC-10, AC-AISVC-12] Middleware to extract and validate X-Tenant-Id header.
|
[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:
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
clear_tenant_context()
|
clear_tenant_context()
|
||||||
|
|
||||||
if request.url.path == "/ai/health":
|
if request.url.path in ("/health", "/ai/health"):
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
tenant_id = request.headers.get(TENANT_ID_HEADER)
|
tenant_id = request.headers.get(TENANT_ID_HEADER)
|
||||||
|
|
@ -68,7 +129,6 @@ class TenantContextMiddleware(BaseHTTPMiddleware):
|
||||||
|
|
||||||
tenant_id = tenant_id.strip()
|
tenant_id = tenant_id.strip()
|
||||||
|
|
||||||
# Validate tenant ID format
|
|
||||||
if not validate_tenant_id_format(tenant_id):
|
if not validate_tenant_id_format(tenant_id):
|
||||||
logger.warning(f"[AC-AISVC-10] Invalid tenant ID format: {tenant_id}")
|
logger.warning(f"[AC-AISVC-10] Invalid tenant ID format: {tenant_id}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -79,13 +139,11 @@ class TenantContextMiddleware(BaseHTTPMiddleware):
|
||||||
).model_dump(exclude_none=True),
|
).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/"):
|
if request.url.path.startswith("/admin/") or request.url.path.startswith("/ai/"):
|
||||||
try:
|
try:
|
||||||
await self._ensure_tenant_exists(request, tenant_id)
|
await self._ensure_tenant_exists(request, tenant_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[AC-AISVC-10] Failed to ensure tenant exists: {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)
|
set_tenant_context(tenant_id)
|
||||||
request.state.tenant_id = tenant_id
|
request.state.tenant_id = tenant_id
|
||||||
|
|
@ -112,7 +170,6 @@ class TenantContextMiddleware(BaseHTTPMiddleware):
|
||||||
name, year = parse_tenant_id(tenant_id)
|
name, year = parse_tenant_id(tenant_id)
|
||||||
|
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
# Check if tenant exists
|
|
||||||
stmt = select(Tenant).where(Tenant.tenant_id == tenant_id)
|
stmt = select(Tenant).where(Tenant.tenant_id == tenant_id)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
existing_tenant = result.scalar_one_or_none()
|
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}")
|
logger.debug(f"[AC-AISVC-10] Tenant already exists: {tenant_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Create new tenant
|
|
||||||
new_tenant = Tenant(
|
new_tenant = Tenant(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
name=name,
|
name=name,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- ai-network
|
- ai-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8080/ai/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue