461 lines
23 KiB
Python
461 lines
23 KiB
Python
from __future__ import annotations
|
||
|
||
from urllib.parse import urlencode, unquote
|
||
|
||
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
|
||
|
||
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.enums.upload_resource import UploadResourceTypeEnum
|
||
from app.schemas.private_portrait import (
|
||
PrivatePortraitAssetCreate,
|
||
PrivatePortraitAssetListOut,
|
||
PrivatePortraitAssetOut,
|
||
PrivatePortraitConfigOut,
|
||
PrivatePortraitDeleteOut,
|
||
PrivatePortraitEnumMetaOut,
|
||
PrivatePortraitProjectCreate,
|
||
PrivatePortraitProjectCreateWithValidateOut,
|
||
PrivatePortraitProjectListOut,
|
||
PrivatePortraitProjectOut,
|
||
PrivatePortraitProjectUpdate,
|
||
PrivatePortraitSelectableAssetListOut,
|
||
PrivatePortraitUploadOut,
|
||
PrivatePortraitValidateSessionCreate,
|
||
PrivatePortraitValidateSessionOut,
|
||
build_private_portrait_enum_meta,
|
||
)
|
||
from app.services.operation_log_service import log_operation_error, log_operation_event
|
||
from app.services.private_portrait.quota_service import get_user_private_portrait_config
|
||
from app.services.private_portrait.asset_service import (
|
||
DOMAIN,
|
||
asset_to_out,
|
||
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.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,
|
||
create_real_person_validate_session,
|
||
update_real_person_project,
|
||
)
|
||
|
||
router = APIRouter(tags=["私域真人素材库"])
|
||
|
||
_REAL_PERSON_API_DESCRIPTION = (
|
||
"私域真人素材库 API。真人和虚拟素材共用用户素材额度;user.private_portrait_asset_limit=0 表示关闭,"
|
||
">0 表示启用并限制总素材数量。真人项目创建后需要通过火山 CreateVisualValidateSession 进行人脸认证,"
|
||
"每个真人项目只允许认证一次。认证回调 resultCode=10000 表示成功,成功后才允许上传可用于 AI 创作的真人素材。"
|
||
)
|
||
|
||
|
||
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=(
|
||
_REAL_PERSON_API_DESCRIPTION
|
||
+ "返回 enabled、asset_limit、used_asset_count、remaining_asset_count 等字段。额度按用户维度限制,真人/虚拟、图片/视频共用;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=(
|
||
"返回前端展示所需枚举:library_type=real_person/aigc_virtual;"
|
||
"asset_type=Image/Video/Audio;project status、asset status、remote_delete_status 等。"
|
||
"当前业务上传只开放 Image 和 Video,Audio 仅作为兼容枚举展示。"
|
||
),
|
||
)
|
||
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,
|
||
summary="创建真人认证素材项目并生成认证会话",
|
||
description=(
|
||
_REAL_PERSON_API_DESCRIPTION
|
||
+ "创建本地真人项目后立即调用火山 CreateVisualValidateSession,返回 H5Link/BytedToken 给 PC 前端展示二维码。"
|
||
"手机扫码完成人脸认证后,PC 端通过 validate_session 查询状态;认证成功后才允许创建真人素材。"
|
||
),
|
||
)
|
||
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="获取真人认证素材项目详情",
|
||
description="获取当前用户真人项目详情,project_id 必须属于当前用户且 library_type=real_person。",
|
||
)
|
||
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="更新真人认证素材项目",
|
||
description="更新真人项目本地展示信息;不会重新发起真人认证。每个真人项目只允许认证一次。",
|
||
)
|
||
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="删除真人认证素材项目",
|
||
description="软删真人项目和本地素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 AssetGroup/Asset;remote_delete_status=pending 表示远端删除处理中。",
|
||
)
|
||
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
|
||
|
||
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)
|
||
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)
|
||
|
||
|
||
@router.post(
|
||
"/private-portrait/projects/{project_id}/validate-sessions",
|
||
response_model=PrivatePortraitValidateSessionOut,
|
||
summary="重新创建真人认证会话",
|
||
description="为真人项目创建新的认证会话。业务层会限制项目只能认证一次;已认证成功的项目不允许重复认证。",
|
||
)
|
||
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="查询真人认证会话状态",
|
||
description="PC 端轮询该接口查看手机扫码认证结果。status/result_code/remote_group_id 可用于判断是否认证成功并提示用户回到 PC 查看项目。",
|
||
)
|
||
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="火山真人认证回调入口",
|
||
description="火山真人认证 H5 回调入口。resultCode=10000 表示认证成功;成功后会创建或更新本地 AssetGroup 映射,并可 redirect 回前端提示页。",
|
||
)
|
||
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=(
|
||
"在已认证成功的真人项目下创建素材。当前仅开放 asset_type=Image/Video,Audio 暂不开放。"
|
||
"Video 必须携带 video_duration,建议前端限制 2~15 秒。CreateAsset 是异步接口,返回后会投递轮询任务,"
|
||
"只有 status=Active 的素材才会出现在 selectable-assets 并可用于 AI 创作。"
|
||
),
|
||
)
|
||
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="查询真人认证素材列表",
|
||
description="查询指定真人项目下的素材。asset_type 可传 Image 或 Video;status 可筛选素材状态。",
|
||
)
|
||
async def list_private_portrait_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.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}",
|
||
response_model=PrivatePortraitAssetOut,
|
||
summary="获取真人认证素材详情",
|
||
description="获取当前用户真人素材详情,asset_id 必须属于当前用户且 library_type=real_person。",
|
||
)
|
||
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",
|
||
response_model=PrivatePortraitAssetOut,
|
||
summary="同步真人认证素材状态",
|
||
description="主动向火山查询并同步真人素材状态。一般由轮询任务自动执行;前端排查或手动刷新时可调用。",
|
||
)
|
||
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="删除真人认证素材",
|
||
description="软删本地真人素材记录。本地先 commit,commit 成功后投递 Celery 删除远端 Asset;remote_delete_status=pending 表示远端删除处理中。",
|
||
)
|
||
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
|
||
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
|
||
|
||
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)
|
||
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)
|
||
|
||
|
||
@router.get(
|
||
"/private-portrait/selectable-assets",
|
||
response_model=PrivatePortraitSelectableAssetListOut,
|
||
summary="查询可用于 AI 创作的真人认证素材",
|
||
description="只返回当前用户真人素材库中 status=Active 且未删除的 Image/Video 素材。该接口给 AI 创作参考内容选择器使用。",
|
||
)
|
||
async def list_private_portrait_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.REAL_PERSON.value, asset_type=asset_type)
|
||
return PrivatePortraitSelectableAssetListOut(items=items, total=total, page=page, page_size=page_size)
|