diff --git a/apps/api/app/api/routes/asset_libraries.py b/apps/api/app/api/routes/asset_libraries.py index ed42e4903..54018327b 100644 --- a/apps/api/app/api/routes/asset_libraries.py +++ b/apps/api/app/api/routes/asset_libraries.py @@ -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( diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index 4696a2355..1ab21067d 100644 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -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( diff --git a/apps/api/app/api/routes/classification_jobs.py b/apps/api/app/api/routes/classification_jobs.py index c3e1ff24d..f69172ea3 100644 --- a/apps/api/app/api/routes/classification_jobs.py +++ b/apps/api/app/api/routes/classification_jobs.py @@ -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( diff --git a/apps/api/app/api/routes/generated_videos.py b/apps/api/app/api/routes/generated_videos.py index 5eebc24e2..ba03d3318 100644 --- a/apps/api/app/api/routes/generated_videos.py +++ b/apps/api/app/api/routes/generated_videos.py @@ -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) diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index 1c8cfb9a1..1da9ca981 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -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) diff --git a/apps/api/app/api/routes/ingest_jobs.py b/apps/api/app/api/routes/ingest_jobs.py index 963ee182c..1737c6853 100644 --- a/apps/api/app/api/routes/ingest_jobs.py +++ b/apps/api/app/api/routes/ingest_jobs.py @@ -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( diff --git a/apps/api/app/api/routes/projects.py b/apps/api/app/api/routes/projects.py index 245f6254d..ba1ee7c2c 100644 --- a/apps/api/app/api/routes/projects.py +++ b/apps/api/app/api/routes/projects.py @@ -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( diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index 491bd15e5..9736e4069 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -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: """上传素材文件并触发导入流水线。""" diff --git a/docs/全面代码审计报告-2026-06-21.md b/docs/全面代码审计报告-2026-06-21.md index c456f6b57..9acafe449 100644 --- a/docs/全面代码审计报告-2026-06-21.md +++ b/docs/全面代码审计报告-2026-06-21.md @@ -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` ## 四、仍需继续治理的问题 diff --git a/tests/unit/test_architecture_boundaries.py b/tests/unit/test_architecture_boundaries.py new file mode 100644 index 000000000..b899a3552 --- /dev/null +++ b/tests/unit/test_architecture_boundaries.py @@ -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