from __future__ import annotations import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any from fastapi import HTTPException from app.config import settings from sqlalchemy import String, cast, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.enums.shot_replicate import ( ModuleCodeEnum, ShotAnalysisStatusEnum, ShotSegmentAnalysisStatusEnum, ShotSegmentReplicateStatusEnum, ShotReplicateLogEventEnum, ShotSegmentSourceModeEnum, ShotSplitStatusEnum, ShotTaskSetStatusEnum, ) from app.models.module_generation_project import ModuleGenerationProject from app.models.shot_replicate_segment import ShotReplicateSegment from app.models.shot_replicate_task_set import ShotReplicateTaskSet from app.models.user import User from app.schemas.shot_replicate import ( ShotAISuggestionOut, ShotSegmentDeleteOut, ShotSegmentDetailOut, ShotSegmentListOut, ShotSegmentSplitRetryOut, ShotReanalyzeOut, ShotReplicateDeleteOut, ShotSegmentOut, ShotSplitByAIOut, ShotSplitByAIRequest, ShotSplitCustomOut, ShotSplitCustomRequest, ShotTaskSetCreate, ShotTaskSetDeleteOut, ShotTaskSetDetailOut, ShotTaskSetListOut, ShotTaskSetOut, ) from app.services.module_generation_log_service import log_module_event_file from app.services.resource_accounting_service import SOURCE_MODEL_SHOT_SEGMENT, soft_delete_resources_by_source from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceSourceModelEnum from app.services.upload_resource import release_upload_resources_by_source from app.services.upload_video_asset_service import ( build_time_node, validate_split_range, validate_upload_video_asset, ) from app.tasks.celery_app import celery_app from app.utils.id_gen import generate_id MODULE = ModuleCodeEnum.SHOT_REPLICATE.value def _now() -> datetime: return datetime.now(timezone.utc) def _normalize_suggestions(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] normalized: list[dict[str, Any]] = [] for idx, item in enumerate(value, start=1): if not isinstance(item, dict): continue start = item.get("拆镜开始秒") end = item.get("拆镜结束秒") try: start_f = float(start) end_f = float(end) except Exception: continue if start_f < 0 or end_f <= start_f: continue normalized.append( { "index": idx, "start_second": start_f, "end_second": end_f, "duration_seconds": round(end_f - start_f, 3), "time_node": str(item.get("拆镜时间节点") or build_time_node(start_f, end_f)), "content": str(item.get("对应时间节点内的内容") or "无"), "category": str(item.get("分类") or "无"), "audience": str(item.get("受众人群") or "无"), "raw": item, } ) return normalized def _task_set_to_out(task_set: ShotReplicateTaskSet, user_name: str | None = None) -> ShotTaskSetOut: data = ShotTaskSetOut.model_validate(task_set) data.user_name = user_name return data def _task_set_to_detail_out(task_set: ShotReplicateTaskSet, user_name: str | None = None) -> ShotTaskSetDetailOut: suggestions = [ShotAISuggestionOut(**{k: v for k, v in item.items() if k != "raw"}) for item in _normalize_suggestions(task_set.ai_suggestion_json)] base = ShotTaskSetDetailOut.model_validate(task_set) base.user_name = user_name base.ai_suggestions = suggestions return base def _segment_to_out(segment: ShotReplicateSegment, project: ModuleGenerationProject | None = None) -> ShotSegmentOut: data = ShotSegmentOut.model_validate(segment) data.segment_name = f"片段{segment.segment_index}" if project: data.module_project_title = project.title data.module_project_status = project.status data.module_project_current_step_code = project.current_step_code data.module_project_flow_version = str(getattr(project, "flow_version", None) or "v1") return data def _segment_to_detail_out(segment: ShotReplicateSegment, project: ModuleGenerationProject | None = None) -> ShotSegmentDetailOut: data = ShotSegmentDetailOut.model_validate(segment) data.segment_name = f"片段{segment.segment_index}" if project: data.module_project_title = project.title data.module_project_status = project.status data.module_project_current_step_code = project.current_step_code data.module_project_flow_version = str(getattr(project, "flow_version", None) or "v1") return data async def get_task_set_for_user( db: AsyncSession, *, task_set_id: str, user: User, for_update: bool = False, ) -> ShotReplicateTaskSet: query = select(ShotReplicateTaskSet).where( ShotReplicateTaskSet.id == task_set_id, ShotReplicateTaskSet.deleted_at.is_(None), ) if not user.is_admin: query = query.where(ShotReplicateTaskSet.user_id == user.id) if for_update: query = query.with_for_update() result = await db.execute(query.limit(1)) task_set = result.scalar_one_or_none() if not task_set: raise HTTPException(status_code=404, detail="拆镜总任务集不存在") return task_set async def get_segment_for_user( db: AsyncSession, *, segment_id: str, user: User, for_update: bool = False, ) -> ShotReplicateSegment: query = select(ShotReplicateSegment).where( ShotReplicateSegment.id == segment_id, ShotReplicateSegment.deleted_at.is_(None), ) if not user.is_admin: query = query.where(ShotReplicateSegment.user_id == user.id) if for_update: query = query.with_for_update() result = await db.execute(query.limit(1)) segment = result.scalar_one_or_none() if not segment: raise HTTPException(status_code=404, detail="拆镜片段不存在") return segment async def create_task_set(db: AsyncSession, *, current_user: User, req: ShotTaskSetCreate) -> ShotReplicateTaskSet: if req.idempotency_key: existing_result = await db.execute( select(ShotReplicateTaskSet).where( ShotReplicateTaskSet.user_id == current_user.id, ShotReplicateTaskSet.idempotency_key == req.idempotency_key, ShotReplicateTaskSet.deleted_at.is_(None), ).limit(1) ) existing = existing_result.scalar_one_or_none() if existing: return existing asset = validate_upload_video_asset(req.video_url, req.video_duration_seconds) task_set = ShotReplicateTaskSet( id=generate_id(), user_id=current_user.id, title=req.title or "拆镜复刻任务", video_url=asset.url, video_path=str(asset.path), video_duration_seconds=asset.duration_seconds, status=ShotTaskSetStatusEnum.PENDING_ANALYSIS.value, analysis_status=ShotAnalysisStatusEnum.PENDING.value, split_status=ShotSplitStatusEnum.NONE.value, segment_count=0, completed_segment_count=0, failed_segment_count=0, idempotency_key=req.idempotency_key, ) db.add(task_set) await db.flush() log_module_event_file( module=MODULE, event_type="SHOT_TASK_SET_CREATED", project_id=task_set.id, user_id=task_set.user_id, message="创建拆镜总任务集", detail={ "task_set_id": task_set.id, "title": task_set.title, "video_url": task_set.video_url, "video_path": task_set.video_path, "video_duration_seconds": task_set.video_duration_seconds, "idempotency_key": task_set.idempotency_key, }, ) return task_set def _user_name_filter_subquery(value: str): """后台按用户名筛选时使用的子查询。 只在传入 user_name 时查询 users 表;keyword 不再关联 users,避免后台关键词搜索扩大查询范围。 """ like = f"%{value.strip()}%" return select(User.id).where(User.username.ilike(like)) async def _user_name_map_by_ids(db: AsyncSession, user_ids: set[str]) -> dict[str, str | None]: """一次性查询当前页涉及的用户,避免列表逐条查询用户表。""" if not user_ids: return {} result = await db.execute(select(User.id, User.username).where(User.id.in_(list(user_ids)))) return {user_id: username for user_id, username in result.all()} async def list_task_sets( db: AsyncSession, *, current_user: User, status: str | None = None, analysis_status: str | None = None, split_status: str | None = None, keyword: str | None = None, user_id: str | None = None, user_name: str | None = None, created_start: datetime | None = None, created_end: datetime | None = None, page: int = 1, page_size: int = 20, ) -> ShotTaskSetListOut: """ 拆镜总任务集列表查询。 性能策略: 1. 主列表不 join User,避免 count/list 复杂化。 2. 管理员按 user_name 搜索时使用 IN (SELECT users.id ...) 子查询;keyword 不查询 users 表。 3. 当前页数据取出后,再按 user_id 去重批量查 user_name,用于后台渲染。 """ query = select(ShotReplicateTaskSet).where(ShotReplicateTaskSet.deleted_at.is_(None)) if not current_user.is_admin: query = query.where(ShotReplicateTaskSet.user_id == current_user.id) else: if user_id and user_id.strip(): query = query.where(ShotReplicateTaskSet.user_id == user_id.strip()) if user_name and user_name.strip(): query = query.where(ShotReplicateTaskSet.user_id.in_(_user_name_filter_subquery(user_name))) if created_start: query = query.where(ShotReplicateTaskSet.created_at >= created_start) if created_end: query = query.where(ShotReplicateTaskSet.created_at <= created_end) if status: query = query.where(ShotReplicateTaskSet.status == status) if analysis_status: query = query.where(ShotReplicateTaskSet.analysis_status == analysis_status) if split_status: query = query.where(ShotReplicateTaskSet.split_status == split_status) if keyword and keyword.strip(): like = f"%{keyword.strip()}%" conditions = [ ShotReplicateTaskSet.id.ilike(like), ShotReplicateTaskSet.title.ilike(like), ShotReplicateTaskSet.original_video_content.ilike(like), ShotReplicateTaskSet.original_video_category.ilike(like), ShotReplicateTaskSet.original_video_audience.ilike(like), cast(ShotReplicateTaskSet.ai_suggestion_json, String).ilike(like), ] query = query.where(or_(*conditions)) total_result = await db.execute(select(func.count()).select_from(query.subquery())) total = int(total_result.scalar() or 0) result = await db.execute( query.order_by(ShotReplicateTaskSet.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) task_sets = list(result.scalars().unique().all()) user_name_map: dict[str, str | None] = {} if current_user.is_admin: user_ids = {task_set.user_id for task_set in task_sets if task_set.user_id} user_name_map = await _user_name_map_by_ids(db, user_ids) return ShotTaskSetListOut( total=total, page=page, page_size=page_size, items=[ _task_set_to_out( task_set, user_name_map.get(task_set.user_id) if current_user.is_admin and task_set.user_id else None, ) for task_set in task_sets ], ) async def task_set_detail(db: AsyncSession, *, current_user: User, task_set_id: str) -> ShotTaskSetDetailOut: task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user) user_result = await db.execute(select(User.username).where(User.id == task_set.user_id).limit(1)) return _task_set_to_detail_out(task_set, user_result.scalar_one_or_none()) async def _next_segment_index(db: AsyncSession, task_set_id: str) -> int: result = await db.execute( select(func.max(ShotReplicateSegment.segment_index)).where( ShotReplicateSegment.task_set_id == task_set_id, ShotReplicateSegment.deleted_at.is_(None), ) ) return int(result.scalar() or 0) + 1 async def refresh_task_set_split_summary(db: AsyncSession, task_set_id: str) -> None: task_set_result = await db.execute(select(ShotReplicateTaskSet).where(ShotReplicateTaskSet.id == task_set_id).with_for_update().limit(1)) task_set = task_set_result.scalar_one_or_none() if not task_set: return result = await db.execute( select(ShotReplicateSegment).where( ShotReplicateSegment.task_set_id == task_set_id, ShotReplicateSegment.deleted_at.is_(None), ) ) segments = list(result.scalars().all()) total = len(segments) completed = len([s for s in segments if s.split_status == ShotSplitStatusEnum.COMPLETED.value]) failed = len([s for s in segments if s.split_status == ShotSplitStatusEnum.FAILED.value]) task_set.segment_count = total task_set.completed_segment_count = completed task_set.failed_segment_count = failed if total <= 0: task_set.split_status = ShotSplitStatusEnum.NONE.value if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value: task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value return old_status = task_set.status old_split_status = task_set.split_status if completed == total: task_set.split_status = ShotSplitStatusEnum.COMPLETED.value task_set.status = ShotTaskSetStatusEnum.SPLIT_COMPLETED.value elif failed == total: task_set.split_status = ShotSplitStatusEnum.FAILED.value task_set.status = ShotTaskSetStatusEnum.FAILED.value elif failed > 0: task_set.split_status = ShotSplitStatusEnum.FAILED.value task_set.status = ShotTaskSetStatusEnum.PARTIAL_FAILED.value else: task_set.split_status = ShotSplitStatusEnum.PROCESSING.value task_set.status = ShotTaskSetStatusEnum.SPLITTING.value if old_status != task_set.status or old_split_status != task_set.split_status: log_module_event_file( module=MODULE, event_type="SHOT_SPLIT_STATUS_CHANGED", project_id=task_set.id, user_id=task_set.user_id, message="拆镜总任务集拆分状态变更", detail={ "task_set_id": task_set.id, "from_status": old_status, "to_status": task_set.status, "from_split_status": old_split_status, "to_split_status": task_set.split_status, "segment_count": total, "completed_segment_count": completed, "failed_segment_count": failed, }, ) async def create_segments_by_ai( db: AsyncSession, *, current_user: User, task_set_id: str, req: ShotSplitByAIRequest, ) -> ShotSplitByAIOut: task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True) if task_set.analysis_status != ShotAnalysisStatusEnum.COMPLETED.value: raise HTTPException(status_code=400, detail="原视频分析未完成,不能按 AI 建议拆镜") suggestions = _normalize_suggestions(task_set.ai_suggestion_json) if not suggestions: raise HTTPException(status_code=400, detail="当前没有可用 AI 建议拆镜方案,请使用自定义拆镜") if req.selected_indices: selected_set = {int(x) for x in req.selected_indices} suggestions = [item for item in suggestions if int(item["index"]) in selected_set] if not suggestions: raise HTTPException(status_code=400, detail="selected_indices 没有匹配到可用 AI 建议") old_result = await db.execute( select(ShotReplicateSegment).where( ShotReplicateSegment.task_set_id == task_set.id, ShotReplicateSegment.source_mode == ShotSegmentSourceModeEnum.AI_SUGGESTION.value, ShotReplicateSegment.deleted_at.is_(None), ) ) old_segments = list(old_result.scalars().all()) if old_segments and not req.replace_existing: raise HTTPException(status_code=409, detail="已存在 AI 建议拆镜片段,如需重拆请传 replace_existing=true") if old_segments and req.replace_existing: now = _now() for segment in old_segments: segment.deleted_at = now created: list[ShotReplicateSegment] = [] next_index = await _next_segment_index(db, task_set.id) for item in suggestions: start, end, duration = validate_split_range( start_second=item["start_second"], end_second=item["end_second"], video_duration_seconds=task_set.video_duration_seconds, ) segment = ShotReplicateSegment( id=generate_id(), task_set_id=task_set.id, user_id=task_set.user_id, segment_index=next_index, source_mode=ShotSegmentSourceModeEnum.AI_SUGGESTION.value, start_second=start, end_second=end, duration_seconds=duration, time_node=build_time_node(start, end), split_status=ShotSplitStatusEnum.PENDING.value, analysis_status=ShotSegmentAnalysisStatusEnum.NOT_REQUIRED.value, replicate_status=ShotSegmentReplicateStatusEnum.NOT_STARTED.value, original_video_content=task_set.original_video_content, original_video_category=task_set.original_video_category, original_video_audience=task_set.original_video_audience, segment_content=item.get("content"), segment_category=item.get("category"), segment_audience=item.get("audience"), ai_suggestion_json=item.get("raw") or item, split_enqueued_at=_now(), split_celery_task_id=f"shot-split:{uuid.uuid4().hex}", ) db.add(segment) created.append(segment) next_index += 1 task_set.status = ShotTaskSetStatusEnum.SPLITTING.value task_set.split_status = ShotSplitStatusEnum.PROCESSING.value await db.flush() await refresh_task_set_split_summary(db, task_set.id) await db.flush() log_module_event_file( module=MODULE, event_type="SHOT_SPLIT_BY_AI_SUBMITTED", project_id=task_set.id, user_id=task_set.user_id, message="按 AI 建议创建拆镜片段", detail={ "task_set_id": task_set.id, "selected_indices": req.selected_indices, "replace_existing": req.replace_existing, "created_segment_count": len(created), "segment_ids": [segment.id for segment in created], }, ) return ShotSplitByAIOut( task_set_id=task_set.id, status=task_set.status, split_status=task_set.split_status, created_segment_count=len(created), segments=[_segment_to_out(segment) for segment in created], ) async def prepare_retry_split_segment( db: AsyncSession, *, current_user: User, segment_id: str, force: bool = False, reason: str | None = None, ) -> ShotSegmentSplitRetryOut: """重置失败切片片段,commit 成功后由 API 投递现有 split_one_segment 任务。""" segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True) task_set = await get_task_set_for_user(db, task_set_id=segment.task_set_id, user=current_user, for_update=True) from_split_status = segment.split_status allowed = {ShotSplitStatusEnum.FAILED.value, ShotSplitStatusEnum.RETRY_WAITING.value} reject_reason: str | None = None if task_set.deleted_at is not None or task_set.status == ShotTaskSetStatusEnum.DELETED.value: reject_reason = "拆镜总任务集已删除,不能重试切片" elif segment.deleted_at is not None: reject_reason = "拆镜片段已删除,不能重试切片" elif from_split_status == ShotSplitStatusEnum.PROCESSING.value: reject_reason = "拆镜片段正在切片处理中,不能重复投递" elif from_split_status == ShotSplitStatusEnum.COMPLETED.value: reject_reason = "拆镜片段已切片完成,不支持重切,避免旧切片资源覆盖" elif from_split_status not in allowed and not force: reject_reason = "仅允许失败或等待重试的切片片段重新投递" elif not task_set.video_path: reject_reason = "原视频本地路径为空,不能重试切片" elif not Path(str(task_set.video_path)).exists(): reject_reason = "原视频本地文件不存在,不能重试切片" if reject_reason: log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_REJECTED.value, project_id=task_set.id, step_id=segment.id, message=reject_reason, detail={ "segment_id": segment.id, "task_set_id": task_set.id, "from_split_status": from_split_status, "force": force, "reason": reason, "status": "rejected", }, ) raise HTTPException(status_code=400, detail=reject_reason) now = _now() segment.split_status = ShotSplitStatusEnum.PENDING.value segment.split_enqueued_at = now segment.split_started_at = None segment.split_lease_until = None segment.split_next_retry_at = None segment.split_retry_count = 0 segment.split_last_error = None segment.split_celery_task_id = f"shot-split:{uuid.uuid4().hex}" task_set.split_error_message = None await refresh_task_set_split_summary(db, task_set.id) await db.flush() log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_RECEIVED.value, project_id=task_set.id, step_id=segment.id, message="拆镜片段切片失败重试已重置,等待投递 Celery", detail={ "segment_id": segment.id, "task_set_id": task_set.id, "from_split_status": from_split_status, "to_split_status": segment.split_status, "force": force, "reason": reason, "source_path": task_set.video_path, "celery_task_name": "shot_replicate.split_one_segment", "queue": "gen_result_download", "status": "pending", }, ) return ShotSegmentSplitRetryOut( message="切片重试已提交,正在重新切割视频片段", task_set_id=task_set.id, segment_id=segment.id, split_status=segment.split_status, celery_task_name="shot_replicate.split_one_segment", ) async def create_custom_segment( db: AsyncSession, *, current_user: User, task_set_id: str, req: ShotSplitCustomRequest, ) -> ShotSplitCustomOut: task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True) start, end, duration = validate_split_range( start_second=req.start_second, end_second=req.end_second, video_duration_seconds=task_set.video_duration_seconds, ) next_index = await _next_segment_index(db, task_set.id) segment = ShotReplicateSegment( id=generate_id(), task_set_id=task_set.id, user_id=task_set.user_id, segment_index=next_index, source_mode=ShotSegmentSourceModeEnum.CUSTOM.value, start_second=start, end_second=end, duration_seconds=duration, time_node=build_time_node(start, end), split_status=ShotSplitStatusEnum.PENDING.value, analysis_status=ShotSegmentAnalysisStatusEnum.PENDING.value, replicate_status=ShotSegmentReplicateStatusEnum.NOT_STARTED.value, split_enqueued_at=_now(), split_celery_task_id=f"shot-split:{uuid.uuid4().hex}", ) db.add(segment) task_set.status = ShotTaskSetStatusEnum.SPLITTING.value task_set.split_status = ShotSplitStatusEnum.PROCESSING.value await db.flush() await refresh_task_set_split_summary(db, task_set.id) await db.flush() log_module_event_file( module=MODULE, event_type="SHOT_SPLIT_CUSTOM_SUBMITTED", project_id=task_set.id, step_id=segment.id, user_id=task_set.user_id, message="按用户自定义时间创建拆镜片段", detail={ "task_set_id": task_set.id, "segment_id": segment.id, "start_second": start, "end_second": end, "duration_seconds": duration, "time_node": segment.time_node, }, ) return ShotSplitCustomOut(task_set_id=task_set.id, segment=_segment_to_out(segment)) async def enqueue_segment_split(segment_id: str, *, countdown: int | None = None, recover: bool = False) -> None: if not celery_app: return from app.tasks.shot_replicate_tasks import split_one_segment split_one_segment.apply_async( args=[segment_id], queue="gen_result_download", countdown=countdown, priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER if recover else settings.DOWNLOAD_TASK_PRIORITY_NORMAL, ) async def list_segments( db: AsyncSession, *, current_user: User, task_set_id: str, source_mode: str | None = None, split_status: str | None = None, analysis_status: str | None = None, replicate_status: str | None = None, page: int = 1, page_size: int = 20, ) -> ShotSegmentListOut: await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user) query = ( select(ShotReplicateSegment, ModuleGenerationProject) .outerjoin(ModuleGenerationProject, ModuleGenerationProject.id == ShotReplicateSegment.module_project_id) .where( ShotReplicateSegment.task_set_id == task_set_id, ShotReplicateSegment.deleted_at.is_(None), ) ) if not current_user.is_admin: query = query.where(ShotReplicateSegment.user_id == current_user.id) if source_mode: query = query.where(ShotReplicateSegment.source_mode == source_mode) if split_status: query = query.where(ShotReplicateSegment.split_status == split_status) if analysis_status: query = query.where(ShotReplicateSegment.analysis_status == analysis_status) if replicate_status: query = query.where(ShotReplicateSegment.replicate_status == replicate_status) total_result = await db.execute(select(func.count()).select_from(query.subquery())) total = int(total_result.scalar() or 0) rows = await db.execute( query.order_by(ShotReplicateSegment.segment_index.asc()) .offset((page - 1) * page_size) .limit(page_size) ) return ShotSegmentListOut( total=total, page=page, page_size=page_size, items=[_segment_to_out(segment, project) for segment, project in rows.all()], ) async def segment_detail(db: AsyncSession, *, current_user: User, segment_id: str) -> ShotSegmentDetailOut: segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user) project: ModuleGenerationProject | None = None if segment.module_project_id: project_result = await db.execute(select(ModuleGenerationProject).where(ModuleGenerationProject.id == segment.module_project_id).limit(1)) project = project_result.scalar_one_or_none() return _segment_to_detail_out(segment, project) async def _delete_linked_replication_project( db: AsyncSession, *, current_user: User, project_id: str, ) -> ShotReplicateDeleteOut: """按项目保存的流程版本分发 V1/V2 删除,供片段和任务集联动删除复用。""" result = await db.execute( select(ModuleGenerationProject) .where( ModuleGenerationProject.id == project_id, ModuleGenerationProject.deleted_at.is_(None), ) .limit(1) ) project = result.scalar_one_or_none() if project and str(getattr(project, "flow_version", None) or "v1") == "v2": from app.services.module_generation_v2.config import SHOT_REPLICATE_V2 from app.services.module_generation_v2.flow_service import delete_project_v2 payload = await delete_project_v2( db, config=SHOT_REPLICATE_V2, current_user=current_user, project_id=project_id, ) return ShotReplicateDeleteOut(**payload) from app.services.shot_replicate_flow_service import delete_shot_replicate_project return await delete_shot_replicate_project( db, current_user=current_user, project_id=project_id, refund_unfinished=False, ) async def delete_segment( db: AsyncSession, *, current_user: User, segment_id: str, ) -> ShotSegmentDeleteOut: """软删除拆镜片段。 只释放用户容量账本记录,不删除 segment_video_path 指向的物理文件。 如果片段已创建复刻项目,则联动调用项目删除逻辑,但用户主动删除不退款; 项目仍有生成中任务时会拒绝删除,避免异步任务继续写回软删数据。 """ query = select(ShotReplicateSegment).where(ShotReplicateSegment.id == segment_id) if not current_user.is_admin: query = query.where(ShotReplicateSegment.user_id == current_user.id) result = await db.execute(query.with_for_update().limit(1)) segment = result.scalar_one_or_none() if not segment: raise HTTPException(status_code=404, detail="拆镜片段不存在") task_set_id = segment.task_set_id module_project_id = segment.module_project_id if segment.deleted_at is not None: return ShotSegmentDeleteOut( message="拆镜片段已删除", segment_id=segment.id, task_set_id=task_set_id, deleted=True, deleted_module_project_id=module_project_id, released_size_bytes=0, ) if segment.split_status == ShotSplitStatusEnum.PROCESSING.value: raise HTTPException(status_code=400, detail="当前拆镜片段正在切割处理中,暂不能删除") if segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value: raise HTTPException(status_code=400, detail="当前拆镜片段正在分析处理中,暂不能删除") if segment.replicate_status == ShotSegmentReplicateStatusEnum.PROCESSING.value: raise HTTPException(status_code=400, detail="当前拆镜片段关联的复刻流程正在处理中,暂不能删除") deleted_at = _now() released_size_bytes = await soft_delete_resources_by_source( db, source_model=SOURCE_MODEL_SHOT_SEGMENT, source_ids=[segment.id], deleted_at=deleted_at, ) upload_release = await release_upload_resources_by_source( db, source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value, source_ids=[segment.id], module=UploadResourceModuleEnum.SHOT_REPLICATE.value, ) pending_delete_resource_ids: list[str] = list(upload_release.get("released_resource_ids") or []) released_size_bytes += int(upload_release.get("released_size_bytes") or 0) upload_resource_released = int(upload_release.get("released") or 0) deleted_module_project_id: str | None = None if module_project_id: project_delete_out = await _delete_linked_replication_project( db, current_user=current_user, project_id=module_project_id, ) deleted_module_project_id = project_delete_out.project_id released_size_bytes += int(project_delete_out.released_size_bytes or 0) upload_resource_released += int(project_delete_out.upload_resource_released or 0) pending_delete_resource_ids.extend(project_delete_out.pending_delete_resource_ids or []) segment.deleted_at = deleted_at segment.replicate_status = ( ShotSegmentReplicateStatusEnum.NOT_STARTED.value if not deleted_module_project_id else ShotSegmentReplicateStatusEnum.FAILED.value ) await refresh_task_set_split_summary(db, task_set_id) await db.flush() log_module_event_file( module=MODULE, event_type="SHOT_SEGMENT_DELETED", project_id=task_set_id, step_id=segment.id, user_id=segment.user_id, message="软删除拆镜片段并释放用户容量账本记录", detail={ "segment_id": segment.id, "task_set_id": task_set_id, "module_project_id": module_project_id, "deleted_module_project_id": deleted_module_project_id, "released_size_bytes": released_size_bytes, "upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"}, "pending_delete_resource_count": len(pending_delete_resource_ids), "physical_file_delete": "after_commit", "refund": False, }, ) return ShotSegmentDeleteOut( message="拆镜片段已删除", segment_id=segment.id, task_set_id=task_set_id, deleted=True, deleted_module_project_id=deleted_module_project_id, released_size_bytes=int(released_size_bytes or 0), upload_resource_released=upload_resource_released, pending_delete_resource_ids=pending_delete_resource_ids, ) async def delete_task_set( db: AsyncSession, *, current_user: User, task_set_id: str, ) -> ShotTaskSetDeleteOut: """软删除整个拆镜任务集。 对外删除入口以 ShotReplicateTaskSet 为边界;内部 ModuleGenerationProject 只作为片段复刻链路被联动软删。这里不 commit、不 rollback、不删除真实文件。 """ task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True) task_set_id_snapshot = task_set.id user_id_snapshot = task_set.user_id if task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value: raise HTTPException(status_code=400, detail="原视频分析正在处理中,暂不能删除任务集") if task_set.split_status == ShotSplitStatusEnum.PROCESSING.value: raise HTTPException(status_code=400, detail="拆镜切片正在处理中,暂不能删除任务集") segments_result = await db.execute( select(ShotReplicateSegment) .where( ShotReplicateSegment.task_set_id == task_set_id_snapshot, ShotReplicateSegment.deleted_at.is_(None), ) .with_for_update() ) segments = list(segments_result.scalars().all()) for segment in segments: if segment.split_status == ShotSplitStatusEnum.PROCESSING.value: raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}正在切割处理中,暂不能删除任务集") if segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value: raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}正在分析处理中,暂不能删除任务集") if segment.replicate_status == ShotSegmentReplicateStatusEnum.PROCESSING.value: raise HTTPException(status_code=400, detail=f"片段{segment.segment_index}关联复刻流程正在处理中,暂不能删除任务集") segment_ids = [segment.id for segment in segments] module_project_ids = [segment.module_project_id for segment in segments if segment.module_project_id] deleted_at = _now() released_size_bytes = 0 upload_resource_released = 0 pending_delete_resource_ids: list[str] = [] task_upload_release = await release_upload_resources_by_source( db, source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_TASK_SET.value, source_ids=[task_set_id_snapshot], module=UploadResourceModuleEnum.SHOT_REPLICATE.value, ) released_size_bytes += int(task_upload_release.get("released_size_bytes") or 0) upload_resource_released += int(task_upload_release.get("released") or 0) pending_delete_resource_ids.extend(task_upload_release.get("released_resource_ids") or []) segment_upload_release = await release_upload_resources_by_source( db, source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_SEGMENT.value, source_ids=segment_ids, module=UploadResourceModuleEnum.SHOT_REPLICATE.value, ) released_size_bytes += int(segment_upload_release.get("released_size_bytes") or 0) upload_resource_released += int(segment_upload_release.get("released") or 0) pending_delete_resource_ids.extend(segment_upload_release.get("released_resource_ids") or []) deleted_module_project_count = 0 for module_project_id in dict.fromkeys(module_project_ids): project_delete_out = await _delete_linked_replication_project( db, current_user=current_user, project_id=module_project_id, ) deleted_module_project_count += 1 released_size_bytes += int(project_delete_out.released_size_bytes or 0) upload_resource_released += int(project_delete_out.upload_resource_released or 0) pending_delete_resource_ids.extend(project_delete_out.pending_delete_resource_ids or []) task_set.deleted_at = deleted_at task_set.status = ShotTaskSetStatusEnum.DELETED.value for segment in segments: segment.deleted_at = deleted_at segment.replicate_status = ShotSegmentReplicateStatusEnum.FAILED.value if segment.module_project_id else segment.replicate_status await db.flush() log_module_event_file( module=MODULE, event_type="SHOT_TASK_SET_DELETED", project_id=task_set_id_snapshot, user_id=user_id_snapshot, message="软删除拆镜任务集并标记上传资源待物理删除", detail={ "task_set_id": task_set_id_snapshot, "segment_count": len(segment_ids), "module_project_count": deleted_module_project_count, "released_size_bytes": released_size_bytes, "upload_resource_released": upload_resource_released, "pending_delete_resource_count": len(pending_delete_resource_ids), "task_upload_release": {k: v for k, v in task_upload_release.items() if k != "released_resource_ids"}, "segment_upload_release": {k: v for k, v in segment_upload_release.items() if k != "released_resource_ids"}, "physical_file_delete": "after_commit", "refund": False, }, ) return ShotTaskSetDeleteOut( message="拆镜任务集已删除", task_set_id=task_set_id_snapshot, deleted=True, deleted_segment_count=len(segment_ids), deleted_module_project_count=deleted_module_project_count, released_size_bytes=int(released_size_bytes or 0), upload_resource_released=upload_resource_released, pending_delete_resource_ids=pending_delete_resource_ids, ) async def prepare_reanalyze_task_set( db: AsyncSession, *, current_user: User, task_set_id: str, force: bool = False, reason: str | None = None, ) -> ShotReanalyzeOut: """重置原视频分析状态,供 API 重新投递 Celery。""" task_set = await get_task_set_for_user(db, task_set_id=task_set_id, user=current_user, for_update=True) if task_set.analysis_status == ShotAnalysisStatusEnum.PROCESSING.value: log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_REJECTED.value, project_id=task_set.id, user_id=task_set.user_id, message="原视频分析正在处理中,拒绝再次分析", detail={"task_set_id": task_set.id, "analysis_status": task_set.analysis_status, "reason": reason}, event_status="rejected", ) raise HTTPException(status_code=409, detail="原视频分析正在处理中,不能重复投递") if task_set.analysis_status == ShotAnalysisStatusEnum.COMPLETED.value and not force: raise HTTPException(status_code=409, detail="原视频分析已完成,如确需重跑请传 force=true") if force: active_segments_result = await db.execute( select(func.count()) .select_from(ShotReplicateSegment) .where( ShotReplicateSegment.task_set_id == task_set.id, ShotReplicateSegment.deleted_at.is_(None), ) ) if int(active_segments_result.scalar() or 0) > 0: raise HTTPException(status_code=409, detail="当前总任务集已存在拆镜片段,不能强制重跑原视频分析") task_set.status = ShotTaskSetStatusEnum.PENDING_ANALYSIS.value task_set.analysis_status = ShotAnalysisStatusEnum.PENDING.value task_set.analysis_error_message = None task_set.original_video_content = None task_set.original_video_category = None task_set.original_video_audience = None task_set.ai_suggestion_json = None task_set.analysis_raw_json = None task_set.analysis_result_json = None await db.flush() log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_RECEIVED.value, project_id=task_set.id, user_id=task_set.user_id, message="原视频再次分析已重置状态", detail={"task_set_id": task_set.id, "force": force, "reason": reason, "video_url": task_set.video_url}, ) return ShotReanalyzeOut( message="原视频再次分析任务已准备投递", task_set_id=task_set.id, segment_id=None, analysis_status=task_set.analysis_status, celery_task_name="shot_replicate.analyze_original_video", ) async def prepare_reanalyze_segment( db: AsyncSession, *, current_user: User, segment_id: str, force: bool = False, reason: str | None = None, ) -> ShotReanalyzeOut: """重置自定义切片视频分析状态,供 API 重新投递 Celery。""" segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True) if segment.split_status != ShotSplitStatusEnum.COMPLETED.value: raise HTTPException(status_code=409, detail="当前片段还未切割完成,不能再次分析") if not segment.segment_video_url: raise HTTPException(status_code=409, detail="当前片段缺少 segment_video_url,不能再次分析") if segment.analysis_status == ShotSegmentAnalysisStatusEnum.PROCESSING.value: log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_REJECTED.value, project_id=segment.task_set_id, step_id=segment.id, user_id=segment.user_id, message="切片视频分析正在处理中,拒绝再次分析", detail={"segment_id": segment.id, "analysis_status": segment.analysis_status, "reason": reason}, event_status="rejected", ) raise HTTPException(status_code=409, detail="切片视频分析正在处理中,不能重复投递") if segment.analysis_status == ShotSegmentAnalysisStatusEnum.COMPLETED.value and not force: raise HTTPException(status_code=409, detail="切片视频分析已完成,如确需重跑请传 force=true") if segment.source_mode != ShotSegmentSourceModeEnum.CUSTOM.value and not force: raise HTTPException(status_code=409, detail="AI 建议片段默认无需单独分析,如确需重跑请传 force=true") segment.analysis_status = ShotSegmentAnalysisStatusEnum.PENDING.value segment.analysis_error_message = None segment.analysis_json = None segment.original_video_content = None segment.original_video_category = None segment.original_video_audience = None segment.segment_content = None segment.segment_category = None segment.segment_audience = None await db.flush() log_module_event_file( module=MODULE, event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_RECEIVED.value, project_id=segment.task_set_id, step_id=segment.id, user_id=segment.user_id, message="切片视频再次分析已重置状态", detail={"segment_id": segment.id, "task_set_id": segment.task_set_id, "force": force, "reason": reason, "video_url": segment.segment_video_url}, ) return ShotReanalyzeOut( message="切片视频再次分析任务已准备投递", task_set_id=segment.task_set_id, segment_id=segment.id, analysis_status=segment.analysis_status, celery_task_name="shot_replicate.analyze_custom_segment_video", )