feat(phase8): 实现剪辑计划 CRUD API (任务 2.04)
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 189h51m12s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 189h51m15s
Deploy / Deploy Staging (push) Failing after 189h51m46s
CI/CD Pipeline / Frontend Lint (push) Failing after 189h52m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 189h52m19s
Deploy / Production Browser E2E (push) Failing after 1848h23m2s
Deploy / Deploy Production (push) Failing after 1848h24m33s
Deploy / Build Production Runtime Images (push) Failing after 1848h27m26s

- 新增 /api/v1/edit-plans 五个端点 (GET list/detail, POST, PUT, DELETE)
- 支持分页、按模板ID/状态筛选
- PUT 端点集成状态机流转 (draft→editing→rendering→completed/failed)
- 非法状态流转返回 400,资源不存在返回 404
- 注册 edit_plans_router 到 API Router
- 编写 28 个单元测试覆盖 CRUD + 状态机 + 异常场景
- 全部测试通过
This commit is contained in:
灵应
2026-07-01 14:47:49 +08:00
parent ba4dc747fb
commit 2730d2ce8d
3 changed files with 806 additions and 0 deletions
+6
View File
@@ -6,6 +6,7 @@ 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.generated_videos import router as generated_videos_router
from app.api.routes.generation_tasks import router as generation_tasks_router
@@ -122,3 +123,8 @@ api_router.include_router(
prefix="/edit-templates",
tags=["EditTemplate"],
)
api_router.include_router(
edit_plans_router,
prefix="/edit-plans",
tags=["EditPlan"],
)
+304
View File
@@ -0,0 +1,304 @@
"""剪辑计划管理 API — Phase 8 模板编排引擎.
RESTful CRUD for EditPlan:
- GET /api/v1/edit-plans 列表(分页 + 状态/模板筛选)
- GET /api/v1/edit-plans/{id} 详情
- POST /api/v1/edit-plans 创建
- PUT /api/v1/edit-plans/{id} 更新(含状态机流转)
- DELETE /api/v1/edit-plans/{id} 删除
"""
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 fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import SQLAlchemyEditPlanRepository
from packages.domain.edit_plan import EditPlan, EditPlanStatus
logger = logging.getLogger(__name__)
router = APIRouter()
# ── Pydantic Schemas ─────────────────────────────────────────────────────────
class EditPlanCreateRequest(BaseModel):
"""创建剪辑计划请求体"""
template_id: str = Field(..., min_length=1, max_length=32, description="关联模板 ID")
name: str = Field(..., min_length=1, max_length=200, description="计划名称")
config: dict[str, Any] = Field(default_factory=dict, description="计划配置 (JSON)")
total_duration: float = Field(default=0.0, ge=0.0, description="总时长 (秒)")
class EditPlanUpdateRequest(BaseModel):
"""更新剪辑计划请求体"""
name: Optional[str] = Field(default=None, min_length=1, max_length=200, description="计划名称")
config: Optional[dict[str, Any]] = Field(default=None, description="计划配置 (JSON)")
total_duration: Optional[float] = Field(default=None, ge=0.0, description="总时长 (秒)")
status: Optional[str] = Field(
default=None,
description="目标状态 (通过状态机流转): editing / rendering / completed / failed / draft",
)
class EditPlanResponse(BaseModel):
"""剪辑计划响应体"""
id: str
template_id: str
name: str
status: str
total_duration: float
config: dict[str, Any]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class EditPlanListResponse(BaseModel):
"""剪辑计划列表响应体"""
items: List[EditPlanResponse]
total: int
page: int
page_size: int
# ── Helpers ───────────────────────────────────────────────────────────────────
def _to_response(p: EditPlan) -> EditPlanResponse:
return EditPlanResponse(
id=p.id,
template_id=p.template_id,
name=p.name,
status=p.status.value if hasattr(p.status, "value") else p.status,
total_duration=p.total_duration,
config=p.config,
created_at=p.created_at,
updated_at=p.updated_at,
)
def _apply_status_transition(plan: EditPlan, target_status_str: str) -> None:
"""通过状态机方法流转状态,非法流转抛出 ValueError"""
try:
target = EditPlanStatus(target_status_str)
except ValueError:
raise ValueError(
f"无效的状态值: {target_status_str},"
f"可选值: draft, editing, rendering, completed, failed"
)
if target == plan.status:
return # 已是目标状态,无需流转
# 根据目标状态选择对应的状态机方法
transition_map = {
EditPlanStatus.EDITING: plan.start_editing,
EditPlanStatus.RENDERING: plan.start_rendering,
EditPlanStatus.COMPLETED: plan.mark_completed,
EditPlanStatus.FAILED: plan.mark_failed,
EditPlanStatus.DRAFT: plan.reset_to_draft,
}
transition_map[target]()
# ── Routes ────────────────────────────────────────────────────────────────────
@router.get("", response_model=EditPlanListResponse)
def list_plans(
page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
template_id: Optional[str] = Query(default=None, description="按模板 ID 筛选"),
status_filter: Optional[str] = Query(
default=None,
alias="status",
description="按状态筛选: draft / editing / rendering / completed / failed",
),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanListResponse:
"""获取剪辑计划列表(支持分页、按模板/状态筛选)"""
repo = SQLAlchemyEditPlanRepository(db)
# 解析状态筛选
status_enum: Optional[EditPlanStatus] = None
if status_filter:
try:
status_enum = EditPlanStatus(status_filter)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"无效的状态值: {status_filter},"
f"可选值: draft, editing, rendering, completed, failed"
),
)
skip = (page - 1) * page_size
# 根据是否有 template_id 选择查询方法
if template_id:
plans = repo.list_by_template(
template_id,
status=status_enum,
skip=skip,
limit=page_size,
)
# count() 不支持 template_id 筛选,通过全量查询计算 total
all_matching = repo.list_by_template(
template_id,
status=status_enum,
skip=0,
limit=10000,
)
total = len(all_matching)
else:
plans = repo.list_all(status=status_enum, skip=skip, limit=page_size)
total = repo.count(status=status_enum)
return EditPlanListResponse(
items=[_to_response(p) for p in plans],
total=total,
page=page,
page_size=page_size,
)
@router.get("/{plan_id}", response_model=EditPlanResponse)
def get_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""获取单个剪辑计划详情"""
repo = SQLAlchemyEditPlanRepository(db)
plan = repo.get(plan_id)
if plan is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
return _to_response(plan)
@router.post("", response_model=EditPlanResponse, status_code=status.HTTP_201_CREATED)
def create_plan(
body: EditPlanCreateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""创建剪辑计划"""
repo = SQLAlchemyEditPlanRepository(db)
try:
plan = EditPlan.create(
template_id=body.template_id,
name=body.name,
config=body.config,
total_duration=body.total_duration,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
created = repo.create(plan)
logger.info(
"创建剪辑计划: id=%s name=%s by user=%s",
created.id,
created.name,
current_user.user.id,
)
return _to_response(created)
@router.put("/{plan_id}", response_model=EditPlanResponse)
def update_plan(
plan_id: str,
body: EditPlanUpdateRequest,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanResponse:
"""更新剪辑计划(支持状态机流转)"""
repo = SQLAlchemyEditPlanRepository(db)
existing = repo.get(plan_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
# 基础字段更新
new_name = body.name.strip() if body.name is not None else existing.name
new_config = body.config if body.config is not None else existing.config
new_total_duration = body.total_duration if body.total_duration is not None else existing.total_duration
# 状态机流转
new_status = existing.status
if body.status is not None:
try:
_apply_status_transition(existing, body.status)
new_status = existing.status
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
try:
updated = EditPlan(
id=existing.id,
template_id=existing.template_id,
name=new_name,
status=new_status,
total_duration=new_total_duration,
config=new_config,
created_at=existing.created_at,
updated_at=existing.updated_at,
)
except (ValueError, TypeError) as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
result = repo.update(updated)
logger.info("更新剪辑计划: id=%s by user=%s", plan_id, current_user.user.id)
return _to_response(result)
@router.delete("/{plan_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_plan(
plan_id: str,
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> None:
"""删除剪辑计划"""
repo = SQLAlchemyEditPlanRepository(db)
existing = repo.get(plan_id)
if existing is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"剪辑计划不存在: {plan_id}",
)
repo.delete(plan_id)
logger.info(
"删除剪辑计划: id=%s by user=%s",
plan_id,
current_user.user.id,
)