Files
video-gen/video-gen-api/app/services/shot_replicate_taskset_service.py
T
2026-07-22 14:48:29 +08:00

1142 lines
46 KiB
Python

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 app.enums.celery_queue import CeleryQueue
from sqlalchemy import String, case, 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_summaries(db: AsyncSession, task_set_ids: set[str] | list[str]) -> None:
ids = sorted({str(item) for item in task_set_ids if item})
if not ids:
return
task_set_result = await db.execute(
select(ShotReplicateTaskSet)
.where(ShotReplicateTaskSet.id.in_(ids))
.order_by(ShotReplicateTaskSet.id.asc())
.with_for_update()
)
task_sets = list(task_set_result.scalars().all())
if not task_sets:
return
count_result = await db.execute(
select(
ShotReplicateSegment.task_set_id,
func.count(ShotReplicateSegment.id).label("total"),
func.sum(
case(
(ShotReplicateSegment.split_status == ShotSplitStatusEnum.COMPLETED.value, 1),
else_=0,
)
).label("completed"),
func.sum(
case(
(ShotReplicateSegment.split_status == ShotSplitStatusEnum.FAILED.value, 1),
else_=0,
)
).label("failed"),
)
.where(
ShotReplicateSegment.task_set_id.in_(ids),
ShotReplicateSegment.deleted_at.is_(None),
)
.group_by(ShotReplicateSegment.task_set_id)
)
count_map = {
str(row.task_set_id): (int(row.total or 0), int(row.completed or 0), int(row.failed or 0))
for row in count_result.all()
}
for task_set in task_sets:
total, completed, failed = count_map.get(str(task_set.id), (0, 0, 0))
task_set.segment_count = total
task_set.completed_segment_count = completed
task_set.failed_segment_count = failed
old_status = task_set.status
old_split_status = task_set.split_status
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
elif 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 refresh_task_set_split_summary(db: AsyncSession, task_set_id: str) -> None:
await refresh_task_set_split_summaries(db, {task_set_id})
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": CeleryQueue.GEN_SHOT_SPLIT.value,
"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=CeleryQueue.GEN_SHOT_SPLIT.value,
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_attempt_no = max(1, int(task_set.analysis_attempt_no or 1)) + 1
task_set.analysis_claim_token = None
task_set.analysis_started_at = None
task_set.analysis_lease_until = None
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_attempt_no = max(1, int(segment.analysis_attempt_no or 1)) + 1
segment.analysis_claim_token = None
segment.analysis_started_at = None
segment.analysis_lease_until = None
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",
)