From 0000c30ef22ea8b96ca17fe4673e5b03b060af3e Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 13 Jul 2026 15:10:39 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=20GetGenerationTa?= =?UTF-8?q?skUseCase=20=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 清理时误删了 packages/application/__init__.py 中的 GetGenerationTaskUseCase 导出,导致 generation_tasks.py 路由 ImportError。 --- packages/application/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/application/__init__.py b/packages/application/__init__.py index e7fc24d04..e0c234713 100755 --- a/packages/application/__init__.py +++ b/packages/application/__init__.py @@ -27,6 +27,7 @@ from .generated_videos import ( from .generation_tasks import ( CreateGenerationTaskCommand, CreateGenerationTaskUseCase, + GetGenerationTaskUseCase, ) from .ingest_jobs import SubmitIngestJobCommand, SubmitIngestJobUseCase from .jobs import ( @@ -63,6 +64,7 @@ __all__ = [ "CreateAssetUseCase", "CreateGenerationTaskCommand", "CreateGenerationTaskUseCase", + "GetGenerationTaskUseCase", "CreateJobCommand", "CreateJobUseCase", "CreateProjectCommand", -- 2.54.0 From 0634fc48334465ba5345f933fa4157e7fb2d3dfa Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 13 Jul 2026 15:10:43 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20OSS=5FENDPOINT?= =?UTF-8?q?=20=E6=8B=BC=E5=86=99=E9=94=99=E8=AF=AF=20aliiyuncs=20=E2=86=92?= =?UTF-8?q?=20aliyun?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSS_ENDPOINT 默认值中的域名拼写错误: - 错误:oss-cn-hangzhou.aliiyuncs.com(多了一个 i) - 正确:oss-cn-hangzhou.aliyuncs.com 同步修复: - apps/api/app/config.py - packages/shared/config.py - tests/unit/test_config_oss.py --- apps/api/app/config.py | 2 +- packages/shared/config.py | 2 +- tests/unit/test_config_oss.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/api/app/config.py b/apps/api/app/config.py index ade9d0179..505281bd2 100755 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -80,7 +80,7 @@ class Settings(BaseSettings): CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1" # OSS 七牛云相关 - OSS_ENDPOINT: str = "oss-cn-hangzhou.aliiyuncs.com" + OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com" OSS_ACCESS_KEY_ID: str = "" OSS_ACCESS_KEY_SECRET: str = "" OSS_BUCKET_NAME: str = "xiaoxia-autocut" diff --git a/packages/shared/config.py b/packages/shared/config.py index ace61d20d..694bfa44f 100755 --- a/packages/shared/config.py +++ b/packages/shared/config.py @@ -24,7 +24,7 @@ class SharedSettings(BaseSettings): celery_result_backend: str = "redis://localhost:6379/1" # OSS Aliyun - oss_endpoint: str = "oss-cn-hangzhou.aliiyuncs.com" + oss_endpoint: str = "oss-cn-hangzhou.aliyuncs.com" oss_access_key_id: str = "" oss_access_key_secret: str = "" oss_bucket_name: str = "xiaoxia-autocut" diff --git a/tests/unit/test_config_oss.py b/tests/unit/test_config_oss.py index 077c84178..c1679d6e9 100644 --- a/tests/unit/test_config_oss.py +++ b/tests/unit/test_config_oss.py @@ -55,7 +55,7 @@ class TestOSSConfigDefaults: def test_oss_endpoint_default(self): settings = _fresh_settings() - assert settings.OSS_ENDPOINT == "oss-cn-hangzhou.aliiyuncs.com" + assert settings.OSS_ENDPOINT == "oss-cn-hangzhou.aliyuncs.com" def test_oss_access_key_id_default_empty(self): settings = _fresh_settings() @@ -88,8 +88,8 @@ class TestOSSConfigEnvOverride: """环境变量能正确覆盖 OSS 配置字段。""" def test_oss_endpoint_override(self): - settings = _fresh_settings(OSS_ENDPOINT="oss-cn-shanghai.aliiyuncs.com") - assert settings.OSS_ENDPOINT == "oss-cn-shanghai.aliiyuncs.com" + settings = _fresh_settings(OSS_ENDPOINT="oss-cn-shanghai.aliyuncs.com") + assert settings.OSS_ENDPOINT == "oss-cn-shanghai.aliyuncs.com" def test_oss_access_key_id_override(self): settings = _fresh_settings(OSS_ACCESS_KEY_ID="test-key-id") -- 2.54.0 From 74a136e931ab75e3ce341b1f82a1ba44d525697e Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 13 Jul 2026 15:27:15 +0800 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=E5=88=A0=E9=99=A4=205=20?= =?UTF-8?q?=E7=BB=84=E5=BA=9F=E5=BC=83=20API=20=E8=B7=AF=E7=94=B1=E5=8F=8A?= =?UTF-8?q?=E5=85=B3=E8=81=94=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除的 API 路由(5 个文件): - dashboard.py — 仪表盘概览(前端已改用 mock 数据) - edit_templates.py — 模板 CRUD(无前端调用,领域层保留) - generated_videos.py — 成片管理(已被 generation/tasks/{id}/results 替代) - recipes.py — 配方 CRUD + use(无任何调用方) - jobs.py — 通用异步任务管理(无调用方) 删除的 schema(3 个文件): - schemas/dashboard.py — 仅 dashboard 路由使用 - schemas/recipe.py — 仅 recipes 路由使用 - schemas/job.py — 仅 jobs 路由使用 - schemas/generated_video.py 保留(generation_tasks.py 仍在使用) 清理 router.py 中对应的 5 组 import 和 include_router 注册。 同步清理 3 个已删路由的测试文件,移除 1 个测试类中的死引用。 路由总数:121 → 116(减少 5 组死接口) --- apps/api/app/api/router.py | 29 - apps/api/app/api/routes/dashboard.py | 92 --- apps/api/app/api/routes/edit_templates.py | 289 --------- apps/api/app/api/routes/generated_videos.py | 123 ---- apps/api/app/api/routes/jobs.py | 325 ---------- apps/api/app/api/routes/recipes.py | 207 ------- apps/api/app/schemas/dashboard.py | 32 - apps/api/app/schemas/job.py | 109 ---- apps/api/app/schemas/recipe.py | 86 --- tests/integration/test_dashboard_api.py | 496 ---------------- .../integration/test_generated_videos_api.py | 554 ------------------ tests/unit/test_edit_templates_api.py | 427 -------------- ...test_video_upload_fix_and_generated_api.py | 45 +- 13 files changed, 3 insertions(+), 2811 deletions(-) delete mode 100644 apps/api/app/api/routes/dashboard.py delete mode 100644 apps/api/app/api/routes/edit_templates.py delete mode 100644 apps/api/app/api/routes/generated_videos.py delete mode 100755 apps/api/app/api/routes/jobs.py delete mode 100644 apps/api/app/api/routes/recipes.py delete mode 100644 apps/api/app/schemas/dashboard.py delete mode 100755 apps/api/app/schemas/job.py delete mode 100644 apps/api/app/schemas/recipe.py delete mode 100644 tests/integration/test_dashboard_api.py delete mode 100644 tests/integration/test_generated_videos_api.py delete mode 100644 tests/unit/test_edit_templates_api.py diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 5fdac8e03..c0711f12d 100755 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -4,19 +4,14 @@ from app.api.routes.assets import router as assets_router from app.api.routes.auth import router as auth_router from app.api.routes.chunked_upload import router as chunked_upload_router from app.api.routes.classification_jobs import router as classification_jobs_router -from app.api.routes.dashboard import router as dashboard_router from app.api.routes.duplication import router as duplication_router from app.api.routes.edit_plans import router as edit_plans_router -from app.api.routes.edit_templates import router as edit_templates_router from app.api.routes.feature_flags import router as feature_flags_router -from app.api.routes.generated_videos import router as generated_videos_router from app.api.routes.generation_tasks import router as generation_tasks_router from app.api.routes.health import router as health_check_router from app.api.routes.ingest_jobs import router as ingest_jobs_router from app.api.routes.internal_render import router as internal_render_router -from app.api.routes.jobs import router as jobs_router from app.api.routes.projects import router as projects_router -from app.api.routes.recipes import router as recipes_router from app.api.routes.subscription import router as subscription_router from app.api.routes.tags import router as tags_router from app.api.routes.task_center import router as task_center_router @@ -89,15 +84,6 @@ api_router.include_router( prefix="/generation", tags=["Generation"], ) -api_router.include_router( - jobs_router, - tags=["Job"], -) -api_router.include_router( - generated_videos_router, - prefix="/generated-videos", - tags=["GeneratedVideo"], -) api_router.include_router( titles_router, prefix="/titles", @@ -123,26 +109,11 @@ api_router.include_router( prefix="/subscription", tags=["Subscription"], ) -api_router.include_router( - recipes_router, - prefix="/recipes", - tags=["Recipe"], -) api_router.include_router( templates_router, prefix="/templates", tags=["Template"], ) -api_router.include_router( - dashboard_router, - prefix="/dashboard", - tags=["Dashboard"], -) -api_router.include_router( - edit_templates_router, - prefix="/edit-templates", - tags=["EditTemplate"], -) api_router.include_router( edit_plans_router, prefix="/edit-plans", diff --git a/apps/api/app/api/routes/dashboard.py b/apps/api/app/api/routes/dashboard.py deleted file mode 100644 index 1cdc838cc..000000000 --- a/apps/api/app/api/routes/dashboard.py +++ /dev/null @@ -1,92 +0,0 @@ -from typing import Any - -from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import ( - get_asset_repository, - get_generation_task_repository, - get_project_repository, - get_title_library_repository, - get_voice_library_repository, -) -from app.schemas.dashboard import DashboardOverviewResponse, RecentTaskItem, SubscriptionInfo -from fastapi import APIRouter, Depends - -router = APIRouter() - - -def _status_value(status) -> str: - return status.value if hasattr(status, "value") else str(status) - - -def _generation_step(status: str) -> str: - if status == "pending": - return "等待 Worker 执行" - if status == "running": - return "正在生成成片" - if status == "completed": - return "生成完成" - if status == "failed": - return "生成失败" - return status - - -@router.get("/overview", response_model=DashboardOverviewResponse) -def get_dashboard_overview( - authenticated_user: AuthenticatedUser = Depends(get_current_user), - project_repository: Any = Depends(get_project_repository), - asset_repository: Any = Depends(get_asset_repository), - generation_task_repository: Any = Depends(get_generation_task_repository), - title_library_repository: Any = Depends(get_title_library_repository), - voice_library_repository: Any = Depends(get_voice_library_repository), -) -> DashboardOverviewResponse: - """Dashboard 概览:用户级汇总数据。""" - user_id = authenticated_user.user.id - - # 获取用户可访问的所有 project - projects = project_repository.find_accessible_projects(user_id) - project_ids = [p.id for p in projects] - - # 素材统计 - total_assets = asset_repository.count_by_project_ids(project_ids) - used_storage_bytes = asset_repository.sum_storage_by_project_ids(project_ids) - - # 标题库 / 配音库统计 - total_titles = title_library_repository.count_by_user(user_id) - total_voices = voice_library_repository.count_by_user(user_id) - - # 生成任务统计 - total_tasks = generation_task_repository.count_by_user(user_id) - - # 最近任务(SQL 层 LIMIT 5) - recent = generation_task_repository.list_recent_by_user(user_id, limit=5) - recent_tasks = [] - for task in recent: - s = _status_value(task.status) - recent_tasks.append( - RecentTaskItem( - id=task.id, - task_type="generation", - status=s, - current_step=_generation_step(s), - error_message=task.error_message or "", - updated_at=task.completed_at or task.started_at or task.created_at, - ) - ) - - # 订阅信息 - user = authenticated_user.user - subscription = SubscriptionInfo( - plan=getattr(user, "subscription_plan", "free") or "free", - is_active=getattr(user, "subscription_status", "") == "active", - ) - - return DashboardOverviewResponse( - total_assets=total_assets, - used_storage_bytes=used_storage_bytes, - total_titles=total_titles, - total_voices=total_voices, - total_tasks=total_tasks, - total_products=len(projects), - subscription=subscription, - recent_tasks=recent_tasks, - ) diff --git a/apps/api/app/api/routes/edit_templates.py b/apps/api/app/api/routes/edit_templates.py deleted file mode 100644 index 2d96de80a..000000000 --- a/apps/api/app/api/routes/edit_templates.py +++ /dev/null @@ -1,289 +0,0 @@ -"""模板管理 API — Phase 8 模板编排引擎. - -RESTful CRUD for EditTemplate: -- GET /api/v1/edit-templates 列表(分页 + 类型筛选) -- GET /api/v1/edit-templates/{id} 详情 -- POST /api/v1/edit-templates 创建(管理员) -- PUT /api/v1/edit-templates/{id} 更新 -- DELETE /api/v1/edit-templates/{id} 删除(软删除 → inactive) - -业务逻辑委托给 EditTemplateService 服务层。 -""" - -from __future__ import annotations - -import logging -from datetime import datetime -from typing import Any, List, Optional - -from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_db_session -from app.services import EditTemplateService -from fastapi import APIRouter, Depends, HTTPException, Query, status -from fastapi.responses import Response -from pydantic import BaseModel, Field -from sqlalchemy.orm import Session - -from packages.domain.config_schemas import normalize_template_config -from packages.domain.edit_template import EditTemplate, EditTemplateStatus - -logger = logging.getLogger(__name__) - -router = APIRouter() - - -# ── Pydantic Schemas ───────────────────────────────────────────────────────── - - -class EditTemplateCreateRequest(BaseModel): - """创建模板请求体""" - - name: str = Field(..., min_length=1, max_length=200, description="模板名称") - description: str = Field(default="", max_length=2000, description="模板描述") - template_type: str = Field(default="default", max_length=50, description="模板类型") - editing_mode: str = Field( - default="one_take", max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip" - ) - config: dict[str, Any] = Field(default_factory=dict, description="模板配置 (JSON)") - preview_url: str = Field(default="", max_length=500, description="预览地址") - sort_weight: int = Field(default=0, ge=0, le=9999, description="排序权重") - - -class EditTemplateUpdateRequest(BaseModel): - """更新模板请求体""" - - name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="模板名称") - description: Optional[str] = Field(default=None, max_length=2000, description="模板描述") - template_type: Optional[str] = Field(default=None, max_length=50, description="模板类型") - editing_mode: Optional[str] = Field( - default=None, max_length=20, description="剪辑模式: one_take/pip/voice_over/voice_pip" - ) - config: Optional[dict[str, Any]] = Field(default=None, description="模板配置 (JSON)") - preview_url: Optional[str] = Field(default=None, max_length=500, description="预览地址") - sort_weight: Optional[int] = Field(default=None, ge=0, le=9999, description="排序权重") - status: Optional[str] = Field(default=None, description="状态: active / inactive") - - -class EditTemplateResponse(BaseModel): - """模板响应体""" - - id: str - name: str - description: str - template_type: str - editing_mode: str - config: dict[str, Any] - preview_url: str - sort_weight: int - status: str - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} - - -class EditTemplateListResponse(BaseModel): - """模板列表响应体""" - - items: List[EditTemplateResponse] - total: int - page: int - page_size: int - - -# ── Helpers ─────────────────────────────────────────────────────────────────── - - -def _require_admin(current_user: AuthenticatedUser) -> None: - """校验当前用户是否为管理员,非管理员返回 403""" - if not getattr(current_user.user, "is_admin", False): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="仅管理员可执行此操作", - ) - - -def _to_response(t: EditTemplate) -> EditTemplateResponse: - return EditTemplateResponse( - id=t.id, - name=t.name, - description=t.description, - template_type=t.template_type, - editing_mode=t.editing_mode, - config=t.config, - preview_url=t.preview_url, - sort_weight=t.sort_weight, - status=t.status.value if hasattr(t.status, "value") else t.status, - created_at=t.created_at, - updated_at=t.updated_at, - ) - - -# ── Routes ──────────────────────────────────────────────────────────────────── - - -@router.get("", response_model=EditTemplateListResponse) -def list_templates( - page: int = Query(default=1, ge=1, description="页码"), - page_size: int = Query(default=20, ge=1, le=100, description="每页数量"), - template_type: Optional[str] = Query(default=None, description="按类型筛选"), - status_filter: Optional[str] = Query( - default=None, - alias="status", - description="按状态筛选: active / inactive", - ), - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), -) -> EditTemplateListResponse: - """获取模板列表(支持分页、按类型/状态筛选)""" - svc = EditTemplateService(db) - - # 解析状态筛选 - status_enum: Optional[EditTemplateStatus] = None - if status_filter: - try: - status_enum = EditTemplateStatus(status_filter) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"无效的状态值: {status_filter},可选值: active, inactive", - ) - - skip = (page - 1) * page_size - templates = svc.list_templates( - template_type=template_type, - status=status_enum, - skip=skip, - limit=page_size, - ) - total = svc.count_templates( - template_type=template_type, - status=status_enum, - ) - - return EditTemplateListResponse( - items=[_to_response(t) for t in templates], - total=total, - page=page, - page_size=page_size, - ) - - -@router.get("/{template_id}", response_model=EditTemplateResponse) -def get_template( - template_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), -) -> EditTemplateResponse: - """获取单个模板详情""" - svc = EditTemplateService(db) - try: - template = svc.get_template_or_raise(template_id) - except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(exc), - ) - return _to_response(template) - - -@router.post("", response_model=EditTemplateResponse, status_code=status.HTTP_201_CREATED) -def create_template( - body: EditTemplateCreateRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), -) -> EditTemplateResponse: - """创建模板(管理员)""" - _require_admin(current_user) - svc = EditTemplateService(db) - # 标准化 config,填充 cover/title/subtitle/bgm 默认值 - normalized_config = normalize_template_config(body.config) - try: - created = svc.create_template( - name=body.name, - description=body.description, - template_type=body.template_type, - editing_mode=body.editing_mode, - config=normalized_config, - preview_url=body.preview_url, - sort_weight=body.sort_weight, - ) - except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), - ) - logger.info("创建模板: id=%s name=%s by user=%s", created.id, created.name, current_user.user.id) - return _to_response(created) - - -@router.put("/{template_id}", response_model=EditTemplateResponse) -def update_template( - template_id: str, - body: EditTemplateUpdateRequest, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), -) -> EditTemplateResponse: - """更新模板""" - _require_admin(current_user) - svc = EditTemplateService(db) - - # 解析状态 - status_enum: Optional[EditTemplateStatus] = None - if body.status is not None: - try: - status_enum = EditTemplateStatus(body.status) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"无效的状态值: {body.status},可选值: active, inactive", - ) - - # 标准化 config(如果提供了) - config_to_update = normalize_template_config(body.config) if body.config is not None else None - - try: - result = svc.update_template( - template_id, - name=body.name, - description=body.description, - template_type=body.template_type, - editing_mode=body.editing_mode, - config=config_to_update, - preview_url=body.preview_url, - sort_weight=body.sort_weight, - status=status_enum, - ) - except ValueError as exc: - err_msg = str(exc) - if "不存在" in err_msg: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=err_msg, - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=err_msg, - ) - logger.info("更新模板: id=%s by user=%s", template_id, current_user.user.id) - return _to_response(result) - - -@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) -def delete_template( - template_id: str, - db: Session = Depends(get_db_session), - current_user: AuthenticatedUser = Depends(get_current_user), -) -> Response: - """删除模板(软删除 → 设为 inactive)""" - _require_admin(current_user) - svc = EditTemplateService(db) - try: - svc.deactivate_template(template_id) - except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(exc), - ) - logger.info("删除模板(软删除): id=%s by user=%s", template_id, current_user.user.id) - return Response(status_code=204) diff --git a/apps/api/app/api/routes/generated_videos.py b/apps/api/app/api/routes/generated_videos.py deleted file mode 100644 index d9f72c031..000000000 --- a/apps/api/app/api/routes/generated_videos.py +++ /dev/null @@ -1,123 +0,0 @@ -from typing import Any - -from app.auth import AuthenticatedUser, get_current_user -from app.core.storage import OSSStorageService, get_storage_service -from app.dependencies import get_generated_video_repository, get_project_repository -from app.schemas.generated_video import ( - GeneratedVideoDownloadUrlResponse, - GeneratedVideoResponse, - ListGeneratedVideosResponse, - UpdateGeneratedVideoReviewRequest, -) -from fastapi import APIRouter, Depends, HTTPException, Query - -from packages.application import ( - GetGeneratedVideoDownloadUrlUseCase, - GetGeneratedVideoUseCase, - ListGeneratedVideosUseCase, -) - -router = APIRouter() - - -def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse: - return GeneratedVideoResponse( - id=item.id, - project_id=item.project_id, - generation_task_id=item.generation_task_id, - name=item.name, - file_url=item.file_url, - file_size=item.file_size, - duration=item.duration, - thumbnail_url=item.thumbnail_url, - width=item.width, - height=item.height, - fps=item.fps, - status=item.status, - review_status=item.review_status, - generation_params=item.generation_params, - download_url=download_url, - ) - - -@router.get("", response_model=ListGeneratedVideosResponse) -def list_generated_videos( - project_id: str | None = Query(None), - authenticated_user: AuthenticatedUser = Depends(get_current_user), - generated_video_repository: Any = Depends(get_generated_video_repository), - project_repository: Any = Depends(get_project_repository), - storage_service: OSSStorageService = Depends(get_storage_service), -) -> ListGeneratedVideosResponse: - user_id = authenticated_user.user.id - use_case = ListGeneratedVideosUseCase(generated_video_repository) - - if project_id: - # If project_id provided, check access and filter by project - project = project_repository.find_by_id(project_id) - if project is None: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - items = use_case.execute(project_id) - else: - # If no project_id, list all videos from accessible projects - accessible_projects = project_repository.find_accessible_projects(user_id) - all_items = [] - for proj in accessible_projects: - all_items.extend(use_case.execute(proj.id)) - items = all_items - - # Generate download URLs for each video - responses = [] - for item in items: - download_url = storage_service.get_download_url(item.file_url) - responses.append(_to_generated_video_response(item, download_url=download_url)) - return ListGeneratedVideosResponse(items=responses) - - -@router.get("/{video_id}", response_model=GeneratedVideoResponse) -def get_generated_video( - video_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - generated_video_repository: Any = Depends(get_generated_video_repository), - storage_service: OSSStorageService = Depends(get_storage_service), -) -> GeneratedVideoResponse: - use_case = GetGeneratedVideoUseCase(generated_video_repository) - item = use_case.execute(video_id) - if item is None: - raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found") - download_url = storage_service.get_download_url(item.file_url) - return _to_generated_video_response(item, download_url=download_url) - - -@router.patch("/{video_id}/review", response_model=GeneratedVideoResponse) -def update_generated_video_review_status( - video_id: str, - request: UpdateGeneratedVideoReviewRequest, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - generated_video_repository: Any = Depends(get_generated_video_repository), - storage_service: OSSStorageService = Depends(get_storage_service), -) -> GeneratedVideoResponse: - video = generated_video_repository.get(video_id) - if video is None: - raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found") - video.review_status = request.review_status - updated = generated_video_repository.update(video) - download_url = storage_service.get_download_url(updated.file_url) - return _to_generated_video_response(updated, download_url=download_url) - - -@router.get("/{video_id}/download-url", response_model=GeneratedVideoDownloadUrlResponse) -def get_generated_video_download_url( - video_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - generated_video_repository: Any = Depends(get_generated_video_repository), - storage_service: OSSStorageService = Depends(get_storage_service), -) -> GeneratedVideoDownloadUrlResponse: - video = generated_video_repository.get(video_id) - if video is None: - raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found") - use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository) - file_url = use_case.execute(video_id) - if file_url is None: - raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found") - download_url = storage_service.get_download_url(file_url) - return GeneratedVideoDownloadUrlResponse(video_id=video_id, download_url=download_url) diff --git a/apps/api/app/api/routes/jobs.py b/apps/api/app/api/routes/jobs.py deleted file mode 100755 index d2348d0b9..000000000 --- a/apps/api/app/api/routes/jobs.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Job API 路由 — Phase 8 任务 2.10. - -提供统一异步任务管理 RESTful 接口: -- POST /api/v1/jobs 创建任务 -- GET /api/v1/jobs/{job_id} 任务详情 -- GET /api/v1/projects/{project_id}/jobs 项目任务列表 -- GET /api/v1/projects/{project_id}/jobs/stats 任务统计 -- PUT /api/v1/jobs/{job_id}/progress 更新进度 -- POST /api/v1/jobs/{job_id}/complete 标记完成 -- POST /api/v1/jobs/{job_id}/fail 标记失败 -- POST /api/v1/jobs/{job_id}/retry 重试任务 -- POST /api/v1/jobs/{job_id}/cancel 取消任务 -- POST /api/v1/jobs/{job_id}/submit 提交执行 -""" - -from __future__ import annotations - -import logging -from typing import Any - -from app.auth import AuthenticatedUser, get_current_user -from app.core.celery_app import celery_app -from app.dependencies import get_job_repository, get_project_repository -from app.schemas.job import ( - CompleteJobRequest, - CreateJobRequest, - FailJobRequest, - JobResponse, - JobStatisticsResponse, - ListJobsResponse, - UpdateProgressRequest, - job_to_response, -) -from fastapi import APIRouter, Depends, HTTPException, Query, status - -from packages.application.jobs import ( - CancelJobUseCase, - CompleteJobCommand, - CompleteJobUseCase, - CreateJobCommand, - CreateJobUseCase, - FailJobCommand, - FailJobUseCase, - GetJobStatisticsUseCase, - GetJobUseCase, - ListJobsUseCase, - RetryJobUseCase, - SubmitJobUseCase, - UpdateJobProgressCommand, - UpdateJobProgressUseCase, -) -from packages.domain.job import JobType - -from app.api.routes._helpers import check_project_access - -logger = logging.getLogger(__name__) - -router = APIRouter() - -# 任务类型 → Celery task name 映射 -_JOB_TYPE_TO_CELERY_TASK: dict[str, str] = { - JobType.VIDEO_COMPOSE: "worker.compose_video", - JobType.RENDER_EDIT_PLAN: "worker.render_edit_plan", - JobType.ASSET_INGEST: "worker.ingest_asset", - JobType.CLASSIFICATION: "worker.classify_asset", - JobType.VOICE_EXTRACTION: "worker.extract_voice", - JobType.GENERATION: "worker.generate_video", -} - - -# ── 创建任务 ────────────────────────────────────────────────────────────────── - - -@router.post("/jobs", response_model=JobResponse, status_code=status.HTTP_201_CREATED) -def create_job( - request: CreateJobRequest, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - job_repo: Any = Depends(get_job_repository), - project_repository: Any = Depends(get_project_repository), -) -> JobResponse: - """创建异步任务。 - - 创建后任务处于 pending 状态,需要调用 /submit 提交执行。 - """ - check_project_access(request.project_id, authenticated_user.user.id, project_repository) - - # 校验 job_type - try: - JobType(request.job_type) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"不支持的任务类型: {request.job_type}," f"可选值: {[t.value for t in JobType]}", - ) - - use_case = CreateJobUseCase(job_repo) - job = use_case.execute( - CreateJobCommand( - project_id=request.project_id, - job_type=request.job_type, - payload=request.payload, - source_id=request.source_id, - created_by_user_id=authenticated_user.user.id, - max_retries=request.max_retries, - ) - ) - - return job_to_response(job) - - -# ── 提交执行 ────────────────────────────────────────────────────────────────── - - -@router.post("/jobs/{job_id}/submit", response_model=JobResponse) -def submit_job( - job_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - job_repo: Any = Depends(get_job_repository), -) -> JobResponse: - """提交任务执行。 - - 将任务状态从 pending 切换为 running,并 dispatch Celery 异步任务。 - """ - # 权限检查:先获取任务并验证权限,再执行状态变更 - job = job_repo.get(job_id) - if job is None: - raise HTTPException(status_code=404, detail=f"Job {job_id} not found") - if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id: - raise HTTPException(status_code=403, detail="Access denied to this job") - - use_case = SubmitJobUseCase(job_repo) - - try: - job = use_case.execute(job_id) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - # Dispatch Celery 任务 - celery_task_name = _JOB_TYPE_TO_CELERY_TASK.get(job.job_type.value) - if celery_task_name: - result = celery_app.send_task(celery_task_name, args=[job.id], kwargs=job.payload) - job.celery_task_id = result.id - job_repo.update(job) - logger.info("已提交 Celery 任务: job_id=%s celery_task_id=%s", job.id, result.id) - - return job_to_response(job) - - -# ── 查询接口 ────────────────────────────────────────────────────────────────── - - -@router.get("/jobs/{job_id}", response_model=JobResponse) -def get_job( - job_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - job_repo: Any = Depends(get_job_repository), -) -> JobResponse: - """获取任务详情。""" - use_case = GetJobUseCase(job_repo) - job = use_case.execute(job_id) - if job is None: - raise HTTPException(status_code=404, detail=f"Job {job_id} not found") - return job_to_response(job) - - -@router.get("/projects/{project_id}/jobs", response_model=ListJobsResponse) -def list_project_jobs( - project_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - job_repo: Any = Depends(get_job_repository), - project_repository: Any = Depends(get_project_repository), - job_type: str | None = Query(default=None, description="按任务类型过滤"), - status_filter: str | None = Query(default=None, alias="status", description="按状态过滤"), - limit: int = Query(default=50, ge=1, le=200), - offset: int = Query(default=0, ge=0), -) -> ListJobsResponse: - """获取项目下的任务列表。""" - check_project_access(project_id, authenticated_user.user.id, project_repository) - - use_case = ListJobsUseCase(job_repo) - jobs = use_case.execute( - project_id=project_id, - job_type=job_type, - status=status_filter, - limit=limit, - offset=offset, - ) - items = [job_to_response(j) for j in jobs] - return ListJobsResponse(items=items, total=len(items)) - - -@router.get("/projects/{project_id}/jobs/stats", response_model=JobStatisticsResponse) -def get_job_statistics( - project_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - job_repo: Any = Depends(get_job_repository), - project_repository: Any = Depends(get_project_repository), -) -> JobStatisticsResponse: - """获取项目任务统计摘要。""" - check_project_access(project_id, authenticated_user.user.id, project_repository) - - use_case = GetJobStatisticsUseCase(job_repo) - stats = use_case.execute(project_id) - return JobStatisticsResponse(**stats) - - -# ── 进度更新 ────────────────────────────────────────────────────────────────── - - -@router.put("/jobs/{job_id}/progress", response_model=JobResponse) -def update_job_progress( - job_id: str, - request: UpdateProgressRequest, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - job_repo: Any = Depends(get_job_repository), -) -> JobResponse: - """更新任务进度。""" - use_case = UpdateJobProgressUseCase(job_repo) - - try: - job = use_case.execute( - UpdateJobProgressCommand( - job_id=job_id, - progress=request.progress, - current_stage=request.current_stage, - ) - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return job_to_response(job) - - -# ── 完成 / 失败 ──────────────────────────────────────────────────────────────── - - -@router.post("/jobs/{job_id}/complete", response_model=JobResponse) -def complete_job( - job_id: str, - request: CompleteJobRequest, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - job_repo: Any = Depends(get_job_repository), -) -> JobResponse: - """标记任务完成。""" - use_case = CompleteJobUseCase(job_repo) - - try: - job = use_case.execute(CompleteJobCommand(job_id=job_id, result=request.result)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return job_to_response(job) - - -@router.post("/jobs/{job_id}/fail", response_model=JobResponse) -def fail_job( - job_id: str, - request: FailJobRequest, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - job_repo: Any = Depends(get_job_repository), -) -> JobResponse: - """标记任务失败。""" - use_case = FailJobUseCase(job_repo) - - try: - job = use_case.execute(FailJobCommand(job_id=job_id, error_message=request.error_message)) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return job_to_response(job) - - -# ── 重试 / 取消 ──────────────────────────────────────────────────────────────── - - -@router.post("/jobs/{job_id}/retry", response_model=JobResponse) -def retry_job( - job_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - job_repo: Any = Depends(get_job_repository), -) -> JobResponse: - """重试失败任务。 - - 将任务重置为 pending,retry_count + 1,但不自动 dispatch。 - 需要再次调用 /submit 提交执行。 - """ - # 权限检查:先获取任务并验证权限,再执行状态变更 - job = job_repo.get(job_id) - if job is None: - raise HTTPException(status_code=404, detail=f"Job {job_id} not found") - if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id: - raise HTTPException(status_code=403, detail="Access denied to this job") - - use_case = RetryJobUseCase(job_repo) - - try: - job = use_case.execute(job_id) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return job_to_response(job) - - -@router.post("/jobs/{job_id}/cancel", response_model=JobResponse) -def cancel_job( - job_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - job_repo: Any = Depends(get_job_repository), -) -> JobResponse: - """取消任务。""" - # 权限检查:先获取任务并验证权限,再执行状态变更 - job = job_repo.get(job_id) - if job is None: - raise HTTPException(status_code=404, detail=f"Job {job_id} not found") - if job.created_by_user_id and job.created_by_user_id != authenticated_user.user.id: - raise HTTPException(status_code=403, detail="Access denied to this job") - - use_case = CancelJobUseCase(job_repo) - - try: - job = use_case.execute(job_id) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - return job_to_response(job) diff --git a/apps/api/app/api/routes/recipes.py b/apps/api/app/api/routes/recipes.py deleted file mode 100644 index cde128660..000000000 --- a/apps/api/app/api/routes/recipes.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Recipe CRUD + use routes.""" - -from __future__ import annotations - -from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import get_db_session, get_user_repository -from app.schemas.recipe import ( - CreateRecipeRequest, - ListRecipesResponse, - RecipeItemResponse, - RecipeResponse, - UpdateRecipeRequest, - UseRecipeResponse, -) -from fastapi import APIRouter, Depends, HTTPException, Query, Response, status -from sqlalchemy.orm import Session - -from packages.adapters.sqlalchemy_impl.recipe_repository import SQLAlchemyRecipeRepository -from packages.application.recipe.commands import ( - CreateRecipeCommand, - RecipeItemCommand, - UpdateRecipeCommand, -) -from packages.application.recipe.use_cases import ( - CreateRecipeUseCase, - DeleteRecipeUseCase, - FeatureDisabledError, - GetRecipeUseCase, - ListRecipesUseCase, - NotFoundError, - UpdateRecipeUseCase, - UseRecipeUseCase, -) -from packages.ports.user_repository import UserRepository - -from app.api.routes._helpers import get_user_plan - -router = APIRouter() - - -def _get_recipe_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyRecipeRepository: - return SQLAlchemyRecipeRepository(session) - - -def _item_to_response(item) -> RecipeItemResponse: - return RecipeItemResponse( - id=item.id, - recipe_id=item.recipe_id, - item_type=item.item_type, - item_id=item.item_id, - position=item.position, - metadata=item.metadata_, - ) - - -def _to_response(recipe) -> RecipeResponse: - return RecipeResponse( - id=recipe.id, - user_id=recipe.user_id, - name=recipe.name, - description=recipe.description, - template_id=recipe.template_id, - generation_params=recipe.generation_params, - items=[_item_to_response(i) for i in getattr(recipe, "items", [])], - is_active=recipe.is_active, - metadata=recipe.metadata_, - created_at=recipe.created_at, - updated_at=recipe.updated_at, - ) - - -@router.get("", response_model=ListRecipesResponse) -def list_recipes( - skip: int = Query(0, ge=0), - limit: int = Query(50, ge=1, le=200), - authenticated_user: AuthenticatedUser = Depends(get_current_user), - recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository), -) -> ListRecipesResponse: - user_id = authenticated_user.user.id - use_case = ListRecipesUseCase(recipe_repository) - recipes = use_case.execute(user_id, skip=skip, limit=limit) - total = recipe_repository.count_by_user(user_id) - return ListRecipesResponse( - items=[_to_response(r) for r in recipes], - total=total, - ) - - -@router.get("/{recipe_id}", response_model=RecipeResponse) -def get_recipe( - recipe_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository), -) -> RecipeResponse: - user_id = authenticated_user.user.id - use_case = GetRecipeUseCase(recipe_repository) - recipe = use_case.execute(recipe_id, user_id) - if recipe is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found") - return _to_response(recipe) - - -@router.post("", response_model=RecipeResponse, status_code=status.HTTP_201_CREATED) -def create_recipe( - request: CreateRecipeRequest, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository), -) -> RecipeResponse: - user_id = authenticated_user.user.id - command = CreateRecipeCommand( - user_id=user_id, - name=request.name, - description=request.description, - template_id=request.template_id, - generation_params=request.generation_params, - items=[ - RecipeItemCommand( - item_type=ic.item_type, - item_id=ic.item_id, - position=ic.position, - metadata_=ic.metadata_, - ) - for ic in request.items - ], - metadata_=request.metadata_, - ) - use_case = CreateRecipeUseCase(recipe_repository) - recipe = use_case.execute(command) - return _to_response(recipe) - - -@router.patch("/{recipe_id}", response_model=RecipeResponse) -def update_recipe( - recipe_id: str, - request: UpdateRecipeRequest, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository), -) -> RecipeResponse: - user_id = authenticated_user.user.id - command = UpdateRecipeCommand( - recipe_id=recipe_id, - user_id=user_id, - name=request.name, - description=request.description, - template_id=request.template_id, - generation_params=request.generation_params, - items=( - [ - RecipeItemCommand( - item_type=ic.item_type, - item_id=ic.item_id, - position=ic.position, - metadata_=ic.metadata_, - ) - for ic in request.items - ] - if request.items is not None - else None - ), - metadata_=request.metadata_, - ) - use_case = UpdateRecipeUseCase(recipe_repository) - try: - recipe = use_case.execute(command) - except NotFoundError: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found") - return _to_response(recipe) - - -@router.delete("/{recipe_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None) -def delete_recipe( - recipe_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository), -) -> Response: - user_id = authenticated_user.user.id - use_case = DeleteRecipeUseCase(recipe_repository) - deleted = use_case.execute(recipe_id, user_id) - if not deleted: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found") - return Response(status_code=204) - - -@router.post("/{recipe_id}/use", response_model=UseRecipeResponse) -def use_recipe( - recipe_id: str, - authenticated_user: AuthenticatedUser = Depends(get_current_user), - recipe_repository: SQLAlchemyRecipeRepository = Depends(_get_recipe_repository), - user_repository: UserRepository = Depends(get_user_repository), -) -> UseRecipeResponse: - user_id = authenticated_user.user.id - plan_name = get_user_plan(user_id, user_repository) - use_case = UseRecipeUseCase(recipe_repository) - try: - result = use_case.execute(recipe_id, user_id, user_plan=plan_name) - except FeatureDisabledError as exc: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=str(exc), - ) - except NotFoundError: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found") - - return UseRecipeResponse( - recipe=_to_response(result.recipe), - warnings=[{"item_type": w.item_type, "item_id": w.item_id, "position": w.position} for w in result.warnings], - ) diff --git a/apps/api/app/schemas/dashboard.py b/apps/api/app/schemas/dashboard.py deleted file mode 100644 index bc823c177..000000000 --- a/apps/api/app/schemas/dashboard.py +++ /dev/null @@ -1,32 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, Field - - -class RecentTaskItem(BaseModel): - id: str - task_type: str = "generation" - status: str - current_step: str = "" - error_message: str = "" - updated_at: datetime | None = None - - -class SubscriptionInfo(BaseModel): - """用户订阅信息。""" - - plan: str = "free" - is_active: bool = False - - -class DashboardOverviewResponse(BaseModel): - """Dashboard 概览数据。""" - - total_assets: int = 0 - used_storage_bytes: int = 0 - total_titles: int = 0 - total_voices: int = 0 - total_tasks: int = 0 - total_products: int = 0 - subscription: SubscriptionInfo = Field(default_factory=SubscriptionInfo) - recent_tasks: list[RecentTaskItem] = Field(default_factory=list) diff --git a/apps/api/app/schemas/job.py b/apps/api/app/schemas/job.py deleted file mode 100755 index f678ddc89..000000000 --- a/apps/api/app/schemas/job.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Job API schemas — Phase 8 任务 2.10.""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any, Optional - -from pydantic import BaseModel, Field - - -class CreateJobRequest(BaseModel): - """创建任务请求体。""" - - project_id: str = Field(..., min_length=1, description="项目 ID") - job_type: str = Field( - ..., - description="任务类型: video_compose / render_edit_plan / asset_ingest / classification / voice_extraction / generation", - ) - payload: dict[str, Any] = Field(default_factory=dict, description="任务输入参数") - source_id: str = Field(default="", description="关联的业务实体 ID(如 edit_plan_id)") - max_retries: int = Field(default=3, ge=0, le=10, description="最大重试次数") - - -class UpdateProgressRequest(BaseModel): - """更新任务进度请求体。""" - - progress: float = Field(..., ge=0.0, le=100.0, description="进度百分比") - current_stage: str = Field(default="", description="当前阶段描述") - - -class CompleteJobRequest(BaseModel): - """完成任务请求体。""" - - result: dict[str, Any] = Field(default_factory=dict, description="任务结果") - - -class FailJobRequest(BaseModel): - """标记任务失败请求体。""" - - error_message: str = Field(..., min_length=1, description="错误信息") - - -class JobResponse(BaseModel): - """任务响应体。""" - - id: str - project_id: str - job_type: str - status: str - progress: float - current_stage: str - payload: dict[str, Any] - result: dict[str, Any] - error_message: str - retry_count: int - max_retries: int - celery_task_id: str - source_id: str - created_by_user_id: str - is_retryable: bool - started_at: Optional[datetime] = None - completed_at: Optional[datetime] = None - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} - - -class ListJobsResponse(BaseModel): - """任务列表响应体。""" - - items: list[JobResponse] - total: int - - -class JobStatisticsResponse(BaseModel): - """任务统计响应体。""" - - project_id: str - total: int - pending: int - running: int - success: int - failed: int - - -def job_to_response(job) -> JobResponse: - """将 Job 领域对象转换为 API 响应。""" - return JobResponse( - id=job.id, - project_id=job.project_id, - job_type=job.job_type.value if hasattr(job.job_type, "value") else str(job.job_type), - status=job.status.value if hasattr(job.status, "value") else str(job.status), - progress=job.progress, - current_stage=job.current_stage, - payload=job.payload, - result=job.result, - error_message=job.error_message, - retry_count=job.retry_count, - max_retries=job.max_retries, - celery_task_id=job.celery_task_id, - source_id=job.source_id, - created_by_user_id=job.created_by_user_id, - is_retryable=job.is_retryable, - started_at=job.started_at, - completed_at=job.completed_at, - created_at=job.created_at, - updated_at=job.updated_at, - ) diff --git a/apps/api/app/schemas/recipe.py b/apps/api/app/schemas/recipe.py deleted file mode 100644 index 7e75d861e..000000000 --- a/apps/api/app/schemas/recipe.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Recipe API schemas.""" - -from __future__ import annotations - -from datetime import datetime -from typing import Any, Dict, List, Optional - -from pydantic import BaseModel, Field - -# ── Response ── - - -class RecipeItemResponse(BaseModel): - id: str - recipe_id: str - item_type: str - item_id: str - position: int - metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata") - - class Config: - populate_by_name = True - - -class RecipeResponse(BaseModel): - id: str - user_id: str - name: str - description: str = "" - template_id: str = "" - generation_params: Dict[str, Any] = Field(default_factory=dict) - items: List[RecipeItemResponse] = Field(default_factory=list) - is_active: bool = True - metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata") - created_at: datetime - updated_at: datetime - - class Config: - populate_by_name = True - - -class ListRecipesResponse(BaseModel): - items: List[RecipeResponse] - total: int = 0 - - -class UseRecipeResponse(BaseModel): - recipe: RecipeResponse - warnings: List[Dict[str, Any]] = Field(default_factory=list) - - -# ── Request ── - - -class RecipeItemRequest(BaseModel): - item_type: str - item_id: str - position: int = 0 - metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata") - - class Config: - populate_by_name = True - - -class CreateRecipeRequest(BaseModel): - name: str - description: str = "" - template_id: str = "" - generation_params: Dict[str, Any] = Field(default_factory=dict) - items: List[RecipeItemRequest] = Field(default_factory=list) - metadata_: Dict[str, Any] = Field(default_factory=dict, alias="metadata") - - class Config: - populate_by_name = True - - -class UpdateRecipeRequest(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - template_id: Optional[str] = None - generation_params: Optional[Dict[str, Any]] = None - items: Optional[List[RecipeItemRequest]] = None - metadata_: Optional[Dict[str, Any]] = Field(default=None, alias="metadata") - - class Config: - populate_by_name = True diff --git a/tests/integration/test_dashboard_api.py b/tests/integration/test_dashboard_api.py deleted file mode 100644 index 0fe22390e..000000000 --- a/tests/integration/test_dashboard_api.py +++ /dev/null @@ -1,496 +0,0 @@ -""" -仪表盘 API 集成测试。 - -覆盖端点: -- GET /dashboard/overview — 仪表盘概览 - -验证返回数据结构、空数据场景、数据汇总正确性。 -""" - -from __future__ import annotations - -import os -import sys -from datetime import datetime, timezone - -# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ────────────────────────── -os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") -os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api")) - -from app.api.routes.dashboard import router -from app.auth import AuthenticatedUser, get_current_user -from app.dependencies import ( - get_asset_repository, - get_generation_task_repository, - get_project_repository, - get_title_library_repository, - get_voice_library_repository, -) - -from packages.domain.entities import Project, User -from packages.domain.generation_task import GenerationTask, GenerationTaskStatus - -# --------------------------------------------------------------------------- -# 1. 内存 Repository -# --------------------------------------------------------------------------- - - -class InMemoryProjectRepository: - def __init__(self): - self._projects: dict[str, Project] = {} - - def save(self, project: Project) -> None: - self._projects[project.id] = project - - def find_by_id(self, project_id: str): - return self._projects.get(project_id) - - def find_by_owner_user_id(self, owner_user_id: str): - return [p for p in self._projects.values() if p.owner_user_id == owner_user_id] - - def find_accessible_projects(self, user_id: str): - return [p for p in self._projects.values() if p.owner_user_id == user_id] - - def count_by_owner(self, owner_user_id: str) -> int: - return len(self.find_by_owner_user_id(owner_user_id)) - - def delete(self, project_id: str) -> bool: - if project_id in self._projects: - del self._projects[project_id] - return True - return False - - -class InMemoryAssetRepository: - def __init__(self): - self._assets = [] - - def add_asset(self, project_id: str, storage_size: int = 0): - self._assets.append({"project_id": project_id, "storage_size": storage_size}) - - def count_by_project_ids(self, project_ids: list[str]) -> int: - return sum(1 for a in self._assets if a["project_id"] in project_ids) - - def sum_storage_by_project_ids(self, project_ids: list[str]) -> int: - return sum(a["storage_size"] for a in self._assets if a["project_id"] in project_ids) - - # 其他方法占位 - def create(self, asset): - return asset - - def find_by_id(self, asset_id): - return None - - def find_by_project(self, project_id, **kwargs): - return [] - - def find_by_library(self, library_id, **kwargs): - return [] - - def update(self, asset): - return asset - - def delete(self, asset_id): - return False - - def batch_delete(self, asset_ids): - return 0 - - def search_candidates(self, **kwargs): - return [] - - def find_by_tag_ids(self, tag_ids): - return [] - - def count_by_project(self, project_id): - return 0 - - def find_by_library_and_file_type(self, library_id, file_type): - return [] - - def find_by_library_and_file_hash(self, library_id, file_hash): - return None - - -class InMemoryGenerationTaskRepository: - def __init__(self): - self._tasks = {} - - def add_task(self, task: GenerationTask): - self._tasks[task.id] = task - - def count_by_user(self, user_id: str) -> int: - return len([t for t in self._tasks.values() if t.created_by_user_id == user_id]) - - def list_recent_by_user(self, user_id: str, limit: int = 5) -> list: - user_tasks = [t for t in self._tasks.values() if t.created_by_user_id == user_id] - # 按 created_at 倒序 - user_tasks.sort(key=lambda t: t.created_at, reverse=True) - return user_tasks[:limit] - - # 其他方法占位 - def create(self, task): - return task - - def get(self, task_id): - return None - - def list_by_project(self, project_id): - return [] - - def list_by_user(self, user_id): - return [] - - def list_by_source_edit_plan(self, plan_id): - return [] - - def update(self, task): - return task - - -class InMemoryTitleLibraryRepository: - def __init__(self): - self._items = {} - - def add_item(self, user_id: str): - from uuid import uuid4 - - item_id = uuid4().hex - self._items[item_id] = {"id": item_id, "user_id": user_id} - return item_id - - def count_by_user(self, user_id: str, is_active: bool = True) -> int: - return len([i for i in self._items.values() if i["user_id"] == user_id]) - - # 其他方法占位 - def list_by_user(self, user_id, **kwargs): - return [] - - def get(self, title_id, user_id): - return None - - def create(self, item): - return item - - def update(self, item): - return item - - def delete(self, title_id, user_id): - return False - - -class InMemoryVoiceLibraryRepository: - def __init__(self): - self._items = {} - - def add_item(self, user_id: str): - from uuid import uuid4 - - item_id = uuid4().hex - self._items[item_id] = {"id": item_id, "user_id": user_id} - return item_id - - def count_by_user(self, user_id: str) -> int: - return len([i for i in self._items.values() if i["user_id"] == user_id]) - - # 其他方法占位 - def list_by_user(self, user_id, **kwargs): - return [] - - def get(self, voice_id, user_id): - return None - - def create(self, item): - return item - - def update(self, item): - return item - - def delete(self, voice_id, user_id): - return False - - -# --------------------------------------------------------------------------- -# 2. 辅助函数 -# --------------------------------------------------------------------------- - - -def _make_user(**overrides) -> User: - defaults = dict( - id="user-test-001", - email="test@example.com", - display_name="Test User", - username="testuser", - subscription_plan="free", - subscription_status="active", - max_projects=3, - max_storage_gb=10, - created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), - ) - defaults.update(overrides) - return User(**defaults) - - -def _make_project(project_id: str, owner_user_id: str = "user-test-001") -> Project: - return Project( - id=project_id, - name=f"Project {project_id}", - owner_user_id=owner_user_id, - description="", - created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), - ) - - -def _make_generation_task( - task_id: str, - user_id: str = "user-test-001", - status: GenerationTaskStatus = GenerationTaskStatus.COMPLETED, - created_at: datetime | None = None, -) -> GenerationTask: - return GenerationTask( - id=task_id, - project_id="proj-1", - asset_library_id="lib-1", - created_by_user_id=user_id, - status=status, - error_message="", - created_at=created_at or datetime.now(timezone.utc), - started_at=datetime.now(timezone.utc) if status != GenerationTaskStatus.PENDING else None, - completed_at=datetime.now(timezone.utc) if status == GenerationTaskStatus.COMPLETED else None, - ) - - -# --------------------------------------------------------------------------- -# 3. Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def project_repo(): - repo = InMemoryProjectRepository() - repo.save(_make_project("proj-1", "user-test-001")) - repo.save(_make_project("proj-2", "user-test-001")) - repo.save(_make_project("proj-other", "other-user")) - return repo - - -@pytest.fixture -def asset_repo(): - return InMemoryAssetRepository() - - -@pytest.fixture -def generation_task_repo(): - return InMemoryGenerationTaskRepository() - - -@pytest.fixture -def title_library_repo(): - return InMemoryTitleLibraryRepository() - - -@pytest.fixture -def voice_library_repo(): - return InMemoryVoiceLibraryRepository() - - -@pytest.fixture -def client(project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo): - """创建带有依赖覆盖的 TestClient。""" - test_app = FastAPI() - test_app.include_router(router, prefix="/dashboard") - - def _override_current_user(): - return AuthenticatedUser(user=_make_user()) - - test_app.dependency_overrides[get_current_user] = _override_current_user - test_app.dependency_overrides[get_project_repository] = lambda: project_repo - test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo - test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo - test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo - test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo - - yield TestClient(test_app) - - test_app.dependency_overrides.clear() - - -# --------------------------------------------------------------------------- -# 4. GET /overview — 仪表盘概览 -# --------------------------------------------------------------------------- - - -class TestDashboardOverview: - """仪表盘概览端点测试。""" - - def test_empty_data_returns_zeros(self, client): - """空数据时所有计数为 0。""" - resp = client.get("/dashboard/overview") - assert resp.status_code == 200 - data = resp.json() - - assert data["total_assets"] == 0 - assert data["used_storage_bytes"] == 0 - assert data["total_titles"] == 0 - assert data["total_voices"] == 0 - assert data["total_tasks"] == 0 - assert data["total_products"] == 2 # fixture 中有 2 个项目 - assert data["recent_tasks"] == [] - - def test_assets_count_and_storage(self, client, asset_repo): - """素材统计正确。""" - asset_repo.add_asset("proj-1", 1024) - asset_repo.add_asset("proj-1", 2048) - asset_repo.add_asset("proj-2", 4096) - # 其他用户的不计入 - asset_repo.add_asset("proj-other", 9999) - - resp = client.get("/dashboard/overview") - data = resp.json() - - assert data["total_assets"] == 3 - assert data["used_storage_bytes"] == 1024 + 2048 + 4096 - - def test_title_library_count(self, client, title_library_repo): - """标题库统计正确。""" - title_library_repo.add_item("user-test-001") - title_library_repo.add_item("user-test-001") - title_library_repo.add_item("user-test-001") - title_library_repo.add_item("other-user") - - resp = client.get("/dashboard/overview") - data = resp.json() - - assert data["total_titles"] == 3 - - def test_voice_library_count(self, client, voice_library_repo): - """配音库统计正确。""" - voice_library_repo.add_item("user-test-001") - voice_library_repo.add_item("other-user") - - resp = client.get("/dashboard/overview") - data = resp.json() - - assert data["total_voices"] == 1 - - def test_generation_tasks_count(self, client, generation_task_repo): - """生成任务统计正确。""" - generation_task_repo.add_task(_make_generation_task("task-1")) - generation_task_repo.add_task(_make_generation_task("task-2")) - generation_task_repo.add_task(_make_generation_task("task-other", user_id="other-user")) - - resp = client.get("/dashboard/overview") - data = resp.json() - - assert data["total_tasks"] == 2 - - def test_recent_tasks_limited_to_5(self, client, generation_task_repo): - """最近任务最多返回 5 个。""" - for i in range(10): - task = _make_generation_task(f"task-{i}") - generation_task_repo.add_task(task) - - resp = client.get("/dashboard/overview") - data = resp.json() - - assert len(data["recent_tasks"]) <= 5 - - def test_recent_tasks_have_correct_fields(self, client, generation_task_repo): - """最近任务包含正确字段。""" - task = _make_generation_task("task-1", status=GenerationTaskStatus.COMPLETED) - generation_task_repo.add_task(task) - - resp = client.get("/dashboard/overview") - data = resp.json() - - assert len(data["recent_tasks"]) == 1 - item = data["recent_tasks"][0] - for field in ["id", "task_type", "status", "current_step", "error_message", "updated_at"]: - assert field in item, f"缺少字段: {field}" - assert item["task_type"] == "generation" - - def test_subscription_info(self, client): - """订阅信息正确。""" - resp = client.get("/dashboard/overview") - data = resp.json() - - assert "subscription" in data - sub = data["subscription"] - assert "plan" in sub - assert "is_active" in sub - assert sub["plan"] == "free" - assert sub["is_active"] is True - - def test_pro_user_subscription( - self, project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo - ): - """Pro 用户订阅信息正确。""" - test_app = FastAPI() - test_app.include_router(router, prefix="/dashboard") - - test_app.dependency_overrides[get_current_user] = lambda: AuthenticatedUser( - user=_make_user(subscription_plan="pro", subscription_status="active") - ) - test_app.dependency_overrides[get_project_repository] = lambda: project_repo - test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo - test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo - test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo - test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo - - c = TestClient(test_app) - resp = c.get("/dashboard/overview") - assert resp.status_code == 200 - assert resp.json()["subscription"]["plan"] == "pro" - assert resp.json()["subscription"]["is_active"] is True - test_app.dependency_overrides.clear() - - def test_total_products_count(self, client, project_repo): - """项目(产品)数量正确。""" - resp = client.get("/dashboard/overview") - data = resp.json() - assert data["total_products"] == 2 - - # 新增一个项目后 - project_repo.save(_make_project("proj-3", "user-test-001")) - resp2 = client.get("/dashboard/overview") - assert resp2.json()["total_products"] == 3 - - def test_unauthorized_returns_401( - self, project_repo, asset_repo, generation_task_repo, title_library_repo, voice_library_repo - ): - """未授权访问返回 401/403。""" - test_app = FastAPI() - test_app.include_router(router, prefix="/dashboard") - - test_app.dependency_overrides[get_project_repository] = lambda: project_repo - test_app.dependency_overrides[get_asset_repository] = lambda: asset_repo - test_app.dependency_overrides[get_generation_task_repository] = lambda: generation_task_repo - test_app.dependency_overrides[get_title_library_repository] = lambda: title_library_repo - test_app.dependency_overrides[get_voice_library_repository] = lambda: voice_library_repo - - c = TestClient(test_app) - resp = c.get("/dashboard/overview") - assert resp.status_code in (401, 403) - test_app.dependency_overrides.clear() - - def test_recent_tasks_status_mapping(self, client, generation_task_repo): - """不同状态的任务显示正确的当前步骤。""" - # 已完成任务 - completed_task = _make_generation_task("task-completed", status=GenerationTaskStatus.COMPLETED) - generation_task_repo.add_task(completed_task) - - resp = client.get("/dashboard/overview") - tasks = resp.json()["recent_tasks"] - completed = [t for t in tasks if t["id"] == "task-completed"][0] - assert completed["status"] == "completed" - assert "完成" in completed["current_step"] or "completed" in completed["current_step"].lower() - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/integration/test_generated_videos_api.py b/tests/integration/test_generated_videos_api.py deleted file mode 100644 index a773dd900..000000000 --- a/tests/integration/test_generated_videos_api.py +++ /dev/null @@ -1,554 +0,0 @@ -""" -生成视频管理 API 集成测试。 - -覆盖端点: -- GET /generated-videos — 列出生成视频 -- GET /generated-videos/{video_id} — 获取生成视频详情 -- PATCH /generated-videos/{video_id}/review — 更新审核状态 -- GET /generated-videos/{video_id}/download-url — 获取下载地址 - -使用 FastAPI TestClient + dependency_overrides 模式, -导入真实路由模块,mock 所有外部依赖。 -""" - -from __future__ import annotations - -import os -import sys -from dataclasses import replace -from datetime import datetime, timezone -from unittest.mock import MagicMock - -# ── 环境变量 & sys.path(必须在导入 app.* 之前设置) ────────────────────────── -os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") -os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api")) - -from app.api.routes.generated_videos import router -from app.auth import AuthenticatedUser, get_current_user -from app.core.storage import get_storage_service -from app.dependencies import get_generated_video_repository, get_project_repository - -from packages.domain.entities import Project, User -from packages.domain.generated_video import GeneratedVideo - -# --------------------------------------------------------------------------- -# 1. 内存 Repository + 辅助函数 -# --------------------------------------------------------------------------- - - -class InMemoryGeneratedVideoRepository: - """内存中的生成视频 Repository。""" - - def __init__(self): - self._items: dict[str, GeneratedVideo] = {} - - def create(self, video: GeneratedVideo) -> GeneratedVideo: - self._items[video.id] = video - return video - - def get(self, video_id: str) -> GeneratedVideo | None: - return self._items.get(video_id) - - def update(self, video: GeneratedVideo) -> GeneratedVideo: - self._items[video.id] = video - return video - - def list_by_project(self, project_id: str) -> list[GeneratedVideo]: - return [v for v in self._items.values() if v.project_id == project_id] - - def list_by_generation_task(self, generation_task_id: str) -> list[GeneratedVideo]: - return [v for v in self._items.values() if v.generation_task_id == generation_task_id] - - def list_by_batch(self, batch_id: str) -> list[GeneratedVideo]: - return [] - - -class InMemoryProjectRepository: - """内存中的项目 Repository。""" - - def __init__(self): - self._projects: dict[str, Project] = {} - - def save(self, project: Project) -> None: - self._projects[project.id] = project - - def find_by_id(self, project_id: str) -> Project | None: - return self._projects.get(project_id) - - def find_by_owner_user_id(self, owner_user_id: str) -> list[Project]: - return [p for p in self._projects.values() if p.owner_user_id == owner_user_id] - - def find_accessible_projects(self, user_id: str) -> list[Project]: - return [p for p in self._projects.values() if p.owner_user_id == user_id] - - def count_by_owner(self, owner_user_id: str) -> int: - return len(self.find_by_owner_user_id(owner_user_id)) - - def delete(self, project_id: str) -> bool: - if project_id in self._projects: - del self._projects[project_id] - return True - return False - - -class MockStorageService: - """Mock OSS 存储服务。""" - - def get_download_url(self, file_url: str) -> str: - return f"https://cdn.example.com/download/{file_url}?token=abc123" - - -def _make_user(**overrides) -> User: - defaults = dict( - id="user-test-001", - email="test@example.com", - display_name="Test User", - username="testuser", - subscription_plan="free", - subscription_status="active", - max_projects=3, - max_storage_gb=10, - created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), - ) - defaults.update(overrides) - return User(**defaults) - - -def _make_project(project_id: str = "proj-1", owner_user_id: str = "user-test-001") -> Project: - return Project( - id=project_id, - name=f"Project {project_id}", - owner_user_id=owner_user_id, - description="", - created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), - ) - - -def _make_video( - project_id: str = "proj-1", - name: str = "output.mp4", - status: str = "completed", - review_status: str = "pending_review", - **kwargs, -) -> GeneratedVideo: - return GeneratedVideo.create( - project_id=project_id, - generation_task_id=kwargs.pop("generation_task_id", "task-1"), - name=name, - file_url=kwargs.pop("file_url", f"generated/{name}"), - file_size=kwargs.pop("file_size", 1024000), - duration=kwargs.pop("duration", 30.5), - width=kwargs.pop("width", 1920), - height=kwargs.pop("height", 1080), - fps=kwargs.pop("fps", 30.0), - thumbnail_url=kwargs.pop("thumbnail_url", None), - generation_params=kwargs.pop("generation_params", {"resolution": "1080p"}), - ) - - -# --------------------------------------------------------------------------- -# 2. Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def video_repo(): - return InMemoryGeneratedVideoRepository() - - -@pytest.fixture -def project_repo(): - repo = InMemoryProjectRepository() - # 默认创建一个项目 - repo.save(_make_project("proj-1", "user-test-001")) - repo.save(_make_project("proj-2", "user-test-001")) - repo.save(_make_project("proj-other", "other-user")) - return repo - - -@pytest.fixture -def storage_service(): - return MockStorageService() - - -@pytest.fixture -def client(video_repo, project_repo, storage_service): - """创建带有依赖覆盖的 TestClient。""" - test_app = FastAPI() - test_app.include_router(router, prefix="/generated-videos") - - def _override_current_user(): - return AuthenticatedUser(user=_make_user()) - - def _override_video_repo(): - return video_repo - - def _override_project_repo(): - return project_repo - - def _override_storage(): - return storage_service - - test_app.dependency_overrides[get_current_user] = _override_current_user - test_app.dependency_overrides[get_generated_video_repository] = _override_video_repo - test_app.dependency_overrides[get_project_repository] = _override_project_repo - test_app.dependency_overrides[get_storage_service] = _override_storage - - yield TestClient(test_app) - - test_app.dependency_overrides.clear() - - -# --------------------------------------------------------------------------- -# 3. GET / — 列出生成视频 -# --------------------------------------------------------------------------- - - -class TestListGeneratedVideos: - """列出生成视频端点测试。""" - - def test_empty_list(self, client): - """无视频时返回空列表。""" - resp = client.get("/generated-videos") - assert resp.status_code == 200 - data = resp.json() - assert data["items"] == [] - - def test_list_all_user_videos(self, client, video_repo, project_repo): - """列出当前用户所有项目的视频。""" - v1 = _make_video(project_id="proj-1", name="video1.mp4") - v2 = _make_video(project_id="proj-2", name="video2.mp4") - v3 = _make_video(project_id="proj-other", name="other.mp4") # 其他用户 - video_repo.create(v1) - video_repo.create(v2) - video_repo.create(v3) - - resp = client.get("/generated-videos") - assert resp.status_code == 200 - data = resp.json() - assert len(data["items"]) == 2 - names = {item["name"] for item in data["items"]} - assert names == {"video1.mp4", "video2.mp4"} - - def test_filter_by_project_id(self, client, video_repo): - """按 project_id 筛选视频。""" - v1 = _make_video(project_id="proj-1", name="a.mp4") - v2 = _make_video(project_id="proj-2", name="b.mp4") - video_repo.create(v1) - video_repo.create(v2) - - resp = client.get("/generated-videos?project_id=proj-1") - assert resp.status_code == 200 - data = resp.json() - assert len(data["items"]) == 1 - assert data["items"][0]["name"] == "a.mp4" - - def test_filter_by_nonexistent_project_returns_404(self, client): - """筛选不存在的项目返回 404。""" - resp = client.get("/generated-videos?project_id=nonexistent") - assert resp.status_code == 404 - - def test_list_includes_download_url(self, client, video_repo): - """列表响应应包含下载地址。""" - v = _make_video(file_url="generated/test.mp4") - video_repo.create(v) - - resp = client.get("/generated-videos") - assert resp.status_code == 200 - item = resp.json()["items"][0] - assert "download_url" in item - assert item["download_url"] is not None - assert "cdn.example.com" in item["download_url"] - - def test_list_response_fields(self, client, video_repo): - """列表响应包含所有必需字段。""" - v = _make_video() - video_repo.create(v) - - resp = client.get("/generated-videos") - item = resp.json()["items"][0] - for field in [ - "id", - "project_id", - "generation_task_id", - "name", - "file_url", - "file_size", - "duration", - "width", - "height", - "fps", - "status", - "review_status", - "generation_params", - "download_url", - ]: - assert field in item, f"缺少字段: {field}" - - def test_unauthorized_returns_401(self, video_repo, project_repo, storage_service): - """未授权访问返回 401/403。""" - test_app = FastAPI() - test_app.include_router(router, prefix="/generated-videos") - - # 不覆盖 get_current_user,使用默认(会拒绝无 token 请求) - test_app.dependency_overrides[get_generated_video_repository] = lambda: video_repo - test_app.dependency_overrides[get_project_repository] = lambda: project_repo - test_app.dependency_overrides[get_storage_service] = lambda: storage_service - - c = TestClient(test_app) - resp = c.get("/generated-videos") - # 无 token 时 fastapi HTTPBearer auto_error=False 会返回 None, - # get_current_user 会抛 401 - assert resp.status_code in (401, 403) - test_app.dependency_overrides.clear() - - -# --------------------------------------------------------------------------- -# 4. GET /{video_id} — 获取生成视频详情 -# --------------------------------------------------------------------------- - - -class TestGetGeneratedVideo: - """获取生成视频详情端点测试。""" - - def test_get_existing_video(self, client, video_repo): - """获取存在的视频返回详情。""" - v = _make_video(name="detail.mp4", duration=45.0) - video_repo.create(v) - - resp = client.get(f"/generated-videos/{v.id}") - assert resp.status_code == 200 - data = resp.json() - assert data["id"] == v.id - assert data["name"] == "detail.mp4" - assert data["duration"] == 45.0 - assert data["status"] == "completed" - - def test_get_includes_download_url(self, client, video_repo): - """详情响应包含下载地址。""" - v = _make_video(file_url="generated/detail.mp4") - video_repo.create(v) - - resp = client.get(f"/generated-videos/{v.id}") - data = resp.json() - assert "download_url" in data - assert "cdn.example.com" in data["download_url"] - - def test_get_nonexistent_returns_404(self, client): - """获取不存在的视频返回 404。""" - resp = client.get("/generated-videos/nonexistent-video-id") - assert resp.status_code == 404 - assert "not found" in resp.json()["detail"].lower() - - def test_get_thumbnail_url(self, client, video_repo): - """有缩略图时返回缩略图 URL。""" - v = _make_video(thumbnail_url="thumbs/test.jpg") - video_repo.create(v) - - resp = client.get(f"/generated-videos/{v.id}") - data = resp.json() - assert data["thumbnail_url"] == "thumbs/test.jpg" - - def test_get_generation_params(self, client, video_repo): - """返回生成参数。""" - params = {"resolution": "4k", "style": "cinematic"} - v = _make_video(generation_params=params) - video_repo.create(v) - - resp = client.get(f"/generated-videos/{v.id}") - data = resp.json() - assert data["generation_params"]["resolution"] == "4k" - assert data["generation_params"]["style"] == "cinematic" - - -# --------------------------------------------------------------------------- -# 5. PATCH /{video_id}/review — 更新审核状态 -# --------------------------------------------------------------------------- - - -class TestUpdateReviewStatus: - """更新审核状态端点测试。""" - - def test_approve_video(self, client, video_repo): - """审核通过。""" - v = _make_video(review_status="pending_review") - video_repo.create(v) - - resp = client.patch( - f"/generated-videos/{v.id}/review", - json={"review_status": "approved"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["review_status"] == "approved" - - # 验证 repository 已更新 - updated = video_repo.get(v.id) - assert updated.review_status == "approved" - - def test_reject_video(self, client, video_repo): - """审核拒绝。""" - v = _make_video(review_status="pending_review") - video_repo.create(v) - - resp = client.patch( - f"/generated-videos/{v.id}/review", - json={"review_status": "rejected"}, - ) - assert resp.status_code == 200 - assert resp.json()["review_status"] == "rejected" - - def test_set_pending_review(self, client, video_repo): - """设置为待审核。""" - v = _make_video(review_status="approved") - video_repo.create(v) - - resp = client.patch( - f"/generated-videos/{v.id}/review", - json={"review_status": "pending_review"}, - ) - assert resp.status_code == 200 - assert resp.json()["review_status"] == "pending_review" - - def test_nonexistent_video_returns_404(self, client): - """更新不存在的视频返回 404。""" - resp = client.patch( - "/nonexistent-id/review", - json={"review_status": "approved"}, - ) - assert resp.status_code == 404 - - def test_invalid_status_returns_422(self, client, video_repo): - """无效审核状态返回 422。""" - v = _make_video() - video_repo.create(v) - - resp = client.patch( - f"/generated-videos/{v.id}/review", - json={"review_status": "invalid_status"}, - ) - assert resp.status_code == 422 - - def test_missing_status_returns_422(self, client, video_repo): - """缺少 review_status 字段返回 422。""" - v = _make_video() - video_repo.create(v) - - resp = client.patch(f"/generated-videos/{v.id}/review", json={}) - assert resp.status_code == 422 - - def test_update_returns_updated_fields(self, client, video_repo): - """更新后返回完整的视频信息。""" - v = _make_video(name="review_test.mp4") - video_repo.create(v) - - resp = client.patch( - f"/generated-videos/{v.id}/review", - json={"review_status": "approved"}, - ) - data = resp.json() - assert data["name"] == "review_test.mp4" - assert "id" in data - assert "download_url" in data - - -# --------------------------------------------------------------------------- -# 6. GET /{video_id}/download-url — 获取下载地址 -# --------------------------------------------------------------------------- - - -class TestGetDownloadUrl: - """获取下载地址端点测试。""" - - def test_get_download_url_success(self, client, video_repo): - """获取下载地址成功。""" - v = _make_video(file_url="generated/video.mp4") - video_repo.create(v) - - resp = client.get(f"/generated-videos/{v.id}/download-url") - assert resp.status_code == 200 - data = resp.json() - assert data["video_id"] == v.id - assert "download_url" in data - assert "cdn.example.com" in data["download_url"] - - def test_nonexistent_video_returns_404(self, client): - """获取不存在视频的下载地址返回 404。""" - resp = client.get("/generated-videos/nonexistent-id/download-url") - assert resp.status_code == 404 - - def test_download_url_format(self, client, video_repo): - """下载地址格式正确。""" - v = _make_video(file_url="my-video.mp4") - video_repo.create(v) - - resp = client.get(f"/generated-videos/{v.id}/download-url") - url = resp.json()["download_url"] - assert url.startswith("https://") - assert "token=" in url - - -# --------------------------------------------------------------------------- -# 7. 跨端点场景 -# --------------------------------------------------------------------------- - - -class TestCrossEndpointScenarios: - """跨端点集成场景。""" - - def test_create_list_detail_review_flow(self, client, video_repo): - """列表 → 详情 → 审核 完整流程。""" - # 准备数据 - v = _make_video(name="flow.mp4", review_status="pending_review") - video_repo.create(v) - - # 1. 列表 - list_resp = client.get("/generated-videos") - assert list_resp.status_code == 200 - assert len(list_resp.json()["items"]) == 1 - - # 2. 详情 - detail_resp = client.get(f"/generated-videos/{v.id}") - assert detail_resp.status_code == 200 - assert detail_resp.json()["name"] == "flow.mp4" - assert detail_resp.json()["review_status"] == "pending_review" - - # 3. 审核通过 - review_resp = client.patch( - f"/generated-videos/{v.id}/review", - json={"review_status": "approved"}, - ) - assert review_resp.status_code == 200 - assert review_resp.json()["review_status"] == "approved" - - # 4. 再次查看详情确认 - detail_resp2 = client.get(f"/generated-videos/{v.id}") - assert detail_resp2.json()["review_status"] == "approved" - - # 5. 获取下载地址 - dl_resp = client.get(f"/generated-videos/{v.id}/download-url") - assert dl_resp.status_code == 200 - assert dl_resp.json()["video_id"] == v.id - - def test_multiple_videos_pagination_simulation(self, client, video_repo): - """多个视频时列表正确返回所有视频。""" - for i in range(5): - v = _make_video(project_id="proj-1", name=f"video_{i}.mp4") - video_repo.create(v) - - resp = client.get("/generated-videos") - assert resp.status_code == 200 - items = resp.json()["items"] - assert len(items) == 5 - names = {item["name"] for item in items} - assert len(names) == 5 # 全部不同 - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/unit/test_edit_templates_api.py b/tests/unit/test_edit_templates_api.py deleted file mode 100644 index a50e60afa..000000000 --- a/tests/unit/test_edit_templates_api.py +++ /dev/null @@ -1,427 +0,0 @@ -"""模板管理 API 单元测试 — Phase 8 任务 2.03. - -覆盖 5 个端点: - GET /api/v1/edit-templates — 列表(分页 + 筛选) - GET /api/v1/edit-templates/{id} — 详情 - POST /api/v1/edit-templates — 创建 - PUT /api/v1/edit-templates/{id} — 更新 - DELETE /api/v1/edit-templates/{id} — 软删除 - -使用 FastAPI TestClient + Stub Repository + dependency_overrides. -""" - -from __future__ import annotations - -import os -import sys -from dataclasses import dataclass, field -from datetime import datetime, timezone -from typing import Any, Optional -from unittest.mock import MagicMock - -os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing") -os.environ.setdefault("DATABASE_URL", "sqlite:///test.db") - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "api")) - -from packages.domain.config_schemas import normalize_template_config -from packages.domain.edit_template import EditTemplate, EditTemplateStatus - -# ── Stub Repository ─────────────────────────────────────────────────────────── - - -class StubEditTemplateRepository: - """内存中模拟 EditTemplate 仓储""" - - def __init__(self) -> None: - self._store: dict[str, EditTemplate] = {} - - def list_all( - self, - *, - template_type: Optional[str] = None, - status: Optional[EditTemplateStatus] = None, - skip: int = 0, - limit: int = 50, - ) -> list[EditTemplate]: - items = list(self._store.values()) - if template_type: - items = [t for t in items if t.template_type == template_type] - if status: - items = [t for t in items if t.status == status] - items.sort(key=lambda t: t.created_at, reverse=True) - return items[skip : skip + limit] - - def list_active( - self, - *, - template_type: Optional[str] = None, - skip: int = 0, - limit: int = 50, - ) -> list[EditTemplate]: - return self.list_all(template_type=template_type, status=EditTemplateStatus.ACTIVE, skip=skip, limit=limit) - - def get(self, template_id: str) -> Optional[EditTemplate]: - return self._store.get(template_id) - - def create(self, template: EditTemplate) -> EditTemplate: - self._store[template.id] = template - return template - - def update(self, template: EditTemplate) -> EditTemplate: - if template.id not in self._store: - raise ValueError(f"EditTemplate {template.id} not found") - self._store[template.id] = template - return template - - def delete(self, template_id: str) -> bool: - if template_id in self._store: - del self._store[template_id] - return True - return False - - def count( - self, - *, - template_type: Optional[str] = None, - status: Optional[EditTemplateStatus] = None, - ) -> int: - items = list(self._store.values()) - if template_type: - items = [t for t in items if t.template_type == template_type] - if status: - items = [t for t in items if t.status == status] - return len(items) - - -# ── Fixtures ────────────────────────────────────────────────────────────────── - - -@dataclass -class FakeUser: - id: str = "user-001" - email: str = "test@example.com" - is_admin: bool = True - - -@dataclass -class FakeAuthenticatedUser: - user: FakeUser = field(default_factory=FakeUser) - session_id: str | None = None - token_type: str | None = None - - -@pytest.fixture -def stub_repo() -> StubEditTemplateRepository: - return StubEditTemplateRepository() - - -@pytest.fixture -def app(stub_repo: StubEditTemplateRepository) -> FastAPI: - """构建测试 FastAPI 应用,注入 Stub Repository""" - import app.services.edit_template_service as service_module - from app.api.routes.edit_templates import router - from app.auth import get_current_user - from app.dependencies import get_db_session - - # 替换服务模块中的 Repository 类 - original_template_repo_cls = service_module.SQLAlchemyEditTemplateRepository - original_clip_config_repo_cls = service_module.SQLAlchemyTemplateClipConfigRepository - service_module.SQLAlchemyEditTemplateRepository = lambda session: stub_repo - service_module.SQLAlchemyTemplateClipConfigRepository = lambda session: stub_repo - - test_app = FastAPI() - test_app.include_router(router, prefix="/api/v1/edit-templates") - - # 覆盖依赖 - def override_get_db_session(): - yield MagicMock() - - def override_get_current_user(): - return FakeAuthenticatedUser() - - test_app.dependency_overrides[get_db_session] = override_get_db_session - test_app.dependency_overrides[get_current_user] = override_get_current_user - - yield test_app - - # 恢复 - service_module.SQLAlchemyEditTemplateRepository = original_template_repo_cls - service_module.SQLAlchemyTemplateClipConfigRepository = original_clip_config_repo_cls - test_app.dependency_overrides.clear() - - -@pytest.fixture -def client(app: FastAPI) -> TestClient: - return TestClient(app) - - -def _make_template(name: str = "测试模板", **kwargs: Any) -> EditTemplate: - return EditTemplate.create(name=name, **kwargs) - - -# ── GET /api/v1/edit-templates (列表) ───────────────────────────────────────── - - -class TestListTemplates: - def test_empty_list(self, client: TestClient) -> None: - resp = client.get("/api/v1/edit-templates") - assert resp.status_code == 200 - data = resp.json() - assert data["items"] == [] - assert data["total"] == 0 - assert data["page"] == 1 - - def test_list_with_items(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - for i in range(3): - stub_repo.create(_make_template(f"模板{i}")) - resp = client.get("/api/v1/edit-templates") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 3 - assert len(data["items"]) == 3 - - def test_pagination(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - for i in range(5): - stub_repo.create(_make_template(f"模板{i}")) - resp = client.get("/api/v1/edit-templates?page=1&page_size=2") - assert resp.status_code == 200 - data = resp.json() - assert len(data["items"]) == 2 - assert data["total"] == 5 - assert data["page"] == 1 - assert data["page_size"] == 2 - - def test_filter_by_type(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - stub_repo.create(_make_template("Vlog模板", template_type="vlog")) - stub_repo.create(_make_template("短视频模板", template_type="short")) - stub_repo.create(_make_template("另一个Vlog", template_type="vlog")) - resp = client.get("/api/v1/edit-templates?template_type=vlog") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 2 - assert all(item["template_type"] == "vlog" for item in data["items"]) - - def test_filter_by_status(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t1 = _make_template("活跃模板") - stub_repo.create(t1) - t2 = _make_template("停用模板", status=EditTemplateStatus.INACTIVE) - stub_repo.create(t2) - resp = client.get("/api/v1/edit-templates?status=active") - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 1 - assert data["items"][0]["name"] == "活跃模板" - - def test_invalid_status_filter(self, client: TestClient) -> None: - resp = client.get("/api/v1/edit-templates?status=invalid") - assert resp.status_code == 400 - - def test_invalid_page(self, client: TestClient) -> None: - resp = client.get("/api/v1/edit-templates?page=0") - assert resp.status_code == 422 - - -# ── GET /api/v1/edit-templates/{id} (详情) ──────────────────────────────────── - - -class TestGetTemplate: - def test_get_existing(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t = _make_template("详情模板", description="这是描述", template_type="vlog") - stub_repo.create(t) - resp = client.get(f"/api/v1/edit-templates/{t.id}") - assert resp.status_code == 200 - data = resp.json() - assert data["id"] == t.id - assert data["name"] == "详情模板" - assert data["description"] == "这是描述" - assert data["template_type"] == "vlog" - assert data["status"] == "active" - - def test_get_not_found(self, client: TestClient) -> None: - resp = client.get("/api/v1/edit-templates/nonexistent-id") - assert resp.status_code == 404 - assert "不存在" in resp.json()["detail"] - - -# ── POST /api/v1/edit-templates (创建) ──────────────────────────────────────── - - -class TestCreateTemplate: - def test_create_basic(self, client: TestClient) -> None: - resp = client.post("/api/v1/edit-templates", json={"name": "新模板"}) - assert resp.status_code == 201 - data = resp.json() - assert data["name"] == "新模板" - assert data["description"] == "" - assert data["template_type"] == "default" - assert data["status"] == "active" - assert data["sort_weight"] == 0 - assert "id" in data - - def test_create_with_all_fields(self, client: TestClient) -> None: - body = { - "name": "完整模板", - "description": "完整描述", - "template_type": "vlog", - "config": {"key": "value"}, - "preview_url": "https://example.com/preview.mp4", - "sort_weight": 10, - } - resp = client.post("/api/v1/edit-templates", json=body) - assert resp.status_code == 201 - data = resp.json() - assert data["name"] == "完整模板" - assert data["description"] == "完整描述" - assert data["template_type"] == "vlog" - assert data["config"] == normalize_template_config({"key": "value"}) - assert data["preview_url"] == "https://example.com/preview.mp4" - assert data["sort_weight"] == 10 - - def test_create_empty_name(self, client: TestClient) -> None: - resp = client.post("/api/v1/edit-templates", json={"name": ""}) - assert resp.status_code == 422 # Pydantic min_length=1 - - def test_create_whitespace_name(self, client: TestClient) -> None: - resp = client.post("/api/v1/edit-templates", json={"name": " "}) - assert resp.status_code == 400 # domain validation - - def test_create_missing_name(self, client: TestClient) -> None: - resp = client.post("/api/v1/edit-templates", json={}) - assert resp.status_code == 422 - - def test_create_negative_sort_weight(self, client: TestClient) -> None: - resp = client.post("/api/v1/edit-templates", json={"name": "模板", "sort_weight": -1}) - assert resp.status_code == 422 - - -# ── PUT /api/v1/edit-templates/{id} (更新) ──────────────────────────────────── - - -class TestUpdateTemplate: - def test_update_name(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t = _make_template("旧名称") - stub_repo.create(t) - resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"name": "新名称"}) - assert resp.status_code == 200 - assert resp.json()["name"] == "新名称" - - def test_update_multiple_fields(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t = _make_template("模板") - stub_repo.create(t) - body = {"name": "更新后", "description": "新描述", "sort_weight": 5} - resp = client.put(f"/api/v1/edit-templates/{t.id}", json=body) - assert resp.status_code == 200 - data = resp.json() - assert data["name"] == "更新后" - assert data["description"] == "新描述" - assert data["sort_weight"] == 5 - - def test_update_status(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t = _make_template("模板") - stub_repo.create(t) - resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"status": "inactive"}) - assert resp.status_code == 200 - assert resp.json()["status"] == "inactive" - - def test_update_invalid_status(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t = _make_template("模板") - stub_repo.create(t) - resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"status": "bogus"}) - assert resp.status_code == 400 - - def test_update_not_found(self, client: TestClient) -> None: - resp = client.put("/api/v1/edit-templates/nonexistent", json={"name": "x"}) - assert resp.status_code == 404 - - def test_partial_update_preserves_others(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t = _make_template("原名", description="原描述", template_type="vlog") - stub_repo.create(t) - resp = client.put(f"/api/v1/edit-templates/{t.id}", json={"name": "新名"}) - assert resp.status_code == 200 - data = resp.json() - assert data["name"] == "新名" - assert data["description"] == "原描述" - assert data["template_type"] == "vlog" - - def test_update_empty_body(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t = _make_template("模板") - stub_repo.create(t) - resp = client.put(f"/api/v1/edit-templates/{t.id}", json={}) - assert resp.status_code == 200 - assert resp.json()["name"] == "模板" - - -# ── DELETE /api/v1/edit-templates/{id} (软删除) ─────────────────────────────── - - -class TestDeleteTemplate: - def test_soft_delete(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t = _make_template("待删除") - stub_repo.create(t) - resp = client.delete(f"/api/v1/edit-templates/{t.id}") - assert resp.status_code == 204 - # 软删除后仍存在,但状态为 inactive - updated = stub_repo.get(t.id) - assert updated is not None - assert updated.status == EditTemplateStatus.INACTIVE - - def test_soft_delete_not_found(self, client: TestClient) -> None: - resp = client.delete("/api/v1/edit-templates/nonexistent") - assert resp.status_code == 404 - - def test_soft_delete_idempotent(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t = _make_template("模板") - stub_repo.create(t) - # 第一次删除 - resp1 = client.delete(f"/api/v1/edit-templates/{t.id}") - assert resp1.status_code == 204 - # 第二次删除(已经是 inactive,但仍可再次设为 inactive) - resp2 = client.delete(f"/api/v1/edit-templates/{t.id}") - assert resp2.status_code == 204 - - def test_deleted_not_in_active_list(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t = _make_template("模板") - stub_repo.create(t) - client.delete(f"/api/v1/edit-templates/{t.id}") - resp = client.get("/api/v1/edit-templates?status=active") - data = resp.json() - assert data["total"] == 0 - - -# ── Response Schema 验证 ────────────────────────────────────────────────────── - - -class TestResponseSchema: - def test_response_has_all_fields(self, client: TestClient, stub_repo: StubEditTemplateRepository) -> None: - t = _make_template("模板", description="描述", template_type="vlog") - stub_repo.create(t) - resp = client.get(f"/api/v1/edit-templates/{t.id}") - data = resp.json() - expected_keys = { - "id", - "name", - "description", - "template_type", - "editing_mode", - "config", - "preview_url", - "sort_weight", - "status", - "created_at", - "updated_at", - } - assert set(data.keys()) == expected_keys - - def test_list_response_structure(self, client: TestClient) -> None: - resp = client.get("/api/v1/edit-templates") - data = resp.json() - assert "items" in data - assert "total" in data - assert "page" in data - assert "page_size" in data - assert isinstance(data["items"], list) diff --git a/tests/unit/test_video_upload_fix_and_generated_api.py b/tests/unit/test_video_upload_fix_and_generated_api.py index c40d1e3c3..419a86739 100644 --- a/tests/unit/test_video_upload_fix_and_generated_api.py +++ b/tests/unit/test_video_upload_fix_and_generated_api.py @@ -169,50 +169,11 @@ class TestGeneratedVideoRepositoryNoNPlus1: assert result == [] -# ─── 3. 成片库 API 端点可用性确认 ─── +# ─── 3. 成片库 Schema 及替代端点可用性确认 ─── -class TestGeneratedVideosAPIAvailability: - """确认成片库 API 路由注册正确,端点可正常返回数据。""" - - def test_generated_videos_routes_registered(self): - """成片库路由已注册到 router。""" - from app.api.routes.generated_videos import router as gv_router - - # 直接检查 generated_videos router 自身注册的路由 - paths = [r.path for r in gv_router.routes if hasattr(r, "path")] - assert len(paths) > 0, "generated_videos router 没有注册任何路由" - # 验证关键端点存在 - assert "" in paths, "列表端点不存在" - assert "/{video_id}" in paths, "详情端点不存在" - - def test_generated_videos_list_endpoint_exists(self): - """GET /generated-videos 端点存在。""" - from apps.api.app.api.routes.generated_videos import router - - paths = [r.path for r in router.routes if hasattr(r, "path")] - assert "" in paths, "GET /generated-videos 列表端点不存在" - - def test_generated_videos_detail_endpoint_exists(self): - """GET /generated-videos/{video_id} 端点存在。""" - from apps.api.app.api.routes.generated_videos import router - - paths = [r.path for r in router.routes if hasattr(r, "path")] - assert "/{video_id}" in paths, "GET /generated-videos/{{video_id}} 详情端点不存在" - - def test_generated_videos_review_endpoint_exists(self): - """PATCH /generated-videos/{video_id}/review 端点存在。""" - from apps.api.app.api.routes.generated_videos import router - - paths = [r.path for r in router.routes if hasattr(r, "path")] - assert "/{video_id}/review" in paths, "PATCH review 端点不存在" - - def test_generated_videos_download_url_endpoint_exists(self): - """GET /generated-videos/{video_id}/download-url 端点存在。""" - from apps.api.app.api.routes.generated_videos import router - - paths = [r.path for r in router.routes if hasattr(r, "path")] - assert "/{video_id}/download-url" in paths, "download-url 端点不存在" +class TestGeneratedVideosSchemaAndAlternativeEndpoints: + """确认成片库 Schema 字段完整,替代端点(generation_tasks)可用。""" def test_generated_video_response_schema_complete(self): """GeneratedVideoResponse 包含所有必要字段。""" -- 2.54.0 From c36ec5e7800b1517fa55c10d47a43af2045ea87a Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 13 Jul 2026 15:47:29 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=E4=BF=AE=E5=A4=8D=20ci-env-vars.md?= =?UTF-8?q?=20=E4=B8=AD=20OSS=5FENDPOINT=20=E6=8B=BC=E5=86=99=20aliiyuncs?= =?UTF-8?q?=20=E2=86=92=20aliyuncs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与代码修复保持同步(PR #261 代码审计发现) --- docs/ci-env-vars.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ci-env-vars.md b/docs/ci-env-vars.md index 5def67aea..ee17b5991 100644 --- a/docs/ci-env-vars.md +++ b/docs/ci-env-vars.md @@ -112,7 +112,7 @@ | 变量名 | 用途说明 | 默认值 | |--------|---------|--------| -| `OSS_ENDPOINT` | OSS Endpoint | `oss-cn-hangzhou.aliiyuncs.com` | +| `OSS_ENDPOINT` | OSS Endpoint | `oss-cn-hangzhou.aliyuncs.com` | | `OSS_ACCESS_KEY_ID` | OSS Access Key ID | `""`(空) | | `OSS_ACCESS_KEY_SECRET` | OSS Access Key Secret | `""`(空) | | `OSS_BUCKET_NAME` | OSS Bucket 名称 | `xiaoxia-autocut` | -- 2.54.0