Files
video-gen/video-gen-api/app/api/v1/private_portrait.py
T
2026-07-07 15:18:00 +08:00

254 lines
16 KiB
Python

from __future__ import annotations
from urllib.parse import urlencode, unquote
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import RedirectResponse
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 (
PrivatePortraitAssetType,
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,
PrivatePortraitDeleteOut,
PrivatePortraitConfigOut,
PrivatePortraitProjectCreate,
PrivatePortraitProjectCreateWithValidateOut,
PrivatePortraitProjectListOut,
PrivatePortraitProjectOut,
PrivatePortraitProjectUpdate,
PrivatePortraitSelectableAssetListOut,
PrivatePortraitValidateSessionCreate,
PrivatePortraitValidateSessionOut,
PrivatePortraitEnumMetaOut,
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,
get_validate_session,
handle_validate_callback,
list_assets,
list_selectable_assets,
soft_delete_asset,
sync_asset_status,
validate_session_to_out,
)
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.real_person.service import (
create_real_person_asset,
create_real_person_project,
create_real_person_validate_session,
update_real_person_project,
)
router = APIRouter(tags=["私域真人素材库"])
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/config",
response_model=PrivatePortraitConfigOut,
summary="获取当前用户私域人像素材额度配置",
description="返回私域人像素材总量限制。额度由真人认证素材库与虚拟人像素材库共用,图片和视频共用,Audio 暂未开放。",
)
async def get_my_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/meta/enums",
response_model=PrivatePortraitEnumMetaOut,
summary="获取私域人像素材库枚举说明",
description="给前端展示状态、类型、素材库类型使用。Audio 仅作为火山支持项展示,当前业务不开放上传。",
)
async def get_private_portrait_enum_meta():
return build_private_portrait_enum_meta()
@router.post(
"/private-portrait/projects",
response_model=PrivatePortraitProjectCreateWithValidateOut,
summary="创建真人认证素材项目并生成认证会话",
description="创建本地真人素材项目,随后调用火山 CreateVisualValidateSession 返回 H5Link。用户完成认证后,回调会创建本地 Asset Group 映射。",
)
async def create_private_portrait_project(payload: PrivatePortraitProjectCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await create_real_person_project(db, user_id=current_user.id, payload=payload)
session = await create_real_person_validate_session(db, user_id=current_user.id, project_id=project.id, callback_redirect_url=payload.callback_redirect_url)
out = PrivatePortraitProjectCreateWithValidateOut(project=project_to_out(project), validate_session=validate_session_to_out(session), poll_interval_ms=2000)
await db.commit()
return out
@router.get(
"/private-portrait/projects",
response_model=PrivatePortraitProjectListOut,
summary="查询当前用户真人认证素材项目列表",
description="只返回 library_type=real_person 的项目。默认查询 active 项目,可用 status 覆盖。",
)
async def list_private_portrait_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.REAL_PERSON.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.REAL_PERSON.value)
return PrivatePortraitProjectListOut(items=[project_to_out(item) for item in items], total=total, page=page, page_size=page_size)
@router.get("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="获取真人认证素材项目详情")
async def get_private_portrait_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.REAL_PERSON.value))
@router.put("/private-portrait/projects/{project_id}", response_model=PrivatePortraitProjectOut, summary="更新真人认证素材项目")
async def update_private_portrait_project(project_id: str, payload: PrivatePortraitProjectUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
project = await update_real_person_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/projects/{project_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材项目")
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
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/projects/{project_id}/validate-sessions", response_model=PrivatePortraitValidateSessionOut, summary="重新创建真人认证会话")
async def create_private_portrait_validate_session(project_id: str, payload: PrivatePortraitValidateSessionCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
session = await create_real_person_validate_session(db, user_id=current_user.id, project_id=project_id, callback_redirect_url=payload.callback_redirect_url)
out = validate_session_to_out(session)
await db.commit()
return out
@router.get("/private-portrait/validate-sessions/{session_id}", response_model=PrivatePortraitValidateSessionOut, summary="查询真人认证会话状态")
async def get_private_portrait_validate_session(session_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return validate_session_to_out(await get_validate_session(db, user_id=current_user.id, session_id=session_id))
@router.get("/private-portrait/validate-callback", summary="火山真人认证回调入口")
async def private_portrait_validate_callback(session_id: str, request: Request, redirect_url: str | None = None, db: AsyncSession = Depends(get_db)):
params = dict(request.query_params)
params.pop("session_id", None)
params.pop("redirect_url", None)
session = await handle_validate_callback(db, session_id=session_id, query_params=params)
redirect_params = {"session_id": session.id, "status": session.status, "resultCode": session.result_code or ""}
if session.remote_group_id:
redirect_params["remote_group_id"] = session.remote_group_id
response = {"session_id": session.id, "status": session.status, "resultCode": session.result_code, "remote_group_id": session.remote_group_id}
await db.commit()
if redirect_url:
base_url = unquote(redirect_url)
sep = "&" if "?" in base_url else "?"
return RedirectResponse(url=f"{base_url}{sep}{urlencode(redirect_params)}")
return response
@router.post("/private-portrait/projects/{project_id}/assets", response_model=PrivatePortraitAssetOut, summary="上传真人认证素材", description="当前支持 Image / Video。Audio 暂不开放。CreateAsset 是异步接口,返回后需要轮询到 Active 才可用于生成。")
async def create_private_portrait_asset(project_id: str, payload: PrivatePortraitAssetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
asset = await create_real_person_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/projects/{project_id}/assets", response_model=PrivatePortraitAssetListOut, summary="查询真人认证素材列表")
async def list_private_portrait_assets(project_id: str, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = Query(None), keyword: str | None = Query(None), asset_type: str | None = Query(None), 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.REAL_PERSON.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/assets/{asset_id}", summary="获取真人认证素材详情")
async def get_private_portrait_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.REAL_PERSON.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/assets/{asset_id}/sync", summary="同步真人认证素材状态")
async def sync_private_portrait_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.REAL_PERSON.value:
raise HTTPException(status_code=404, detail="私域人像素材不存在")
out = asset_to_out(asset)
await db.commit()
return out
@router.delete("/private-portrait/assets/{asset_id}", response_model=PrivatePortraitDeleteOut, summary="删除真人认证素材")
async def delete_private_portrait_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.REAL_PERSON.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/selectable-assets", response_model=PrivatePortraitSelectableAssetListOut, summary="查询可用于生成的真人认证素材")
async def list_private_portrait_selectable_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), project_id: str | None = Query(None), keyword: str | None = Query(None), 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.REAL_PERSON.value, asset_type=asset_type)
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)