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

1241 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query, UploadFile
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.celery_queue import CeleryQueue
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.enums.common import ModuleEventTypeEnum
from app.enums.generation_task import GenerationOwnerType
from app.enums.shot_replicate import (
ModuleCodeEnum,
ShotAnalysisStatusEnum,
ShotReplicateLogEventEnum,
ShotReplicateStepCodeEnum,
ShotSegmentAnalysisStatusEnum,
ShotSegmentReplicateStatusEnum,
ShotSegmentSourceModeEnum,
ShotSplitStatusEnum,
ShotTaskSetStatusEnum,
)
from app.schemas.shot_replicate import (
ShotReplicateActionOut,
ShotReplicateGenerateImagePromptRequest,
ShotReplicateGenerateImageRequest,
ShotReplicateGenerateVideoPromptRequest,
ShotReplicateGenerateVideoRequest,
ShotReplicateImagePromptUpdateRequest,
ShotReanalyzeOut,
ShotReanalyzeRequest,
ShotSegmentSplitRetryOut,
ShotSplitRetryRequest,
ShotReplicateMaterialUpdateRequest,
ShotReplicateSpecOut,
ShotReplicateTaskDetailOut,
ShotReplicateVideoPromptSchemaUpdateRequest,
ShotSegmentDeleteOut,
ShotTaskSetDeleteOut,
ShotSegmentDetailOut,
ShotSegmentListOut,
ShotSegmentReplicationCreateRequest,
ShotSplitByAIOut,
ShotSplitByAIRequest,
ShotSplitCustomOut,
ShotSplitCustomRequest,
ShotTaskSetCreate,
ShotTaskSetDetailOut,
ShotTaskSetListOut,
)
from app.services.shot_replicate_flow_service import (
_get_project_for_user,
generate_image_from_prompt,
generate_video_from_prompt,
mark_shot_replicate_step_dispatch_failed,
project_to_detail_out,
submit_image_prompt_optimize,
submit_video_prompt_optimize,
update_shot_replicate_image_prompt,
update_shot_replicate_material_input,
update_shot_replicate_video_prompt_schema,
)
from app.services.shot_replicate_taskset_service import (
create_custom_segment,
create_segments_by_ai,
create_task_set,
delete_segment,
delete_task_set,
list_segments,
list_task_sets,
prepare_reanalyze_segment,
prepare_reanalyze_task_set,
prepare_retry_split_segment,
segment_detail,
task_set_detail,
)
from app.services.module_generation_log_service import log_module_error, log_module_event_file
from app.services.module_async_recovery_service import (
TASK_SHOT_IMAGE_PROMPT,
TASK_SHOT_VIDEO_PROMPT,
register_module_step_task,
)
from app.tasks.celery_app import celery_app
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceSourceModelEnum, UploadResourceTypeEnum
from app.services.upload_resource import upload_reference_file, bind_upload_resources, cleanup_upload_resource_files_after_commit
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
MODULE = ModuleCodeEnum.SHOT_REPLICATE.value
def _safe_user_id(user: object | None) -> str | None:
"""从 ORM 对象中安全取用户ID,避免 rollback/commit 后访问过期属性触发 MissingGreenlet。"""
if user is None:
return None
try:
value = getattr(user, "__dict__", {}).get("id")
if value is not None:
return str(value)
except Exception:
pass
try:
identity = sa_inspect(user).identity
if identity:
return str(identity[0])
except Exception:
pass
return None
def _safe_user_is_admin(user: object | None) -> bool:
"""安全判断管理员身份;如果对象属性已过期,保守按普通用户处理。"""
if user is None:
return False
try:
data = getattr(user, "__dict__", {})
if "is_admin" in data:
return bool(data.get("is_admin"))
except Exception:
pass
return False
def _user_context(user: object | None) -> SimpleNamespace:
return SimpleNamespace(id=_safe_user_id(user), is_admin=_safe_user_is_admin(user))
router = APIRouter(prefix="/shot-replications", tags=["shot-replications"])
def _log_api_error(
*,
event_type: str,
current_user: User | None = None,
project_id: str | None = None,
step_id: str | None = None,
message: str | None = None,
exc: BaseException | None = None,
detail: dict | None = None,
) -> None:
log_module_error(
module=MODULE,
event_type=event_type,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message=message,
detail=detail,
exc=exc,
)
def _log_api_exception_from_locals(exc: BaseException, local_values: dict, message: str) -> None:
current_user = local_values.get("current_user")
project_id = local_values.get("project_id_value") or local_values.get("project_id") or local_values.get("task_set_id") or local_values.get("task_set_id_value")
step_id = local_values.get("step_id_value") or local_values.get("step_id") or local_values.get("segment_id") or local_values.get("segment_id_value")
req = local_values.get("req")
detail = {"api": local_values.get("__name__"), "request": req.model_dump() if hasattr(req, "model_dump") else str(req) if req is not None else None}
_log_api_error(
event_type=ShotReplicateLogEventEnum.API_REQUEST_FAILED.value,
current_user=current_user if isinstance(current_user, User) else None,
project_id=str(project_id) if project_id else None,
step_id=str(step_id) if step_id else None,
message=message,
detail=detail,
exc=exc,
)
def _ensure_celery_enabled(*, current_user: User | None = None, project_id: str | None = None, step_id: str | None = None) -> None:
if celery_app is not None:
return
message = "Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker"
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISABLED.value,
current_user=current_user,
project_id=project_id,
step_id=step_id,
message=message,
detail={"api": "shot_replicate"},
)
raise HTTPException(status_code=503, detail=message)
async def _reload_project_detail(db: AsyncSession, current_user: User, project_id: str) -> ShotReplicateTaskDetailOut:
project = await _get_project_for_user(
db,
project_id=project_id,
user=_user_context(current_user),
for_update=False,
populate_existing=True,
)
return await project_to_detail_out(db, project)
async def _mark_dispatch_failed_and_raise(
db: AsyncSession,
*,
current_user: User,
project_id: str,
step_id: str | None,
message: str,
) -> None:
if step_id:
try:
await mark_shot_replicate_step_dispatch_failed(
db,
current_user=_user_context(current_user),
project_id=project_id,
step_id=step_id,
error_message=message,
)
await db.commit()
except Exception as exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_MARK_FAILED.value,
current_user=current_user,
project_id=project_id,
step_id=step_id,
message="Celery 投递失败后标记步骤失败也失败",
detail={"dispatch_error": message},
exc=exc,
)
log_module_error(
module=MODULE,
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
project_id=project_id,
step_id=step_id,
user_id=_safe_user_id(current_user),
message=message,
detail={"reason": "celery_dispatch_failed"},
error=message,
)
raise HTTPException(status_code=503, detail=message)
@router.get(
"/spec",
response_model=ShotReplicateSpecOut,
summary="查询拆镜复刻模块状态枚举和步骤 JSON 结构说明",
description="保留给调试和前端兜底读取。原业务接口已经在 Path、Query、Body 和响应模型字段上直接展示参数说明与枚举值。",
)
async def get_spec():
return ShotReplicateSpecOut()
@router.post(
"/upload-image",
summary="上传拆镜复刻图片素材",
description="上传后先记录为 pending UploadResource;创建拆镜复刻项目后绑定业务记录。",
)
async def upload_shot_replicate_image(
file: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await upload_reference_file(
db,
file=file,
current_user=current_user,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
resource_type=UploadResourceTypeEnum.IMAGE.value,
gen_type="video",
)
await db.commit()
return {
"url": result.url,
"filename": result.filename,
"type": "image",
"module": result.module,
"resource_id": result.resource_id,
"file_size_bytes": result.file_size_bytes,
}
@router.post(
"/upload-video",
summary="上传拆镜复刻视频素材",
description="上传后先记录为 pending UploadResource;创建拆镜总任务集后绑定 ShotReplicateTaskSet。",
)
async def upload_shot_replicate_video(
file: UploadFile = File(...),
duration_seconds: float | None = Query(None, description="前端识别的视频时长秒数,可选"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await upload_reference_file(
db,
file=file,
current_user=current_user,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
resource_type=UploadResourceTypeEnum.VIDEO.value,
duration_seconds=duration_seconds,
)
await db.commit()
return {
"url": result.url,
"filename": result.filename,
"type": "video",
"module": result.module,
"resource_id": result.resource_id,
"file_size_bytes": result.file_size_bytes,
"duration_seconds": result.duration_seconds,
}
@router.post(
"/task-sets",
response_model=ShotTaskSetDetailOut,
summary="创建拆镜总任务集并异步分析原视频",
description=(
"创建拆镜总任务集,保存原视频地址和时长,随后异步投递原视频 AI 分析任务。"
"分析完成后会写入原视频内容、分类、受众和 AI 建议拆镜时间段。"
"状态枚举直接见本接口响应字段:status、analysis_status、split_status。"
),
)
async def create_shot_task_set(
req: ShotTaskSetCreate = Body(..., description="创建拆镜总任务集参数:原视频 URL、视频时长、标题和可选幂等键"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id"))
try:
task_set = await create_task_set(db, current_user=current_user, req=req)
task_set_id = task_set.id
await bind_upload_resources(
db,
user_id=current_user.id,
module=UploadResourceModuleEnum.SHOT_REPLICATE.value,
source_model=UploadResourceSourceModelEnum.SHOT_REPLICATE_TASK_SET.value,
source_id=task_set_id,
resource_ids=[req.video_resource_id],
urls=[req.video_url],
allow_common_migrate=True,
)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"创建拆镜总任务集失败: {exc}")
raise HTTPException(status_code=500, detail=f"创建拆镜总任务集失败: {exc}")
try:
from app.tasks.shot_replicate_tasks import analyze_original_video
analyze_original_video.apply_async(args=[task_set_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0)
except Exception as exc:
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=task_set_id,
message=f"拆镜分析任务投递失败: {exc}",
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=exc,
)
raise HTTPException(status_code=503, detail=f"拆镜分析任务投递失败: {exc}")
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
@router.get(
"/task-sets",
response_model=ShotTaskSetListOut,
summary="查询拆镜总任务集列表",
)
async def list_shot_task_sets(
status: ShotTaskSetStatusEnum | None = Query(None, description="总任务状态筛选:pending_analysis=等待分析,analyzing=分析中,analysis_completed=分析完成,analysis_failed=分析失败,splitting=拆镜中,split_completed=拆镜完成,partial_failed=部分失败,failed=失败,deleted=已软删"),
analysis_status: ShotAnalysisStatusEnum | None = Query(None, description="原视频分析状态筛选:pending=待分析,processing=分析中,completed=分析完成,failed=分析失败"),
split_status: ShotSplitStatusEnum | None = Query(None, description="拆镜状态筛选:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试"),
keyword: str | None = Query(None, description="标题/原视频内容/分类/受众关键词,模糊搜索;普通用户只在自己的数据内搜索"),
user_id: str | None = Query(None, description="管理员专用:按用户ID筛选;普通用户不可使用"),
user_name: str | None = Query(None, description="管理员专用:按用户名模糊筛选;普通用户不可使用"),
created_start: datetime | None = Query(None, description="管理员专用:创建时间开始,ISO datetime;普通用户不可使用"),
created_end: datetime | None = Query(None, description="管理员专用:创建时间结束,ISO datetime;普通用户不可使用"),
page: int = Query(1, ge=1, description="分页页码,从1开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
admin_only_params = {
"user_id": user_id,
"user_name": user_name,
"created_start": created_start,
"created_end": created_end,
}
if not _safe_user_is_admin(current_user) and any(value is not None and str(value).strip() != "" for value in admin_only_params.values()):
raise HTTPException(status_code=403, detail="当前搜索条件仅管理员可用")
return await list_task_sets(
db,
current_user=current_user,
status=status.value if status else None,
analysis_status=analysis_status.value if analysis_status else None,
split_status=split_status.value if split_status else None,
keyword=keyword,
user_id=user_id,
user_name=user_name,
created_start=created_start,
created_end=created_end,
page=page,
page_size=page_size,
)
@router.get(
"/task-sets/{task_set_id}",
response_model=ShotTaskSetDetailOut,
summary="获取拆镜总任务集详情",
description="根据拆镜总任务集ID查询详情,包含原视频分析结果、AI 建议拆镜列表和当前总任务状态。",
)
async def get_shot_task_set(
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await task_set_detail(db, current_user=_user_context(current_user), task_set_id=task_set_id)
@router.post(
"/task-sets/{task_set_id}/reanalyze",
response_model=ShotReanalyzeOut,
summary="重新投递原视频 AI 分析任务",
description="用于处理原视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_original_video。",
)
async def reanalyze_task_set(
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
req: ShotReanalyzeRequest = Body(default_factory=ShotReanalyzeRequest, description="再次分析参数"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ensure_celery_enabled(current_user=current_user, project_id=task_set_id)
try:
out = await prepare_reanalyze_task_set(
db,
current_user=current_user,
task_set_id=task_set_id,
force=req.force,
reason=req.reason,
)
await db.commit()
except HTTPException as exc:
await db.rollback()
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_REJECTED.value,
project_id=task_set_id,
user_id=_safe_user_id(current_user),
message="原视频再次分析请求被拒绝",
detail={"task_set_id": task_set_id, "request": req.model_dump(), "http_status": exc.status_code, "detail": exc.detail},
error=str(exc.detail),
)
raise
except Exception as exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_FAILED.value,
current_user=current_user,
project_id=task_set_id,
message=f"原视频再次分析状态重置失败: {exc}",
detail={"task_set_id": task_set_id, "request": req.model_dump()},
exc=exc,
)
raise HTTPException(status_code=500, detail=f"原视频再次分析状态重置失败: {exc}")
try:
from app.tasks.shot_replicate_tasks import analyze_original_video
analyze_original_video.apply_async(args=[task_set_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.TASK_SET_REANALYZE_SUBMITTED.value,
project_id=task_set_id,
user_id=_safe_user_id(current_user),
message="原视频再次分析任务已投递",
detail={"task_set_id": task_set_id, "task": "analyze_original_video", "request": req.model_dump()},
)
except Exception as exc:
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=task_set_id,
message=f"原视频再次分析任务投递失败: {exc}",
detail={"task_set_id": task_set_id, "task": "analyze_original_video"},
exc=exc,
)
raise HTTPException(status_code=503, detail=f"原视频再次分析任务投递失败: {exc}")
out.message = "原视频再次分析任务已提交"
return out
@router.post(
"/task-sets/{task_set_id}/split-by-ai",
response_model=ShotSplitByAIOut,
summary="按 AI 建议方案异步拆镜",
description=(
"基于原视频 AI 分析生成的建议时间段创建拆镜片段,并异步投递 ffmpeg 切割任务。"
"selected_indices 不传时默认按全部 AI 建议拆镜;replace_existing=true 时会软删旧 AI 建议片段后重新创建。"
),
)
async def split_by_ai(
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
req: ShotSplitByAIRequest = Body(default_factory=ShotSplitByAIRequest, description="AI 建议拆镜参数:可选择建议序号,也可选择是否覆盖旧 AI 片段"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id"))
try:
out = await create_segments_by_ai(db, current_user=current_user, task_set_id=task_set_id, req=req)
segment_ids = [item.id for item in out.segments]
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"按 AI 建议拆镜失败: {exc}")
raise HTTPException(status_code=500, detail=f"按 AI 建议拆镜失败: {exc}")
from app.tasks.shot_replicate_tasks import split_one_segment
for segment_id in segment_ids:
split_one_segment.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_SPLIT.value, countdown=0)
return out
@router.post(
"/task-sets/{task_set_id}/split-custom",
response_model=ShotSplitCustomOut,
summary="按用户自定义开始/结束秒异步拆单条片段",
description="按用户传入的 start_second/end_second 创建 custom 来源片段,并异步投递 ffmpeg 切割任务;切割完成后可进入复刻项目。",
)
async def split_custom(
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
req: ShotSplitCustomRequest = Body(..., description="自定义拆镜时间段参数,end_second 必须大于 start_second"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ensure_celery_enabled(current_user=current_user, project_id=locals().get("project_id") or locals().get("task_set_id"))
try:
out = await create_custom_segment(db, current_user=current_user, task_set_id=task_set_id, req=req)
segment_id = out.segment.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"自定义拆镜失败: {exc}")
raise HTTPException(status_code=500, detail=f"自定义拆镜失败: {exc}")
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=0)
return out
@router.get(
"/task-sets/{task_set_id}/segments",
response_model=ShotSegmentListOut,
summary="查询拆镜片段列表",
description="分页查询指定拆镜总任务集下的片段列表,可按来源、切割状态、片段分析状态和复刻状态筛选。",
)
async def list_task_set_segments(
task_set_id: str = Path(..., description="拆镜总任务集ID,即 shot_replicate_task_sets.id"),
source_mode: ShotSegmentSourceModeEnum | None = Query(None, description="片段来源:ai_suggestion=AI 建议拆镜,custom=用户自定义拆镜"),
split_status: ShotSplitStatusEnum | None = Query(None, description="切割状态:none=尚未拆镜,pending=待拆镜,processing=拆镜中,completed=拆镜完成,failed=拆镜失败,retry_waiting=等待恢复重试"),
analysis_status: ShotSegmentAnalysisStatusEnum | None = Query(None, description="片段分析状态:not_required=无需单独分析,pending=等待分析,processing=分析中,completed=分析完成,failed=分析失败"),
replicate_status: ShotSegmentReplicateStatusEnum | None = Query(None, description="片段复刻状态:not_started=未复刻,project_created=已创建复刻项目,processing=复刻处理中,completed=复刻完成,failed=复刻失败"),
page: int = Query(1, ge=1, description="分页页码,从1开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,范围1-100"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_segments(
db,
current_user=current_user,
task_set_id=task_set_id,
source_mode=source_mode.value if source_mode else None,
split_status=split_status.value if split_status else None,
analysis_status=analysis_status.value if analysis_status else None,
replicate_status=replicate_status.value if replicate_status else None,
page=page,
page_size=page_size,
)
@router.get(
"/segments/{segment_id}",
response_model=ShotSegmentDetailOut,
summary="获取拆镜片段详情",
description="根据拆镜片段ID查询单个片段详情,包含切割结果、片段分析结果、复刻项目ID和状态。",
)
async def get_segment(
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await segment_detail(db, current_user=current_user, segment_id=segment_id)
@router.post(
"/segments/{segment_id}/reanalyze",
response_model=ShotReanalyzeOut,
summary="重新投递切片视频 AI 分析任务",
description="用于处理自定义切片视频分析失败或待处理的异常数据;重置分析状态后重新投递 analyze_custom_segment_video。",
)
async def reanalyze_segment(
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
req: ShotReanalyzeRequest = Body(default_factory=ShotReanalyzeRequest, description="再次分析参数"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ensure_celery_enabled(current_user=current_user, step_id=segment_id)
try:
out = await prepare_reanalyze_segment(
db,
current_user=current_user,
segment_id=segment_id,
force=req.force,
reason=req.reason,
)
task_set_id = out.task_set_id
await db.commit()
except HTTPException as exc:
await db.rollback()
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_REJECTED.value,
step_id=segment_id,
user_id=_safe_user_id(current_user),
message="切片视频再次分析请求被拒绝",
detail={"segment_id": segment_id, "request": req.model_dump(), "http_status": exc.status_code, "detail": exc.detail},
error=str(exc.detail),
)
raise
except Exception as exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_FAILED.value,
current_user=current_user,
step_id=segment_id,
message=f"切片视频再次分析状态重置失败: {exc}",
detail={"segment_id": segment_id, "request": req.model_dump()},
exc=exc,
)
raise HTTPException(status_code=500, detail=f"切片视频再次分析状态重置失败: {exc}")
try:
from app.tasks.shot_replicate_tasks import analyze_custom_segment_video
analyze_custom_segment_video.apply_async(args=[segment_id], queue=CeleryQueue.GEN_SHOT_ANALYSIS.value, countdown=0)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.SEGMENT_REANALYZE_SUBMITTED.value,
project_id=task_set_id,
step_id=segment_id,
user_id=_safe_user_id(current_user),
message="切片视频再次分析任务已投递",
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video", "request": req.model_dump()},
)
except Exception as exc:
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=task_set_id,
step_id=segment_id,
message=f"切片视频再次分析任务投递失败: {exc}",
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "analyze_custom_segment_video"},
exc=exc,
)
raise HTTPException(status_code=503, detail=f"切片视频再次分析任务投递失败: {exc}")
out.message = "切片视频再次分析任务已提交"
return out
@router.post(
"/segments/{segment_id}/retry-split",
response_model=ShotSegmentSplitRetryOut,
summary="重试拆镜片段视频切片",
description="用于处理 ShotReplicateSegment 视频切片失败;重置 split_status 后复用现有 split_one_segment Celery 任务重新切割。",
)
async def retry_split_segment(
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
req: ShotSplitRetryRequest = Body(default_factory=ShotSplitRetryRequest, description="切片失败重试参数"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ensure_celery_enabled(current_user=current_user, step_id=segment_id)
try:
out = await prepare_retry_split_segment(
db,
current_user=current_user,
segment_id=segment_id,
force=req.force,
reason=req.reason,
)
task_set_id = out.task_set_id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_error(
event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_DISPATCH_FAILED.value,
current_user=current_user,
step_id=segment_id,
message=f"切片失败重试状态重置失败: {exc}",
detail={"segment_id": segment_id, "request": req.model_dump()},
exc=exc,
)
raise HTTPException(status_code=500, detail=f"切片失败重试状态重置失败: {exc}")
try:
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=0,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
)
log_module_event_file(
module=MODULE,
event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_SUBMITTED.value,
project_id=task_set_id,
step_id=segment_id,
user_id=_safe_user_id(current_user),
message="拆镜片段切片重试任务已投递",
detail={
"segment_id": segment_id,
"task_set_id": task_set_id,
"task": "split_one_segment",
"queue": CeleryQueue.GEN_SHOT_SPLIT.value,
"request": req.model_dump(),
},
)
except Exception as exc:
_log_api_error(
event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=task_set_id,
step_id=segment_id,
message=f"拆镜片段切片重试任务投递失败,等待恢复任务兜底: {exc}",
detail={"segment_id": segment_id, "task_set_id": task_set_id, "task": "split_one_segment"},
exc=exc,
)
out.message = "切片状态已重置,但 Celery 投递失败,将等待恢复任务兜底"
return out
@router.delete(
"/segments/{segment_id}",
response_model=ShotSegmentDeleteOut,
summary="软删除拆镜片段",
description=(
"软删除单个拆镜片段;不删除 segment_video_path 指向的物理文件。"
"如片段已创建拆镜复刻项目,会联动软删除该项目和已生成资源账本,但用户主动删除不退款。"
"如片段或关联项目仍有处理中任务,会拒绝删除。"
),
)
async def delete_shot_segment(
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
user_id = _safe_user_id(current_user)
pending_ids: list[str] = []
try:
out = await delete_segment(db, current_user=current_user, segment_id=segment_id)
pending_ids = list(out.pending_delete_resource_ids or [])
await db.commit()
except HTTPException as exc:
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜片段 HTTPException 回滚失败",
user_id=user_id,
module=MODULE,
detail={"segment_id": segment_id},
original_exc=exc,
)
raise
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜片段主事务回滚失败",
user_id=user_id,
module=MODULE,
detail={"segment_id": segment_id},
original_exc=exc,
)
_log_api_exception_from_locals(exc, locals(), f"删除拆镜片段失败: {exc}")
log_upload_resource_exception(
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_FAILED.value,
message=f"删除拆镜片段失败: {exc}",
user_id=user_id,
module=MODULE,
detail={"segment_id": segment_id},
exc=exc,
)
raise HTTPException(status_code=500, detail=f"删除拆镜片段失败: {exc}")
if pending_ids:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
await db.commit()
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜片段 cleanup 事务回滚失败",
user_id=user_id,
module=MODULE,
detail={"segment_id": segment_id, "pending_ids": pending_ids},
original_exc=exc,
)
log_upload_resource_exception(
event_type=UploadResourceEventEnum.SHOT_SEGMENT_DELETE_CLEANUP_FAILED.value,
message=f"删除拆镜片段后清理真实文件失败: {exc}",
user_id=user_id,
resource_ids=pending_ids,
module=MODULE,
detail={"segment_id": segment_id},
exc=exc,
)
return out
@router.post(
"/segments/{segment_id}/replication-projects",
response_model=ShotReplicateActionOut,
summary="将拆镜片段创建为拆镜复刻项目",
description=(
"以拆镜片段作为锁定素材视频创建 ModuleGenerationProject 复刻项目。"
"创建后只同步生成第1步 material_input,后续第2-5步需要调用 /projects/{project_id}/steps/{step_id}/... 系列接口手动推进。"
"素材视频来自片段 segment_video_url,不允许前端传入或后续修改。"
),
)
async def create_replication_project_from_segment(
segment_id: str = Path(..., description="拆镜片段ID,即 shot_replicate_segments.id"),
req: ShotSegmentReplicationCreateRequest = Body(..., description="从拆镜片段创建复刻项目参数:生成项目名称、核心内容点、新产品图片和可选幂等键"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
log_module_event_file(
module=MODULE,
event_type=ModuleEventTypeEnum.V1_CREATE_BLOCKED.value,
user_id=current_user.id,
step_id=segment_id,
message="拦截拆镜复刻 V1 创建请求",
detail={"api_version": "v1", "flow_version": "v1", "segment_id": segment_id},
)
raise HTTPException(
status_code=410,
detail="V1 创建流程已停止,请使用 V2 API",
)
@router.get(
"/projects/{project_id}",
response_model=ShotReplicateTaskDetailOut,
summary="获取拆镜复刻项目详情",
description="获取拆镜复刻 ModuleGenerationProject 项目详情,聚合返回素材、图片提词、图片生成、视频提词、视频生成和当前有效步骤列表。",
)
async def get_project(
project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await _reload_project_detail(db, current_user, project_id)
@router.put(
"/projects/{project_id}/material",
response_model=ShotReplicateActionOut,
summary="修改拆镜复刻素材信息,素材视频不允许修改",
description=(
"修改拆镜复刻第1步素材输入,并重建第1步 material_input 新版本。"
"素材视频 material_video_url 锁定为拆镜片段视频,不允许修改;可修改新产品图片、参考素材项目名、生成项目名和核心内容点。"
"修改后会软删除第2、3、4、5步当前有效任务,并清空旧图片/视频结果。"
),
)
async def update_material(
project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
req: ShotReplicateMaterialUpdateRequest = Body(..., description="第1步素材输入修改参数;不接收 material_video_url,至少传一个允许修改字段"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project_id_value, step_id_value = await update_shot_replicate_material_input(db, current_user=current_user, project_id=project_id, req=req)
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"修改素材输入失败: {exc}")
raise HTTPException(status_code=500, detail=f"修改素材输入失败: {exc}")
return ShotReplicateActionOut(message="素材输入已修改,素材视频保持锁定", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.put(
"/projects/{project_id}/steps/{step_id}/image-prompt",
response_model=ShotReplicateActionOut,
summary="直接修改第2步图片 AI 优化提词",
description="直接修改第2步 image_prompt_optimize 的图片提示词,不调用 AI、不扣积分;保存后软删除第3、4、5步当前有效任务。",
)
async def update_image_prompt(
project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
step_id: str = Path(..., description="第2步图片 AI 提词子任务ID,即 module_generation_steps.idstep_code=image_prompt_optimize"),
req: ShotReplicateImagePromptUpdateRequest = Body(..., description="图片 AI 优化提词修改参数"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step = await update_shot_replicate_image_prompt(db, current_user=current_user, project_id=project_id, step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"修改图片 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"修改图片 AI 提词失败: {exc}")
return ShotReplicateActionOut(message="图片 AI 提词已修改,后续步骤已软删除", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.put(
"/projects/{project_id}/steps/{step_id}/video-prompt-schema",
response_model=ShotReplicateActionOut,
summary="修改第4步视频 AI 提词 JSON schema",
description=(
"修改第4步 video_prompt_optimize 的视频提词 JSON schema,不调用 AI、不扣积分。"
"服务端会锁定视频时长、比例、分辨率、帧率、动态时间规划等关键结构;保存后软删除第5步视频生成任务。"
),
)
async def update_video_prompt_schema(
project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
step_id: str = Path(..., description="第4步视频 AI 提词子任务ID,即 module_generation_steps.idstep_code=video_prompt_optimize"),
req: ShotReplicateVideoPromptSchemaUpdateRequest = Body(..., description="视频 AI 提词 JSON schema 修改参数"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
project, step = await update_shot_replicate_video_prompt_schema(db, current_user=current_user, project_id=project_id, step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"修改视频 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"修改视频 AI 提词失败: {exc}")
return ShotReplicateActionOut(message="视频 AI 提词 schema 已修改,第5步视频生成已软删除", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.post(
"/projects/{project_id}/steps/{step_id}/generate-image-prompt",
response_model=ShotReplicateActionOut,
summary="基于第1步素材输入生成图片 AI 提词",
description=(
"基于第1步 material_input 子任务手动生成第2步 image_prompt_optimize。"
"如果已存在旧的第2、3、4、5步,会先软删除旧步骤,再创建新的第2步并投递 Celery 文本提词任务。"
),
)
async def generate_image_prompt(
project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
step_id: str = Path(..., description="第1步素材输入子任务ID,即 module_generation_steps.idstep_code=material_input"),
req: ShotReplicateGenerateImagePromptRequest = Body(default_factory=ShotReplicateGenerateImagePromptRequest, description="图片提词生成参数,当前无需传参,额外字段会忽略"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try:
project, step = await submit_image_prompt_optimize(db, current_user=current_user, project_id=project_id, material_step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"提交图片 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交图片 AI 提词失败: {exc}")
try:
from app.tasks.shot_replicate_flow_tasks import start_image_prompt_optimize
await register_module_step_task(
module=MODULE,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.IMAGE_PROMPT_OPTIMIZE.value,
task_name=TASK_SHOT_IMAGE_PROMPT,
)
start_image_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue=CeleryQueue.GEN_CHATAPI_CREATE.value, countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"图片 AI 提词任务投递失败: {exc}")
return ShotReplicateActionOut(message="图片 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.post(
"/projects/{project_id}/steps/{step_id}/generate-image",
response_model=ShotReplicateActionOut,
summary="基于第2步图片 AI 提词生成图片",
description=(
"基于第2步 image_prompt_optimize 子任务生成第3步 image_generate。"
"请求体传入图片引擎和图片参数;ChatGenerationTask 幂等键由后端自动生成。"
"如果已存在旧的第3、4、5步,会先软删除旧步骤,再创建新的第3步。"
),
)
async def generate_image(
project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
step_id: str = Path(..., description="第2步图片 AI 提词子任务ID,即 module_generation_steps.idstep_code=image_prompt_optimize"),
req: ShotReplicateGenerateImageRequest = Body(..., description="图片生成引擎和参数;可选值来自图片引擎配置接口"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try:
project, step, chat_task = await generate_image_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req)
project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"提交图片生成失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交图片生成失败: {exc}")
try:
from app.tasks.generation_create_tasks import chatapi_create_generation_task
chatapi_create_generation_task.apply_async(
args=[chat_task_id_value],
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
countdown=0,
)
except Exception as exc:
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"图片生成任务投递失败,等待生成恢复任务补投: {exc}",
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
exc=exc,
)
return ShotReplicateActionOut(message="图片生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.post(
"/projects/{project_id}/steps/{step_id}/generate-video-prompt",
response_model=ShotReplicateActionOut,
summary="基于第3步图片结果生成视频 AI 提词 JSON schema",
description=(
"基于第3步 image_generate 子任务生成第4步 video_prompt_optimize。"
"视频时长、比例、分辨率在本步骤确定并写入第4步 output_json;第5步视频生成只选择视频引擎。"
"如果已存在旧的第4、5步,会先软删除旧步骤,再创建新的第4步并投递 Celery 文本提词任务。"
),
)
async def generate_video_prompt(
project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
step_id: str = Path(..., description="第3步图片生成子任务ID,即 module_generation_steps.idstep_code=image_generate"),
req: ShotReplicateGenerateVideoPromptRequest = Body(..., description="视频提词生成参数:视频引擎、时长、比例、分辨率和目标平台;可选值来自视频引擎配置接口"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try:
project, step = await submit_video_prompt_optimize(db, current_user=current_user, project_id=project_id, image_step_id=step_id, req=req)
project_id_value, step_id_value = project.id, step.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"提交视频 AI 提词失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交视频 AI 提词失败: {exc}")
try:
from app.tasks.shot_replicate_flow_tasks import start_video_prompt_optimize
await register_module_step_task(
module=MODULE,
project_id=project_id_value,
step_id=step_id_value,
step_code=ShotReplicateStepCodeEnum.VIDEO_PROMPT_OPTIMIZE.value,
task_name=TASK_SHOT_VIDEO_PROMPT,
)
start_video_prompt_optimize.apply_async(args=[project_id_value, step_id_value], queue=CeleryQueue.GEN_CHATAPI_CREATE.value, countdown=0)
except Exception as exc:
await _mark_dispatch_failed_and_raise(db, current_user=current_user, project_id=project_id_value, step_id=step_id_value, message=f"视频 AI 提词任务投递失败: {exc}")
return ShotReplicateActionOut(message="视频 AI 提词任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.post(
"/projects/{project_id}/steps/{step_id}/generate-video",
response_model=ShotReplicateActionOut,
summary="基于第4步视频 AI 提词生成最终视频",
description=(
"基于第4步 video_prompt_optimize 子任务生成第5步 video_generate。"
"请求体只需要选择视频生成引擎 engine_idduration、aspect_ratio、resolution 从第4步视频提词结果继承。"
"如果已存在旧的第5步,会先软删除旧步骤,再创建新的第5步。"
),
)
async def generate_video(
project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
step_id: str = Path(..., description="第4步视频 AI 提词子任务ID,即 module_generation_steps.idstep_code=video_prompt_optimize"),
req: ShotReplicateGenerateVideoRequest = Body(..., description="视频生成参数:只传 engine_id,其它视频参数继承第4步视频提词结果"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
_ensure_celery_enabled(current_user=current_user, project_id=project_id, step_id=step_id)
try:
project, step, chat_task = await generate_video_from_prompt(db, current_user=current_user, project_id=project_id, prompt_step_id=step_id, req=req)
project_id_value, step_id_value, chat_task_id_value = project.id, step.id, chat_task.id
await db.commit()
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
_log_api_exception_from_locals(exc, locals(), f"提交视频生成失败: {exc}")
raise HTTPException(status_code=500, detail=f"提交视频生成失败: {exc}")
try:
from app.tasks.generation_create_tasks import chatapi_create_generation_task
chatapi_create_generation_task.apply_async(
args=[chat_task_id_value],
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": 1},
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
countdown=0,
)
except Exception as exc:
_log_api_error(
event_type=ShotReplicateLogEventEnum.CELERY_DISPATCH_FAILED.value,
current_user=current_user,
project_id=project_id_value,
step_id=step_id_value,
message=f"视频生成任务投递失败,等待生成恢复任务补投: {exc}",
detail={"recoverable": True, "chat_task_id": chat_task_id_value},
exc=exc,
)
return ShotReplicateActionOut(message="视频生成任务已提交", project_id=project_id_value, step_id=step_id_value, detail=await _reload_project_detail(db, current_user, project_id_value))
@router.delete(
"/task-sets/{task_set_id}",
response_model=ShotTaskSetDeleteOut,
summary="软删除拆镜任务集",
description="软删除整个 ShotReplicateTaskSet,并联动软删除全部片段和片段内部复刻项目;UploadResource 真实文件在事务提交后清理。",
)
async def delete_shot_task_set(
task_set_id: str = Path(..., description="拆镜任务集ID,即 shot_replicate_task_sets.id"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
user_id = _safe_user_id(current_user)
pending_ids: list[str] = []
try:
out = await delete_task_set(db, current_user=current_user, task_set_id=task_set_id)
pending_ids = list(out.pending_delete_resource_ids or [])
await db.commit()
except HTTPException as exc:
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜任务集 HTTPException 回滚失败",
user_id=user_id,
module=MODULE,
detail={"task_set_id": task_set_id},
original_exc=exc,
)
raise
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜任务集主事务回滚失败",
user_id=user_id,
module=MODULE,
detail={"task_set_id": task_set_id},
original_exc=exc,
)
_log_api_exception_from_locals(exc, locals(), f"删除拆镜任务集失败: {exc}")
log_upload_resource_exception(
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_FAILED.value,
message=f"删除拆镜任务集失败: {exc}",
user_id=user_id,
module=MODULE,
detail={"task_set_id": task_set_id},
exc=exc,
)
raise HTTPException(status_code=500, detail=f"删除拆镜任务集失败: {exc}")
if pending_ids:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids)
await db.commit()
except Exception as exc: # noqa: BLE001
await safe_rollback_with_log(
db,
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_ROLLBACK_FAILED.value,
message="删除拆镜任务集 cleanup 事务回滚失败",
user_id=user_id,
module=MODULE,
detail={"task_set_id": task_set_id, "pending_ids": pending_ids},
original_exc=exc,
)
log_upload_resource_exception(
event_type=UploadResourceEventEnum.SHOT_TASK_SET_DELETE_CLEANUP_FAILED.value,
message=f"删除拆镜任务集后清理真实文件失败: {exc}",
user_id=user_id,
resource_ids=pending_ids,
module=MODULE,
detail={"task_set_id": task_set_id},
exc=exc,
)
return out