974 lines
45 KiB
Python
974 lines
45 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from types import SimpleNamespace
|
||
|
||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||
from sqlalchemy import inspect as sa_inspect
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.dependencies import get_current_user, get_db
|
||
from app.models.user import User
|
||
from app.enums.shot_replicate import (
|
||
ModuleCodeEnum,
|
||
ShotAnalysisStatusEnum,
|
||
ShotReplicateLogEventEnum,
|
||
ShotReplicateStepCodeEnum,
|
||
ShotSegmentAnalysisStatusEnum,
|
||
ShotSegmentReplicateStatusEnum,
|
||
ShotSegmentSourceModeEnum,
|
||
ShotSplitStatusEnum,
|
||
ShotTaskSetStatusEnum,
|
||
)
|
||
from app.schemas.shot_replicate import (
|
||
ShotReplicateActionOut,
|
||
ShotReplicateDeleteOut,
|
||
ShotReplicateGenerateImagePromptRequest,
|
||
ShotReplicateGenerateImageRequest,
|
||
ShotReplicateGenerateVideoPromptRequest,
|
||
ShotReplicateGenerateVideoRequest,
|
||
ShotReplicateImagePromptUpdateRequest,
|
||
ShotReanalyzeOut,
|
||
ShotReanalyzeRequest,
|
||
ShotReplicateMaterialUpdateRequest,
|
||
ShotReplicateSpecOut,
|
||
ShotReplicateTaskDetailOut,
|
||
ShotReplicateVideoPromptSchemaUpdateRequest,
|
||
ShotSegmentDeleteOut,
|
||
ShotSegmentDetailOut,
|
||
ShotSegmentListOut,
|
||
ShotSegmentReplicationCreateRequest,
|
||
ShotSplitByAIOut,
|
||
ShotSplitByAIRequest,
|
||
ShotSplitCustomOut,
|
||
ShotSplitCustomRequest,
|
||
ShotTaskSetCreate,
|
||
ShotTaskSetDetailOut,
|
||
ShotTaskSetListOut,
|
||
)
|
||
from app.services.shot_replicate_flow_service import (
|
||
_get_project_for_user,
|
||
create_shot_replicate_project_from_segment,
|
||
delete_shot_replicate_project,
|
||
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,
|
||
get_segment_for_user,
|
||
list_segments,
|
||
list_task_sets,
|
||
prepare_reanalyze_segment,
|
||
prepare_reanalyze_task_set,
|
||
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,
|
||
register_shot_segment_analysis_task,
|
||
register_shot_split_task,
|
||
register_shot_task_set_analysis_task,
|
||
)
|
||
from app.tasks.celery_app import celery_app
|
||
|
||
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(
|
||
"/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 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
|
||
|
||
await register_shot_task_set_analysis_task(task_set_id)
|
||
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", 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
|
||
|
||
await register_shot_task_set_analysis_task(task_set_id)
|
||
analyze_original_video.apply_async(args=[task_set_id], queue="gen_chatapi_create", 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:
|
||
await register_shot_split_task(segment_id, task_set_id=task_set_id)
|
||
split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", 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
|
||
|
||
await register_shot_split_task(segment_id, task_set_id=task_set_id)
|
||
split_one_segment.apply_async(args=[segment_id], queue="gen_result_download", 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
|
||
|
||
await register_shot_segment_analysis_task(segment_id, task_set_id=task_set_id)
|
||
analyze_custom_segment_video.apply_async(args=[segment_id], queue="gen_chatapi_create", 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.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),
|
||
):
|
||
try:
|
||
out = await delete_segment(db, current_user=current_user, segment_id=segment_id)
|
||
await db.commit()
|
||
return out
|
||
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}")
|
||
|
||
|
||
@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),
|
||
):
|
||
try:
|
||
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
||
project = await create_shot_replicate_project_from_segment(db, current_user=current_user, segment=segment, req=req)
|
||
project_id = project.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}")
|
||
|
||
return ShotReplicateActionOut(
|
||
message="已从拆镜片段创建复刻项目,素材视频已锁定",
|
||
project_id=project_id,
|
||
step_id=None,
|
||
detail=await _reload_project_detail(db, current_user, project_id),
|
||
)
|
||
|
||
|
||
@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.id,step_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.id,step_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.id,step_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="gen_chatapi_create", 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.id,step_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], queue="gen_chatapi_create", 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"图片生成任务投递失败: {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.id,step_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="gen_chatapi_create", 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_id;duration、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.id,step_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], queue="gen_chatapi_create", 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"视频生成任务投递失败: {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(
|
||
"/projects/{project_id}",
|
||
response_model=ShotReplicateDeleteOut,
|
||
summary="软删除拆镜复刻项目",
|
||
description="软删除拆镜复刻 ModuleGenerationProject,并联动软删除当前有效步骤和关联的 ChatGenerationTask。",
|
||
)
|
||
async def delete_project(
|
||
project_id: str = Path(..., description="拆镜复刻项目ID,即 module_generation_projects.id"),
|
||
current_user: User = Depends(get_current_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
try:
|
||
out = await delete_shot_replicate_project(db, current_user=current_user, project_id=project_id)
|
||
await db.commit()
|
||
return out
|
||
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}")
|