refactor(api): guard route adapter boundaries
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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,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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
"""上传素材文件并触发导入流水线。"""
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user