feat: 剪辑计划片段CRUD API - 列表/详情/创建/更新/删除
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 55s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 46s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m21s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m5s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1493h40m55s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1493h40m58s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1493h41m0s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1493h41m33s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1493h41m33s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1493h41m41s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1493h41m41s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1493h41m41s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1493h41m51s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1493h41m53s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1493h41m53s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1493h41m55s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1493h41m55s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1493h41m57s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1493h41m57s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1493h41m57s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1494h12m42s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1494h12m47s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1494h13m20s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1494h13m37s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1494h13m38s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1494h13m40s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 55s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 46s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m21s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m5s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Failing after 1493h40m55s
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Failing after 1493h40m58s
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Failing after 1493h41m0s
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Failing after 1493h41m33s
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Failing after 1493h41m33s
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Failing after 1493h41m41s
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Failing after 1493h41m41s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Failing after 1493h41m41s
CI Build & Deploy Pipeline / Production Browser E2E (push) Failing after 1493h41m51s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Failing after 1493h41m53s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 1493h41m53s
CI Build & Deploy Pipeline / Build Production Web Image (push) Failing after 1493h41m55s
CI Build & Deploy Pipeline / Build Production API Image (push) Failing after 1493h41m55s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Failing after 1493h41m57s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Failing after 1493h41m57s
CI Build & Deploy Pipeline / Build Staging API Image (push) Failing after 1493h41m57s
CI Build & Deploy Pipeline / Deploy Production (pull_request) Failing after 1494h12m42s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Failing after 1494h12m47s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Failing after 1494h13m20s
CI Build & Deploy Pipeline / Deploy Production (push) Failing after 1494h13m37s
CI Build & Deploy Pipeline / Build Production Worker Image (push) Failing after 1494h13m38s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Failing after 1494h13m40s
新增 edit_plans_clips.py 路由模块,暴露 5 个接口:
- GET /{plan_id}/clips 获取片段列表(支持状态过滤+分页)
- POST /{plan_id}/clips 创建片段
- GET /{plan_id}/clips/{clip_id} 获取片段详情
- PUT /{plan_id}/clips/{clip_id} 更新片段
- DELETE /{plan_id}/clips/{clip_id} 删除片段
复用 EditPlanService 已有的 clip CRUD 方法
支持项目级权限校验(通过 plan.project_id 关联)
自动触发编辑状态回退(编辑时从 completed/failed 切回 editing)
This commit is contained in:
@@ -460,9 +460,11 @@ def delete_plan(
|
||||
# ── Include sub-routers (拆分模块) ────────────────────────────────────────────
|
||||
|
||||
from .edit_plans_ai import router as ai_router
|
||||
from .edit_plans_clips import router as clips_router
|
||||
from .edit_plans_generation import router as generation_router
|
||||
from .edit_plans_timeline import router as timeline_router
|
||||
|
||||
router.include_router(generation_router)
|
||||
router.include_router(ai_router)
|
||||
router.include_router(timeline_router)
|
||||
router.include_router(clips_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"])
|
||||
|
||||
Executable
+275
@@ -0,0 +1,275 @@
|
||||
"""剪辑计划片段(Clip)CRUD 路由。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session, get_project_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClipStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ──────────────────────────────────────────────────────────────────
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EditPlanClipResponse(BaseModel):
|
||||
"""剪辑片段响应体"""
|
||||
|
||||
id: str
|
||||
plan_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
asset_id: str = ""
|
||||
text_content: str = ""
|
||||
start_time: float = 0.0
|
||||
duration: float = 0.0
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.0
|
||||
playback_speed: float = 1.0
|
||||
status: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class EditPlanClipListResponse(BaseModel):
|
||||
"""剪辑片段列表响应体"""
|
||||
|
||||
items: List[EditPlanClipResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class EditPlanClipCreateRequest(BaseModel):
|
||||
"""创建剪辑片段请求体"""
|
||||
|
||||
clip_type: str = Field(..., min_length=1, max_length=50, description="片段类型: main/intro/outro/overlay/background/b_roll 等")
|
||||
order: int = Field(..., ge=0, description="排序序号")
|
||||
asset_id: str = Field(default="", max_length=64, description="关联素材 ID")
|
||||
text_content: str = Field(default="", max_length=5000, description="文本内容(字幕/配音等)")
|
||||
start_time: float = Field(default=0.0, ge=0.0, description="起始时间 (秒)")
|
||||
duration: float = Field(default=0.0, ge=0.0, description="时长 (秒)")
|
||||
transition_effect: str = Field(default="cut", max_length=50, description="转场效果")
|
||||
transition_duration: float = Field(default=0.0, ge=0.0, description="转场时长 (秒)")
|
||||
playback_speed: float = Field(default=1.0, gt=0.0, le=10.0, description="播放速度倍率")
|
||||
config: dict[str, Any] = Field(default_factory=dict, description="扩展配置 (JSON)")
|
||||
|
||||
|
||||
class EditPlanClipUpdateRequest(BaseModel):
|
||||
"""更新剪辑片段请求体"""
|
||||
|
||||
clip_type: Optional[str] = Field(default=None, min_length=1, max_length=50, description="片段类型")
|
||||
order: Optional[int] = Field(default=None, ge=0, description="排序序号")
|
||||
asset_id: Optional[str] = Field(default=None, max_length=64, description="关联素材 ID")
|
||||
text_content: Optional[str] = Field(default=None, max_length=5000, description="文本内容")
|
||||
start_time: Optional[float] = Field(default=None, ge=0.0, description="起始时间 (秒)")
|
||||
duration: Optional[float] = Field(default=None, ge=0.0, description="时长 (秒)")
|
||||
transition_effect: Optional[str] = Field(default=None, max_length=50, description="转场效果")
|
||||
transition_duration: Optional[float] = Field(default=None, ge=0.0, description="转场时长 (秒)")
|
||||
playback_speed: Optional[float] = Field(default=None, gt=0.0, le=10.0, description="播放速度倍率")
|
||||
config: Optional[dict[str, Any]] = Field(default=None, description="扩展配置 (JSON)")
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_plan_access(plan_id: str, user_id: str, project_repository: Any, db: Session) -> Any:
|
||||
"""验证用户是否有权限访问该剪辑计划(通过项目关联)。
|
||||
返回 plan 对象供后续使用,避免重复查询。
|
||||
"""
|
||||
from ._helpers import check_project_access
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
svc = EditPlanService(db)
|
||||
plan = svc.get_plan(plan_id)
|
||||
if plan is None:
|
||||
raise HTTPException(status_code=404, detail=f"剪辑计划不存在: {plan_id}")
|
||||
if plan.project_id:
|
||||
check_project_access(plan.project_id, user_id, project_repository)
|
||||
return plan
|
||||
|
||||
|
||||
def _clip_to_response(clip) -> EditPlanClipResponse:
|
||||
"""将领域对象转换为响应体"""
|
||||
return EditPlanClipResponse(
|
||||
id=clip.id,
|
||||
plan_id=clip.plan_id,
|
||||
clip_type=clip.clip_type,
|
||||
order=clip.order,
|
||||
asset_id=clip.asset_id or "",
|
||||
text_content=clip.text_content or "",
|
||||
start_time=clip.start_time,
|
||||
duration=clip.duration,
|
||||
transition_effect=clip.transition_effect or "cut",
|
||||
transition_duration=clip.transition_duration or 0.0,
|
||||
playback_speed=clip.playback_speed or 1.0,
|
||||
status=clip.status.value if hasattr(clip.status, "value") else str(clip.status),
|
||||
config=clip.config or {},
|
||||
created_at=clip.created_at.isoformat() if clip.created_at else None,
|
||||
updated_at=clip.updated_at.isoformat() if clip.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
def _get_svc(db: Session):
|
||||
"""获取 EditPlanService 实例"""
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
return EditPlanService(db)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=EditPlanClipListResponse)
|
||||
def list_clips(
|
||||
plan_id: str,
|
||||
status_filter: Optional[str] = Query(None, alias="status", description="按状态过滤"),
|
||||
skip: int = Query(0, ge=0, description="分页偏移"),
|
||||
limit: int = Query(100, ge=1, le=500, description="每页数量"),
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipListResponse:
|
||||
"""获取剪辑计划的片段列表"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
status_enum = EditPlanClipStatus(status_filter) if status_filter else None
|
||||
clips = svc.list_clips(plan_id, status=status_enum, skip=skip, limit=limit)
|
||||
total = svc.count_clips(plan_id, status=status_enum)
|
||||
|
||||
return EditPlanClipListResponse(
|
||||
items=[_clip_to_response(c) for c in clips],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=EditPlanClipResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_clip(
|
||||
plan_id: str,
|
||||
body: EditPlanClipCreateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""创建剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
try:
|
||||
clip = svc.create_clip(
|
||||
plan_id=plan_id,
|
||||
clip_type=body.clip_type,
|
||||
order=body.order,
|
||||
asset_id=body.asset_id,
|
||||
text_content=body.text_content,
|
||||
start_time=body.start_time,
|
||||
duration=body.duration,
|
||||
transition_effect=body.transition_effect,
|
||||
transition_duration=body.transition_duration,
|
||||
playback_speed=body.playback_speed,
|
||||
config=body.config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
logger.info("创建剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip.id, current_user.user.id)
|
||||
return _clip_to_response(clip)
|
||||
|
||||
|
||||
@router.get("/{clip_id}", response_model=EditPlanClipResponse)
|
||||
def get_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""获取剪辑片段详情"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
return _clip_to_response(clip)
|
||||
|
||||
|
||||
@router.put("/{clip_id}", response_model=EditPlanClipResponse)
|
||||
def update_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
body: EditPlanClipUpdateRequest,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> EditPlanClipResponse:
|
||||
"""更新剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
# 验证 clip 属于该 plan
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
try:
|
||||
updated = svc.update_clip(
|
||||
clip_id,
|
||||
clip_type=body.clip_type,
|
||||
order=body.order,
|
||||
asset_id=body.asset_id,
|
||||
text_content=body.text_content,
|
||||
start_time=body.start_time,
|
||||
duration=body.duration,
|
||||
transition_effect=body.transition_effect,
|
||||
transition_duration=body.transition_duration,
|
||||
playback_speed=body.playback_speed,
|
||||
config=body.config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
logger.info("更新剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
return _clip_to_response(updated)
|
||||
|
||||
|
||||
@router.delete("/{clip_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_clip(
|
||||
plan_id: str,
|
||||
clip_id: str,
|
||||
db: Session = Depends(get_db_session),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> None:
|
||||
"""删除剪辑片段"""
|
||||
_check_plan_access(plan_id, current_user.user.id, project_repository, db)
|
||||
|
||||
svc = _get_svc(db)
|
||||
# 验证 clip 属于该 plan
|
||||
clip = svc.get_clip(clip_id)
|
||||
if clip is None:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
if clip.plan_id != plan_id:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
deleted = svc.delete_clip(clip_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"片段不存在: {clip_id}")
|
||||
|
||||
logger.info("删除剪辑片段: plan_id=%s clip_id=%s by user=%s", plan_id, clip_id, current_user.user.id)
|
||||
return None
|
||||
Reference in New Issue
Block a user