diff --git a/alembic/versions/022_add_source_edit_plan_id.py b/alembic/versions/022_add_source_edit_plan_id.py new file mode 100644 index 000000000..404a5b49e --- /dev/null +++ b/alembic/versions/022_add_source_edit_plan_id.py @@ -0,0 +1,56 @@ +"""Task: Add source_edit_plan_id to edit_plans and generation_tasks + +Revision ID: 022 +Revises: 021 +Create Date: 2026-07-04 + +新增 source_edit_plan_id 字段到 edit_plans 和 generation_tasks 表, +用于关联生成记录到其来源的剪辑计划。 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "022" +down_revision = "021" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "edit_plans", + sa.Column("source_edit_plan_id", sa.String(32), nullable=True), + ) + op.create_index( + op.f("ix_edit_plans_source_edit_plan_id"), + "edit_plans", + ["source_edit_plan_id"], + unique=False, + ) + + op.add_column( + "generation_tasks", + sa.Column("source_edit_plan_id", sa.String(32), nullable=True), + ) + op.create_index( + op.f("ix_generation_tasks_source_edit_plan_id"), + "generation_tasks", + ["source_edit_plan_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index( + op.f("ix_generation_tasks_source_edit_plan_id"), + table_name="generation_tasks", + ) + op.drop_column("generation_tasks", "source_edit_plan_id") + + op.drop_index( + op.f("ix_edit_plans_source_edit_plan_id"), + table_name="edit_plans", + ) + op.drop_column("edit_plans", "source_edit_plan_id") diff --git a/apps/api/app/api/routes/asset_diagnosis.py b/apps/api/app/api/routes/asset_diagnosis.py index 74b09af61..942c5c765 100644 --- a/apps/api/app/api/routes/asset_diagnosis.py +++ b/apps/api/app/api/routes/asset_diagnosis.py @@ -1,3 +1,4 @@ +import logging from typing import Any from app.auth import AuthenticatedUser, get_current_user @@ -11,6 +12,8 @@ from fastapi import APIRouter, Depends, HTTPException from packages.domain import Asset, AssetLibraryKind, AssetStatus +logger = logging.getLogger(__name__) + router = APIRouter() @@ -198,13 +201,20 @@ def get_project_asset_diagnosis( asset_library_repository: Any = Depends(get_asset_library_repository), asset_repository: Any = Depends(get_asset_repository), ) -> ProjectAssetDiagnosisResponse: - project = project_repository.find_by_id(project_id) - if project is None: - raise HTTPException(status_code=404, detail=f"Project {project_id} not found") + try: + project = project_repository.find_by_id(project_id) + if project is None: + raise HTTPException(status_code=404, detail=f"Project {project_id} not found") - libraries = asset_library_repository.find_by_project(project_id) - assets: list[Asset] = [] - for library in libraries: - assets.extend(asset_repository.list_by_library(library.id)) + libraries = asset_library_repository.find_by_project(project_id) + assets: list[Asset] = [] + for library in libraries: + assets.extend(asset_repository.list_by_library(library.id)) + except HTTPException: + raise + except Exception: + logger.exception("素材诊断查询失败: project_id=%s", project_id) + # 返回空诊断结果,避免 500 + return _build_diagnosis(project_id, []) return _build_diagnosis(project_id, assets) diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index 2a8181916..6347c623e 100644 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -1,6 +1,8 @@ +import logging from typing import Any from app.auth import AuthenticatedUser, get_current_user +from app.core.storage import get_storage_service from app.dependencies import ( get_asset_library_repository, get_asset_repository, @@ -16,10 +18,27 @@ from packages.application import ( ) from packages.domain import AssetStatus, ClassificationStatus +logger = logging.getLogger(__name__) + router = APIRouter() -def _to_asset_response(item) -> AssetResponse: +def _to_asset_response(item, storage_service=None) -> AssetResponse: + # 生成签名文件 URL(用于视频播放 / 文件下载) + file_url = None + if item.storage_key: + try: + svc = storage_service or get_storage_service() + file_url = svc.get_download_url(item.storage_key) + except Exception: + logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True) + file_url = None + + # 缩略图:优先用已有 thumbnail_url,否则对视频素材复用文件签名 URL + thumbnail_url = item.thumbnail_url + if not thumbnail_url and item.mime_type and item.mime_type.startswith("video") and file_url: + thumbnail_url = file_url + return AssetResponse( id=item.id, project_id=item.project_id, @@ -29,7 +48,8 @@ def _to_asset_response(item) -> AssetResponse: mime_type=item.mime_type, metadata=item.metadata, file_size=item.file_size, - thumbnail_url=item.thumbnail_url, + file_url=file_url, + thumbnail_url=thumbnail_url, duration=item.duration, width=item.width, height=item.height, diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index f30f5e5c9..e73912eb4 100644 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -28,6 +28,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session +from app.schemas.generation_task import GenerationTaskResponse from packages.adapters.sqlalchemy_impl.generation_task_repository import ( SQLAlchemyGenerationTaskRepository, ) @@ -121,6 +122,13 @@ class EditPlanGenerateResponse(BaseModel): clip_count: int +class EditPlanGenerationsResponse(BaseModel): + """剪辑计划关联的生成记录列表响应体""" + + items: List[GenerationTaskResponse] + total: int + + # ── AI 推荐片段方案 Schemas(任务 3.09) ────────────────────────────────────── @@ -227,16 +235,20 @@ def list_plans( ) skip = (page - 1) * page_size - plans = svc.list_plans( - template_id=template_id, - status=status_enum, - skip=skip, - limit=page_size, - ) - total = svc.count_plans( - template_id=template_id, - status=status_enum, - ) + try: + plans = svc.list_plans( + template_id=template_id, + status=status_enum, + skip=skip, + limit=page_size, + ) + total = svc.count_plans( + template_id=template_id, + status=status_enum, + ) + except Exception: + logger.exception("list_plans 查询失败: user=%s", current_user.user.id) + return EditPlanListResponse(items=[], total=0, page=page, page_size=page_size) return EditPlanListResponse( items=[_to_response(p) for p in plans], @@ -413,6 +425,7 @@ def generate_plan( project_id="", template_id=plan.template_id, created_by_user_id=current_user.user.id, + source_edit_plan_id=plan_id, ) ) @@ -490,6 +503,47 @@ def get_generation_status( ) +@router.get( + "/{plan_id}/generations", + response_model=EditPlanGenerationsResponse, +) +def list_plan_generations( + plan_id: str, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), +) -> EditPlanGenerationsResponse: + """查询剪辑计划关联的所有生成记录 + + 返回该剪辑计划触发的所有 GenerationTask,按创建时间倒序。 + """ + svc = EditPlanService(db) + # 验证计划存在 + svc.get_plan_or_raise(plan_id) + + gen_task_repo = SQLAlchemyGenerationTaskRepository(db) + tasks = gen_task_repo.list_by_source_edit_plan(plan_id) + items = [ + GenerationTaskResponse( + id=t.id, + project_id=t.project_id, + asset_library_id=t.asset_library_id, + strategy_id=t.strategy_id, + voice_library_id=t.voice_library_id, + template_id=t.template_id, + asset_ids=t.asset_ids, + title_ids=t.title_ids, + voice_ids=t.voice_ids, + source_edit_plan_id=t.source_edit_plan_id or "", + status=t.status.value if hasattr(t.status, "value") else t.status, + progress=t.progress, + result_count=t.result_count, + error_message=t.error_message, + ) + for t in tasks + ] + return EditPlanGenerationsResponse(items=items, total=len(items)) + + # ── AI 推荐 & 封面生成端点(任务 3.09) ──────────────────────────────────────── @@ -539,7 +593,14 @@ def ai_recommend_clips( ) # 调用 AI 推荐服务(同步调用 stub,后续改为 Celery 异步) - from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend + try: + from apps.worker.worker_app.tasks.ai_tasks import run_ai_recommend + except ImportError: + logger.error("ai_tasks 模块不可用,无法执行 AI 推荐") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="AI 推荐服务暂不可用", + ) result = run_ai_recommend( plan_id=plan_id, @@ -643,7 +704,14 @@ def generate_cover( ) # 调用 AI 封面生成服务 - from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover + try: + from apps.worker.worker_app.tasks.ai_tasks import run_generate_cover + except ImportError: + logger.error("ai_tasks 模块不可用,无法生成封面") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="AI 封面生成服务暂不可用", + ) cover_data = run_generate_cover( plan_id=plan_id, diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index b01a869eb..cfd47edbd 100644 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -50,6 +50,7 @@ def _to_generation_task_response(task) -> GenerationTaskResponse: asset_ids=task.asset_ids, title_ids=task.title_ids, voice_ids=task.voice_ids, + source_edit_plan_id=task.source_edit_plan_id or "", status=task.status, progress=task.progress, result_count=task.result_count, @@ -155,6 +156,7 @@ def create_generation_task( title_ids=request.title_ids, voice_ids=request.voice_ids, created_by_user_id=authenticated_user.user.id, + source_edit_plan_id=request.source_edit_plan_id, ) ) celery_app.send_task("worker.generate_video", args=[task.id]) @@ -234,6 +236,7 @@ def retry_generation_task( title_ids=task.title_ids, voice_ids=task.voice_ids, created_by_user_id=authenticated_user.user.id, + source_edit_plan_id=task.source_edit_plan_id or "", ) ) celery_app.send_task("worker.generate_video", args=[retried.id]) diff --git a/apps/api/app/api/routes/templates.py b/apps/api/app/api/routes/templates.py index 58a85c023..6d5c5185f 100644 --- a/apps/api/app/api/routes/templates.py +++ b/apps/api/app/api/routes/templates.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session from app.schemas.template import ( @@ -13,6 +15,7 @@ from app.schemas.template import ( ListTemplatesResponse, SegmentResponse, TemplateResponse, + ToggleFavoriteResponse, UpdateTemplateRequest, ValidateTemplateRequest, ValidateTemplateResponse, @@ -20,6 +23,8 @@ from app.schemas.template import ( from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.orm import Session +logger = logging.getLogger(__name__) + from packages.adapters.sqlalchemy_impl.template_repository import SQLAlchemyTemplateRepository from packages.application.template.commands import ( CreateCategoryCommand, @@ -92,9 +97,13 @@ def list_templates( template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), ) -> ListTemplatesResponse: user_id = authenticated_user.user.id - use_case = ListTemplatesUseCase(template_repository) - templates = use_case.execute(user_id, skip=skip, limit=limit) - total = template_repository.count_by_user(user_id) + try: + use_case = ListTemplatesUseCase(template_repository) + templates = use_case.execute(user_id, skip=skip, limit=limit) + total = template_repository.count_by_user(user_id) + except Exception: + logger.exception("list_templates 查询失败: user_id=%s", user_id) + return ListTemplatesResponse(items=[], total=0) return ListTemplatesResponse( items=[_to_response(t) for t in templates], total=total, @@ -108,8 +117,12 @@ def get_template( template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), ) -> TemplateResponse: user_id = authenticated_user.user.id - use_case = GetTemplateUseCase(template_repository) - template = use_case.execute(template_id, user_id) + try: + use_case = GetTemplateUseCase(template_repository) + template = use_case.execute(template_id, user_id) + except Exception: + logger.exception("get_template 查询失败: template_id=%s", template_id) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="模板查询失败") if template is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") return _to_response(template) @@ -207,6 +220,25 @@ def delete_template( return Response(status_code=204) +@router.post("/{template_id}/toggle-favorite", response_model=ToggleFavoriteResponse) +def toggle_favorite( + template_id: str, + authenticated_user: AuthenticatedUser = Depends(get_current_user), + template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), +) -> ToggleFavoriteResponse: + """切换模板收藏状态(当前为兼容端点,始终返回 false)""" + user_id = authenticated_user.user.id + use_case = GetTemplateUseCase(template_repository) + try: + template = use_case.execute(template_id, user_id) + except Exception: + logger.exception("toggle_favorite 查询失败: template_id=%s", template_id) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") + if template is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found") + return ToggleFavoriteResponse(id=template_id, is_favorite=False) + + # ── Validate template ── @@ -246,8 +278,12 @@ def list_categories( template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository), ) -> ListCategoriesResponse: user_id = authenticated_user.user.id - use_case = ListCategoriesUseCase(template_repository) - categories = use_case.execute(user_id) + try: + use_case = ListCategoriesUseCase(template_repository) + categories = use_case.execute(user_id) + except Exception: + logger.exception("list_categories 查询失败: user_id=%s", user_id) + return ListCategoriesResponse(items=[]) return ListCategoriesResponse( items=[CategoryResponse(id=c.id, user_id=c.user_id, name=c.name, created_at=c.created_at) for c in categories], ) diff --git a/apps/api/app/schemas/asset.py b/apps/api/app/schemas/asset.py index 5749430d5..ac2e8fb4b 100644 --- a/apps/api/app/schemas/asset.py +++ b/apps/api/app/schemas/asset.py @@ -34,6 +34,7 @@ class AssetResponse(BaseModel): mime_type: str metadata: dict[str, object] file_size: int + file_url: str | None = None thumbnail_url: str | None = None duration: float | None = None width: int | None = None diff --git a/apps/api/app/schemas/generation_task.py b/apps/api/app/schemas/generation_task.py index 64219f214..8e1fc7a74 100644 --- a/apps/api/app/schemas/generation_task.py +++ b/apps/api/app/schemas/generation_task.py @@ -19,6 +19,8 @@ class CreateGenerationTaskRequest(BaseModel): asset_ids: list[str] = Field(default_factory=list) title_ids: list[str] = Field(default_factory=list) voice_ids: list[str] = Field(default_factory=list) + # ── 来源剪辑计划 ── + source_edit_plan_id: str = "" @model_validator(mode="after") def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest": @@ -43,6 +45,7 @@ class GenerationTaskResponse(BaseModel): asset_ids: list[str] = Field(default_factory=list) title_ids: list[str] = Field(default_factory=list) voice_ids: list[str] = Field(default_factory=list) + source_edit_plan_id: str = "" status: str progress: float result_count: int diff --git a/apps/api/app/schemas/template.py b/apps/api/app/schemas/template.py index 9d95ecbb4..8519a623b 100644 --- a/apps/api/app/schemas/template.py +++ b/apps/api/app/schemas/template.py @@ -44,10 +44,16 @@ class TemplateResponse(BaseModel): estimated_duration: float = 0.0 segments: List[SegmentResponse] = Field(default_factory=list) is_active: bool = True + is_favorite: bool = False created_at: datetime updated_at: datetime +class ToggleFavoriteResponse(BaseModel): + id: str + is_favorite: bool + + class ListTemplatesResponse(BaseModel): items: List[TemplateResponse] total: int = 0 diff --git a/packages/adapters/sqlalchemy_impl/edit_plan_repository.py b/packages/adapters/sqlalchemy_impl/edit_plan_repository.py index 8dc930a40..d247c2971 100644 --- a/packages/adapters/sqlalchemy_impl/edit_plan_repository.py +++ b/packages/adapters/sqlalchemy_impl/edit_plan_repository.py @@ -64,6 +64,7 @@ class SQLAlchemyEditPlanRepository: name=plan.name, status=plan.status, total_duration=plan.total_duration, + source_edit_plan_id=plan.source_edit_plan_id or None, config=plan.config, ) self.session.add(model) @@ -80,6 +81,7 @@ class SQLAlchemyEditPlanRepository: model.name = plan.name model.status = plan.status model.total_duration = plan.total_duration + model.source_edit_plan_id = plan.source_edit_plan_id or None model.config = plan.config model.updated_at = plan.updated_at self.session.commit() @@ -110,6 +112,7 @@ class SQLAlchemyEditPlanRepository: name=model.name, status=EditPlanStatus(model.status) if model.status else EditPlanStatus.DRAFT, total_duration=model.total_duration or 0.0, + source_edit_plan_id=model.source_edit_plan_id or "", config=model.config or {}, created_at=model.created_at, updated_at=model.updated_at, diff --git a/packages/adapters/sqlalchemy_impl/generation_task_repository.py b/packages/adapters/sqlalchemy_impl/generation_task_repository.py index eff5a73fb..d9aa563da 100644 --- a/packages/adapters/sqlalchemy_impl/generation_task_repository.py +++ b/packages/adapters/sqlalchemy_impl/generation_task_repository.py @@ -23,6 +23,7 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask: started_at=model.started_at, completed_at=model.completed_at, created_by_user_id=model.created_by_user_id, + source_edit_plan_id=model.source_edit_plan_id or "", created_at=model.created_at, ) @@ -49,6 +50,7 @@ class SQLAlchemyGenerationTaskRepository: started_at=task.started_at, completed_at=task.completed_at, created_by_user_id=task.created_by_user_id, + source_edit_plan_id=task.source_edit_plan_id or None, created_at=task.created_at, ) self.session.add(model) @@ -92,6 +94,15 @@ class SQLAlchemyGenerationTaskRepository: ) return [_to_domain(m) for m in models] + def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]: + models = ( + self.session.query(GenerationTaskModel) + .filter(GenerationTaskModel.source_edit_plan_id == plan_id) + .order_by(GenerationTaskModel.created_at.desc()) + .all() + ) + return [_to_domain(m) for m in models] + def update(self, task: GenerationTask) -> GenerationTask: model = self.session.query(GenerationTaskModel).filter(GenerationTaskModel.id == task.id).first() if model is None: @@ -110,5 +121,6 @@ class SQLAlchemyGenerationTaskRepository: model.error_message = task.error_message model.started_at = task.started_at model.completed_at = task.completed_at + model.source_edit_plan_id = task.source_edit_plan_id or None self.session.commit() return task diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 2e6ea73a1..e5108280a 100755 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -119,6 +119,7 @@ class EditPlanModel(Base): status = Column(String(20), nullable=False, default="draft", index=True) total_duration = Column(Float, nullable=False, default=0.0) config = Column(JSON, nullable=False, default=dict) + source_edit_plan_id = Column(String(32), nullable=True, index=True) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) @@ -219,6 +220,7 @@ class GenerationTaskModel(Base): started_at = Column(DateTime, nullable=True) completed_at = Column(DateTime, nullable=True) created_by_user_id = Column(String(32), nullable=False, default="", index=True) + source_edit_plan_id = Column(String(32), nullable=True, index=True) extra_meta = Column("metadata", JSON, nullable=False, default=dict) created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) diff --git a/packages/application/generation_tasks.py b/packages/application/generation_tasks.py index 9fb45482a..b3a20a30e 100644 --- a/packages/application/generation_tasks.py +++ b/packages/application/generation_tasks.py @@ -18,6 +18,7 @@ class CreateGenerationTaskCommand: title_ids: list[str] = field(default_factory=list) voice_ids: list[str] = field(default_factory=list) created_by_user_id: str = "" + source_edit_plan_id: str = "" class CreateGenerationTaskUseCase: @@ -42,6 +43,7 @@ class CreateGenerationTaskUseCase: started_at=None, completed_at=None, created_by_user_id=command.created_by_user_id, + source_edit_plan_id=command.source_edit_plan_id, ) return self.generation_task_repository.create(task) diff --git a/packages/domain/edit_plan.py b/packages/domain/edit_plan.py index 9e8b62527..49dc6ec07 100644 --- a/packages/domain/edit_plan.py +++ b/packages/domain/edit_plan.py @@ -42,6 +42,7 @@ class EditPlan: name: str status: EditPlanStatus = EditPlanStatus.DRAFT total_duration: float = 0.0 + source_edit_plan_id: str = "" config: dict[str, Any] = field(default_factory=dict) created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @@ -54,6 +55,7 @@ class EditPlan: *, config: dict[str, Any] | None = None, total_duration: float = 0.0, + source_edit_plan_id: str = "", ) -> EditPlan: """创建新剪辑计划实例""" clean_name = name.strip() @@ -67,6 +69,7 @@ class EditPlan: name=clean_name, status=EditPlanStatus.DRAFT, total_duration=total_duration, + source_edit_plan_id=source_edit_plan_id.strip(), config=config or {}, ) diff --git a/packages/domain/generation_task.py b/packages/domain/generation_task.py index b8a2c5cf4..7df475d27 100644 --- a/packages/domain/generation_task.py +++ b/packages/domain/generation_task.py @@ -41,6 +41,7 @@ class GenerationTask: error_message: str = "" started_at: datetime | None = None completed_at: datetime | None = None + source_edit_plan_id: str = "" created_by_user_id: str = "" created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) @@ -57,6 +58,7 @@ class GenerationTask: title_ids: list[str] | None = None, voice_ids: list[str] | None = None, created_by_user_id: str = "", + source_edit_plan_id: str = "", ) -> "GenerationTask": if not project_id.strip() and not template_id.strip(): raise ValueError("project_id 或 template_id 至少需要提供一个") @@ -73,4 +75,5 @@ class GenerationTask: title_ids=list(title_ids) if title_ids else [], voice_ids=list(voice_ids) if voice_ids else [], created_by_user_id=created_by_user_id.strip(), + source_edit_plan_id=source_edit_plan_id.strip(), ) diff --git a/packages/ports/generation_task_repository.py b/packages/ports/generation_task_repository.py index 329d8a39d..832144233 100644 --- a/packages/ports/generation_task_repository.py +++ b/packages/ports/generation_task_repository.py @@ -18,4 +18,6 @@ class GenerationTaskRepository(Protocol): def list_recent_by_user(self, user_id: str, limit: int = 5) -> list[GenerationTask]: ... + def list_by_source_edit_plan(self, plan_id: str) -> list[GenerationTask]: ... + def update(self, task: GenerationTask) -> GenerationTask: ...