拆镜复刻片段重试切片API | 真人/虚拟素材库上传API

This commit is contained in:
2026-07-09 12:07:28 +08:00
parent c05a52498e
commit 2b996e114e
18 changed files with 737 additions and 25 deletions
+94 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from urllib.parse import urlencode, unquote
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +18,7 @@ from app.enums.private_portrait import (
)
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
from app.models.user import User
from app.enums.upload_resource import UploadResourceTypeEnum
from app.schemas.private_portrait import (
PrivatePortraitAssetCreate,
PrivatePortraitAssetListOut,
@@ -31,6 +32,7 @@ from app.schemas.private_portrait import (
PrivatePortraitProjectOut,
PrivatePortraitProjectUpdate,
PrivatePortraitSelectableAssetListOut,
PrivatePortraitUploadOut,
PrivatePortraitValidateSessionCreate,
PrivatePortraitValidateSessionOut,
build_private_portrait_enum_meta,
@@ -55,6 +57,8 @@ from app.services.private_portrait.project_service import (
refresh_project_counters,
soft_delete_project,
)
from app.services.private_portrait.upload_service import upload_private_portrait_asset_file
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
from app.services.private_portrait.real_person.service import (
create_real_person_asset,
create_real_person_project,
@@ -124,6 +128,62 @@ async def get_private_portrait_enum_meta():
return build_private_portrait_enum_meta()
@router.post(
"/private-portrait/uploads/image",
response_model=PrivatePortraitUploadOut,
summary="上传真人图片素材",
description="上传真人图片素材并写入 UploadResourcemodule=private_portrait_real。创建素材时需回传 resource_id 到 upload_resource_id。",
)
async def upload_private_portrait_image(file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
try:
out = await upload_private_portrait_asset_file(
db,
file=file,
current_user=current_user,
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
resource_type=UploadResourceTypeEnum.IMAGE.value,
)
await db.commit()
return out
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"上传真人图片素材失败: {exc}")
@router.post(
"/private-portrait/uploads/video",
response_model=PrivatePortraitUploadOut,
summary="上传真人视频素材",
description="上传真人视频素材并写入 UploadResourcemodule=private_portrait_real。创建素材时需回传 resource_id 到 upload_resource_id。",
)
async def upload_private_portrait_video(
file: UploadFile = File(...),
duration_seconds: float | None = Query(None, description="客户端解析的视频秒数,服务端会写入 UploadResource 并在创建素材时回填"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
out = await upload_private_portrait_asset_file(
db,
file=file,
current_user=current_user,
library_type=PrivatePortraitLibraryType.REAL_PERSON.value,
resource_type=UploadResourceTypeEnum.VIDEO.value,
duration_seconds=duration_seconds,
)
await db.commit()
return out
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"上传真人视频素材失败: {exc}")
@router.post(
"/private-portrait/projects",
response_model=PrivatePortraitProjectCreateWithValidateOut,
@@ -196,6 +256,7 @@ async def update_private_portrait_project(project_id: str, payload: PrivatePortr
async def delete_private_portrait_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
project_id_snapshot = project.id
pending_upload_resource_ids = list(getattr(project, "_pending_upload_resource_ids", []) or [])
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote
@@ -204,6 +265,21 @@ async def delete_private_portrait_project(project_id: str, current_user: User =
_log_task_dispatch_success(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot)
except Exception as exc:
_log_task_dispatch_failed(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot, exc=exc)
if pending_upload_resource_ids:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids)
await db.commit()
except Exception as exc:
await db.rollback()
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value,
source=PrivatePortraitEventSource.API.value,
user_id=current_user.id,
project_id=project_id_snapshot,
exc=exc,
detail={"resource_ids": pending_upload_resource_ids},
)
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@@ -337,6 +413,7 @@ async def delete_private_portrait_asset(asset_id: str, current_user: User = Depe
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.REAL_PERSON.value)
asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id
pending_upload_resource_ids = list(getattr(asset, "_pending_upload_resource_ids", []) or [])
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote
@@ -345,6 +422,22 @@ async def delete_private_portrait_asset(asset_id: str, current_user: User = Depe
_log_task_dispatch_success(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot)
except Exception as exc:
_log_task_dispatch_failed(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc)
if pending_upload_resource_ids:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids)
await db.commit()
except Exception as exc:
await db.rollback()
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value,
source=PrivatePortraitEventSource.API.value,
user_id=current_user.id,
project_id=project_id_snapshot,
asset_id=asset_id_snapshot,
exc=exc,
detail={"resource_ids": pending_upload_resource_ids},
)
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@@ -1,6 +1,6 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -15,6 +15,7 @@ from app.enums.private_portrait import (
)
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
from app.models.user import User
from app.enums.upload_resource import UploadResourceTypeEnum
from app.schemas.private_portrait import (
PrivatePortraitAssetCreate,
PrivatePortraitAssetListOut,
@@ -26,6 +27,7 @@ from app.schemas.private_portrait import (
PrivatePortraitProjectOut,
PrivatePortraitProjectUpdate,
PrivatePortraitSelectableAssetListOut,
PrivatePortraitUploadOut,
PrivatePortraitVirtualProjectCreate,
build_private_portrait_enum_meta,
)
@@ -46,6 +48,8 @@ from app.services.private_portrait.project_service import (
refresh_project_counters,
soft_delete_project,
)
from app.services.private_portrait.upload_service import upload_private_portrait_asset_file
from app.services.upload_resource import cleanup_upload_resource_files_after_commit
from app.services.private_portrait.virtual.service import create_virtual_asset, create_virtual_project, update_virtual_project
router = APIRouter(tags=["私域虚拟人像素材库"])
@@ -103,6 +107,62 @@ async def get_virtual_private_portrait_enum_meta():
return build_private_portrait_enum_meta()
@router.post(
"/private-portrait/virtual/uploads/image",
response_model=PrivatePortraitUploadOut,
summary="上传虚拟图片素材",
description="上传虚拟图片素材并写入 UploadResourcemodule=private_portrait_virtual。创建素材时需回传 resource_id 到 upload_resource_id。",
)
async def upload_private_portrait_virtual_image(file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
try:
out = await upload_private_portrait_asset_file(
db,
file=file,
current_user=current_user,
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
resource_type=UploadResourceTypeEnum.IMAGE.value,
)
await db.commit()
return out
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"上传虚拟图片素材失败: {exc}")
@router.post(
"/private-portrait/virtual/uploads/video",
response_model=PrivatePortraitUploadOut,
summary="上传虚拟视频素材",
description="上传虚拟视频素材并写入 UploadResourcemodule=private_portrait_virtual。创建素材时需回传 resource_id 到 upload_resource_id。",
)
async def upload_private_portrait_virtual_video(
file: UploadFile = File(...),
duration_seconds: float | None = Query(None, description="客户端解析的视频秒数,服务端会写入 UploadResource 并在创建素材时回填"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
out = await upload_private_portrait_asset_file(
db,
file=file,
current_user=current_user,
library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value,
resource_type=UploadResourceTypeEnum.VIDEO.value,
duration_seconds=duration_seconds,
)
await db.commit()
return out
except HTTPException:
await db.rollback()
raise
except Exception as exc:
await db.rollback()
raise HTTPException(status_code=500, detail=f"上传虚拟视频素材失败: {exc}")
@router.post(
"/private-portrait/virtual-projects",
response_model=PrivatePortraitProjectOut,
@@ -170,6 +230,7 @@ async def update_private_portrait_virtual_project(project_id: str, payload: Priv
async def delete_private_portrait_virtual_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await soft_delete_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
project_id_snapshot = project.id
pending_upload_resource_ids = list(getattr(project, "_pending_upload_resource_ids", []) or [])
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote
@@ -178,6 +239,21 @@ async def delete_private_portrait_virtual_project(project_id: str, current_user:
_log_task_dispatch_success(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot)
except Exception as exc:
_log_task_dispatch_failed(task_name="private_portrait.delete_project_remote", user_id=current_user.id, project_id=project_id_snapshot, exc=exc)
if pending_upload_resource_ids:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids)
await db.commit()
except Exception as exc:
await db.rollback()
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value,
source=PrivatePortraitEventSource.API.value,
user_id=current_user.id,
project_id=project_id_snapshot,
exc=exc,
detail={"resource_ids": pending_upload_resource_ids},
)
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@@ -266,6 +342,7 @@ async def delete_private_portrait_virtual_asset(asset_id: str, current_user: Use
asset = await soft_delete_asset(db, user_id=current_user.id, asset_id=asset_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id
pending_upload_resource_ids = list(getattr(asset, "_pending_upload_resource_ids", []) or [])
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote
@@ -274,6 +351,22 @@ async def delete_private_portrait_virtual_asset(asset_id: str, current_user: Use
_log_task_dispatch_success(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot)
except Exception as exc:
_log_task_dispatch_failed(task_name="private_portrait.delete_asset_remote", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc)
if pending_upload_resource_ids:
try:
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_upload_resource_ids)
await db.commit()
except Exception as exc:
await db.rollback()
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.ASSET_UPLOAD_RELEASE_FAILED.value,
source=PrivatePortraitEventSource.API.value,
user_id=current_user.id,
project_id=project_id_snapshot,
asset_id=asset_id_snapshot,
exc=exc,
detail={"resource_ids": pending_upload_resource_ids},
)
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Body, Depends, File, HTTPException, Path, Query,
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.dependencies import get_current_user, get_db
from app.models.user import User
from app.enums.shot_replicate import (
@@ -30,6 +31,8 @@ from app.schemas.shot_replicate import (
ShotReplicateImagePromptUpdateRequest,
ShotReanalyzeOut,
ShotReanalyzeRequest,
ShotSegmentSplitRetryOut,
ShotSplitRetryRequest,
ShotReplicateMaterialUpdateRequest,
ShotReplicateSpecOut,
ShotReplicateTaskDetailOut,
@@ -71,6 +74,7 @@ from app.services.shot_replicate_taskset_service import (
list_task_sets,
prepare_reanalyze_segment,
prepare_reanalyze_task_set,
prepare_retry_split_segment,
segment_detail,
task_set_detail,
)
@@ -685,6 +689,83 @@ async def reanalyze_segment(
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
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,
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": "gen_result_download",
"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,