拆镜复刻片段重试切片API | 真人/虚拟素材库上传API
This commit is contained in:
@@ -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="上传真人图片素材并写入 UploadResource,module=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="上传真人视频素材并写入 UploadResource,module=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="上传虚拟图片素材并写入 UploadResource,module=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="上传虚拟视频素材并写入 UploadResource,module=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,
|
||||
|
||||
@@ -178,6 +178,15 @@ class PrivatePortraitEventType(str, Enum):
|
||||
ASSET_CREATE_START = "ASSET_CREATE_START"
|
||||
ASSET_CREATE_SUCCESS = "ASSET_CREATE_SUCCESS"
|
||||
ASSET_CREATE_FAILED = "ASSET_CREATE_FAILED"
|
||||
ASSET_UPLOAD_START = "ASSET_UPLOAD_START"
|
||||
ASSET_UPLOAD_SUCCESS = "ASSET_UPLOAD_SUCCESS"
|
||||
ASSET_UPLOAD_FAILED = "ASSET_UPLOAD_FAILED"
|
||||
ASSET_UPLOAD_BIND_START = "ASSET_UPLOAD_BIND_START"
|
||||
ASSET_UPLOAD_BIND_SUCCESS = "ASSET_UPLOAD_BIND_SUCCESS"
|
||||
ASSET_UPLOAD_BIND_FAILED = "ASSET_UPLOAD_BIND_FAILED"
|
||||
ASSET_UPLOAD_RELEASE_START = "ASSET_UPLOAD_RELEASE_START"
|
||||
ASSET_UPLOAD_RELEASE_SUCCESS = "ASSET_UPLOAD_RELEASE_SUCCESS"
|
||||
ASSET_UPLOAD_RELEASE_FAILED = "ASSET_UPLOAD_RELEASE_FAILED"
|
||||
|
||||
ASSET_SYNC_START = "ASSET_SYNC_START"
|
||||
ASSET_SYNC_SUCCESS = "ASSET_SYNC_SUCCESS"
|
||||
|
||||
@@ -133,6 +133,10 @@ class ShotReplicateLogEventEnum(StrEnum):
|
||||
SPLIT_BY_AI_SUBMITTED = "SHOT_SPLIT_BY_AI_SUBMITTED"
|
||||
SPLIT_CUSTOM_SUBMITTED = "SHOT_SPLIT_CUSTOM_SUBMITTED"
|
||||
SEGMENT_DELETED = "SHOT_SEGMENT_DELETED"
|
||||
SEGMENT_SPLIT_RETRY_RECEIVED = "SHOT_SEGMENT_SPLIT_RETRY_RECEIVED"
|
||||
SEGMENT_SPLIT_RETRY_SUBMITTED = "SHOT_SEGMENT_SPLIT_RETRY_SUBMITTED"
|
||||
SEGMENT_SPLIT_RETRY_REJECTED = "SHOT_SEGMENT_SPLIT_RETRY_REJECTED"
|
||||
SEGMENT_SPLIT_RETRY_DISPATCH_FAILED = "SHOT_SEGMENT_SPLIT_RETRY_DISPATCH_FAILED"
|
||||
|
||||
|
||||
class ShotReplicateRemoteActionEnum(StrEnum):
|
||||
|
||||
@@ -11,6 +11,8 @@ class UploadResourceModuleEnum(StrEnum):
|
||||
HOME_MATERIAL = "home_material"
|
||||
HOT_OPENING_REPLICATE = "hot_opening_replicate"
|
||||
SHOT_REPLICATE = "shot_replicate"
|
||||
PRIVATE_PORTRAIT_REAL = "private_portrait_real"
|
||||
PRIVATE_PORTRAIT_VIRTUAL = "private_portrait_virtual"
|
||||
|
||||
|
||||
class UploadResourceTypeEnum(StrEnum):
|
||||
@@ -83,6 +85,7 @@ class UploadResourceSourceModelEnum(StrEnum):
|
||||
HOME_MATERIAL_ASSET = "HomeMaterialAsset"
|
||||
SYSTEM_CONFIG = "SystemConfig"
|
||||
OPEN_TYPE = "OpenType"
|
||||
PRIVATE_PORTRAIT_ASSET = "PrivatePortraitAsset"
|
||||
|
||||
|
||||
class UploadResourceEventEnum(StrEnum):
|
||||
@@ -112,6 +115,7 @@ class UploadResourceEventEnum(StrEnum):
|
||||
BIND_SUCCESS = "bind_success"
|
||||
BIND_CONFLICT = "bind_conflict"
|
||||
BIND_SKIPPED = "bind_skipped"
|
||||
BIND_FAILED = "bind_failed"
|
||||
|
||||
BACKFILL_START = "backfill_start"
|
||||
BACKFILL_FILE_MATCHED = "backfill_file_matched"
|
||||
@@ -166,6 +170,8 @@ UPLOAD_RESOURCE_MODULE_LABELS: dict[str, str] = {
|
||||
UploadResourceModuleEnum.HOME_MATERIAL.value: "首页素材",
|
||||
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value: "爆款开头复刻",
|
||||
UploadResourceModuleEnum.SHOT_REPLICATE.value: "拆镜复刻",
|
||||
UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value: "私域真人素材",
|
||||
UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value: "私域虚拟素材",
|
||||
}
|
||||
|
||||
UPLOAD_RESOURCE_TYPE_LABELS: dict[str, str] = {
|
||||
|
||||
@@ -241,12 +241,27 @@ class PrivatePortraitAssetGroupOut(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PrivatePortraitUploadOut(BaseModel):
|
||||
url: str = Field(..., description="上传后的本地资源 URL,可直接用于创建私域素材")
|
||||
filename: str = Field(..., description="原始文件名或安全文件名")
|
||||
type: str = Field(..., description="资源类型:image/video")
|
||||
module: str = Field(..., description="上传模块:private_portrait_real/private_portrait_virtual")
|
||||
resource_id: str = Field(..., description="UploadResource.id。创建素材时必须作为 upload_resource_id 传回后端绑定素材")
|
||||
file_size_bytes: int = Field(0, description="文件大小,单位字节")
|
||||
duration_seconds: float | None = Field(None, description="视频素材秒数,图片为空")
|
||||
|
||||
|
||||
class PrivatePortraitAssetCreate(BaseModel):
|
||||
url: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="已上传到本系统且可公网访问的素材 URL。支持图片/视频,后端会转换为公网地址后调用火山 CreateAsset。",
|
||||
)
|
||||
upload_resource_id: str | None = Field(
|
||||
None,
|
||||
max_length=32,
|
||||
description="可选但新客户端必须传:真人/虚拟专用上传接口返回的 UploadResource.id。后端 CreateAsset 成功后会绑定到 PrivatePortraitAsset。",
|
||||
)
|
||||
asset_type: str = Field(
|
||||
default=PrivatePortraitAssetType.IMAGE.value,
|
||||
description="素材类型枚举:Image=图片,Video=视频,Audio=音频。当前业务仅开放 Image / Video,Audio 会被拒绝。",
|
||||
@@ -273,10 +288,10 @@ class PrivatePortraitAssetCreate(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_video_duration(self) -> "PrivatePortraitAssetCreate":
|
||||
if self.asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
# 允许新客户端只传 upload_resource_id,服务层会优先从 UploadResource.duration_seconds 回填秒数。
|
||||
# 如果前端已传 video_duration,则这里先做范围校验,避免无效视频进入远端入库。
|
||||
if self.asset_type == PrivatePortraitAssetType.VIDEO.value and self.video_duration is not None:
|
||||
duration = self.video_duration
|
||||
if duration is None:
|
||||
raise ValueError("Video 素材必须提供 video_duration")
|
||||
if duration < PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS:
|
||||
raise ValueError(f"视频素材最短不能少于 {PRIVATE_PORTRAIT_VIDEO_MIN_DURATION_SECONDS} 秒")
|
||||
if duration > PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS:
|
||||
|
||||
@@ -740,6 +740,27 @@ class ShotReanalyzeOut(BaseModel):
|
||||
analysis_status: str = Field(..., description="重置后的分析状态")
|
||||
celery_task_name: str = Field(..., description="已投递或待投递的 Celery 任务名")
|
||||
|
||||
class ShotSplitRetryRequest(BaseModel):
|
||||
force: bool = Field(False, description="是否强制重置;当前只用于兼容入参,已完成切片仍会拒绝重切,避免旧文件覆盖和资源释放复杂化")
|
||||
reason: str | None = Field(None, max_length=200, description="切片失败重试原因,会写入模块日志")
|
||||
|
||||
@field_validator("reason", mode="before")
|
||||
@classmethod
|
||||
def _strip_reason(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip()
|
||||
return value or None
|
||||
|
||||
|
||||
class ShotSegmentSplitRetryOut(BaseModel):
|
||||
message: str = Field(..., description="操作结果提示")
|
||||
task_set_id: str = Field(..., description="拆镜总任务集ID")
|
||||
segment_id: str = Field(..., description="拆镜片段ID")
|
||||
split_status: str = Field(..., description="重置后的切片状态")
|
||||
celery_task_name: str = Field(..., description="已投递或待投递的 Celery 任务名")
|
||||
|
||||
|
||||
class ShotSplitByAIOut(BaseModel):
|
||||
task_set_id: str = Field(..., description="拆镜总任务集ID")
|
||||
status: str = Field(..., description="总任务状态:pending_analysis/analyzing/analysis_completed/analysis_failed/splitting/split_completed/partial_failed/failed/deleted")
|
||||
|
||||
@@ -65,7 +65,7 @@ class UploadResourceHistoryItemOut(BaseModel):
|
||||
history_source: Literal["upload_resource"] = Field("upload_resource", description="素材云历史来源固定为 upload_resource")
|
||||
history_source_label: str = Field("历史上传素材", description="素材云历史来源中文名称")
|
||||
|
||||
module: str = Field(..., description="上传资源所属模块:common=普通上传,hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻")
|
||||
module: str = Field(..., description="上传资源所属模块:common=普通上传,hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻,private_portrait_real=私域真人素材,private_portrait_virtual=私域虚拟素材")
|
||||
module_label: str = Field(..., description="上传资源所属模块中文名称")
|
||||
resource_type: Literal["image", "video", "audio"] = Field(..., description="资源类型:image=图片,video=视频,audio=音频")
|
||||
resource_type_label: str = Field(..., description="资源类型中文名称")
|
||||
@@ -182,4 +182,6 @@ UPLOAD_RESOURCE_HISTORY_ALLOWED_MODULES = {
|
||||
UploadResourceModuleEnum.COMMON.value,
|
||||
UploadResourceModuleEnum.HOT_OPENING_REPLICATE.value,
|
||||
UploadResourceModuleEnum.SHOT_REPLICATE.value,
|
||||
UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value,
|
||||
UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -31,11 +31,22 @@ from app.enums.private_portrait import (
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
PrivatePortraitValidateSessionStatus,
|
||||
)
|
||||
from app.enums.upload_resource import (
|
||||
UploadResourceBindStatusEnum,
|
||||
UploadResourceDeletePolicyEnum,
|
||||
UploadResourceModuleEnum,
|
||||
UploadResourceSourceModelEnum,
|
||||
UploadResourceTypeEnum,
|
||||
)
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject, PrivatePortraitValidateSession
|
||||
from app.models.upload_resource import UploadResource
|
||||
from app.schemas.private_portrait import PrivatePortraitAssetCreate, PrivatePortraitAssetOut, PrivatePortraitSelectableAssetOut, PrivatePortraitValidateSessionOut
|
||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||
from app.services.private_portrait.ark_client import ArkPrivateAssetClient
|
||||
from app.services.private_portrait.project_service import get_user_project, refresh_project_counters
|
||||
from app.services.private_portrait.upload_service import private_portrait_upload_module
|
||||
from app.services.upload_resource import bind_upload_resources, release_upload_resources_by_source
|
||||
from app.services.upload_resource.path_resolver import upload_url_to_storage_path
|
||||
from app.services.private_portrait.quota_service import (
|
||||
count_user_counting_assets,
|
||||
ensure_private_portrait_asset_quota_available,
|
||||
@@ -133,6 +144,78 @@ def _assert_private_asset_video_duration(payload: PrivatePortraitAssetCreate) ->
|
||||
raise HTTPException(status_code=400, detail=f"视频素材最长不能超过 {PRIVATE_PORTRAIT_VIDEO_MAX_DURATION_SECONDS} 秒")
|
||||
|
||||
|
||||
def _resource_type_for_asset_type(asset_type: str) -> str:
|
||||
if asset_type == PrivatePortraitAssetType.VIDEO.value:
|
||||
return UploadResourceTypeEnum.VIDEO.value
|
||||
return UploadResourceTypeEnum.IMAGE.value
|
||||
|
||||
|
||||
def _safe_set_payload_attr(payload: PrivatePortraitAssetCreate, name: str, value: Any) -> None:
|
||||
if value is None:
|
||||
return
|
||||
try:
|
||||
setattr(payload, name, value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _resolve_upload_resource_for_asset(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str,
|
||||
payload: PrivatePortraitAssetCreate,
|
||||
module: str,
|
||||
) -> UploadResource | None:
|
||||
"""定位并校验待绑定的 UploadResource。
|
||||
|
||||
新客户端必须传 upload_resource_id;旧客户端没传时按 url 反查 storage_path 兼容。
|
||||
不通过 relationship 懒加载,全部按 ID/路径批查,避免 commit 后 ORM 失效风险。
|
||||
"""
|
||||
resource_id = str(payload.upload_resource_id or "").strip() or None
|
||||
storage_path = upload_url_to_storage_path(payload.url)
|
||||
if not resource_id and not storage_path:
|
||||
return None
|
||||
|
||||
filters = [
|
||||
UploadResource.user_id == user_id,
|
||||
UploadResource.deleted_at.is_(None),
|
||||
]
|
||||
if resource_id and storage_path:
|
||||
filters.append(or_(UploadResource.id == resource_id, UploadResource.storage_path == storage_path))
|
||||
elif resource_id:
|
||||
filters.append(UploadResource.id == resource_id)
|
||||
else:
|
||||
filters.append(UploadResource.storage_path == storage_path)
|
||||
|
||||
result = await db.execute(select(UploadResource).where(*filters).with_for_update().limit(1))
|
||||
resource = result.scalar_one_or_none()
|
||||
if not resource:
|
||||
if resource_id:
|
||||
raise HTTPException(status_code=404, detail="上传资源不存在或不属于当前用户")
|
||||
return None
|
||||
|
||||
if resource.bind_status != UploadResourceBindStatusEnum.PENDING.value or resource.source_id or resource.source_model:
|
||||
raise HTTPException(status_code=409, detail="上传资源已绑定其他素材,不能重复使用")
|
||||
if resource.delete_policy != UploadResourceDeletePolicyEnum.USER_DELETABLE.value:
|
||||
raise HTTPException(status_code=409, detail="上传资源当前不允许绑定私域素材")
|
||||
|
||||
expected_type = _resource_type_for_asset_type(payload.asset_type)
|
||||
if resource.resource_type != expected_type:
|
||||
raise HTTPException(status_code=400, detail="上传资源类型与素材类型不一致")
|
||||
|
||||
if resource.module not in {module, UploadResourceModuleEnum.COMMON.value}:
|
||||
raise HTTPException(status_code=409, detail="上传资源所属模块不匹配,请重新上传素材")
|
||||
|
||||
if payload.asset_type == PrivatePortraitAssetType.VIDEO.value and payload.video_duration is None and resource.duration_seconds is not None:
|
||||
_safe_set_payload_attr(payload, "video_duration", float(resource.duration_seconds))
|
||||
if payload.file_size is None and resource.file_size_bytes is not None:
|
||||
_safe_set_payload_attr(payload, "file_size", int(resource.file_size_bytes or 0))
|
||||
if payload.mime_type is None and resource.mime_type:
|
||||
_safe_set_payload_attr(payload, "mime_type", resource.mime_type)
|
||||
|
||||
return resource
|
||||
|
||||
|
||||
def validate_session_to_out(session: PrivatePortraitValidateSession, *, include_user: bool = False) -> PrivatePortraitValidateSessionOut:
|
||||
return PrivatePortraitValidateSessionOut(
|
||||
id=session.id,
|
||||
@@ -417,11 +500,14 @@ async def create_asset(
|
||||
library_type: str | None = None,
|
||||
) -> PrivatePortraitAsset:
|
||||
_assert_enabled_asset_type(payload.asset_type)
|
||||
_assert_private_asset_video_duration(payload)
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
if project.status != PrivatePortraitProjectStatus.ACTIVE.value:
|
||||
raise HTTPException(status_code=400, detail="项目未激活,不能上传素材")
|
||||
|
||||
module = private_portrait_upload_module(project.library_type)
|
||||
upload_resource = await _resolve_upload_resource_for_asset(db, user_id=user_id, payload=payload, module=module)
|
||||
_assert_private_asset_video_duration(payload)
|
||||
|
||||
limit, current_count = await ensure_private_portrait_asset_quota_available(db, user_id=user_id, project_id=project_id, library_type=project.library_type, asset_type=payload.asset_type)
|
||||
group = await get_project_active_group(db, user_id=user_id, project_id=project.id, library_type=project.library_type)
|
||||
public_url = _public_url(payload.url)
|
||||
@@ -445,7 +531,7 @@ async def create_asset(
|
||||
)
|
||||
db.add(asset)
|
||||
await db.flush()
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"asset_limit": limit, "used_asset_count": current_count, "library_type": project.library_type, "asset_type": payload.asset_type, "remote_project_name": project.remote_project_name})
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, detail={"asset_limit": limit, "used_asset_count": current_count, "library_type": project.library_type, "asset_type": payload.asset_type, "remote_project_name": project.remote_project_name, "upload_resource_id": upload_resource.id if upload_resource else payload.upload_resource_id})
|
||||
try:
|
||||
remote_resp = await ArkPrivateAssetClient().create_asset(project_name=project.remote_project_name, group_id=group.remote_group_id, url=public_url, asset_type=payload.asset_type, name=payload.name)
|
||||
remote_asset_id = remote_resp.get("Id") or remote_resp.get("AssetId") or remote_resp.get("assetId")
|
||||
@@ -456,6 +542,28 @@ async def create_asset(
|
||||
asset.status = PrivatePortraitAssetStatus.PROCESSING.value
|
||||
asset.next_poll_at = now + timedelta(seconds=_poll_interval_seconds(asset.asset_type))
|
||||
asset.raw_response_json = _json(remote_resp)
|
||||
bind_stats = await bind_upload_resources(
|
||||
db,
|
||||
user_id=user_id,
|
||||
module=module,
|
||||
source_model=UploadResourceSourceModelEnum.PRIVATE_PORTRAIT_ASSET.value,
|
||||
source_id=asset.id,
|
||||
resource_ids=[payload.upload_resource_id, upload_resource.id if upload_resource else None],
|
||||
urls=[payload.url],
|
||||
allow_common_migrate=True,
|
||||
)
|
||||
if bind_stats.get("bound"):
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_BIND_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
group_id=group.id,
|
||||
asset_id=asset.id,
|
||||
detail={"module": module, "upload_resource_id": payload.upload_resource_id, "bind_stats": bind_stats},
|
||||
)
|
||||
await refresh_project_counters(db, [project.id])
|
||||
await db.flush()
|
||||
await db.refresh(asset)
|
||||
@@ -465,7 +573,7 @@ async def create_asset(
|
||||
asset.status = PrivatePortraitAssetStatus.FAILED.value
|
||||
asset.error_message = _exception_message(exc)
|
||||
await db.flush()
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, exc=exc)
|
||||
log_operation_error(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_CREATE_FAILED.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=project.id, group_id=group.id, asset_id=asset.id, exc=exc, detail={"upload_resource_id": payload.upload_resource_id, "module": module})
|
||||
raise
|
||||
|
||||
|
||||
@@ -598,10 +706,19 @@ async def soft_delete_asset(db: AsyncSession, *, user_id: str, asset_id: str, li
|
||||
asset.deleted_at = now
|
||||
asset.status = PrivatePortraitAssetStatus.LOCAL_DELETED.value
|
||||
asset.remote_delete_status = PrivatePortraitRemoteDeleteStatus.PENDING.value
|
||||
module = private_portrait_upload_module(asset.library_type)
|
||||
upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.PRIVATE_PORTRAIT_ASSET.value,
|
||||
source_ids=[asset.id],
|
||||
module=module,
|
||||
)
|
||||
setattr(asset, "_pending_upload_resource_ids", list(upload_release.get("released_resource_ids") or []))
|
||||
setattr(asset, "_upload_resource_release", upload_release)
|
||||
await refresh_project_counters(db, [asset.project_id])
|
||||
await db.flush()
|
||||
await db.refresh(asset)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type})
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_LOCAL.value, event_status=PrivatePortraitEventStatus.SUCCESS.value, source=PrivatePortraitEventSource.API.value, user_id=user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type, "upload_resource_release": {k: v for k, v in getattr(asset, "_upload_resource_release", {}).items() if k != "released_resource_ids"}, "pending_upload_resource_count": len(getattr(asset, "_pending_upload_resource_ids", []))})
|
||||
return asset
|
||||
|
||||
|
||||
@@ -617,7 +734,7 @@ async def delete_asset_remote(db: AsyncSession, *, asset_id: str) -> None:
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_SUCCESS.value, event_status=PrivatePortraitEventStatus.SKIPPED.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, message="远程删除跳过:素材没有 remote_asset_id")
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type})
|
||||
log_operation_event(domain=DOMAIN, event_type=PrivatePortraitEventType.ASSET_DELETE_REMOTE_START.value, event_status=PrivatePortraitEventStatus.PENDING.value, source=PrivatePortraitEventSource.CELERY.value, user_id=asset.user_id, project_id=asset.project_id, asset_id=asset.id, detail={"remote_asset_id": asset.remote_asset_id, "remote_project_name": asset.remote_project_name, "library_type": asset.library_type, "asset_type": asset.asset_type, "upload_resource_release": {k: v for k, v in getattr(asset, "_upload_resource_release", {}).items() if k != "released_resource_ids"}, "pending_upload_resource_count": len(getattr(asset, "_pending_upload_resource_ids", []))})
|
||||
try:
|
||||
await ArkPrivateAssetClient(for_celery=True).delete_asset(project_name=asset.remote_project_name, asset_id=asset.remote_asset_id)
|
||||
asset.status = PrivatePortraitAssetStatus.REMOTE_DELETED.value
|
||||
|
||||
@@ -19,9 +19,12 @@ from app.enums.private_portrait import (
|
||||
PrivatePortraitProjectStatus,
|
||||
PrivatePortraitRemoteDeleteStatus,
|
||||
)
|
||||
from app.enums.upload_resource import UploadResourceSourceModelEnum
|
||||
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitAssetGroup, PrivatePortraitProject
|
||||
from app.schemas.private_portrait import PrivatePortraitProjectCreate, PrivatePortraitProjectOut, PrivatePortraitProjectUpdate
|
||||
from app.services.operation_log_service import log_operation_event
|
||||
from app.services.private_portrait.upload_service import private_portrait_upload_module
|
||||
from app.services.upload_resource import release_upload_resources_by_source
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
@@ -264,8 +267,29 @@ async def soft_delete_project(
|
||||
) -> PrivatePortraitProject:
|
||||
project = await get_user_project(db, user_id=user_id, project_id=project_id, library_type=library_type)
|
||||
now = datetime.now(timezone.utc)
|
||||
asset_id_rows = await db.execute(
|
||||
select(PrivatePortraitAsset.id)
|
||||
.where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None))
|
||||
)
|
||||
asset_ids = [row[0] for row in asset_id_rows.all()]
|
||||
module = private_portrait_upload_module(project.library_type)
|
||||
|
||||
project.deleted_at = now
|
||||
project.status = PrivatePortraitProjectStatus.DELETED.value
|
||||
upload_release = await release_upload_resources_by_source(
|
||||
db,
|
||||
source_model=UploadResourceSourceModelEnum.PRIVATE_PORTRAIT_ASSET.value,
|
||||
source_ids=asset_ids,
|
||||
module=module,
|
||||
) if asset_ids else {
|
||||
"matched": 0,
|
||||
"released": 0,
|
||||
"already_deleted": 0,
|
||||
"released_resource_ids": [],
|
||||
}
|
||||
setattr(project, "_pending_upload_resource_ids", list(upload_release.get("released_resource_ids") or []))
|
||||
setattr(project, "_upload_resource_release", upload_release)
|
||||
|
||||
await db.execute(
|
||||
update(PrivatePortraitAsset)
|
||||
.where(PrivatePortraitAsset.project_id == project_id, PrivatePortraitAsset.deleted_at.is_(None))
|
||||
@@ -285,6 +309,6 @@ async def soft_delete_project(
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
message="本地软删私域人像素材项目",
|
||||
detail={"library_type": project.library_type, "remote_project_name": project.remote_project_name},
|
||||
detail={"library_type": project.library_type, "remote_project_name": project.remote_project_name, "asset_count": len(asset_ids), "upload_resource_release": {k: v for k, v in upload_release.items() if k != "released_resource_ids"}, "pending_upload_resource_count": len(getattr(project, "_pending_upload_resource_ids", []))},
|
||||
)
|
||||
return project
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.enums.private_portrait import (
|
||||
PrivatePortraitEventSource,
|
||||
PrivatePortraitEventStatus,
|
||||
PrivatePortraitEventType,
|
||||
PrivatePortraitLibraryType,
|
||||
)
|
||||
from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceTypeEnum
|
||||
from app.models.user import User
|
||||
from app.schemas.private_portrait import PrivatePortraitUploadOut
|
||||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||||
from app.services.upload_resource import upload_reference_file
|
||||
|
||||
DOMAIN = "private_portrait"
|
||||
PRIVATE_PORTRAIT_IMAGE_MAX_BYTES = 10 * 1024 * 1024
|
||||
PRIVATE_PORTRAIT_VIDEO_MAX_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def private_portrait_upload_module(library_type: str) -> str:
|
||||
if library_type == PrivatePortraitLibraryType.REAL_PERSON.value:
|
||||
return UploadResourceModuleEnum.PRIVATE_PORTRAIT_REAL.value
|
||||
if library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
|
||||
return UploadResourceModuleEnum.PRIVATE_PORTRAIT_VIRTUAL.value
|
||||
raise HTTPException(status_code=400, detail="素材库类型不支持")
|
||||
|
||||
|
||||
async def upload_private_portrait_asset_file(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
file: UploadFile,
|
||||
current_user: User,
|
||||
library_type: str,
|
||||
resource_type: str,
|
||||
duration_seconds: float | None = None,
|
||||
) -> PrivatePortraitUploadOut:
|
||||
if resource_type not in {UploadResourceTypeEnum.IMAGE.value, UploadResourceTypeEnum.VIDEO.value}:
|
||||
raise HTTPException(status_code=400, detail="私域素材上传仅支持图片或视频")
|
||||
|
||||
module = private_portrait_upload_module(library_type)
|
||||
max_bytes = PRIVATE_PORTRAIT_VIDEO_MAX_BYTES if resource_type == UploadResourceTypeEnum.VIDEO.value else PRIVATE_PORTRAIT_IMAGE_MAX_BYTES
|
||||
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_START.value,
|
||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=current_user.id,
|
||||
detail={
|
||||
"module": module,
|
||||
"library_type": library_type,
|
||||
"resource_type": resource_type,
|
||||
"filename": file.filename,
|
||||
"content_type": file.content_type,
|
||||
"duration_seconds": duration_seconds,
|
||||
},
|
||||
)
|
||||
try:
|
||||
result = await upload_reference_file(
|
||||
db,
|
||||
file=file,
|
||||
current_user=current_user,
|
||||
module=module,
|
||||
resource_type=resource_type,
|
||||
gen_type="private_portrait",
|
||||
duration_seconds=duration_seconds,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
out = PrivatePortraitUploadOut(
|
||||
url=result.url,
|
||||
filename=result.filename,
|
||||
type=result.resource_type,
|
||||
module=result.module,
|
||||
resource_id=result.resource_id,
|
||||
file_size_bytes=result.file_size_bytes,
|
||||
duration_seconds=result.duration_seconds,
|
||||
)
|
||||
log_operation_event(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_SUCCESS.value,
|
||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=current_user.id,
|
||||
detail={
|
||||
"module": module,
|
||||
"library_type": library_type,
|
||||
"resource_type": resource_type,
|
||||
"resource_id": result.resource_id,
|
||||
"url": result.url,
|
||||
"file_size_bytes": result.file_size_bytes,
|
||||
"duration_seconds": result.duration_seconds,
|
||||
},
|
||||
)
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_operation_error(
|
||||
domain=DOMAIN,
|
||||
event_type=PrivatePortraitEventType.ASSET_UPLOAD_FAILED.value,
|
||||
source=PrivatePortraitEventSource.API.value,
|
||||
user_id=current_user.id,
|
||||
exc=exc,
|
||||
detail={
|
||||
"module": module,
|
||||
"library_type": library_type,
|
||||
"resource_type": resource_type,
|
||||
"filename": file.filename,
|
||||
},
|
||||
)
|
||||
raise
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -29,6 +30,7 @@ from app.schemas.shot_replicate import (
|
||||
ShotSegmentDeleteOut,
|
||||
ShotSegmentDetailOut,
|
||||
ShotSegmentListOut,
|
||||
ShotSegmentSplitRetryOut,
|
||||
ShotReanalyzeOut,
|
||||
ShotSegmentOut,
|
||||
ShotSplitByAIOut,
|
||||
@@ -500,6 +502,97 @@ async def create_segments_by_ai(
|
||||
)
|
||||
|
||||
|
||||
async def prepare_retry_split_segment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
current_user: User,
|
||||
segment_id: str,
|
||||
force: bool = False,
|
||||
reason: str | None = None,
|
||||
) -> ShotSegmentSplitRetryOut:
|
||||
"""重置失败切片片段,commit 成功后由 API 投递现有 split_one_segment 任务。"""
|
||||
segment = await get_segment_for_user(db, segment_id=segment_id, user=current_user, for_update=True)
|
||||
task_set = await get_task_set_for_user(db, task_set_id=segment.task_set_id, user=current_user, for_update=True)
|
||||
from_split_status = segment.split_status
|
||||
allowed = {ShotSplitStatusEnum.FAILED.value, ShotSplitStatusEnum.RETRY_WAITING.value}
|
||||
|
||||
reject_reason: str | None = None
|
||||
if task_set.deleted_at is not None or task_set.status == ShotTaskSetStatusEnum.DELETED.value:
|
||||
reject_reason = "拆镜总任务集已删除,不能重试切片"
|
||||
elif segment.deleted_at is not None:
|
||||
reject_reason = "拆镜片段已删除,不能重试切片"
|
||||
elif from_split_status == ShotSplitStatusEnum.PROCESSING.value:
|
||||
reject_reason = "拆镜片段正在切片处理中,不能重复投递"
|
||||
elif from_split_status == ShotSplitStatusEnum.COMPLETED.value:
|
||||
reject_reason = "拆镜片段已切片完成,不支持重切,避免旧切片资源覆盖"
|
||||
elif from_split_status not in allowed and not force:
|
||||
reject_reason = "仅允许失败或等待重试的切片片段重新投递"
|
||||
elif not task_set.video_path:
|
||||
reject_reason = "原视频本地路径为空,不能重试切片"
|
||||
elif not Path(str(task_set.video_path)).exists():
|
||||
reject_reason = "原视频本地文件不存在,不能重试切片"
|
||||
|
||||
if reject_reason:
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_REJECTED.value,
|
||||
project_id=task_set.id,
|
||||
step_id=segment.id,
|
||||
status="rejected",
|
||||
message=reject_reason,
|
||||
detail={
|
||||
"segment_id": segment.id,
|
||||
"task_set_id": task_set.id,
|
||||
"from_split_status": from_split_status,
|
||||
"force": force,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=reject_reason)
|
||||
|
||||
now = _now()
|
||||
segment.split_status = ShotSplitStatusEnum.PENDING.value
|
||||
segment.split_enqueued_at = now
|
||||
segment.split_started_at = None
|
||||
segment.split_lease_until = None
|
||||
segment.split_next_retry_at = None
|
||||
segment.split_retry_count = 0
|
||||
segment.split_last_error = None
|
||||
segment.split_celery_task_id = f"shot-split:{uuid.uuid4().hex}"
|
||||
task_set.split_error_message = None
|
||||
|
||||
await refresh_task_set_split_summary(db, task_set.id)
|
||||
await db.flush()
|
||||
|
||||
log_module_event_file(
|
||||
module=MODULE,
|
||||
event_type=ShotReplicateLogEventEnum.SEGMENT_SPLIT_RETRY_RECEIVED.value,
|
||||
project_id=task_set.id,
|
||||
step_id=segment.id,
|
||||
status="pending",
|
||||
message="拆镜片段切片失败重试已重置,等待投递 Celery",
|
||||
detail={
|
||||
"segment_id": segment.id,
|
||||
"task_set_id": task_set.id,
|
||||
"from_split_status": from_split_status,
|
||||
"to_split_status": segment.split_status,
|
||||
"force": force,
|
||||
"reason": reason,
|
||||
"source_path": task_set.video_path,
|
||||
"celery_task_name": "shot_replicate.split_one_segment",
|
||||
"queue": "gen_result_download",
|
||||
},
|
||||
)
|
||||
|
||||
return ShotSegmentSplitRetryOut(
|
||||
message="切片重试已提交,正在重新切割视频片段",
|
||||
task_set_id=task_set.id,
|
||||
segment_id=segment.id,
|
||||
split_status=segment.split_status,
|
||||
celery_task_name="shot_replicate.split_one_segment",
|
||||
)
|
||||
|
||||
|
||||
async def create_custom_segment(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.enums.upload_resource import UploadResourceModuleEnum, UploadResourceTy
|
||||
COMMON_IMAGE_RE = re.compile(r"^images/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_img_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
COMMON_VIDEO_RE = re.compile(r"^videos/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/video_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
COMMON_AUDIO_RE = re.compile(r"^audios/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/audio_ref_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
MODULE_RE = re.compile(r"^(?P<module>hot_opening_replicate|shot_replicate|private_portrait_real|private_portrait_virtual)/(?P<kind>images|videos)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>video_img|video_ref)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
SHOT_SEGMENT_RE = re.compile(r"^shot_segments/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<segment_id>[^/]+)\.mp4$")
|
||||
LEGACY_GEN_RE = re.compile(r"^(?P<kind>images|videos)/gen_(?P<user_id>[^_]+)_(?P<rand>[0-9a-zA-Z]+)\.(?P<ext>[^/]+)$")
|
||||
ADMIN_UPLOAD_RE = re.compile(r"^admin_uploads/(?P<scene>system_logo|system_pdf|open_type_thumb|common)/(?P<kind>images|videos|audios|files)/(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/(?P<prefix>admin_img|admin_video|admin_audio|admin_pdf|admin_file)_(?P<user_id>[^_]+)_(?P<ymd>\d{8})_(?P<hms>\d{6})_(?P<rand>[0-9a-fA-F]{8})\.(?P<ext>[^/]+)$")
|
||||
|
||||
Reference in New Issue
Block a user