refactor(api): guard route adapter boundaries
Deploy / Deploy Production (push) Failing after 2101h25m5s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 2101h27m42s
Deploy / Deploy Staging (push) Has been cancelled

This commit is contained in:
Xiaoxia AI
2026-06-21 02:02:55 +08:00
parent e5224d090b
commit 0501e96cd2
10 changed files with 75 additions and 28 deletions
+3 -3
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
from app.dependencies import get_asset_library_repository
from app.schemas.asset_library import AssetLibraryResponse, CreateAssetLibraryRequest, ListAssetLibrariesResponse
from packages.adapters.sqlalchemy_impl import SQLAlchemyAssetLibraryRepository
from typing import Any
from packages.application import CreateAssetLibraryCommand, CreateAssetLibraryUseCase, ListAssetLibrariesUseCase
from packages.domain import AssetLibraryKind
@@ -25,7 +25,7 @@ def _to_asset_library_response(item) -> AssetLibraryResponse:
def list_asset_libraries(
project_id: str,
kind: str | None = None,
asset_library_repository: SQLAlchemyAssetLibraryRepository = Depends(get_asset_library_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> ListAssetLibrariesResponse:
use_case = ListAssetLibrariesUseCase(asset_library_repository)
parsed_kind = AssetLibraryKind(kind) if kind else None
@@ -36,7 +36,7 @@ def list_asset_libraries(
@router.post("", response_model=AssetLibraryResponse)
def create_asset_library(
request: CreateAssetLibraryRequest,
asset_library_repository: SQLAlchemyAssetLibraryRepository = Depends(get_asset_library_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
) -> AssetLibraryResponse:
use_case = CreateAssetLibraryUseCase(asset_library_repository)
item = use_case.execute(
+3 -3
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
from app.dependencies import get_asset_repository
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse
from packages.adapters.sqlalchemy_impl import SQLAlchemyAssetRepository
from typing import Any
from packages.application import CreateAssetCommand, CreateAssetUseCase, ListAssetsUseCase
from packages.domain import AssetStatus, ClassificationStatus
@@ -36,7 +36,7 @@ def _to_asset_response(item) -> AssetResponse:
@router.get("", response_model=ListAssetsResponse)
def list_assets(
library_id: str,
asset_repository: SQLAlchemyAssetRepository = Depends(get_asset_repository),
asset_repository: Any = Depends(get_asset_repository),
) -> ListAssetsResponse:
use_case = ListAssetsUseCase(asset_repository)
items = use_case.execute(library_id)
@@ -46,7 +46,7 @@ def list_assets(
@router.post("", response_model=AssetResponse)
def create_asset(
request: CreateAssetRequest,
asset_repository: SQLAlchemyAssetRepository = Depends(get_asset_repository),
asset_repository: Any = Depends(get_asset_repository),
) -> AssetResponse:
use_case = CreateAssetUseCase(asset_repository)
item = use_case.execute(
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException
from app.core.celery_app import celery_app
from app.dependencies import get_classification_job_repository
from app.schemas.classification_job import ClassificationJobResponse, SubmitClassificationJobRequest
from packages.adapters.sqlalchemy_impl import SQLAlchemyClassificationJobRepository
from typing import Any
from packages.application import SubmitClassificationJobCommand, SubmitClassificationJobUseCase
router = APIRouter()
@@ -14,7 +14,7 @@ router = APIRouter()
@router.get("/{job_id}", response_model=ClassificationJobResponse)
def get_classification_job(
job_id: str,
classification_job_repository: SQLAlchemyClassificationJobRepository = Depends(get_classification_job_repository),
classification_job_repository: Any = Depends(get_classification_job_repository),
) -> ClassificationJobResponse:
job = classification_job_repository.get(job_id)
if job is None:
@@ -34,7 +34,7 @@ def get_classification_job(
@router.post("", response_model=ClassificationJobResponse)
def submit_classification_job(
request: SubmitClassificationJobRequest,
classification_job_repository: SQLAlchemyClassificationJobRepository = Depends(get_classification_job_repository),
classification_job_repository: Any = Depends(get_classification_job_repository),
) -> ClassificationJobResponse:
use_case = SubmitClassificationJobUseCase(classification_job_repository)
job = use_case.execute(
+4 -4
View File
@@ -7,7 +7,7 @@ from app.schemas.generated_video import (
GeneratedVideoResponse,
ListGeneratedVideosResponse,
)
from packages.adapters.sqlalchemy_impl import SQLAlchemyGeneratedVideoRepository
from typing import Any
from packages.application import (
GetGeneratedVideoDownloadUrlUseCase,
GetGeneratedVideoUseCase,
@@ -20,7 +20,7 @@ router = APIRouter()
@router.get("", response_model=ListGeneratedVideosResponse)
def list_generated_videos(
project_id: str,
generated_video_repository: SQLAlchemyGeneratedVideoRepository = Depends(get_generated_video_repository),
generated_video_repository: Any = Depends(get_generated_video_repository),
) -> ListGeneratedVideosResponse:
use_case = ListGeneratedVideosUseCase(generated_video_repository)
items = use_case.execute(project_id)
@@ -48,7 +48,7 @@ def list_generated_videos(
@router.get("/{video_id}", response_model=GeneratedVideoResponse)
def get_generated_video(
video_id: str,
generated_video_repository: SQLAlchemyGeneratedVideoRepository = Depends(get_generated_video_repository),
generated_video_repository: Any = Depends(get_generated_video_repository),
) -> GeneratedVideoResponse:
use_case = GetGeneratedVideoUseCase(generated_video_repository)
item = use_case.execute(video_id)
@@ -73,7 +73,7 @@ def get_generated_video(
@router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse)
def get_generated_video_download_url(
video_id: str,
generated_video_repository: SQLAlchemyGeneratedVideoRepository = Depends(get_generated_video_repository),
generated_video_repository: Any = Depends(get_generated_video_repository),
storage_service: MinIOService = Depends(get_minio_service),
) -> GeneratedVideoDownloadUrlResponse:
use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository)
+4 -4
View File
@@ -4,7 +4,7 @@ from app.core.celery_app import celery_app
from app.dependencies import get_generation_task_repository, get_generated_video_repository
from app.schemas.generation_task import CreateGenerationTaskRequest, GenerationTaskResponse
from app.schemas.generated_video import GeneratedVideoResponse, ListGeneratedVideosResponse
from packages.adapters.sqlalchemy_impl import SQLAlchemyGeneratedVideoRepository, SQLAlchemyGenerationTaskRepository
from typing import Any
from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
@@ -18,7 +18,7 @@ router = APIRouter()
@router.post("/tasks", response_model=GenerationTaskResponse)
def create_generation_task(
request: CreateGenerationTaskRequest,
generation_task_repository: SQLAlchemyGenerationTaskRepository = Depends(get_generation_task_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> GenerationTaskResponse:
use_case = CreateGenerationTaskUseCase(generation_task_repository)
task = use_case.execute(
@@ -49,7 +49,7 @@ def create_generation_task(
@router.get("/tasks/{task_id}", response_model=GenerationTaskResponse)
def get_generation_task(
task_id: str,
generation_task_repository: SQLAlchemyGenerationTaskRepository = Depends(get_generation_task_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
) -> GenerationTaskResponse:
use_case = GetGenerationTaskUseCase(generation_task_repository)
task = use_case.execute(task_id)
@@ -72,7 +72,7 @@ def get_generation_task(
@router.get("/tasks/{task_id}/results", response_model=ListGeneratedVideosResponse)
def list_generation_results(
task_id: str,
generated_video_repository: SQLAlchemyGeneratedVideoRepository = Depends(get_generated_video_repository),
generated_video_repository: Any = Depends(get_generated_video_repository),
) -> ListGeneratedVideosResponse:
use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
items = use_case.execute(task_id)
+3 -3
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
from app.core.celery_app import celery_app
from app.dependencies import get_ingest_job_repository
from app.schemas.ingest_job import IngestJobResponse, SubmitIngestJobRequest
from packages.adapters.sqlalchemy_impl import SQLAlchemyIngestJobRepository
from typing import Any
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
router = APIRouter()
@@ -12,7 +12,7 @@ router = APIRouter()
@router.get("/{job_id}", response_model=IngestJobResponse)
def get_ingest_job(
job_id: str,
ingest_job_repository: SQLAlchemyIngestJobRepository = Depends(get_ingest_job_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
) -> IngestJobResponse:
job = ingest_job_repository.get(job_id)
if job is None:
@@ -32,7 +32,7 @@ def get_ingest_job(
@router.post("", response_model=IngestJobResponse)
def submit_ingest_job(
request: SubmitIngestJobRequest,
ingest_job_repository: SQLAlchemyIngestJobRepository = Depends(get_ingest_job_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
) -> IngestJobResponse:
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
+3 -3
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends
from app.dependencies import get_project_repository
from app.schemas.project import CreateProjectRequest, ListProjectsResponse, ProjectResponse
from packages.adapters.sqlalchemy_impl import SQLAlchemyProjectRepository
from typing import Any
from packages.application import CreateProjectCommand, CreateProjectUseCase, ListProjectsUseCase
router = APIRouter()
@@ -11,7 +11,7 @@ router = APIRouter()
@router.get("", response_model=ListProjectsResponse)
def list_projects(
workspace_id: str,
project_repository: SQLAlchemyProjectRepository = Depends(get_project_repository),
project_repository: Any = Depends(get_project_repository),
) -> ListProjectsResponse:
use_case = ListProjectsUseCase(project_repository)
projects = use_case.execute(workspace_id)
@@ -31,7 +31,7 @@ def list_projects(
@router.post("", response_model=ProjectResponse)
def create_project(
request: CreateProjectRequest,
project_repository: SQLAlchemyProjectRepository = Depends(get_project_repository),
project_repository: Any = Depends(get_project_repository),
) -> ProjectResponse:
use_case = CreateProjectUseCase(project_repository)
project = use_case.execute(
+2 -2
View File
@@ -5,7 +5,7 @@ from app.core.celery_app import celery_app
from app.core.storage import MinIOService, get_minio_service
from app.dependencies import get_ingest_job_repository
from app.schemas.upload import UploadAssetResponse
from packages.adapters.sqlalchemy_impl import SQLAlchemyIngestJobRepository
from typing import Any
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
router = APIRouter()
@@ -17,7 +17,7 @@ async def upload_asset(
workspace_id: str = Form(..., description="工作空间 ID"),
project_id: str = Form(..., description="项目 ID"),
library_id: str = Form(..., description="素材库 ID"),
ingest_job_repository: SQLAlchemyIngestJobRepository = Depends(get_ingest_job_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
storage_service: MinIOService = Depends(get_minio_service),
) -> UploadAssetResponse:
"""上传素材文件并触发导入流水线。"""
+22 -3
View File
@@ -157,6 +157,25 @@
- 注册、登录、登出、密码重置、邀请成员 use case 改为构造注入,默认使用 adapter 懒加载工厂。
- 相关测试从 patch 模块全局变量改为注入 mock 服务。
### 9. P1:API 路由层直接依赖具体 SQLAlchemy Adapter
**涉及文件**:`apps/api/app/api/routes/*.py`、`tests/unit/test_architecture_boundaries.py`
**问题**:
- 多个 API route 直接 import `SQLAlchemy*Repository`。
- 路由层本应只依赖 FastAPI dependency provider 和 application use case,不应知道具体仓储实现。
- 具体 adapter 只能出现在 composition root,例如 `apps/api/app/dependencies.py`。
**根因**:
- 为了类型标注方便,把基础设施类型泄漏进 API route。
- 没有架构守卫测试阻止回归。
**修复**:
- 移除 route 层对 `packages.adapters.sqlalchemy_impl` 的直接 import。
- route 层仓储参数改为 `Any`,具体实现保留在 `dependencies.py` 注入。
- 新增 `tests/unit/test_architecture_boundaries.py`,防止 route 层再次直接 import SQLAlchemy adapter。
- 同时守住 `domain.auth` 不再导出 Redis/SMTP 全局单例。
## 三、已补充测试
### 新增
@@ -165,7 +184,7 @@
- `tests/unit/test_login_use_case.py`
- `tests/unit/test_register_user_use_case.py`
- `tests/unit/test_password_reset_use_case.py`
- `tests/unit/test_invite_member_use_case.py`
- `tests/unit/test_architecture_boundaries.py`
覆盖:
- 登录 token 是可验证 JWT。
@@ -176,11 +195,11 @@
### 已运行通过
```bash
python -m pytest tests/unit/test_auth_simple.py tests/unit/test_password_hasher.py tests/integration/test_generation_pipeline.py tests/integration/test_projects.py -q
python -m pytest tests/unit/test_architecture_boundaries.py tests/unit/test_auth_simple.py tests/unit/test_password_hasher.py tests/integration/test_generation_pipeline.py tests/integration/test_projects.py -q
python -m pytest tests/unit/test_login_use_case.py tests/unit/test_register_user_use_case.py tests/unit/test_password_reset_use_case.py tests/unit/test_invite_member_use_case.py tests/unit/test_session_store.py tests/unit/test_email_service.py -q
```
结果:`30 passed`,`52 passed`
结果:`31 passed`,`52 passed`
## 四、仍需继续治理的问题
@@ -0,0 +1,28 @@
from pathlib import Path
ALLOWED_API_ADAPTER_IMPORTS = {
Path("apps/api/app/dependencies.py"),
Path("apps/api/app/db.py"),
Path("apps/api/app/api/routes/auth_simple.py"),
}
def test_api_routes_do_not_import_sqlalchemy_adapters_directly():
offenders: list[str] = []
for path in Path("apps/api/app/api/routes").glob("*.py"):
if path in ALLOWED_API_ADAPTER_IMPORTS:
continue
text = path.read_text(encoding="utf-8")
if "packages.adapters.sqlalchemy_impl" in text or "SQLAlchemy" in text:
offenders.append(str(path))
assert offenders == []
def test_domain_auth_does_not_export_infrastructure_singletons():
text = Path("packages/domain/auth/__init__.py").read_text(encoding="utf-8")
assert "session_store," not in text
assert "email_service," not in text
assert '"session_store"' not in text
assert '"email_service"' not in text