Files
video-gen/video-gen-api/app/api/v1/private_portrait_virtual.py
T

297 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.enums.private_portrait import (
PrivatePortraitEventSource,
PrivatePortraitEventStatus,
PrivatePortraitEventType,
PrivatePortraitLibraryType,
PrivatePortraitProjectStatus,
PrivatePortraitRemoteDeleteStatus,
)
from app.models.private_portrait import PrivatePortraitAsset, PrivatePortraitProject
from app.models.user import User
from app.schemas.private_portrait import (
PrivatePortraitAssetCreate,
PrivatePortraitAssetListOut,
PrivatePortraitAssetOut,
PrivatePortraitConfigOut,
PrivatePortraitDeleteOut,
PrivatePortraitEnumMetaOut,
PrivatePortraitProjectListOut,
PrivatePortraitProjectOut,
PrivatePortraitProjectUpdate,
PrivatePortraitSelectableAssetListOut,
PrivatePortraitVirtualProjectCreate,
build_private_portrait_enum_meta,
)
from app.services.operation_log_service import log_operation_error, log_operation_event
from app.services.private_portrait.asset_service import (
DOMAIN,
asset_to_out,
get_user_private_portrait_config,
list_assets,
list_selectable_assets,
soft_delete_asset,
sync_asset_status,
)
from app.services.private_portrait.project_service import (
get_user_project,
list_projects,
project_to_out,
refresh_project_counters,
soft_delete_project,
)
from app.services.private_portrait.virtual.service import create_virtual_asset, create_virtual_project, update_virtual_project
router = APIRouter(tags=["私域虚拟人像素材库"])
_VIRTUAL_API_DESCRIPTION = (
"私域虚拟素材库 API。真人和虚拟素材共用用户素材额度;user.private_portrait_asset_limit=0 表示关闭,"
">0 表示启用并限制总素材数量。创建虚拟项目会同步调用火山 CreateAssetGroupGroupType=AIGC"
"remote_project_name 默认使用 default。当前上传只开放 Image/VideoAudio 暂不开放。"
)
def _log_task_dispatch_failed(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None, exc: BaseException) -> None:
log_operation_error(
domain=DOMAIN,
event_type=PrivatePortraitEventType.TASK_DISPATCH_FAILED.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project_id,
asset_id=asset_id,
exc=exc,
detail={"task_name": task_name},
)
def _log_task_dispatch_success(*, task_name: str, user_id: str | None = None, project_id: str | None = None, asset_id: str | None = None) -> None:
log_operation_event(
domain=DOMAIN,
event_type=PrivatePortraitEventType.TASK_DISPATCH_SUCCESS.value,
event_status=PrivatePortraitEventStatus.SUCCESS.value,
source=PrivatePortraitEventSource.API.value,
user_id=user_id,
project_id=project_id,
asset_id=asset_id,
detail={"task_name": task_name},
)
@router.get(
"/private-portrait/virtual/config",
response_model=PrivatePortraitConfigOut,
summary="获取虚拟素材库额度配置",
description=_VIRTUAL_API_DESCRIPTION + "返回虚拟素材库可用额度,实际与真人素材库共用。",
)
async def get_my_virtual_private_portrait_config(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return await get_user_private_portrait_config(db, user_id=current_user.id)
@router.get(
"/private-portrait/virtual-meta/enums",
response_model=PrivatePortraitEnumMetaOut,
summary="获取虚拟素材库枚举说明",
description="返回前端展示所需枚举:library_type、asset_type、project status、asset status、remote_delete_status 等。Audio 暂不开放上传。",
)
async def get_virtual_private_portrait_enum_meta():
return build_private_portrait_enum_meta()
@router.post(
"/private-portrait/virtual-projects",
response_model=PrivatePortraitProjectOut,
summary="创建虚拟素材项目组",
description=_VIRTUAL_API_DESCRIPTION + "创建成功后返回本地项目详情,后续上传虚拟素材必须归属到该 project_id。",
)
async def create_private_portrait_virtual_project(payload: PrivatePortraitVirtualProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await create_virtual_project(db, user_id=current_user.id, payload=payload)
out = project_to_out(project)
await db.commit()
return out
@router.get(
"/private-portrait/virtual-projects",
response_model=PrivatePortraitProjectListOut,
summary="查询当前用户虚拟素材项目列表",
description="只返回 library_type=aigc_virtual 的项目。默认查询 active 项目,可通过 status 覆盖。",
)
async def list_private_portrait_virtual_projects(
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
keyword: str | None = Query(None, description="项目名称模糊搜索"),
status: str | None = Query(None, description="项目状态筛选,不传默认 active"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
query_status = status or PrivatePortraitProjectStatus.ACTIVE.value
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
await refresh_project_counters(db, [item.id for item in items])
await db.commit()
items, total = await list_projects(db, user_id=current_user.id, page=page, page_size=page_size, keyword=keyword, status=query_status, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value)
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
@router.get(
"/private-portrait/virtual-projects/{project_id}",
response_model=PrivatePortraitProjectOut,
summary="获取虚拟素材项目详情",
description="获取当前用户虚拟素材项目详情,project_id 必须属于当前用户且 library_type=aigc_virtual。",
)
async def get_private_portrait_virtual_project(project_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return project_to_out(await get_user_project(db, user_id=current_user.id, project_id=project_id, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value))
@router.put(
"/private-portrait/virtual-projects/{project_id}",
response_model=PrivatePortraitProjectOut,
summary="更新虚拟素材项目",
description="更新虚拟素材项目本地展示信息。不会重新创建远端 AssetGroup。",
)
async def update_private_portrait_virtual_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await update_virtual_project(db, user_id=current_user.id, project_id=project_id, payload=payload)
out = project_to_out(project)
await db.commit()
return out
@router.delete(
"/private-portrait/virtual-projects/{project_id}",
response_model=PrivatePortraitDeleteOut,
summary="删除虚拟素材项目",
description="软删虚拟素材项目和本地素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 AssetGroup/Assetremote_delete_status=pending 表示远端删除处理中。",
)
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
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_project_remote
delete_private_portrait_project_remote.delay(project_id_snapshot)
_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)
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.post(
"/private-portrait/virtual-projects/{project_id}/assets",
response_model=PrivatePortraitAssetOut,
summary="上传虚拟素材",
description=(
"在虚拟素材项目下创建素材。当前仅开放 asset_type=Image/VideoAudio 暂不开放。"
"Video 必须携带 video_duration,建议前端限制 2~15 秒。CreateAsset 是异步接口,返回后会投递轮询任务,"
"只有 status=Active 的素材才会出现在 virtual-selectable-assets 并可用于 AI 创作。"
),
)
async def create_private_portrait_virtual_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await create_virtual_asset(db, user_id=current_user.id, project_id=project_id, payload=payload)
asset_id_snapshot = asset.id
project_id_snapshot = asset.project_id
out = asset_to_out(asset)
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import poll_private_portrait_asset_status
poll_private_portrait_asset_status.delay(asset_id_snapshot)
_log_task_dispatch_success(task_name="private_portrait.poll_asset_status", 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.poll_asset_status", user_id=current_user.id, project_id=project_id_snapshot, asset_id=asset_id_snapshot, exc=exc)
return out
@router.get(
"/private-portrait/virtual-projects/{project_id}/assets",
response_model=PrivatePortraitAssetListOut,
summary="查询虚拟素材列表",
description="查询指定虚拟项目下的素材。asset_type 可传 Image 或 Videostatus 可筛选素材状态。",
)
async def list_private_portrait_virtual_assets(
project_id: str,
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
status: str | None = Query(None, description="素材状态筛选,不传查全部"),
keyword: str | None = Query(None, description="素材名称模糊搜索"),
asset_type: str | None = Query(None, description="素材类型:Image=图片,Video=视频;Audio 暂不开放"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
assets, total, project_name_map = await list_assets(db, user_id=current_user.id, project_id=project_id, status=status, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
return PrivatePortraitAssetListOut(items=[asset_to_out(asset, project_name=project_name_map.get(asset.project_id)) for asset in assets], total=total, page=page, page_size=page_size)
@router.get(
"/private-portrait/virtual-assets/{asset_id}",
response_model=PrivatePortraitAssetOut,
summary="获取虚拟素材详情",
description="获取当前用户虚拟素材详情,asset_id 必须属于当前用户且 library_type=aigc_virtual。",
)
async def get_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = (await db.execute(select(PrivatePortraitAsset).where(PrivatePortraitAsset.id == asset_id, PrivatePortraitAsset.user_id == current_user.id, PrivatePortraitAsset.library_type == PrivatePortraitLibraryType.AIGC_VIRTUAL.value).limit(1))).scalar_one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="虚拟人像素材不存在")
project = (await db.execute(select(PrivatePortraitProject).where(PrivatePortraitProject.id == asset.project_id).limit(1))).scalar_one_or_none()
return asset_to_out(asset, project_name=project.name if project else None)
@router.post(
"/private-portrait/virtual-assets/{asset_id}/sync",
response_model=PrivatePortraitAssetOut,
summary="同步虚拟素材状态",
description="主动向火山查询并同步虚拟素材状态。一般由轮询任务自动执行;前端排查或手动刷新时可调用。",
)
async def sync_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await sync_asset_status(db, user_id=current_user.id, asset_id=asset_id)
if asset.library_type != PrivatePortraitLibraryType.AIGC_VIRTUAL.value:
raise HTTPException(status_code=404, detail="虚拟人像素材不存在")
out = asset_to_out(asset)
await db.commit()
return out
@router.delete(
"/private-portrait/virtual-assets/{asset_id}",
response_model=PrivatePortraitDeleteOut,
summary="删除虚拟素材",
description="软删本地虚拟素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 Assetremote_delete_status=pending 表示远端删除处理中。",
)
async def delete_private_portrait_virtual_asset(asset_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
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
await db.commit()
try:
from app.tasks.private_portrait_asset_tasks import delete_private_portrait_asset_remote
delete_private_portrait_asset_remote.delay(asset_id_snapshot)
_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)
return PrivatePortraitDeleteOut(success=True, remote_delete_status=PrivatePortraitRemoteDeleteStatus.PENDING.value)
@router.get(
"/private-portrait/virtual-selectable-assets",
response_model=PrivatePortraitSelectableAssetListOut,
summary="查询可用于 AI 创作的虚拟素材",
description="只返回当前用户虚拟素材库中 status=Active 且未删除的 Image/Video 素材。该接口给 AI 创作参考内容选择器使用。",
)
async def list_private_portrait_virtual_selectable_assets(
page: int = Query(1, ge=1, description="页码,从 1 开始"),
page_size: int = Query(20, ge=1, le=100, description="每页数量,最大 100"),
project_id: str | None = Query(None, description="按虚拟项目 ID 筛选"),
keyword: str | None = Query(None, description="素材名称模糊搜索"),
asset_type: str | None = Query(None, description="素材类型:Image 或 Video,不传查全部"),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
items, total = await list_selectable_assets(db, user_id=current_user.id, project_id=project_id, keyword=keyword, page=page, page_size=page_size, library_type=PrivatePortraitLibraryType.AIGC_VIRTUAL.value, asset_type=asset_type)
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)