From 986fc92da3e6a30e5f29f8d20d02614493cfe464 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Thu, 16 Jul 2026 09:49:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=89=87=E6=AE=B5=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=93=8D=E4=BD=9CAPI=20-=20=E9=87=8D=E6=8E=92=E5=BA=8F=20+=20?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 2 个批量操作接口: - POST /{plan_id}/clips/reorder 批量重排序(前端拖拽后一次性提交) - POST /{plan_id}/clips/batch-delete 批量删除片段 自动触发编辑状态回退(completed/failed → editing) 自动校验片段归属(不属于该plan的片段会被拒绝/跳过) --- apps/api/app/api/routes/edit_plans.py | 2 + .../app/api/routes/edit_plans_clips_batch.py | 181 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100755 apps/api/app/api/routes/edit_plans_clips_batch.py diff --git a/apps/api/app/api/routes/edit_plans.py b/apps/api/app/api/routes/edit_plans.py index 33412b4a7..3f8eba102 100755 --- a/apps/api/app/api/routes/edit_plans.py +++ b/apps/api/app/api/routes/edit_plans.py @@ -461,6 +461,7 @@ def delete_plan( from .edit_plans_ai import router as ai_router from .edit_plans_clips import router as clips_router +from .edit_plans_clips_batch import router as clips_batch_router from .edit_plans_generation import router as generation_router from .edit_plans_timeline import router as timeline_router @@ -468,3 +469,4 @@ 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"]) +router.include_router(clips_batch_router, prefix="/{plan_id}/clips", tags=["EditPlan Clips"]) diff --git a/apps/api/app/api/routes/edit_plans_clips_batch.py b/apps/api/app/api/routes/edit_plans_clips_batch.py new file mode 100755 index 000000000..ed253b570 --- /dev/null +++ b/apps/api/app/api/routes/edit_plans_clips_batch.py @@ -0,0 +1,181 @@ +"""剪辑计划片段批量操作 API。""" + +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, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ── Schemas ────────────────────────────────────────────────────────────────── + + +class ClipReorderItem(BaseModel): + """重排序条目""" + + clip_id: str + new_order: int = Field(..., ge=0, description="新的排序序号") + + +class ClipReorderRequest(BaseModel): + """片段重排序请求""" + + items: List[ClipReorderItem] = Field(..., min_length=1, max_length=500, description="重排序条目列表") + + +class ClipReorderResponse(BaseModel): + """片段重排序响应""" + + success: bool + updated_count: int + message: str = "" + + +class ClipBatchDeleteRequest(BaseModel): + """批量删除片段请求""" + + clip_ids: List[str] = Field(..., min_length=1, max_length=500, description="要删除的片段ID列表") + + +class ClipBatchDeleteResponse(BaseModel): + """批量删除片段响应""" + + success: bool + deleted_count: int + message: str = "" + + +# ── 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 _get_svc(db: Session): + """获取 EditPlanService 实例""" + from app.services.edit_plan_service import EditPlanService + + return EditPlanService(db) + + +# ── Routes ─────────────────────────────────────────────────────────────────── + + +@router.post("/reorder", response_model=ClipReorderResponse) +def reorder_clips( + plan_id: str, + body: ClipReorderRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> ClipReorderResponse: + """批量重排序片段 + + 前端拖拽调整顺序后,一次性提交所有变更的 order。 + 自动触发编辑状态回退(从 completed/failed 切回 editing)。 + """ + _check_plan_access(plan_id, current_user.user.id, project_repository, db) + + svc = _get_svc(db) + + # 验证所有 clip 都属于该 plan + clip_ids = [item.clip_id for item in body.items] + existing_clips = svc.list_clips(plan_id, skip=0, limit=10000) + existing_ids = {c.id for c in existing_clips} + + invalid_ids = [cid for cid in clip_ids if cid not in existing_ids] + if invalid_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"以下片段不属于该计划: {', '.join(invalid_ids[:5])}", + ) + + # 执行重排序 + updated_count = 0 + for item in body.items: + try: + svc.update_clip(item.clip_id, order=item.new_order) + updated_count += 1 + except ValueError as e: + logger.warning("重排序片段失败: clip_id=%s error=%s", item.clip_id, e) + + logger.info( + "批量重排序片段: plan_id=%s count=%d by user=%s", + plan_id, + updated_count, + current_user.user.id, + ) + + return ClipReorderResponse( + success=True, + updated_count=updated_count, + message=f"成功更新 {updated_count} 个片段的顺序", + ) + + +@router.post("/batch-delete", response_model=ClipBatchDeleteResponse) +def batch_delete_clips( + plan_id: str, + body: ClipBatchDeleteRequest, + db: Session = Depends(get_db_session), + current_user: AuthenticatedUser = Depends(get_current_user), + project_repository: Any = Depends(get_project_repository), +) -> ClipBatchDeleteResponse: + """批量删除片段 + + 自动触发编辑状态回退(从 completed/failed 切回 editing)。 + """ + _check_plan_access(plan_id, current_user.user.id, project_repository, db) + + svc = _get_svc(db) + + # 验证所有 clip 都属于该 plan + existing_clips = svc.list_clips(plan_id, skip=0, limit=10000) + existing_ids = {c.id for c in existing_clips} + + valid_ids = [cid for cid in body.clip_ids if cid in existing_ids] + skipped = len(body.clip_ids) - len(valid_ids) + + # 执行删除 + deleted_count = 0 + for clip_id in valid_ids: + if svc.delete_clip(clip_id): + deleted_count += 1 + + message = f"成功删除 {deleted_count} 个片段" + if skipped > 0: + message += f",跳过 {skipped} 个不存在的片段" + + logger.info( + "批量删除片段: plan_id=%s deleted=%d skipped=%d by user=%s", + plan_id, + deleted_count, + skipped, + current_user.user.id, + ) + + return ClipBatchDeleteResponse( + success=True, + deleted_count=deleted_count, + message=message, + )