fix: 修复素材库诊断/缩略图/视频URL + 剪辑计划错误处理
Deploy / Staging E2E Tests (push) Has been cancelled
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 111h7m48s
CI/CD Pipeline / Frontend Lint (push) Failing after 111h7m57s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 111h7m57s

Task #105: 修复剪辑计划/模板页面报服务器繁忙
- templates.py: list_templates/get_template/list_categories 加 try/except
- templates.py: 新增 POST /{template_id}/toggle-favorite 兼容端点
- edit_plans.py: list_plans 加 try/except,ai_tasks 加 ImportError 守卫

Task #106: 修复素材库诊断按钮 + 缩略图/视频URL
- asset_diagnosis.py: get_project_asset_diagnosis 加 try/except
- assets.py: 注入 storage_service,生成签名 file_url
- asset.py schema: 新增 file_url 字段
- 视频素材 thumbnail_url 为空时复用 file_url 作为封面

其他:
- edit_plans/generation_tasks 支持 source_edit_plan_id
- Alembic 迁移 022: 两表加 source_edit_plan_id 字段
This commit is contained in:
灵应
2026-07-04 21:46:32 +08:00
parent 230d610a6d
commit 356df4663e
16 changed files with 258 additions and 28 deletions
@@ -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")
+17 -7
View File
@@ -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)
+22 -2
View File
@@ -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,
+80 -12
View File
@@ -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,
@@ -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])
+43 -7
View File
@@ -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],
)
+1
View File
@@ -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
+3
View File
@@ -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
+6
View File
@@ -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
@@ -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,
@@ -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
@@ -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))
+2
View File
@@ -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)
+3
View File
@@ -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 {},
)
+3
View File
@@ -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(),
)
@@ -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: ...